@protolabsai/ui 0.22.1 → 0.23.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.
@@ -372,6 +372,45 @@ a:hover {
372
372
  margin-top: var(--pl-space-2);
373
373
  }
374
374
 
375
+ /* ── count badge (round numeric pill) — shared by Tabs / TabBar / SurfaceRail.
376
+ Distinct from .pl-badge (a lowercase text/label pill). ── */
377
+ .pl-count {
378
+ display: inline-flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ min-width: 16px;
382
+ height: 16px;
383
+ padding: 0 5px;
384
+ font-family: var(--pl-font-mono);
385
+ font-size: 10px;
386
+ line-height: 1;
387
+ color: var(--pl-color-fg-muted);
388
+ background: var(--pl-color-bg-subtle);
389
+ border: var(--pl-border-width) solid var(--pl-color-border);
390
+ border-radius: 999px;
391
+ }
392
+
393
+ /* ── icon button (bare clickable glyph) — shared base for close / add / copy /
394
+ dialog + toast dismiss. Each use adds its own size/margin delta. ── */
395
+ .pl-iconbtn {
396
+ display: inline-flex;
397
+ align-items: center;
398
+ justify-content: center;
399
+ padding: 0;
400
+ color: var(--pl-color-fg-muted);
401
+ background: none;
402
+ border: none;
403
+ border-radius: var(--pl-radius);
404
+ cursor: pointer;
405
+ transition:
406
+ background var(--pl-motion-fast) var(--pl-motion-ease),
407
+ color var(--pl-motion-fast) var(--pl-motion-ease);
408
+ }
409
+ .pl-iconbtn:hover {
410
+ color: var(--pl-color-fg);
411
+ background: var(--pl-color-bg-hover);
412
+ }
413
+
375
414
  /* ── divider ── */
