@tangle-network/ui 11.44.1 → 11.46.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 +47 -0
- package/dist/files/file-artifact-pane.js +2 -1
- package/dist/files/file-tabs.d.ts +8 -2
- package/dist/files/file-tabs.js +78 -13
- package/dist/primitives/heading.js +2 -2
- package/dist/primitives/segmented-control.d.ts +7 -4
- package/dist/primitives/segmented-control.js +5 -3
- package/dist/primitives/sidebar-drop-zone.js +1 -1
- package/dist/run/run-row-shell.js +1 -1
- package/package.json +2 -2
- package/src/files/file-artifact-pane.tsx +1 -0
- package/src/files/file-tabs.test.tsx +66 -0
- package/src/files/file-tabs.tsx +77 -17
- package/src/primitives/heading.tsx +2 -2
- package/src/primitives/interaction-states.test.tsx +5 -3
- package/src/primitives/segmented-control.test.tsx +3 -2
- package/src/primitives/segmented-control.tsx +14 -7
- package/src/primitives/sidebar-drop-zone.tsx +1 -1
- package/src/run/run-row-shell.tsx +1 -1
- package/src/styles/presentation.css +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.46.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7924be7: SegmentedControl (row): the selected segment is a raised thumb on one quiet well instead of a tint inside an inset ring, the track uses the hairline border token, and the control is 36px tall so it sits inside a 44px toolbar with air above and below.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 9bd1487: RunRowShell shows its description (the file, command or query) at every width, truncated, instead of hiding it below 640px, where a transcript read as a column of bare verbs ("Read", "Read", "Shell").
|
|
12
|
+
|
|
13
|
+
## 11.45.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 4678ab2: The canonical spine (`:root`, `.dark`, `.light`, `data-theme` `dark`, `light`
|
|
22
|
+
and `tangle-dark`) has a real surface ladder. `--hsl-raised` is now the spine's
|
|
23
|
+
L3 plane and `--hsl-raised-2` its L4 plane, instead of both repeating the card,
|
|
24
|
+
and every adjacent plane and well is at least 4.4 OKLCH L apart (gated in
|
|
25
|
+
`surface-ladder.test.ts`):
|
|
26
|
+
|
|
27
|
+
- Dark: page `#0a0a14`, card `#191826`, raised `#252237`, raised-2 and popover
|
|
28
|
+
`#312e47`. A field in a raised card takes the card colour instead of the page's
|
|
29
|
+
near-black well, and dark cards and menus carry the 1px top highlight
|
|
30
|
+
(`--edge-highlight`).
|
|
31
|
+
- Light: page `#eceef3`, card white, raised `#edeff4`, raised-2 white. A field in
|
|
32
|
+
a raised card is white.
|
|
33
|
+
|
|
34
|
+
`--hsl-secondary` and `--hsl-popover` moved with the ladder (dark `#221f33` to
|
|
35
|
+
`#252237`, `#2c2942` to `#312e47`; light `#f2f3f8` to `#edeff4`). Named themes
|
|
36
|
+
other than Hospitality keep their flat ladder.
|
|
37
|
+
|
|
38
|
+
One hairline: `--border-subtle` and `--border-default` now alias `--hsl-border`
|
|
39
|
+
in the spine, so every divider, card edge and table rule renders one colour. The
|
|
40
|
+
dark hairline is `#37364a` (1.5:1 on the card, as documented; it measured 1.2:1).
|
|
41
|
+
`--glass-border-hover` now follows `--border-hover`.
|
|
42
|
+
|
|
43
|
+
The `--font-size-xs`, `-sm`, `-base` and `-lg` tokens now equal Tailwind's
|
|
44
|
+
`text-xs`, `text-sm`, `text-base` and `text-lg` (12, 14, 16 and 18px; they were
|
|
45
|
+
11, 12, 15 and 16px), so `sm` means 14px in CSS and in a class. `text-eyebrow`,
|
|
46
|
+
the `Heading` eyebrow and subsection variants and `.tangle-api-meta` keep their
|
|
47
|
+
rendered sizes on the new steps. Code that relied on the old values reads
|
|
48
|
+
`--font-size-xs` for 12px captions and `--font-size-base` for 16px emphasis.
|
|
49
|
+
|
|
3
50
|
## 11.44.1
|
|
4
51
|
|
|
5
52
|
### 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
|
|
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;
|
package/dist/files/file-tabs.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
30
|
-
|
|
31
|
-
className: "
|
|
32
|
-
|
|
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", {
|
|
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
|
|
41
|
-
children: /* @__PURE__ */ jsx(X, {
|
|
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-
|
|
11
|
-
eyebrow: "font-semibold uppercase text-muted-foreground text-[length:var(--font-size-
|
|
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",
|
|
@@ -11,10 +11,13 @@ import * as React from "react";
|
|
|
11
11
|
* console warning fires when both are omitted.
|
|
12
12
|
*
|
|
13
13
|
* Design rules baked into the default variant:
|
|
14
|
-
* - The row track is
|
|
15
|
-
* control reads as one inset group on whatever plane it sits on
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* - The row track is one quiet well (`--well`, the hairline `border`), so
|
|
15
|
+
* the control reads as one inset group on whatever plane it sits on, and
|
|
16
|
+
* it is compact (36px) so it sits inside a 44px toolbar with air above
|
|
17
|
+
* and below instead of touching its edges.
|
|
18
|
+
* - Only the SELECTED segment shows a surface: a raised thumb (the card
|
|
19
|
+
* colour with a hairline and a soft shadow) with accent text. No ring is
|
|
20
|
+
* drawn around the track's segments, so the group never stacks rings. A tab
|
|
18
21
|
* shows it as an underline instead (`ixTabUnderline`).
|
|
19
22
|
* - Unselected segments have NO background — they're plain-text labels
|
|
20
23
|
* that darken on hover. This keeps the selected segment as the
|
|
@@ -7,6 +7,8 @@ import * as React from "react";
|
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
9
9
|
// src/primitives/segmented-control.tsx
|
|
10
|
+
/** The row variant's selected segment: a raised thumb on the well, not a tint inside a ring. */
|
|
11
|
+
const rowThumb = "aria-checked:bg-card aria-checked:border-border aria-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08)] hover:aria-checked:bg-card";
|
|
10
12
|
/** Size of the edge chevron, in px. The selected option is kept this far
|
|
11
13
|
* (plus the strip's own padding) from an edge so the chevron never covers it. */
|
|
12
14
|
const SCROLL_CUE_SIZE = 32;
|
|
@@ -123,7 +125,7 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
|
|
|
123
125
|
ref: stripRef,
|
|
124
126
|
onKeyDown: handleKeyDown,
|
|
125
127
|
onScroll: measureOverflow,
|
|
126
|
-
className: cn("relative flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-
|
|
128
|
+
className: cn("relative flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-border bg-[var(--well)] p-0.5", variant === "tabs" && "flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", className),
|
|
127
129
|
children: [
|
|
128
130
|
overflow.start && /* @__PURE__ */ jsx(ScrollCue, {
|
|
129
131
|
side: "start",
|
|
@@ -146,10 +148,10 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
|
|
|
146
148
|
if (!active) onValueChange(option.value);
|
|
147
149
|
},
|
|
148
150
|
className: cn("relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]", variant === "row" && [
|
|
149
|
-
"rounded-md border border-transparent px-3 py-1
|
|
151
|
+
"rounded-md border border-transparent px-3 py-1",
|
|
150
152
|
"focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)] focus-visible:outline-offset-0",
|
|
151
153
|
"hover:bg-[var(--ix-hover-bg)] active:bg-[var(--ix-sel-bg)]",
|
|
152
|
-
|
|
154
|
+
rowThumb
|
|
153
155
|
], variant === "tabs" && [
|
|
154
156
|
"shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2",
|
|
155
157
|
"pointer-coarse:min-h-11 pointer-coarse:min-w-11",
|
|
@@ -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-
|
|
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: [
|
|
@@ -76,7 +76,7 @@ function RunRowShell({ icon, title, description, descriptionMono = false, status
|
|
|
76
76
|
children: title
|
|
77
77
|
}),
|
|
78
78
|
description ? /* @__PURE__ */ jsx("span", {
|
|
79
|
-
className: cn("
|
|
79
|
+
className: cn("min-w-0 flex-1 truncate text-sm text-muted-foreground transition-colors group-hover:text-foreground", descriptionMono && "font-mono"),
|
|
80
80
|
children: description
|
|
81
81
|
}) : null,
|
|
82
82
|
/* @__PURE__ */ jsxs("span", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.46.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.
|
|
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",
|
|
@@ -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
|
+
});
|
package/src/files/file-tabs.tsx
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FileTabs
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
47
|
-
: "text-muted-foreground hover:bg-
|
|
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="
|
|
59
|
-
<span className="
|
|
60
|
-
|
|
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
|
|
68
|
-
|
|
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="
|
|
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-
|
|
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-
|
|
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> = {
|
|
@@ -191,15 +191,17 @@ describe("each primitive renders its interaction helper", () => {
|
|
|
191
191
|
expect(classes(screen.getByText(/Guests, staff/))).toContain("line-clamp-2");
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
it("SegmentedControl row: a well track, soft items,
|
|
194
|
+
it("SegmentedControl row: a quiet well track, soft items, a raised thumb on aria-checked and no ring", () => {
|
|
195
195
|
const options = [{ value: "a", label: "A–Z" }, { value: "b", label: "Recent" }];
|
|
196
196
|
render(<SegmentedControl aria-label="Sort" value="a" onValueChange={() => {}} options={options} />);
|
|
197
197
|
const track = screen.getByRole("radiogroup");
|
|
198
198
|
expect(classes(track)).toContain("bg-[var(--well)]");
|
|
199
|
-
expect(classes(track)).toContain("border-
|
|
199
|
+
expect(classes(track)).toContain("border-border");
|
|
200
200
|
expect(classes(track)).not.toContain("bg-card");
|
|
201
201
|
for (const radio of screen.getAllByRole("radio")) {
|
|
202
|
-
expectHelpers(radio, ixControlSoft
|
|
202
|
+
expectHelpers(radio, ixControlSoft);
|
|
203
|
+
expect(classes(radio)).toContain("aria-checked:bg-card");
|
|
204
|
+
expect([...classes(radio)].some((token) => token.includes("--ix-sel-ring"))).toBe(false);
|
|
203
205
|
for (const token of FOCUS_RING) expect(classes(radio)).toContain(token);
|
|
204
206
|
expect(classes(radio)).not.toContain("bg-[var(--accent-surface)]");
|
|
205
207
|
}
|
|
@@ -271,9 +271,10 @@ describe("SegmentedControl", () => {
|
|
|
271
271
|
const radiogroup = screen.getByRole("radiogroup")
|
|
272
272
|
expect(radiogroup.className).toContain("flex-wrap")
|
|
273
273
|
expect(radiogroup.className).toContain("rounded-lg")
|
|
274
|
-
// The row track is
|
|
274
|
+
// The row track is one quiet well: the hairline border, not a field's strong border.
|
|
275
275
|
expect(radiogroup.className).toContain("bg-[var(--well)]")
|
|
276
|
-
expect(radiogroup.className).toContain("border-
|
|
276
|
+
expect(radiogroup.className).toContain("border-border")
|
|
277
|
+
expect(radiogroup.className).not.toContain("border-[var(--field-border)]")
|
|
277
278
|
})
|
|
278
279
|
|
|
279
280
|
it("applies tabs variant classes with nowrap", () => {
|
|
@@ -6,9 +6,13 @@ import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
|
6
6
|
import * as React from "react"
|
|
7
7
|
import { touchTarget } from "../lib/control-presentation"
|
|
8
8
|
import { focusRing, focusRingInset } from "../lib/focus"
|
|
9
|
-
import { ixControlSoft,
|
|
9
|
+
import { ixControlSoft, ixTabUnderline } from "../lib/interaction"
|
|
10
10
|
import { cn } from "../lib/utils"
|
|
11
11
|
|
|
12
|
+
/** The row variant's selected segment: a raised thumb on the well, not a tint inside a ring. */
|
|
13
|
+
const rowThumb =
|
|
14
|
+
"aria-checked:bg-card aria-checked:border-border aria-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08)] hover:aria-checked:bg-card"
|
|
15
|
+
|
|
12
16
|
/**
|
|
13
17
|
* Visually lightweight segmented control for single-value selection.
|
|
14
18
|
*
|
|
@@ -21,10 +25,13 @@ import { cn } from "../lib/utils"
|
|
|
21
25
|
* console warning fires when both are omitted.
|
|
22
26
|
*
|
|
23
27
|
* Design rules baked into the default variant:
|
|
24
|
-
* - The row track is
|
|
25
|
-
* control reads as one inset group on whatever plane it sits on
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* - The row track is one quiet well (`--well`, the hairline `border`), so
|
|
29
|
+
* the control reads as one inset group on whatever plane it sits on, and
|
|
30
|
+
* it is compact (36px) so it sits inside a 44px toolbar with air above
|
|
31
|
+
* and below instead of touching its edges.
|
|
32
|
+
* - Only the SELECTED segment shows a surface: a raised thumb (the card
|
|
33
|
+
* colour with a hairline and a soft shadow) with accent text. No ring is
|
|
34
|
+
* drawn around the track's segments, so the group never stacks rings. A tab
|
|
28
35
|
* shows it as an underline instead (`ixTabUnderline`).
|
|
29
36
|
* - Unselected segments have NO background — they're plain-text labels
|
|
30
37
|
* that darken on hover. This keeps the selected segment as the
|
|
@@ -224,7 +231,7 @@ export function SegmentedControl<T extends string = string>({
|
|
|
224
231
|
className={cn(
|
|
225
232
|
"relative flex gap-1",
|
|
226
233
|
variant === "row" &&
|
|
227
|
-
"flex-wrap items-center rounded-lg border border-
|
|
234
|
+
"flex-wrap items-center rounded-lg border border-border bg-[var(--well)] p-0.5",
|
|
228
235
|
variant === "tabs" &&
|
|
229
236
|
"flex-nowrap items-end border-b border-border pb-0 overflow-x-auto",
|
|
230
237
|
className,
|
|
@@ -251,7 +258,7 @@ export function SegmentedControl<T extends string = string>({
|
|
|
251
258
|
}}
|
|
252
259
|
className={cn(
|
|
253
260
|
"relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]",
|
|
254
|
-
variant === "row" && ["rounded-md border border-transparent px-3 py-1
|
|
261
|
+
variant === "row" && ["rounded-md border border-transparent px-3 py-1", focusRing, ixControlSoft, rowThumb],
|
|
255
262
|
// The strip scrolls horizontally, which would clip an outer ring.
|
|
256
263
|
// The border-b-2 slot stays transparent: the underline is ixTabUnderline's inset shadow.
|
|
257
264
|
variant === "tabs" && ["shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2", touchTarget, focusRingInset, ixTabUnderline],
|
|
@@ -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-
|
|
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,
|
|
@@ -166,7 +166,7 @@ export function RunRowShell({
|
|
|
166
166
|
{description ? (
|
|
167
167
|
<span
|
|
168
168
|
className={cn(
|
|
169
|
-
"
|
|
169
|
+
"min-w-0 flex-1 truncate text-sm text-muted-foreground transition-colors group-hover:text-foreground",
|
|
170
170
|
descriptionMono && "font-mono",
|
|
171
171
|
)}
|
|
172
172
|
>
|
|
@@ -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-
|
|
8
|
-
.tangle-heading[data-variant="eyebrow"] { font-size: var(--font-size-
|
|
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-
|
|
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); }
|