@tangle-network/ui 11.44.1 → 11.45.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,42 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.45.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 14a7a9e: FileTabs: the active tab paints the card plane and covers the strip's baseline, so it joins the content below it; the close button sits inside each tab; long names are cut in the middle so their end and extension stay readable. The strip is a `tablist` with one tab stop: arrow keys, Home and End move the selection, and Delete closes the focused tab. New `label` names the list (default "Open files"). A host whose own border is the baseline passes `shadow-none`, as FileArtifactPane does.
8
+
9
+ ### Patch Changes
10
+
11
+ - 4678ab2: The canonical spine (`:root`, `.dark`, `.light`, `data-theme` `dark`, `light`
12
+ and `tangle-dark`) has a real surface ladder. `--hsl-raised` is now the spine's
13
+ L3 plane and `--hsl-raised-2` its L4 plane, instead of both repeating the card,
14
+ and every adjacent plane and well is at least 4.4 OKLCH L apart (gated in
15
+ `surface-ladder.test.ts`):
16
+
17
+ - Dark: page `#0a0a14`, card `#191826`, raised `#252237`, raised-2 and popover
18
+ `#312e47`. A field in a raised card takes the card colour instead of the page's
19
+ near-black well, and dark cards and menus carry the 1px top highlight
20
+ (`--edge-highlight`).
21
+ - Light: page `#eceef3`, card white, raised `#edeff4`, raised-2 white. A field in
22
+ a raised card is white.
23
+
24
+ `--hsl-secondary` and `--hsl-popover` moved with the ladder (dark `#221f33` to
25
+ `#252237`, `#2c2942` to `#312e47`; light `#f2f3f8` to `#edeff4`). Named themes
26
+ other than Hospitality keep their flat ladder.
27
+
28
+ One hairline: `--border-subtle` and `--border-default` now alias `--hsl-border`
29
+ in the spine, so every divider, card edge and table rule renders one colour. The
30
+ dark hairline is `#37364a` (1.5:1 on the card, as documented; it measured 1.2:1).
31
+ `--glass-border-hover` now follows `--border-hover`.
32
+
33
+ The `--font-size-xs`, `-sm`, `-base` and `-lg` tokens now equal Tailwind's
34
+ `text-xs`, `text-sm`, `text-base` and `text-lg` (12, 14, 16 and 18px; they were
35
+ 11, 12, 15 and 16px), so `sm` means 14px in CSS and in a class. `text-eyebrow`,
36
+ the `Heading` eyebrow and subsection variants and `.tangle-api-meta` keep their
37
+ rendered sizes on the new steps. Code that relied on the old values reads
38
+ `--font-size-xs` for 12px captions and `--font-size-base` for 16px emphasis.
39
+
3
40
  ## 11.44.1
4
41
 
5
42
  ### Patch Changes
@@ -21,7 +21,8 @@ function FileArtifactPane({ filename, content, blobUrl, mimeType, size, onClose,
21
21
  tabs,
22
22
  activeId: activeTabId,
23
23
  onSelect: onTabSelect,
24
- onClose: onTabClose
24
+ onClose: onTabClose,
25
+ className: "shadow-none"
25
26
  }) : void 0;
26
27
  const isEditableMarkdown = (detectFileFormat(filename, mimeType) === "markdown" || (path ? detectFileFormat(path, mimeType) === "markdown" : false)) && editor?.enabled;
