@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 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
@@ -5,7 +5,7 @@ import {
5
5
  MessageList,
6
6
  ThinkingIndicator,
7
7
  UserMessage
8
- } from "./chunk-OTZWQJ5W.js";
8
+ } from "./chunk-IH2VRPGZ.js";
9
9
  import "./chunk-AZWDI2JG.js";
10
10
  import "./chunk-SRAKCVPO.js";
11
11
  import "./chunk-4ORVXSMU.js";
@@ -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-muted-foreground/70 text-sm", children: hint })
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-muted-foreground/70 text-sm", children: hint })
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-muted-foreground opacity-70 text-xs", children: subtitle }),
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-muted-foreground opacity-50", children: [
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("div", { className: cn("flex items-center gap-2 px-3 py-1.5", className), children: [
125
- /* @__PURE__ */ jsxs2("div", { className: "flex gap-[3px]", children: [
126
- /* @__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" } }),
127
- /* @__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" } }),
128
- /* @__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" } })
129
- ] }),
130
- elapsed > 3 && /* @__PURE__ */ jsxs2("span", { className: "text-[var(--font-size-xs)] tabular-nums text-muted-foreground", children: [
131
- elapsed,
132
- "s"
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
- const lastAssistantMessage = [...messages].reverse().find((message) => message.role === "assistant");
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 != null && !items.some(
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-XZFJAF6G.js";
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-OTZWQJ5W.js";
140
+ } from "./chunk-IH2VRPGZ.js";
141
141
  import {
142
142
  useAutoScroll,
143
143
  useRunCollapseState,
@@ -44,7 +44,7 @@ import {
44
44
  UploadProgress,
45
45
  useTheme,
46
46
  useToast
47
- } from "./chunk-XZFJAF6G.js";
47
+ } from "./chunk-HSMVDKL3.js";
48
48
  import {
49
49
  Avatar,
50
50
  AvatarFallback,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.2.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.1.0"
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
- const lastAssistantMessage = [...messages].reverse().find((message) => message.role === "assistant");
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
- lastAssistantMessage != null &&
350
- !items.some(
351
- (item) =>
352
- item.kind === "message" &&
353
- item.role === "assistant" &&
354
- item.id.startsWith(lastAssistantMessage.id),
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 className={cn("flex items-center gap-2 px-3 py-1.5", className)}>
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
+ })
@@ -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-muted-foreground/70 text-sm">{hint}</p>
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-muted-foreground/70 text-sm">{hint}</p>
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-muted-foreground opacity-70 text-xs">{subtitle}</p>
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-muted-foreground opacity-50">
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" },