376
415
  .pl-divider {
377
416
  border: 0;
@@ -1137,23 +1176,8 @@ a.pl-changelog__version:hover {
1137
1176
  height: 15px;
1138
1177
  }
1139
1178
 
1140
- .pl-tab__badge {
1141
- display: inline-flex;
1142
- align-items: center;
1143
- justify-content: center;
1144
- min-width: 16px;
1145
- height: 16px;
1146
- padding: 0 5px;
1147
- font-family: var(--pl-font-mono);
1148
- font-size: 10px;
1149
- line-height: 1;
1150
- color: var(--pl-color-fg-muted);
1151
- background: var(--pl-color-bg-subtle);
1152
- border: var(--pl-border-width) solid var(--pl-color-border);
1153
- border-radius: 999px;
1154
- }
1155
-
1156
- .pl-tab--active .pl-tab__badge {
1179
+ /* count badge is the shared .pl-count (primitives.css) */
1180
+ .pl-tab--active .pl-count {
1157
1181
  color: var(--pl-color-fg);
1158
1182
  }
1159
1183
 
@@ -1420,76 +1444,24 @@ a.pl-changelog__version:hover {
1420
1444
  text-overflow: ellipsis;
1421
1445
  white-space: nowrap;
1422
1446
  }
1423
- .pl-tabbar__edit {
1424
- min-width: 60px;
1425
- max-width: 140px;
1426
- padding: 1px 4px;
1427
- font: inherit;
1428
- font-size: 13px;
1429
- color: var(--pl-color-fg);
1430
- background: var(--pl-color-bg-inset);
1431
- border: var(--pl-border-width) solid var(--pl-color-accent);
1432
- border-radius: calc(var(--pl-radius) - 2px);
1433
- outline: none;
1434
- }
1435
- .pl-tabbar__badge {
1436
- display: inline-flex;
1437
- align-items: center;
1438
- justify-content: center;
1439
- min-width: 16px;
1440
- height: 16px;
1441
- padding: 0 5px;
1442
- font-family: var(--pl-font-mono);
1443
- font-size: 10px;
1444
- line-height: 1;
1445
- color: var(--pl-color-fg-muted);
1446
- background: var(--pl-color-bg-subtle);
1447
- border: var(--pl-border-width) solid var(--pl-color-border);
1448
- border-radius: 999px;
1449
- }
1447
+ /* inline rename uses the shared EditableText control (.pl-editable__input) */
1448
+ /* count badge is the shared .pl-count (primitives.css) */
1449
+ /* close / add extend the shared .pl-iconbtn (primitives.css) — deltas only */
1450
1450
  .pl-tabbar__close {
1451
- display: inline-flex;
1452
- align-items: center;
1453
- justify-content: center;
1454
1451
  width: 18px;
1455
1452
  height: 18px;
1456
1453
  margin-right: -3px;
1457
- padding: 0;
1458
1454
  color: var(--pl-color-fg-subtle);
1459
- background: none;
1460
- border: none;
1461
- border-radius: var(--pl-radius);
1462
- cursor: pointer;
1463
1455
  opacity: 0.7;
1464
- transition:
1465
- background var(--pl-motion-fast) var(--pl-motion-ease),
1466
- color var(--pl-motion-fast) var(--pl-motion-ease),
1467
- opacity var(--pl-motion-fast) var(--pl-motion-ease);
1456
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
1468
1457
  }
1469
1458
  .pl-tabbar__close:hover {
1470
- color: var(--pl-color-fg);
1471
- background: var(--pl-color-bg-hover);
1472
1459
  opacity: 1;
1473
1460
  }
1474
1461
  .pl-tabbar__add {
1475
- display: inline-flex;
1476
- align-items: center;
1477
- justify-content: center;
1478
1462
  width: 28px;
1479
1463
  flex-shrink: 0;
1480
1464
  margin-left: 2px;
1481
- color: var(--pl-color-fg-muted);
1482
- background: none;
1483
- border: none;
1484
- cursor: pointer;
1485
- border-radius: var(--pl-radius);
1486
- transition:
1487
- background var(--pl-motion-fast) var(--pl-motion-ease),
1488
- color var(--pl-motion-fast) var(--pl-motion-ease);
1489
- }
1490
- .pl-tabbar__add:hover {
1491
- color: var(--pl-color-fg);
1492
- background: var(--pl-color-bg-hover);
1493
1465
  }
1494
1466
 
1495
1467
  /* ── ui component: forms.css ───────────────────────────────────────────────── */
@@ -1748,6 +1720,33 @@ a.pl-changelog__version:hover {
1748
1720
  color: var(--pl-color-fg);
1749
1721
  }
1750
1722
 
1723
+ /* ── EditableText (inline rename) ── */
1724
+ /* Both states are font:inherit so the label↔input flip is seamless in any context. */
1725
+ .pl-editable {
1726
+ cursor: text;
1727
+ border-radius: calc(var(--pl-radius) - 2px);
1728
+ }
1729
+ .pl-editable:hover {
1730
+ background: var(--pl-color-bg-hover);
1731
+ }
1732
+ .pl-editable:focus-visible {
1733
+ outline: 2px solid var(--pl-color-accent);
1734
+ outline-offset: 2px;
1735
+ }
1736
+ .pl-editable__input {
1737
+ min-width: 60px;
1738
+ padding: 1px 4px;
1739
+ font: inherit;
1740
+ color: var(--pl-color-fg);
1741
+ background: var(--pl-color-bg-inset);
1742
+ border: var(--pl-border-width) solid var(--pl-color-accent);
1743
+ border-radius: calc(var(--pl-radius) - 2px);
1744
+ outline: none;
1745
+ }
1746
+ .pl-editable__input--invalid {
1747
+ border-color: var(--pl-color-status-error);
1748
+ }
1749
+
1751
1750
  /* ── ui component: overlays.css ────────────────────────────────────────────── */
1752
1751
  /* @protolabsai/ui — overlays styles (over @protolabsai/design --pl-* tokens). */
1753
1752
 
@@ -1799,28 +1798,14 @@ a.pl-changelog__version:hover {
1799
1798
  color: var(--pl-color-fg);
1800
1799
  }
1801
1800
 
1801
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
1802
1802
  .pl-dialog__close {
1803
- display: inline-flex;
1804
- align-items: center;
1805
- justify-content: center;
1806
1803
  width: 24px;
1807
1804
  height: 24px;
1808
1805
  font-size: 18px;
1809
1806
  line-height: 1;
1810
- color: var(--pl-color-fg-muted);
1811
- background: transparent;
1812
- border: none;
1813
- border-radius: var(--pl-radius);
1814
- cursor: pointer;
1815
- transition:
1816
- color var(--pl-motion-fast) var(--pl-motion-ease),
1817
- background var(--pl-motion-fast) var(--pl-motion-ease);
1818
1807
  }
1819
1808
 
1820
- .pl-dialog__close:hover {
1821
- color: var(--pl-color-fg);
1822
- background: var(--pl-color-bg-hover);
1823
- }
1824
1809
 
1825
1810
  .pl-dialog__body {
1826
1811
  padding: var(--pl-space-4);
@@ -1979,19 +1964,13 @@ a.pl-changelog__version:hover {
1979
1964
  color: var(--pl-color-fg-muted);
1980
1965
  }
1981
1966
 
1967
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
1982
1968
  .pl-toast__close {
1983
1969
  flex-shrink: 0;
1984
1970
  font-size: 16px;
1985
1971
  line-height: 1;
1986
- color: var(--pl-color-fg-muted);
1987
- background: transparent;
1988
- border: none;
1989
- cursor: pointer;
1990
1972
  }
1991
1973
 
1992
- .pl-toast__close:hover {
1993
- color: var(--pl-color-fg);
1994
- }
1995
1974
 
1996
1975
  /* ── Tooltip (CSS-only) ──────────────────────────────────────────────────────── */
1997
1976
  .pl-tip-wrap {
@@ -2546,22 +2525,17 @@ a.pl-changelog__version:hover {
2546
2525
  white-space: nowrap;
2547
2526
  }
2548
2527
 
2549
- .pl-rail__badge {
2528
+ /* rail count badge = shared .pl-count (primitives.css) + absolute positioning */
2529
+ .pl-count--rail {
2550
2530
  position: absolute;
2551
2531
  top: 4px;
2552
2532
  right: 9px;
2553
- display: inline-flex;
2554
- align-items: center;
2555
- justify-content: center;
2556
2533
  min-width: 15px;
2557
2534
  height: 15px;
2558
2535
  padding: 0 4px;
2559
- font-family: var(--pl-font-mono);
2560
2536
  font-size: 9px;
2561
2537
  color: var(--pl-color-fg);
2562
- background: var(--pl-color-bg-subtle);
2563
- border: var(--pl-border-width) solid var(--pl-color-border-strong);
2564
- border-radius: 999px;
2538
+ border-color: var(--pl-color-border-strong);
2565
2539
  }
2566
2540
 
2567
2541
  .pl-rail__dot {
@@ -2984,13 +2958,9 @@ a.pl-changelog__version:hover {
2984
2958
  .pl-toolcard__status--running {
2985
2959
  color: var(--pl-color-status-warning);
2986
2960
  }
2961
+ /* reuses the shared `pl-spin` keyframe (data.css) */
2987
2962
  .pl-toolcard__spin {
2988
- animation: pl-toolcard-spin 0.8s linear infinite;
2989
- }
2990
- @keyframes pl-toolcard-spin {
2991
- to {
2992
- transform: rotate(360deg);
2993
- }
2963
+ animation: pl-spin 0.8s linear infinite;
2994
2964
  }
2995
2965
 
2996
2966
  .pl-toolcard__body {
@@ -3032,23 +3002,9 @@ a.pl-changelog__version:hover {
3032
3002
  letter-spacing: 0.04em;
3033
3003
  color: var(--pl-color-fg-subtle);
3034
3004
  }
3005
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
3035
3006
  .pl-toolcard__copy {
3036
- display: inline-flex;
3037
- align-items: center;
3038
- justify-content: center;
3039
3007
  padding: 2px;
3040
- background: none;
3041
- border: none;
3042
- border-radius: var(--pl-radius);
3043
- color: var(--pl-color-fg-muted);
3044
- cursor: pointer;
3045
- transition:
3046
- background var(--pl-motion-fast) var(--pl-motion-ease),
3047
- color var(--pl-motion-fast) var(--pl-motion-ease);
3048
- }
3049
- .pl-toolcard__copy:hover {
3050
- color: var(--pl-color-fg);
3051
- background: var(--pl-color-bg-hover);
3052
3008
  }
3053
3009
 
3054
3010
  /* ── ui component: theming.css ─────────────────────────────────────────────── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { EditableText } from "./forms";
4
+
5
+ const meta: Meta = { title: "Components/Forms/EditableText" };
6
+ export default meta;
7
+ type Story = StoryObj;
8
+
9
+ /** Uncontrolled: double-click (or focus + Enter) to edit; Enter commits, Esc cancels.
10
+ * The input is `font: inherit`, so it flips in place seamlessly. */
11
+ export const Inline: Story = {
12
+ render: () => {
13
+ function Demo() {
14
+ const [name, setName] = useState("untitled-agent");
15
+ return (
16
+ <div style={{ fontSize: 16, fontWeight: 600, color: "var(--pl-color-fg)" }}>
17
+ <EditableText value={name} onCommit={setName} aria-label="Agent name" />
18
+ <p style={{ fontFamily: "var(--pl-font-mono)", fontSize: 12, color: "var(--pl-color-fg-muted)", fontWeight: 400 }}>
19
+ value: {name} · double-click to rename
20
+ </p>
21
+ </div>
22
+ );
23
+ }
24
+ return <Demo />;
25
+ },
26
+ };
27
+
28
+ /** With inline validation — slugs only; commit is blocked while invalid (red border). */
29
+ export const Validated: Story = {
30
+ render: () => {
31
+ function Demo() {
32
+ const [slug, setSlug] = useState("my-project");
33
+ return (
34
+ <div style={{ fontFamily: "var(--pl-font-mono)", fontSize: 14, color: "var(--pl-color-fg)" }}>
35
+ <EditableText value={slug} onCommit={setSlug} validate={(v) => /^[a-z0-9-]+$/.test(v)} aria-label="Slug" />
36
+ </div>
37
+ );
38
+ }
39
+ return <Demo />;
40
+ },
41
+ };
package/src/app-shell.tsx CHANGED
@@ -50,7 +50,7 @@ function RailItemInner({ item }: { item: RailItem }) {
50
50
  </span>
51
51
  <span className="pl-rail__label">{item.label}</span>
52
52
  {item.badge ? (
53
- <span className="pl-rail__badge">{item.badge > 9 ? "9+" : item.badge}</span>
53
+ <span className="pl-count pl-count--rail">{item.badge > 9 ? "9+" : item.badge}</span>
54
54
  ) : item.dot ? (
55
55
  <span className="pl-rail__dot" aria-label="active" />
56
56
  ) : null}
package/src/forms.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
2
- import { createContext, useContext } from "react";
2
+ import { createContext, useContext, useEffect, useState } from "react";
3
3
  import { cx } from "./internal";
4
4
 
5
5
  /** A labeled input/textarea bound to a string value (form fields, editors). */
@@ -171,3 +171,105 @@ export function Radio({
171
171
  </label>
172
172
  );
173
173
  }
174
+
175
+ /** Inline rename: a text label that flips to an input in place — Enter commits,
176
+ * Esc cancels, blur commits (or cancels via `commitOnBlur={false}`). Uncontrolled
177
+ * by default (double-click / Enter to edit); pass `editing` + `onEditingChange` to
178
+ * drive it from a host (e.g. a paired trigger). The input is `font: inherit`, so the
179
+ * flip is visually seamless. The canonical inline-edit control — TabBar renders this. */
180
+ export function EditableText({
181
+ value,
182
+ onCommit,
183
+ onCancel,
184
+ validate,
185
+ commitOnBlur = true,
186
+ editing: editingProp,
187
+ onEditingChange,
188
+ placeholder,
189
+ className,
190
+ inputClassName,
191
+ "aria-label": ariaLabel,
192
+ }: {
193
+ value: string;
194
+ onCommit: (next: string) => void;
195
+ onCancel?: () => void;
196
+ /** Optional inline validity — a falsy result blocks commit (stays in edit). */
197
+ validate?: (v: string) => boolean;
198
+ /** Blur commits (default) or cancels. */
199
+ commitOnBlur?: boolean;
200
+ /** Controlled edit state. Omit for uncontrolled (double-click to edit). */
201
+ editing?: boolean;
202
+ onEditingChange?: (editing: boolean) => void;
203
+ placeholder?: string;
204
+ className?: string;
205
+ inputClassName?: string;
206
+ "aria-label"?: string;
207
+ }) {
208
+ const [uncontrolled, setUncontrolled] = useState(false);
209
+ const editing = editingProp ?? uncontrolled;
210
+ const setEditing = (v: boolean) => {
211
+ if (editingProp === undefined) setUncontrolled(v);
212
+ onEditingChange?.(v);
213
+ };
214
+ const [draft, setDraft] = useState(value);
215
+ // Reset the draft to the live value each time editing (re)starts.
216
+ useEffect(() => {
217
+ if (editing) setDraft(value);
218
+ // eslint-disable-next-line react-hooks/exhaustive-deps
219
+ }, [editing]);
220
+
221
+ const invalid = validate ? !validate(draft.trim()) : false;
222
+ const commit = () => {
223
+ const next = draft.trim();
224
+ if (validate && !validate(next)) return; // stay in edit while invalid
225
+ if (next && next !== value) onCommit(next);
226
+ setEditing(false);
227
+ };
228
+ const cancel = () => {
229
+ setDraft(value);
230
+ onCancel?.();
231
+ setEditing(false);
232
+ };
233
+
234
+ if (!editing) {
235
+ return (
236
+ <span
237
+ className={cx("pl-editable", className)}
238
+ tabIndex={0}
239
+ role="button"
240
+ aria-label={ariaLabel}
241
+ onDoubleClick={() => setEditing(true)}
242
+ onKeyDown={(e) => {
243
+ if (e.key === "Enter" || e.key === "F2") {
244
+ e.preventDefault();
245
+ setEditing(true);
246
+ }
247
+ }}
248
+ >
249
+ {value || placeholder}
250
+ </span>
251
+ );
252
+ }
253
+ return (
254
+ <input
255
+ className={cx("pl-editable__input", invalid && "pl-editable__input--invalid", inputClassName)}
256
+ autoFocus
257
+ value={draft}
258
+ placeholder={placeholder}
259
+ aria-label={ariaLabel}
260
+ aria-invalid={invalid || undefined}
261
+ onChange={(e) => setDraft(e.target.value)}
262
+ onClick={(e) => e.stopPropagation()}
263
+ onBlur={() => (commitOnBlur ? commit() : cancel())}
264
+ onKeyDown={(e) => {
265
+ if (e.key === "Enter") {
266
+ e.preventDefault();
267
+ commit();
268
+ } else if (e.key === "Escape") {
269
+ e.preventDefault();
270
+ cancel();
271
+ }
272
+ }}
273
+ />
274
+ );
275
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
2
2
  import { useId, useState } from "react";
3
3
  import { cx } from "./internal";
4
+ import { EditableText } from "./forms";
4
5
 
5
6
  export type TabItem = {
6
7
  id: string;
@@ -50,7 +51,7 @@ export function Tabs({
50
51
  </span>
51
52
  )}
52
53
  <span className="pl-tab__label">{t.label}</span>
53
- {t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
54
+ {t.badge != null && <span className="pl-count">{t.badge}</span>}
54
55
  {t.locked ? (
55
56
  <span className="pl-tab__lock" aria-hidden>
56
57
  🔒
@@ -111,20 +112,8 @@ export function TabBar({
111
112
  addLabel?: string;
112
113
  ariaLabel?: string;
113
114
  }) {
114
- const [editing, setEditing] = useState<string | null>(null);
115
- const [draft, setDraft] = useState("");
116
- const startEdit = (t: TabBarItem) => {
117
- if (!onRename) return;
118
- setEditing(t.id);
119
- setDraft(t.label);
120
- };
121
- const commit = () => {
122
- if (editing != null && onRename) {
123
- const v = draft.trim();
124
- if (v) onRename(editing, v);
125
- }
126
- setEditing(null);
127
- };
115
+ // Which tab is mid-rename; the inline editor itself is the shared EditableText.
116
+ const [editingId, setEditingId] = useState<string | null>(null);
128
117
  return (
129
118
  <div className="pl-tabbar" role="tablist" aria-label={ariaLabel}>
130
119
  {items.map((t) => (
@@ -134,10 +123,10 @@ export function TabBar({
134
123
  tabIndex={0}
135
124
  aria-selected={t.id === activeId}
136
125
  className={cx("pl-tabbar__tab", t.id === activeId && "pl-tabbar__tab--active")}
137
- onClick={() => editing !== t.id && onSelect(t.id)}
138
- onDoubleClick={() => startEdit(t)}
126
+ onClick={() => editingId !== t.id && onSelect(t.id)}
127
+ onDoubleClick={() => onRename && setEditingId(t.id)}
139
128
  onKeyDown={(e) => {
140
- if (editing === t.id) return;
129
+ if (editingId === t.id) return;
141
130
  if (e.key === "Enter" || e.key === " ") {
142
131
  e.preventDefault();
143
132
  onSelect(t.id);
@@ -149,32 +138,24 @@ export function TabBar({
149
138
  {t.icon}
150
139
  </span>
151
140
  )}
152
- {editing === t.id ? (
153
- <input
154
- className="pl-tabbar__edit"
155
- autoFocus
156
- value={draft}
141
+ {editingId === t.id ? (
142
+ <EditableText
143
+ editing
144
+ value={t.label}
157
145
  aria-label="Rename tab"
158
- onChange={(e) => setDraft(e.target.value)}
159
- onClick={(e) => e.stopPropagation()}
160
- onBlur={commit}
161
- onKeyDown={(e) => {
162
- if (e.key === "Enter") {
163
- e.preventDefault();
164
- commit();
165
- } else if (e.key === "Escape") {
166
- setEditing(null);
167
- }
146
+ onCommit={(v) => onRename?.(t.id, v)}
147
+ onEditingChange={(e) => {
148
+ if (!e) setEditingId(null);
168
149
  }}
169
150
  />
170
151
  ) : (
171
152
  <span className="pl-tabbar__label">{t.label}</span>
172
153
  )}
173
- {t.badge != null && <span className="pl-tabbar__badge">{t.badge}</span>}
174
- {onClose && editing !== t.id && (
154
+ {t.badge != null && <span className="pl-count">{t.badge}</span>}
155
+ {onClose && editingId !== t.id && (
175
156
  <button
176
157
  type="button"
177
- className="pl-tabbar__close"
158
+ className="pl-iconbtn pl-tabbar__close"
178
159
  aria-label={`Close ${t.label}`}
179
160
  onClick={(e) => {
180
161
  e.stopPropagation();
@@ -189,7 +170,7 @@ export function TabBar({
189
170
  </div>
190
171
  ))}
191
172
  {onAdd && (
192
- <button type="button" className="pl-tabbar__add" aria-label={addLabel} title={addLabel} onClick={onAdd}>
173
+ <button type="button" className="pl-iconbtn pl-tabbar__add" aria-label={addLabel} title={addLabel} onClick={onAdd}>
193
174
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
194
175
  <path d="M12 5v14M5 12h14" />
195
176
  </svg>
package/src/overlays.tsx CHANGED
@@ -100,7 +100,7 @@ export function Dialog({
100
100
  {title}
101
101
  </div>
102
102
  {onClose && (
103
- <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
103
+ <button type="button" className="pl-iconbtn pl-dialog__close" aria-label="Close" onClick={onClose}>
104
104
  ×
105
105
  </button>
106
106
  )}
@@ -200,7 +200,7 @@ export function Drawer({
200
200
  {title}
201
201
  </div>
202
202
  {onClose && (
203
- <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
203
+ <button type="button" className="pl-iconbtn pl-dialog__close" aria-label="Close" onClick={onClose}>
204
204
  ×
205
205
  </button>
206
206
  )}
@@ -265,7 +265,7 @@ function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: str
265
265
  {toast.title != null && <div className="pl-toast__title">{toast.title}</div>}
266
266
  <div className="pl-toast__msg">{toast.message}</div>
267
267
  </div>
268
- <button type="button" className="pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
268
+ <button type="button" className="pl-iconbtn pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
269
269
  ×
270
270
  </button>
271
271
  </div>
@@ -65,22 +65,17 @@
65
65
  white-space: nowrap;
66
66
  }
67
67
 
68
- .pl-rail__badge {
68
+ /* rail count badge = shared .pl-count (primitives.css) + absolute positioning */
69
+ .pl-count--rail {
69
70
  position: absolute;
70
71
  top: 4px;
71
72
  right: 9px;
72
- display: inline-flex;
73
- align-items: center;
74
- justify-content: center;
75
73
  min-width: 15px;
76
74
  height: 15px;
77
75
  padding: 0 4px;
78
- font-family: var(--pl-font-mono);
79
76
  font-size: 9px;
80
77
  color: var(--pl-color-fg);
81
- background: var(--pl-color-bg-subtle);
82
- border: var(--pl-border-width) solid var(--pl-color-border-strong);
83
- border-radius: 999px;
78
+ border-color: var(--pl-color-border-strong);
84
79
  }
85
80
 
86
81
  .pl-rail__dot {
@@ -252,3 +252,30 @@
252
252
  font-size: 13px;
253
253
  color: var(--pl-color-fg);
254
254
  }
255
+
256
+ /* ── EditableText (inline rename) ── */
257
+ /* Both states are font:inherit so the label↔input flip is seamless in any context. */
258
+ .pl-editable {
259
+ cursor: text;
260
+ border-radius: calc(var(--pl-radius) - 2px);
261
+ }
262
+ .pl-editable:hover {
263
+ background: var(--pl-color-bg-hover);
264
+ }
265
+ .pl-editable:focus-visible {
266
+ outline: 2px solid var(--pl-color-accent);
267
+ outline-offset: 2px;
268
+ }
269
+ .pl-editable__input {
270
+ min-width: 60px;
271
+ padding: 1px 4px;
272
+ font: inherit;
273
+ color: var(--pl-color-fg);
274
+ background: var(--pl-color-bg-inset);
275
+ border: var(--pl-border-width) solid var(--pl-color-accent);
276
+ border-radius: calc(var(--pl-radius) - 2px);
277
+ outline: none;
278
+ }
279
+ .pl-editable__input--invalid {
280
+ border-color: var(--pl-color-status-error);
281
+ }
@@ -32,23 +32,8 @@
32
32
  height: 15px;
33
33
  }
34
34
 
35
- .pl-tab__badge {
36
- display: inline-flex;
37
- align-items: center;
38
- justify-content: center;
39
- min-width: 16px;
40
- height: 16px;
41
- padding: 0 5px;
42
- font-family: var(--pl-font-mono);
43
- font-size: 10px;
44
- line-height: 1;
45
- color: var(--pl-color-fg-muted);
46
- background: var(--pl-color-bg-subtle);
47
- border: var(--pl-border-width) solid var(--pl-color-border);
48
- border-radius: 999px;
49
- }
50
-
51
- .pl-tab--active .pl-tab__badge {
35
+ /* count badge is the shared .pl-count (primitives.css) */
36
+ .pl-tab--active .pl-count {
52
37
  color: var(--pl-color-fg);
53
38
  }
54
39
 
@@ -315,74 +300,22 @@
315
300
  text-overflow: ellipsis;
316
301
  white-space: nowrap;
317
302
  }
318
- .pl-tabbar__edit {
319
- min-width: 60px;
320
- max-width: 140px;
321
- padding: 1px 4px;
322
- font: inherit;
323
- font-size: 13px;
324
- color: var(--pl-color-fg);
325
- background: var(--pl-color-bg-inset);
326
- border: var(--pl-border-width) solid var(--pl-color-accent);
327
- border-radius: calc(var(--pl-radius) - 2px);
328
- outline: none;
329
- }
330
- .pl-tabbar__badge {
331
- display: inline-flex;
332
- align-items: center;
333
- justify-content: center;
334
- min-width: 16px;
335
- height: 16px;
336
- padding: 0 5px;
337
- font-family: var(--pl-font-mono);
338
- font-size: 10px;
339
- line-height: 1;
340
- color: var(--pl-color-fg-muted);
341
- background: var(--pl-color-bg-subtle);
342
- border: var(--pl-border-width) solid var(--pl-color-border);
343
- border-radius: 999px;
344
- }
303
+ /* inline rename uses the shared EditableText control (.pl-editable__input) */
304
+ /* count badge is the shared .pl-count (primitives.css) */
305
+ /* close / add extend the shared .pl-iconbtn (primitives.css) — deltas only */
345
306
  .pl-tabbar__close {
346
- display: inline-flex;
347
- align-items: center;
348
- justify-content: center;
349
307
  width: 18px;
350
308
  height: 18px;
351
309
  margin-right: -3px;
352
- padding: 0;
353
310
  color: var(--pl-color-fg-subtle);
354
- background: none;
355
- border: none;
356
- border-radius: var(--pl-radius);
357
- cursor: pointer;
358
311
  opacity: 0.7;
359
- transition:
360
- background var(--pl-motion-fast) var(--pl-motion-ease),
361
- color var(--pl-motion-fast) var(--pl-motion-ease),
362
- opacity var(--pl-motion-fast) var(--pl-motion-ease);
312
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
363
313
  }
364
314
  .pl-tabbar__close:hover {
365
- color: var(--pl-color-fg);
366
- background: var(--pl-color-bg-hover);
367
315
  opacity: 1;
368
316
  }
369
317
  .pl-tabbar__add {
370
- display: inline-flex;
371
- align-items: center;
372
- justify-content: center;
373
318
  width: 28px;
374
319
  flex-shrink: 0;
375
320
  margin-left: 2px;
376
- color: var(--pl-color-fg-muted);
377
- background: none;
378
- border: none;
379
- cursor: pointer;
380
- border-radius: var(--pl-radius);
381
- transition:
382
- background var(--pl-motion-fast) var(--pl-motion-ease),
383
- color var(--pl-motion-fast) var(--pl-motion-ease);
384
- }
385
- .pl-tabbar__add:hover {
386
- color: var(--pl-color-fg);
387
- background: var(--pl-color-bg-hover);
388
321
  }
@@ -48,28 +48,14 @@
48
48
  color: var(--pl-color-fg);
49
49
  }
50
50
 
51
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
51
52
  .pl-dialog__close {
52
- display: inline-flex;
53
- align-items: center;
54
- justify-content: center;
55
53
  width: 24px;
56
54
  height: 24px;
57
55
  font-size: 18px;
58
56
  line-height: 1;
59
- color: var(--pl-color-fg-muted);
60
- background: transparent;
61
- border: none;
62
- border-radius: var(--pl-radius);
63
- cursor: pointer;
64
- transition:
65
- color var(--pl-motion-fast) var(--pl-motion-ease),
66
- background var(--pl-motion-fast) var(--pl-motion-ease);
67
57
  }
68
58
 
69
- .pl-dialog__close:hover {
70
- color: var(--pl-color-fg);
71
- background: var(--pl-color-bg-hover);
72
- }
73
59
 
74
60
  .pl-dialog__body {
75
61
  padding: var(--pl-space-4);
@@ -228,19 +214,13 @@
228
214
  color: var(--pl-color-fg-muted);
229
215
  }
230
216
 
217
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
231
218
  .pl-toast__close {
232
219
  flex-shrink: 0;
233
220
  font-size: 16px;
234
221
  line-height: 1;
235
- color: var(--pl-color-fg-muted);
236
- background: transparent;
237
- border: none;
238
- cursor: pointer;
239
222
  }
240
223
 
241
- .pl-toast__close:hover {
242
- color: var(--pl-color-fg);
243
- }
244
224
 
245
225
  /* ── Tooltip (CSS-only) ──────────────────────────────────────────────────────── */
246
226
  .pl-tip-wrap {
@@ -135,6 +135,45 @@
135
135
  margin-top: var(--pl-space-2);
136
136
  }
137
137
 
138
+ /* ── count badge (round numeric pill) — shared by Tabs / TabBar / SurfaceRail.
139
+ Distinct from .pl-badge (a lowercase text/label pill). ── */
140
+ .pl-count {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ min-width: 16px;
145
+ height: 16px;
146
+ padding: 0 5px;
147
+ font-family: var(--pl-font-mono);
148
+ font-size: 10px;
149
+ line-height: 1;
150
+ color: var(--pl-color-fg-muted);
151
+ background: var(--pl-color-bg-subtle);
152
+ border: var(--pl-border-width) solid var(--pl-color-border);
153
+ border-radius: 999px;
154
+ }
155
+
156
+ /* ── icon button (bare clickable glyph) — shared base for close / add / copy /
157
+ dialog + toast dismiss. Each use adds its own size/margin delta. ── */
158
+ .pl-iconbtn {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ padding: 0;
163
+ color: var(--pl-color-fg-muted);
164
+ background: none;
165
+ border: none;
166
+ border-radius: var(--pl-radius);
167
+ cursor: pointer;
168
+ transition:
169
+ background var(--pl-motion-fast) var(--pl-motion-ease),
170
+ color var(--pl-motion-fast) var(--pl-motion-ease);
171
+ }
172
+ .pl-iconbtn:hover {
173
+ color: var(--pl-color-fg);
174
+ background: var(--pl-color-bg-hover);
175
+ }
176
+
138
177
  /* ── divider ── */
139
178
  .pl-divider {
140
179
  border: 0;
@@ -112,13 +112,9 @@
112
112
  .pl-toolcard__status--running {
113
113
  color: var(--pl-color-status-warning);
114
114
  }
115
+ /* reuses the shared `pl-spin` keyframe (data.css) */
115
116
  .pl-toolcard__spin {
116
- animation: pl-toolcard-spin 0.8s linear infinite;
117
- }
118
- @keyframes pl-toolcard-spin {
119
- to {
120
- transform: rotate(360deg);
121
- }
117
+ animation: pl-spin 0.8s linear infinite;
122
118
  }
123
119
 
124
120
  .pl-toolcard__body {
@@ -160,21 +156,7 @@
160
156
  letter-spacing: 0.04em;
161
157
  color: var(--pl-color-fg-subtle);
162
158
  }
159
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
163
160
  .pl-toolcard__copy {
164
- display: inline-flex;
165
- align-items: center;
166
- justify-content: center;
167
161
  padding: 2px;
168
- background: none;
169
- border: none;
170
- border-radius: var(--pl-radius);
171
- color: var(--pl-color-fg-muted);
172
- cursor: pointer;
173
- transition:
174
- background var(--pl-motion-fast) var(--pl-motion-ease),
175
- color var(--pl-motion-fast) var(--pl-motion-ease);
176
- }
177
- .pl-toolcard__copy:hover {
178
- color: var(--pl-color-fg);
179
- background: var(--pl-color-bg-hover);
180
162
  }
package/src/tool-card.tsx CHANGED
@@ -133,7 +133,7 @@ function CopyButton({ text }: { text: string }) {
133
133
  return (
134
134
  <button
135
135
  type="button"
136
- className="pl-toolcard__copy"
136
+ className="pl-iconbtn pl-toolcard__copy"
137
137
  title="Copy to clipboard"
138
138
  aria-label={copied ? "Copied" : "Copy"}
139
139
  onClick={async () => {