27
28
  const headerActions = onDownload || onClose ? /* @__PURE__ */ jsxs(Fragment$1, { children: [onDownload && /* @__PURE__ */ jsx("button", {
@@ -1,5 +1,9 @@
1
1
  /**
2
- * FileTabs — tab bar for open files in the preview panel.
2
+ * FileTabs: one tab per open file. The strip sits on its host's chrome plane and
3
+ * draws its baseline as an inset shadow (a host whose border is the baseline
4
+ * passes `shadow-none`); the active tab paints the card plane over it, so it
5
+ * joins the content below. A long name is cut in the middle, keeping its end
6
+ * and extension. Arrow keys, Home and End move the selection; Delete closes.
3
7
  */
4
8
  export interface FileTabData {
5
9
  id: string;
@@ -12,6 +16,8 @@ export interface FileTabsProps {
12
16
  activeId?: string;
13
17
  onSelect: (id: string) => void;
14
18
  onClose: (id: string) => void;
19
+ /** Accessible name of the tab list. Defaults to "Open files". */
20
+ label?: string;
15
21
  className?: string;
16
22
  }
17
- export declare function FileTabs({ tabs, activeId, onSelect, onClose, className }: FileTabsProps): import("react").JSX.Element | null;
23
+ export declare function FileTabs({ tabs, activeId, onSelect, onClose, label, className }: FileTabsProps): import("react").JSX.Element | null;
@@ -1,10 +1,16 @@
1
- import { focusRing, focusRingInset } from "../lib/focus.js";
1
+ import { controlMotion } from "../lib/motion.js";
2
+ import { focusRingInset } from "../lib/focus.js";
2
3
  import { cn } from "../lib/utils.js";
4
+ import { useEffect, useRef } from "react";
3
5
  import { jsx, jsxs } from "react/jsx-runtime";
4
6
  import { FileCode, FileSpreadsheet, FileText, X } from "lucide-react";
5
7
  // src/files/file-tabs.tsx
6
8
  /**
7
- * FileTabs — tab bar for open files in the preview panel.
9
+ * FileTabs: one tab per open file. The strip sits on its host's chrome plane and
10
+ * draws its baseline as an inset shadow (a host whose border is the baseline
11
+ * passes `shadow-none`); the active tab paints the card plane over it, so it
12
+ * joins the content below. A long name is cut in the middle, keeping its end
13
+ * and extension. Arrow keys, Home and End move the selection; Delete closes.
8
14
  */
9
15
  function getTabIcon(name) {
10
16
  const ext = name.split(".").pop()?.toLowerCase() || "";
@@ -12,33 +18,92 @@ function getTabIcon(name) {
12
18
  if (["csv", "xlsx"].includes(ext)) return FileSpreadsheet;
13
19
  return FileCode;
14
20
  }
15
- function FileTabs({ tabs, activeId, onSelect, onClose, className }) {
21
+ /** A long name keeps its extension and the characters before it after the ellipsis. */
22
+ function splitName(name) {
23
+ if (name.length <= 18) return [name, ""];
24
+ const dot = name.lastIndexOf(".");
25
+ const keep = Math.min(Math.max((dot > 0 ? name.length - dot : 0) + 6, 8), 12);
26
+ return [name.slice(0, -keep), name.slice(-keep)];
27
+ }
28
+ function FileTabs({ tabs, activeId, onSelect, onClose, label = "Open files", className }) {
29
+ const stripRef = useRef(null);
30
+ useEffect(() => {
31
+ const strip = stripRef.current;
32
+ const tab = strip?.querySelector("[aria-selected=\"true\"]")?.parentElement;
33
+ if (strip && tab) strip.scrollLeft = Math.min(tab.offsetLeft, Math.max(strip.scrollLeft, tab.offsetLeft + tab.offsetWidth - strip.clientWidth));
34
+ }, [activeId, tabs.length]);
16
35
  if (tabs.length === 0) return null;
36
+ const tabButtons = () => [...stripRef.current?.querySelectorAll("[role=\"tab\"]") ?? []];
37
+ const onKeyDown = (event) => {
38
+ const at = tabButtons().indexOf(event.target);
39
+ const last = tabs.length - 1;
40
+ const next = {
41
+ ArrowRight: at === last ? 0 : at + 1,
42
+ ArrowLeft: at === 0 ? last : at - 1,
43
+ Home: 0,
44
+ End: last
45
+ }[event.key];
46
+ if (at < 0 || next === void 0 && event.key !== "Delete") return;
47
+ event.preventDefault();
48
+ if (next !== void 0) {
49
+ tabButtons()[next]?.focus();
50
+ onSelect(tabs[next].id);
51
+ return;
52
+ }
53
+ onClose(tabs[at].id);
54
+ requestAnimationFrame(() => (tabButtons().find((tab) => tab.getAttribute("aria-selected") === "true") ?? tabButtons().at(-1))?.focus());
55
+ };
17
56
  return /* @__PURE__ */ jsx("div", {
18
- className: cn("flex items-center border-b border-border bg-background overflow-x-auto", className),
57
+ ref: stripRef,
58
+ role: "tablist",
59
+ "aria-label": label,
60
+ onKeyDown,
61
+ className: cn("relative flex h-10 min-w-0 items-end gap-1 overflow-x-auto px-2 shadow-[inset_0_-1px_0_hsl(var(--border))] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", className),
19
62
  children: tabs.map((tab) => {
20
63
  const isActive = tab.id === activeId;
21
64
  const Icon = getTabIcon(tab.name);
65
+ const [head, tail] = splitName(tab.name);
22
66
  return /* @__PURE__ */ jsxs("div", {
23
- className: cn("group flex items-center border-r border-border shrink-0", isActive ? "bg-card text-foreground border-b-2 border-b-primary" : "text-muted-foreground hover:bg-muted/50"),
67
+ title: tab.path,
68
+ className: cn("group relative flex h-[calc(100%-0.375rem)] max-w-72 shrink-0 items-center rounded-t-lg border border-b-0 text-sm", controlMotion, isActive ? "border-border bg-card text-foreground" : "border-transparent text-muted-foreground hover:bg-[var(--ix-hover-bg)] hover:text-foreground"),
24
69
  children: [/* @__PURE__ */ jsxs("button", {
25
70
  type: "button",
71
+ role: "tab",
72
+ "aria-selected": isActive,
73
+ "aria-label": tab.dirty ? `${tab.name}, unsaved changes` : tab.name,
74
+ tabIndex: isActive || !activeId && tab === tabs[0] ? 0 : -1,
26
75
  onClick: () => onSelect(tab.id),
27
- className: cn("flex min-w-0 items-center gap-1.5 px-3 py-1.5 text-xs transition-colors hover:text-foreground", focusRingInset),
76
+ className: cn("flex h-full min-w-0 items-center gap-1.5 rounded-t-lg pl-3 pr-1", focusRingInset),
28
77
  children: [
29
- /* @__PURE__ */ jsx(Icon, { className: "h-3 w-3 shrink-0" }),
30
- /* @__PURE__ */ jsx("span", {
31
- className: "max-w-[120px] truncate",
32
- children: tab.name
78
+ /* @__PURE__ */ jsx(Icon, {
79
+ "aria-hidden": true,
80
+ className: "size-3.5 shrink-0 text-muted-foreground"
81
+ }),
82
+ /* @__PURE__ */ jsxs("span", {
83
+ className: "flex min-w-0",
84
+ children: [/* @__PURE__ */ jsx("span", {
85
+ className: "overflow-hidden text-ellipsis whitespace-pre",
86
+ children: head
87
+ }), tail && /* @__PURE__ */ jsx("span", {
88
+ className: "shrink-0 whitespace-pre",
89
+ children: tail
90
+ })]
33
91
  }),
34
- tab.dirty && /* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-primary" })
92
+ tab.dirty && /* @__PURE__ */ jsx("span", {
93
+ "aria-hidden": true,
94
+ className: "size-1.5 shrink-0 rounded-full bg-[var(--accent-text)]"
95
+ })
35
96
  ]
36
97
  }), /* @__PURE__ */ jsx("button", {
37
98
  type: "button",
99
+ tabIndex: -1,
38
100
  "aria-label": `Close ${tab.name}`,
39
101
  onClick: () => onClose(tab.id),
40
- className: cn("mr-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-accent focus-visible:opacity-100 group-hover:opacity-100", focusRing),
41
- children: /* @__PURE__ */ jsx(X, { className: "h-2.5 w-2.5" })
102
+ className: cn("mr-1.5 grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-[var(--ix-hover-bg)] hover:text-foreground", controlMotion, !isActive && "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"),
103
+ children: /* @__PURE__ */ jsx(X, {
104
+ "aria-hidden": true,
105
+ className: "size-3"
106
+ })
42
107
  })]
43
108
  }, tab.id);
44
109
  })
@@ -7,8 +7,8 @@ const VARIANT_CLASS = {
7
7
  hero: "font-display font-bold text-foreground text-[length:var(--font-size-hero,2.5rem)] leading-[1.1] tracking-[var(--tracking-tight,-0.02em)]",
8
8
  page: "font-display font-semibold text-foreground text-[length:var(--font-size-3xl,1.875rem)] leading-[var(--line-height-heading,1.15)] tracking-[var(--tracking-tight,-0.02em)]",
9
9
  section: "font-display font-semibold text-foreground text-[length:var(--font-size-xl,1.25rem)] leading-[1.3] tracking-[var(--tracking-snug,-0.01em)]",
10
- subsection: "font-semibold text-foreground text-[length:var(--font-size-lg,1rem)] leading-[1.4]",
11
- eyebrow: "font-semibold uppercase text-muted-foreground text-[length:var(--font-size-sm,0.75rem)] leading-none tracking-[var(--tracking-wide,0.08em)]"
10
+ subsection: "font-semibold text-foreground text-[length:var(--font-size-base,1rem)] leading-[1.4]",
11
+ eyebrow: "font-semibold uppercase text-muted-foreground text-[length:var(--font-size-xs,0.75rem)] leading-none tracking-[var(--tracking-wide,0.08em)]"
12
12
  };
13
13
  const DEFAULT_TAG = {
14
14
  display: "h1",
@@ -64,7 +64,7 @@ function SidebarDropZone({ onDrop, accept, disabled, title = "Drop files here",
64
64
  onDragLeave: handleDragLeave,
65
65
  onDragOver: handleDragOver,
66
66
  onDrop: handleDrop,
67
- className: cn("rounded-lg border-2 border-dashed transition-all duration-150", isVisible ? "p-4" : "p-0 border-transparent", dragOver ? "border-[var(--brand-cool,hsl(var(--ring)))] bg-[var(--accent-surface-soft)]" : persistent ? "border-[var(--border-subtle,hsl(var(--border)))] bg-transparent hover:border-[var(--border-default,hsl(var(--border)))] hover:bg-[var(--bg-hover,hsl(var(--accent)))]" : "", disabled && "opacity-50 pointer-events-none", className),
67
+ className: cn("rounded-lg border-2 border-dashed transition-all duration-150", isVisible ? "p-4" : "p-0 border-transparent", dragOver ? "border-[var(--brand-cool,hsl(var(--ring)))] bg-[var(--accent-surface-soft)]" : persistent ? "border-[var(--border-subtle,hsl(var(--border)))] bg-transparent hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover,hsl(var(--accent)))]" : "", disabled && "opacity-50 pointer-events-none", className),
68
68
  children: isVisible && /* @__PURE__ */ jsxs("div", {
69
69
  className: "flex flex-col items-center gap-2 text-center",
70
70
  children: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.44.1",
3
+ "version": "11.45.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -166,7 +166,7 @@
166
166
  "peerDependencies": {
167
167
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
168
168
  "@nanostores/react": ">=0.8.4 <3.0.0",
169
- "@tangle-network/brand": "^1.27.1",
169
+ "@tangle-network/brand": "^1.29.0",
170
170
  "@tiptap/core": ">=3.20.4 <4.0.0",
171
171
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
172
172
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -90,6 +90,7 @@ export function FileArtifactPane({
90
90
  activeId={activeTabId}
91
91
  onSelect={onTabSelect}
92
92
  onClose={onTabClose}
93
+ className="shadow-none"
93
94
  />
94
95
  ) : undefined;
95
96
  const isMarkdown =
@@ -0,0 +1,66 @@
1
+ import { fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { useState } from "react";
3
+ import { describe, expect, it } from "vitest";
4
+ import { FileTabs, type FileTabData } from "./file-tabs";
5
+
6
+ const TABS: FileTabData[] = [
7
+ { id: "a", name: "records-graph-20261026a.md", path: "pages/graphs/records-graph-20261026a.md" },
8
+ { id: "b", name: "parity.json", path: "run/parity.json", dirty: true },
9
+ { id: "c", name: "README.md", path: "README.md" },
10
+ ];
11
+
12
+ function Editor() {
13
+ const [tabs, setTabs] = useState(TABS);
14
+ const [active, setActive] = useState("a");
15
+ return (
16
+ <FileTabs
17
+ tabs={tabs}
18
+ activeId={active}
19
+ onSelect={setActive}
20
+ onClose={(id) => {
21
+ const at = tabs.findIndex((tab) => tab.id === id);
22
+ const rest = tabs.filter((tab) => tab.id !== id);
23
+ setTabs(rest);
24
+ if (id === active && rest.length) setActive(rest[Math.min(at, rest.length - 1)]!.id);
25
+ }}
26
+ />
27
+ );
28
+ }
29
+
30
+ describe("FileTabs", () => {
31
+ it("is one tab list with a single tab stop, named by each full file name", () => {
32
+ render(<Editor />);
33
+ const list = screen.getByRole("tablist", { name: "Open files" });
34
+ const tabs = within(list).getAllByRole("tab");
35
+ expect(tabs.map((tab) => tab.getAttribute("aria-label"))).toEqual([
36
+ "records-graph-20261026a.md",
37
+ "parity.json, unsaved changes",
38
+ "README.md",
39
+ ]);
40
+ expect(tabs.map((tab) => tab.tabIndex)).toEqual([0, -1, -1]);
41
+ expect(screen.getByRole("tab", { name: "records-graph-20261026a.md" })).toHaveAttribute("aria-selected", "true");
42
+ expect(tabs[0]!.textContent).toBe("records-graph-20261026a.md");
43
+ });
44
+
45
+ it("moves the selection with arrow keys, Home and End, and closes the focused tab with Delete", () => {
46
+ render(<Editor />);
47
+ const first = screen.getByRole("tab", { name: "records-graph-20261026a.md" });
48
+ first.focus();
49
+ fireEvent.keyDown(first, { key: "ArrowRight" });
50
+ expect(screen.getByRole("tab", { name: "parity.json, unsaved changes" })).toHaveAttribute("aria-selected", "true");
51
+ expect(document.activeElement).toBe(screen.getByRole("tab", { name: "parity.json, unsaved changes" }));
52
+ fireEvent.keyDown(document.activeElement!, { key: "End" });
53
+ expect(screen.getByRole("tab", { name: "README.md" })).toHaveAttribute("aria-selected", "true");
54
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
55
+ expect(screen.getByRole("tab", { name: "records-graph-20261026a.md" })).toHaveAttribute("aria-selected", "true");
56
+ fireEvent.keyDown(document.activeElement!, { key: "Delete" });
57
+ expect(screen.queryByRole("tab", { name: "records-graph-20261026a.md" })).toBeNull();
58
+ expect(screen.getByRole("tab", { name: "parity.json, unsaved changes" })).toHaveAttribute("aria-selected", "true");
59
+ });
60
+
61
+ it("closes a tab from its own close button", () => {
62
+ render(<Editor />);
63
+ fireEvent.click(screen.getByRole("button", { name: "Close README.md" }));
64
+ expect(screen.getAllByRole("tab")).toHaveLength(2);
65
+ });
66
+ });
@@ -1,9 +1,15 @@
1
1
  /**
2
- * FileTabs — tab bar for open files in the preview panel.
2
+ * FileTabs: one tab per open file. The strip sits on its host's chrome plane and
3
+ * draws its baseline as an inset shadow (a host whose border is the baseline
4
+ * passes `shadow-none`); the active tab paints the card plane over it, so it
5
+ * joins the content below. A long name is cut in the middle, keeping its end
6
+ * and extension. Arrow keys, Home and End move the selection; Delete closes.
3
7
  */
4
8
 
9
+ import { useEffect, useRef, type KeyboardEvent } from "react";
5
10
  import { X, FileText, FileCode, FileSpreadsheet } from "lucide-react";
6
- import { focusRing, focusRingInset } from "../lib/focus";
11
+ import { focusRingInset } from "../lib/focus";
12
+ import { controlMotion } from "../lib/motion";
7
13
  import { cn } from "../lib/utils";
8
14
 
9
15
  export interface FileTabData {
@@ -18,6 +24,8 @@ export interface FileTabsProps {
18
24
  activeId?: string;
19
25
  onSelect: (id: string) => void;
20
26
  onClose: (id: string) => void;
27
+ /** Accessible name of the tab list. Defaults to "Open files". */
28
+ label?: string;
21
29
  className?: string;
22
30
  }
23
31
 
@@ -28,47 +36,99 @@ function getTabIcon(name: string) {
28
36
  return FileCode;
29
37
  }
30
38
 
31
- export function FileTabs({ tabs, activeId, onSelect, onClose, className }: FileTabsProps) {
39
+ /** A long name keeps its extension and the characters before it after the ellipsis. */
40
+ function splitName(name: string): [string, string] {
41
+ if (name.length <= 18) return [name, ""];
42
+ const dot = name.lastIndexOf(".");
43
+ const keep = Math.min(Math.max((dot > 0 ? name.length - dot : 0) + 6, 8), 12);
44
+ return [name.slice(0, -keep), name.slice(-keep)];
45
+ }
46
+
47
+ export function FileTabs({ tabs, activeId, onSelect, onClose, label = "Open files", className }: FileTabsProps) {
48
+ const stripRef = useRef<HTMLDivElement>(null);
49
+
50
+ // Reveal the active tab inside the strip only; scrollIntoView would also scroll the page.
51
+ useEffect(() => {
52
+ const strip = stripRef.current;
53
+ const tab = strip?.querySelector<HTMLElement>('[aria-selected="true"]')?.parentElement;
54
+ if (strip && tab) strip.scrollLeft = Math.min(tab.offsetLeft, Math.max(strip.scrollLeft, tab.offsetLeft + tab.offsetWidth - strip.clientWidth));
55
+ }, [activeId, tabs.length]);
56
+
32
57
  if (tabs.length === 0) return null;
33
58
 
59
+ const tabButtons = () => [...(stripRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [])];
60
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
61
+ const at = tabButtons().indexOf(event.target as HTMLButtonElement);
62
+ const last = tabs.length - 1;
63
+ const next = ({ ArrowRight: at === last ? 0 : at + 1, ArrowLeft: at === 0 ? last : at - 1, Home: 0, End: last } as Record<string, number>)[event.key];
64
+ if (at < 0 || (next === undefined && event.key !== "Delete")) return;
65
+ event.preventDefault();
66
+ if (next !== undefined) {
67
+ tabButtons()[next]?.focus();
68
+ onSelect(tabs[next]!.id);
69
+ return;
70
+ }
71
+ onClose(tabs[at]!.id);
72
+ // Once the host has re-rendered, keep focus on the tab it opened.
73
+ requestAnimationFrame(() => (tabButtons().find((tab) => tab.getAttribute("aria-selected") === "true") ?? tabButtons().at(-1))?.focus());
74
+ };
75
+
34
76
  return (
35
- <div className={cn("flex items-center border-b border-border bg-background overflow-x-auto", className)}>
77
+ <div
78
+ ref={stripRef}
79
+ role="tablist"
80
+ aria-label={label}
81
+ onKeyDown={onKeyDown}
82
+ className={cn(
83
+ "relative flex h-10 min-w-0 items-end gap-1 overflow-x-auto px-2 shadow-[inset_0_-1px_0_hsl(var(--border))] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
84
+ className,
85
+ )}
86
+ >
36
87
  {tabs.map((tab) => {
37
88
  const isActive = tab.id === activeId;
38
89
  const Icon = getTabIcon(tab.name);
90
+ const [head, tail] = splitName(tab.name);
39
91
 
40
92
  return (
41
93
  <div
42
94
  key={tab.id}
95
+ title={tab.path}
43
96
  className={cn(
44
- "group flex items-center border-r border-border shrink-0",
97
+ "group relative flex h-[calc(100%-0.375rem)] max-w-72 shrink-0 items-center rounded-t-lg border border-b-0 text-sm",
98
+ controlMotion,
45
99
  isActive
46
- ? "bg-card text-foreground border-b-2 border-b-primary"
47
- : "text-muted-foreground hover:bg-muted/50",
100
+ ? "border-border bg-card text-foreground"
101
+ : "border-transparent text-muted-foreground hover:bg-[var(--ix-hover-bg)] hover:text-foreground",
48
102
  )}
49
103
  >
50
104
  <button
51
105
  type="button"
106
+ role="tab"
107
+ aria-selected={isActive}
108
+ aria-label={tab.dirty ? `${tab.name}, unsaved changes` : tab.name}
109
+ tabIndex={isActive || (!activeId && tab === tabs[0]) ? 0 : -1}
52
110
  onClick={() => onSelect(tab.id)}
53
- className={cn(
54
- "flex min-w-0 items-center gap-1.5 px-3 py-1.5 text-xs transition-colors hover:text-foreground",
55
- focusRingInset,
56
- )}
111
+ className={cn("flex h-full min-w-0 items-center gap-1.5 rounded-t-lg pl-3 pr-1", focusRingInset)}
57
112
  >
58
- <Icon className="h-3 w-3 shrink-0" />
59
- <span className="max-w-[120px] truncate">{tab.name}</span>
60
- {tab.dirty && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
113
+ <Icon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
114
+ <span className="flex min-w-0">
115
+ <span className="overflow-hidden text-ellipsis whitespace-pre">{head}</span>
116
+ {tail && <span className="shrink-0 whitespace-pre">{tail}</span>}
117
+ </span>
118
+ {tab.dirty && <span aria-hidden className="size-1.5 shrink-0 rounded-full bg-[var(--accent-text)]" />}
61
119
  </button>
62
120
  <button
63
121
  type="button"
122
+ tabIndex={-1}
64
123
  aria-label={`Close ${tab.name}`}
65
124
  onClick={() => onClose(tab.id)}
66
125
  className={cn(
67
- "mr-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-accent focus-visible:opacity-100 group-hover:opacity-100",
68
- focusRing,
126
+ "mr-1.5 grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-[var(--ix-hover-bg)] hover:text-foreground",
127
+ controlMotion,
128
+ !isActive && "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
69
129
  )}
70
130
  >
71
- <X className="h-2.5 w-2.5" />
131
+ <X aria-hidden className="size-3" />
72
132
  </button>
73
133
  </div>
74
134
  );
@@ -20,9 +20,9 @@ const VARIANT_CLASS: Record<HeadingVariant, string> = {
20
20
  section:
21
21
  "font-display font-semibold text-foreground text-[length:var(--font-size-xl,1.25rem)] leading-[1.3] tracking-[var(--tracking-snug,-0.01em)]",
22
22
  subsection:
23
- "font-semibold text-foreground text-[length:var(--font-size-lg,1rem)] leading-[1.4]",
23
+ "font-semibold text-foreground text-[length:var(--font-size-base,1rem)] leading-[1.4]",
24
24
  eyebrow:
25
- "font-semibold uppercase text-muted-foreground text-[length:var(--font-size-sm,0.75rem)] leading-none tracking-[var(--tracking-wide,0.08em)]",
25
+ "font-semibold uppercase text-muted-foreground text-[length:var(--font-size-xs,0.75rem)] leading-none tracking-[var(--tracking-wide,0.08em)]",
26
26
  };
27
27
 
28
28
  const DEFAULT_TAG: Record<HeadingVariant, React.ElementType> = {
@@ -113,7 +113,7 @@ export function SidebarDropZone({
113
113
  dragOver
114
114
  ? "border-[var(--brand-cool,hsl(var(--ring)))] bg-[var(--accent-surface-soft)]"
115
115
  : persistent
116
- ? "border-[var(--border-subtle,hsl(var(--border)))] bg-transparent hover:border-[var(--border-default,hsl(var(--border)))] hover:bg-[var(--bg-hover,hsl(var(--accent)))]"
116
+ ? "border-[var(--border-subtle,hsl(var(--border)))] bg-transparent hover:border-[var(--border-strong)] hover:bg-[var(--bg-hover,hsl(var(--accent)))]"
117
117
  : "",
118
118
  disabled && "opacity-50 pointer-events-none",
119
119
  className,
@@ -4,8 +4,8 @@
4
4
  .tangle-heading[data-variant="hero"] { font-size: var(--font-size-hero); line-height: var(--line-height-display); }
5
5
  .tangle-heading[data-variant="page"] { font-size: var(--font-size-3xl); }
6
6
  .tangle-heading[data-variant="section"] { font-size: var(--font-size-xl); }
7
- .tangle-heading[data-variant="subsection"] { font-size: var(--font-size-lg); }
8
- .tangle-heading[data-variant="eyebrow"] { font-size: var(--font-size-sm); text-transform: uppercase; letter-spacing: var(--tracking-wide); color: hsl(var(--muted-foreground)); }
7
+ .tangle-heading[data-variant="subsection"] { font-size: var(--font-size-base); }
8
+ .tangle-heading[data-variant="eyebrow"] { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: var(--tracking-wide); color: hsl(var(--muted-foreground)); }
9
9
  .tangle-page-header { min-width: 0; margin-bottom: 1.5rem; }
10
10
  .tangle-page-header-main { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1.5rem; }
11
11
  .tangle-intro .tangle-page-header-main { display: block; }
@@ -68,7 +68,7 @@
68
68
  .tangle-native-dialog { max-width: min(32rem,calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: auto; margin: auto; padding: 1.5rem; border: 1px solid hsl(var(--border)); border-radius: var(--radius-lg); background: hsl(var(--card)); color: hsl(var(--card-foreground)); }
69
69
  .tangle-native-dialog::backdrop { background: var(--scrim); }
70
70
 
71
- .tangle-api-meta { font-family: var(--font-mono); font-size: var(--font-size-sm); color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
71
+ .tangle-api-meta { font-family: var(--font-mono); font-size: var(--font-size-xs); color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
72
72
  .tangle-api-schema { margin: 1rem 0; }
73
73
  .tangle-api-schema > div { padding-block: 0.75rem; border-bottom: 1px solid hsl(var(--border)); overflow-wrap: anywhere; }
74
74
  .tangle-api-schema dt { font-family: var(--font-mono); font-size: var(--font-size-label); }