@tangle-network/ui 11.2.2 → 11.2.4
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 +16 -0
- package/dist/chat.js +1 -1
- package/dist/{chunk-XZFJAF6G.js → chunk-HSMVDKL3.js} +4 -4
- package/dist/{chunk-OTZWQJ5W.js → chunk-IH2VRPGZ.js} +30 -14
- package/dist/index.js +2 -2
- package/dist/primitives.js +1 -1
- package/package.json +2 -2
- package/src/chat/chat-container.test.tsx +62 -0
- package/src/chat/chat-container.tsx +20 -8
- package/src/chat/thinking-indicator.stories.tsx +20 -0
- package/src/chat/thinking-indicator.tsx +5 -1
- package/src/primitives/faint-text.test.tsx +127 -0
- package/src/primitives/input.tsx +2 -2
- package/src/primitives/stat-card.tsx +5 -1
- package/src/primitives/terminal-display.tsx +1 -1
- package/src/stories/theme-showcase.stories.tsx +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.2.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
**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.
|
|
12
|
+
|
|
13
|
+
## 11.2.3
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 7eeed23: Show the animated thinking row immediately after a user submits a message, before the first assistant event arrives.
|
|
18
|
+
|
|
3
19
|
## 11.2.2
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/dist/chat.js
CHANGED
|
@@ -163,7 +163,7 @@ var Input = React2.forwardRef(
|
|
|
163
163
|
),
|
|
164
164
|
input,
|
|
165
165
|
error && /* @__PURE__ */ jsx2("p", { className: "text-[var(--surface-danger-text)] text-sm font-medium", children: error }),
|
|
166
|
-
hint && !error && /* @__PURE__ */ jsx2("p", { className: "text-
|
|
166
|
+
hint && !error && /* @__PURE__ */ jsx2("p", { className: "text-[var(--text-dim)] text-sm", children: hint })
|
|
167
167
|
] });
|
|
168
168
|
}
|
|
169
169
|
);
|
|
@@ -203,7 +203,7 @@ var Textarea = React2.forwardRef(
|
|
|
203
203
|
),
|
|
204
204
|
textarea,
|
|
205
205
|
error && /* @__PURE__ */ jsx2("p", { className: "text-[var(--surface-danger-text)] text-sm", children: error }),
|
|
206
|
-
hint && !error && /* @__PURE__ */ jsx2("p", { className: "text-
|
|
206
|
+
hint && !error && /* @__PURE__ */ jsx2("p", { className: "text-[var(--text-dim)] text-sm", children: hint })
|
|
207
207
|
] });
|
|
208
208
|
}
|
|
209
209
|
);
|
|
@@ -755,7 +755,7 @@ var StatCard = React10.forwardRef(
|
|
|
755
755
|
/* @__PURE__ */ jsxs9("div", { className: "space-y-1", children: [
|
|
756
756
|
/* @__PURE__ */ jsx11("p", { className: "text-muted-foreground text-sm", children: title }),
|
|
757
757
|
/* @__PURE__ */ jsx11("p", { className: "font-bold text-3xl tracking-tight", children: value }),
|
|
758
|
-
subtitle && /* @__PURE__ */ jsx11("p", { className: "text-
|
|
758
|
+
subtitle && /* @__PURE__ */ jsx11("p", { className: "text-[var(--text-dim)] text-xs", children: subtitle }),
|
|
759
759
|
trend && trendStatus && /* @__PURE__ */ jsxs9(
|
|
760
760
|
"div",
|
|
761
761
|
{
|
|
@@ -872,7 +872,7 @@ var TerminalLine = React11.forwardRef(
|
|
|
872
872
|
children: [
|
|
873
873
|
(type === "input" || type === "command") && /* @__PURE__ */ jsx12("span", { className: "shrink-0 select-none text-[var(--surface-success-text)]", children: prompt }),
|
|
874
874
|
type === "thinking" && /* @__PURE__ */ jsx12("span", { className: "shrink-0 select-none", children: "..." }),
|
|
875
|
-
timestamp && /* @__PURE__ */ jsxs10("span", { className: "shrink-0 select-none text-
|
|
875
|
+
timestamp && /* @__PURE__ */ jsxs10("span", { className: "shrink-0 select-none text-[var(--text-dim)]", children: [
|
|
876
876
|
"[",
|
|
877
877
|
timestamp,
|
|
878
878
|
"]"
|
|
@@ -121,17 +121,25 @@ function ThinkingIndicator({ className }) {
|
|
|
121
121
|
const interval = window.setInterval(() => setElapsed((current) => current + 1), 1e3);
|
|
122
122
|
return () => window.clearInterval(interval);
|
|
123
123
|
}, []);
|
|
124
|
-
return /* @__PURE__ */ jsxs2(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
124
|
+
return /* @__PURE__ */ jsxs2(
|
|
125
|
+
"div",
|
|
126
|
+
{
|
|
127
|
+
role: "status",
|
|
128
|
+
"aria-label": "Agent is thinking",
|
|
129
|
+
className: cn("flex items-center gap-2 px-3 py-1.5", className),
|
|
130
|
+
children: [
|
|
131
|
+
/* @__PURE__ */ jsxs2("div", { className: "flex gap-[3px]", children: [
|
|
132
|
+
/* @__PURE__ */ jsx3("span", { className: "h-[var(--indicator-dot-size)] w-[var(--indicator-dot-size)] animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "0ms" } }),
|
|
133
|
+
/* @__PURE__ */ jsx3("span", { className: "h-[var(--indicator-dot-size)] w-[var(--indicator-dot-size)] animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "150ms" } }),
|
|
134
|
+
/* @__PURE__ */ jsx3("span", { className: "h-[var(--indicator-dot-size)] w-[var(--indicator-dot-size)] animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "300ms" } })
|
|
135
|
+
] }),
|
|
136
|
+
elapsed > 3 && /* @__PURE__ */ jsxs2("span", { className: "text-[var(--font-size-xs)] tabular-nums text-muted-foreground", children: [
|
|
137
|
+
elapsed,
|
|
138
|
+
"s"
|
|
139
|
+
] })
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
);
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
// src/chat/agent-timeline.tsx
|
|
@@ -439,7 +447,15 @@ function mapToolPartToTimelineType(part) {
|
|
|
439
447
|
}
|
|
440
448
|
function buildTimelineItems(messages, partMap, isStreaming, onOpenUIAction, enableOpenUI = true) {
|
|
441
449
|
const items = [];
|
|
442
|
-
|
|
450
|
+
let latestUserIndex = -1;
|
|
451
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
452
|
+
if (messages[index].role === "user") {
|
|
453
|
+
latestUserIndex = index;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const responseMessages = latestUserIndex >= 0 ? messages.slice(latestUserIndex + 1) : messages;
|
|
458
|
+
const lastAssistantMessage = [...responseMessages].reverse().find((message) => message.role === "assistant");
|
|
443
459
|
const toToolCall = (part) => {
|
|
444
460
|
const meta = getToolDisplayMetadata(part);
|
|
445
461
|
const start = part.state.time?.start;
|
|
@@ -566,9 +582,9 @@ function buildTimelineItems(messages, partMap, isStreaming, onOpenUIAction, enab
|
|
|
566
582
|
});
|
|
567
583
|
flushToolBuffer(parts.length);
|
|
568
584
|
}
|
|
569
|
-
const showThinking = isStreaming && lastAssistantMessage
|
|
585
|
+
const showThinking = isStreaming && latestUserIndex >= 0 && (lastAssistantMessage == null || !items.some(
|
|
570
586
|
(item) => item.kind === "message" && item.role === "assistant" && item.id.startsWith(lastAssistantMessage.id)
|
|
571
|
-
);
|
|
587
|
+
));
|
|
572
588
|
return { items, showThinking };
|
|
573
589
|
}
|
|
574
590
|
var ChatContainer = memo3(
|
package/dist/index.js
CHANGED
|
@@ -109,7 +109,7 @@ import {
|
|
|
109
109
|
UploadProgress,
|
|
110
110
|
useTheme,
|
|
111
111
|
useToast
|
|
112
|
-
} from "./chunk-
|
|
112
|
+
} from "./chunk-HSMVDKL3.js";
|
|
113
113
|
import {
|
|
114
114
|
Avatar,
|
|
115
115
|
AvatarFallback,
|
|
@@ -137,7 +137,7 @@ import {
|
|
|
137
137
|
MessageList,
|
|
138
138
|
ThinkingIndicator,
|
|
139
139
|
UserMessage
|
|
140
|
-
} from "./chunk-
|
|
140
|
+
} from "./chunk-IH2VRPGZ.js";
|
|
141
141
|
import {
|
|
142
142
|
useAutoScroll,
|
|
143
143
|
useRunCollapseState,
|
package/dist/primitives.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.2.
|
|
3
|
+
"version": "11.2.4",
|
|
4
4
|
"description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
"react-dom": "^18 || ^19",
|
|
142
142
|
"react-router": "^7 || ^8",
|
|
143
143
|
"yjs": "^13.6.0",
|
|
144
|
-
"@tangle-network/brand": "^1.
|
|
144
|
+
"@tangle-network/brand": "^1.3.0"
|
|
145
145
|
},
|
|
146
146
|
"peerDependenciesMeta": {
|
|
147
147
|
"@nanostores/react": {
|
|
@@ -27,6 +27,68 @@ const editPart: ToolPart = {
|
|
|
27
27
|
const partMap: Record<string, SessionPart[]> = { m1: [editPart] }
|
|
28
28
|
|
|
29
29
|
describe("ChatContainer timeline tool rendering", () => {
|
|
30
|
+
it("shows thinking as soon as a submitted user message is streaming", () => {
|
|
31
|
+
render(
|
|
32
|
+
<ChatContainer
|
|
33
|
+
messages={[{ id: "user-1", role: "user" }]}
|
|
34
|
+
partMap={{
|
|
35
|
+
"user-1": [{ type: "text", text: "hello" }],
|
|
36
|
+
}}
|
|
37
|
+
isStreaming
|
|
38
|
+
presentation="timeline"
|
|
39
|
+
/>,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
screen.getByRole("status", { name: "Agent is thinking" }),
|
|
44
|
+
).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("does not let an earlier assistant reply hide the next turn's thinking state", () => {
|
|
48
|
+
render(
|
|
49
|
+
<ChatContainer
|
|
50
|
+
messages={[
|
|
51
|
+
{ id: "user-1", role: "user" },
|
|
52
|
+
{ id: "assistant-1", role: "assistant" },
|
|
53
|
+
{ id: "user-2", role: "user" },
|
|
54
|
+
]}
|
|
55
|
+
partMap={{
|
|
56
|
+
"user-1": [{ type: "text", text: "first" }],
|
|
57
|
+
"assistant-1": [{ type: "text", text: "done" }],
|
|
58
|
+
"user-2": [{ type: "text", text: "second" }],
|
|
59
|
+
}}
|
|
60
|
+
isStreaming
|
|
61
|
+
presentation="timeline"
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(
|
|
66
|
+
screen.getByRole("status", { name: "Agent is thinking" }),
|
|
67
|
+
).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("replaces the generic thinking row when the current reply has text", () => {
|
|
71
|
+
render(
|
|
72
|
+
<ChatContainer
|
|
73
|
+
messages={[
|
|
74
|
+
{ id: "user-1", role: "user" },
|
|
75
|
+
{ id: "assistant-1", role: "assistant" },
|
|
76
|
+
]}
|
|
77
|
+
partMap={{
|
|
78
|
+
"user-1": [{ type: "text", text: "hello" }],
|
|
79
|
+
"assistant-1": [{ type: "text", text: "working on it" }],
|
|
80
|
+
}}
|
|
81
|
+
isStreaming
|
|
82
|
+
presentation="timeline"
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(
|
|
87
|
+
screen.queryByRole("status", { name: "Agent is thinking" }),
|
|
88
|
+
).not.toBeInTheDocument()
|
|
89
|
+
expect(screen.getByText("working on it")).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
|
|
30
92
|
it("shows a clean tool detail (the file path), not the raw input JSON", () => {
|
|
31
93
|
render(
|
|
32
94
|
<ChatContainer
|
|
@@ -179,7 +179,18 @@ function buildTimelineItems(
|
|
|
179
179
|
enableOpenUI = true,
|
|
180
180
|
): { items: AgentTimelineItem[]; showThinking: boolean } {
|
|
181
181
|
const items: AgentTimelineItem[] = [];
|
|
182
|
-
|
|
182
|
+
let latestUserIndex = -1;
|
|
183
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
184
|
+
if (messages[index].role === "user") {
|
|
185
|
+
latestUserIndex = index;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const responseMessages =
|
|
190
|
+
latestUserIndex >= 0 ? messages.slice(latestUserIndex + 1) : messages;
|
|
191
|
+
const lastAssistantMessage = [...responseMessages]
|
|
192
|
+
.reverse()
|
|
193
|
+
.find((message) => message.role === "assistant");
|
|
183
194
|
const toToolCall = (part: ToolPart) => {
|
|
184
195
|
const meta = getToolDisplayMetadata(part as ToolPart);
|
|
185
196
|
const start = part.state.time?.start;
|
|
@@ -346,13 +357,14 @@ function buildTimelineItems(
|
|
|
346
357
|
|
|
347
358
|
const showThinking =
|
|
348
359
|
isStreaming &&
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
(
|
|
352
|
-
item
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
360
|
+
latestUserIndex >= 0 &&
|
|
361
|
+
(lastAssistantMessage == null ||
|
|
362
|
+
!items.some(
|
|
363
|
+
(item) =>
|
|
364
|
+
item.kind === "message" &&
|
|
365
|
+
item.role === "assistant" &&
|
|
366
|
+
item.id.startsWith(lastAssistantMessage.id),
|
|
367
|
+
));
|
|
356
368
|
|
|
357
369
|
return { items, showThinking };
|
|
358
370
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { ChatContainer } from './chat-container'
|
|
2
3
|
import { ThinkingIndicator } from './thinking-indicator'
|
|
3
4
|
|
|
4
5
|
const meta: Meta<typeof ThinkingIndicator> = {
|
|
@@ -42,6 +43,25 @@ export const InContext: Story = {
|
|
|
42
43
|
),
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The production handoff state: the user's message is already visible while
|
|
48
|
+
* the first assistant event has not arrived yet.
|
|
49
|
+
*/
|
|
50
|
+
export const PendingChatResponse: Story = {
|
|
51
|
+
render: () => (
|
|
52
|
+
<div className="h-80 rounded-xl border border-border bg-[var(--bg-root)]">
|
|
53
|
+
<ChatContainer
|
|
54
|
+
messages={[{ id: 'user-1', role: 'user' }]}
|
|
55
|
+
partMap={{
|
|
56
|
+
'user-1': [{ type: 'text', text: 'Hello — can you inspect this sandbox?' }],
|
|
57
|
+
}}
|
|
58
|
+
isStreaming
|
|
59
|
+
presentation="timeline"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
/**
|
|
46
66
|
* Multiple indicators to show staggered animation timing.
|
|
47
67
|
*/
|
|
@@ -14,7 +14,11 @@ export function ThinkingIndicator({ className }: ThinkingIndicatorProps) {
|
|
|
14
14
|
}, []);
|
|
15
15
|
|
|
16
16
|
return (
|
|
17
|
-
<div
|
|
17
|
+
<div
|
|
18
|
+
role="status"
|
|
19
|
+
aria-label="Agent is thinking"
|
|
20
|
+
className={cn("flex items-center gap-2 px-3 py-1.5", className)}
|
|
21
|
+
>
|
|
18
22
|
<div className="flex gap-[3px]">
|
|
19
23
|
<span className="h-[var(--indicator-dot-size)] w-[var(--indicator-dot-size)] animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "0ms" }} />
|
|
20
24
|
<span className="h-[var(--indicator-dot-size)] w-[var(--indicator-dot-size)] animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "150ms" }} />
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { Input, Textarea } from "./input"
|
|
4
|
+
import { StatCard } from "./stat-card"
|
|
5
|
+
import { TerminalLine } from "./terminal-display"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The quietest line in each primitive — a field hint, a stat subtitle, a log
|
|
9
|
+
* timestamp — must take its colour from a TOKEN, never from a faded stronger
|
|
10
|
+
* one.
|
|
11
|
+
*
|
|
12
|
+
* A translucent foreground does not render the colour it names: it renders that
|
|
13
|
+
* colour composited over whatever plane is behind it. So `text-muted-foreground/70`
|
|
14
|
+
* measures one ratio on a card and a different one on the canvas, and the same
|
|
15
|
+
* markup is legible in one place and not in another. That is how these three
|
|
16
|
+
* landed at 3.65:1, 2.91:1 and 3.4:1 while looking, in the source, like they had
|
|
17
|
+
* been styled deliberately.
|
|
18
|
+
*
|
|
19
|
+
* These assert the RULE rather than the current class string — the point is that
|
|
20
|
+
* faint text carries no alpha, not that it carries one specific token today.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A class with its variant chain removed: `dark:hover:text-white/50` → `text-white/50`.
|
|
25
|
+
*
|
|
26
|
+
* Split on the colons that separate variants, which are the ones at bracket
|
|
27
|
+
* depth zero — an arbitrary value carries its own (`text-[color:var(--x)]`,
|
|
28
|
+
* `supports-[display:grid]:`) and splitting on those would mangle the utility.
|
|
29
|
+
*/
|
|
30
|
+
function bareUtility(cls: string): string {
|
|
31
|
+
let depth = 0
|
|
32
|
+
let start = 0
|
|
33
|
+
for (let i = 0; i < cls.length; i++) {
|
|
34
|
+
const c = cls[i]
|
|
35
|
+
if (c === "[") depth++
|
|
36
|
+
else if (c === "]") depth--
|
|
37
|
+
else if (c === ":" && depth === 0) start = i + 1
|
|
38
|
+
}
|
|
39
|
+
return cls.slice(start)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Colour utilities, where a trailing `/NN` is an alpha modifier rather than a fraction. */
|
|
43
|
+
const COLOUR = "text|bg|border|ring|outline|decoration|placeholder|caret|accent|fill|stroke|divide|from|via|to|shadow"
|
|
44
|
+
/** `text-white/70`, `bg-card/60`, `text-[var(--x)]/40`. */
|
|
45
|
+
const ALPHA_MODIFIER = new RegExp(`^(?:${COLOUR})-.+/\\d{1,3}$`)
|
|
46
|
+
/** `opacity-50` — the same fade by another route. */
|
|
47
|
+
const OPACITY_UTILITY = /^opacity-\d{1,3}$/
|
|
48
|
+
|
|
49
|
+
function expectSolidColour(el: HTMLElement | null, what: string) {
|
|
50
|
+
expect(el, `${what} should render`).not.toBeNull()
|
|
51
|
+
// Per class, not across the whole string: a variant prefix has no whitespace
|
|
52
|
+
// in it, so a single regex over the joined list never sees where one class
|
|
53
|
+
// ends — and `dark:text-white/50` slips past a pattern anchored on spaces.
|
|
54
|
+
const faded = (el?.className ?? "")
|
|
55
|
+
.split(/\s+/)
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.filter((cls) => {
|
|
58
|
+
const bare = bareUtility(cls)
|
|
59
|
+
return ALPHA_MODIFIER.test(bare) || OPACITY_UTILITY.test(bare)
|
|
60
|
+
})
|
|
61
|
+
expect(faded, `${what} must take a token, not a faded colour`).toEqual([])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("the fade detector itself", () => {
|
|
65
|
+
// The guard below is only worth as much as this table. An earlier version
|
|
66
|
+
// anchored on whitespace and so never saw a variant prefix — `dark:text-white/50`
|
|
67
|
+
// read as clean, which is the exact mistake the guard exists to catch.
|
|
68
|
+
const FADED = [
|
|
69
|
+
"text-muted-foreground/70",
|
|
70
|
+
"text-[var(--text-dim)]/70",
|
|
71
|
+
"dark:text-white/50",
|
|
72
|
+
"hover:text-muted-foreground/70",
|
|
73
|
+
"group-hover:text-foreground/40",
|
|
74
|
+
"md:bg-card/60",
|
|
75
|
+
"supports-[display:grid]:text-white/50",
|
|
76
|
+
"opacity-50",
|
|
77
|
+
"hover:opacity-50",
|
|
78
|
+
]
|
|
79
|
+
const SOLID = [
|
|
80
|
+
"text-[var(--text-dim)]",
|
|
81
|
+
"text-sm",
|
|
82
|
+
"text-muted-foreground",
|
|
83
|
+
"dark:text-white",
|
|
84
|
+
// A slash that is a FRACTION, not an alpha modifier — must not trip the guard.
|
|
85
|
+
"w-1/2",
|
|
86
|
+
"basis-1/3",
|
|
87
|
+
// A colon inside an arbitrary value, which must survive variant-stripping.
|
|
88
|
+
"text-[color:var(--text-dim)]",
|
|
89
|
+
]
|
|
90
|
+
const isFaded = (cls: string) =>
|
|
91
|
+
ALPHA_MODIFIER.test(bareUtility(cls)) || OPACITY_UTILITY.test(bareUtility(cls))
|
|
92
|
+
|
|
93
|
+
for (const cls of FADED) {
|
|
94
|
+
it(`catches ${cls}`, () => expect(isFaded(cls)).toBe(true))
|
|
95
|
+
}
|
|
96
|
+
for (const cls of SOLID) {
|
|
97
|
+
it(`allows ${cls}`, () => expect(isFaded(cls)).toBe(false))
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("faint text is a token, not a faded one", () => {
|
|
102
|
+
it("Input renders its hint at full strength", () => {
|
|
103
|
+
render(<Input label="Name" hint="Shown on your public profile" />)
|
|
104
|
+
expectSolidColour(
|
|
105
|
+
screen.getByText("Shown on your public profile"),
|
|
106
|
+
"the Input hint",
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("Textarea renders its hint at full strength", () => {
|
|
111
|
+
render(<Textarea label="Bio" hint="Markdown is supported" />)
|
|
112
|
+
expectSolidColour(
|
|
113
|
+
screen.getByText("Markdown is supported"),
|
|
114
|
+
"the Textarea hint",
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("StatCard renders its subtitle at full strength", () => {
|
|
119
|
+
render(<StatCard title="Sandboxes" value={12} subtitle="up from 9" />)
|
|
120
|
+
expectSolidColour(screen.getByText("up from 9"), "the StatCard subtitle")
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("TerminalLine renders its timestamp at full strength", () => {
|
|
124
|
+
render(<TerminalLine timestamp="12:04:11">compiling</TerminalLine>)
|
|
125
|
+
expectSolidColour(screen.getByText("[12:04:11]"), "the TerminalLine timestamp")
|
|
126
|
+
})
|
|
127
|
+
})
|
package/src/primitives/input.tsx
CHANGED
|
@@ -65,7 +65,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
65
65
|
{input}
|
|
66
66
|
{error && <p className="text-[var(--surface-danger-text)] text-sm font-medium">{error}</p>}
|
|
67
67
|
{hint && !error && (
|
|
68
|
-
<p className="text-
|
|
68
|
+
<p className="text-[var(--text-dim)] text-sm">{hint}</p>
|
|
69
69
|
)}
|
|
70
70
|
</div>
|
|
71
71
|
);
|
|
@@ -125,7 +125,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
125
125
|
{textarea}
|
|
126
126
|
{error && <p className="text-[var(--surface-danger-text)] text-sm">{error}</p>}
|
|
127
127
|
{hint && !error && (
|
|
128
|
-
<p className="text-
|
|
128
|
+
<p className="text-[var(--text-dim)] text-sm">{hint}</p>
|
|
129
129
|
)}
|
|
130
130
|
</div>
|
|
131
131
|
);
|
|
@@ -58,8 +58,12 @@ const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
|
|
|
58
58
|
<div className="space-y-1">
|
|
59
59
|
<p className="text-muted-foreground text-sm">{title}</p>
|
|
60
60
|
<p className="font-bold text-3xl tracking-tight">{value}</p>
|
|
61
|
+
{/* A token, never `opacity-70` over the muted tone: a translucent
|
|
62
|
+
foreground renders as the token COMPOSITED over whatever plane sits
|
|
63
|
+
behind it, so the same class lands a different colour — and a
|
|
64
|
+
different contrast ratio — on a card than on the canvas. */}
|
|
61
65
|
{subtitle && (
|
|
62
|
-
<p className="text-
|
|
66
|
+
<p className="text-[var(--text-dim)] text-xs">{subtitle}</p>
|
|
63
67
|
)}
|
|
64
68
|
{trend && trendStatus && (
|
|
65
69
|
<div
|
|
@@ -113,7 +113,7 @@ const TerminalLine = React.forwardRef<HTMLDivElement, TerminalLineProps>(
|
|
|
113
113
|
<span className="shrink-0 select-none">...</span>
|
|
114
114
|
)}
|
|
115
115
|
{timestamp && (
|
|
116
|
-
<span className="shrink-0 select-none text-
|
|
116
|
+
<span className="shrink-0 select-none text-[var(--text-dim)]">
|
|
117
117
|
[{timestamp}]
|
|
118
118
|
</span>
|
|
119
119
|
)}
|
|
@@ -11,6 +11,7 @@ type Story = StoryObj;
|
|
|
11
11
|
|
|
12
12
|
const THEMES: { name: string; theme?: string }[] = [
|
|
13
13
|
{ name: "Tangle (neutral)", theme: undefined }, // default :root dark
|
|
14
|
+
{ name: "Tangle · dark", theme: "tangle-dark" }, // the same spine, named
|
|
14
15
|
{ name: "Tangle · light", theme: "tangle-light" },
|
|
15
16
|
{ name: "Aubergine (bazaar)", theme: "aubergine" },
|
|
16
17
|
{ name: "Aubergine · light", theme: "aubergine-light" },
|