@protolabsai/ui 0.13.0 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -60,3 +60,31 @@ export const TabsWithSlots: Story = {
60
60
  return <Demo />;
61
61
  },
62
62
  };
63
+
64
+ export const TabsResponsive: Story = {
65
+ name: "Tabs (responsive → dropdown)",
66
+ render: () => {
67
+ function Demo() {
68
+ const [active, setActive] = useState("activity");
69
+ const items = [
70
+ { id: "overview", label: "Overview", icon: <Glyph /> },
71
+ { id: "activity", label: "Activity", icon: <Glyph />, badge: 12 },
72
+ { id: "knowledge", label: "Knowledge", icon: <Glyph /> },
73
+ { id: "settings", label: "Settings", icon: <Glyph /> },
74
+ ];
75
+ // A container query responds to the Tabs' own width — resize these boxes (not
76
+ // the window) to see the wide one stay a strip and the narrow one collapse.
77
+ return (
78
+ <div style={{ display: "grid", gap: 24 }}>
79
+ <div style={{ width: 520, border: "1px solid var(--pl-color-border)", borderRadius: 4, padding: 8 }}>
80
+ <Tabs responsive ariaLabel="Section" items={items} active={active} onSelect={setActive} />
81
+ </div>
82
+ <div style={{ width: 260, border: "1px solid var(--pl-color-border)", borderRadius: 4, padding: 8 }}>
83
+ <Tabs responsive ariaLabel="Section" items={items} active={active} onSelect={setActive} />
84
+ </div>
85
+ </div>
86
+ );
87
+ }
88
+ return <Demo />;
89
+ },
90
+ };
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Badge } from "./primitives";
4
- import { ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./data";
3
+ import { Badge, Button } from "./primitives";
4
+ import { Alert, Progress, ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./data";
5
5
 
6
6
  const meta: Meta = { title: "Components/Data" };
7
7
  export default meta;
@@ -83,3 +83,47 @@ export const Scroll: Story = {
83
83
  </ScrollArea>
84
84
  ),
85
85
  };
86
+
87
+ export const Progresses: Story = {
88
+ render: () => (
89
+ <div style={{ display: "grid", gap: 20, maxWidth: 360 }}>
90
+ <Progress value={72} label="Indexing corpus" showValue />
91
+ <Progress value={45} status="success" label="Upload" showValue />
92
+ <Progress value={88} status="warning" label="Rate-limit budget" showValue />
93
+ <Progress value={20} status="error" label="Failed shards" showValue />
94
+ <Progress indeterminate label="Connecting…" />
95
+ </div>
96
+ ),
97
+ };
98
+
99
+ export const Alerts: Story = {
100
+ render: () => {
101
+ function Demo() {
102
+ const [open, setOpen] = useState(true);
103
+ return (
104
+ <div style={{ display: "grid", gap: 12, maxWidth: 520 }}>
105
+ <Alert status="info" title="Heads up">main is protected — every change lands via PR, admins included.</Alert>
106
+ <Alert status="success" title="Published">@protolabsai/ui@0.13.0 is live on npm.</Alert>
107
+ <Alert status="warning" title="Rate limit near">88% of the hourly budget consumed.</Alert>
108
+ <Alert
109
+ status="error"
110
+ title="Build failed"
111
+ action={
112
+ <Button size="sm" variant="ghost">
113
+ Retry
114
+ </Button>
115
+ }
116
+ >
117
+ The deps stage copied the wrong node_modules.
118
+ </Alert>
119
+ {open && (
120
+ <Alert status="neutral" title="Dismissible" onDismiss={() => setOpen(false)}>
121
+ Tap the × to dismiss this one.
122
+ </Alert>
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+ return <Demo />;
128
+ },
129
+ };
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Checkbox, Field, Input, Select, Switch, Textarea } from "./forms";
3
+ import { Checkbox, Field, Input, Radio, RadioGroup, Select, Switch, Textarea } from "./forms";
4
4
 
5
5
  const meta: Meta = { title: "Components/Forms" };
6
6
  export default meta;
@@ -58,3 +58,20 @@ export const Toggles: Story = {
58
58
  return <Demo />;
59
59
  },
60
60
  };
61
+
62
+ export const Radios: Story = {
63
+ render: () => {
64
+ function Demo() {
65
+ const [transport, setTransport] = useState("a2a");
66
+ return (
67
+ <RadioGroup value={transport} onValueChange={setTransport} name="transport">
68
+ <Radio value="in-process" label="In-process" />
69
+ <Radio value="a2a" label="Remote A2A" />
70
+ <Radio value="fn" label="Function handler" />
71
+ <Radio value="managed" label="Managed (disabled)" disabled />
72
+ </RadioGroup>
73
+ );
74
+ }
75
+ return <Demo />;
76
+ },
77
+ };
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Accordion, AccordionItem } from "./navigation";
3
+
4
+ const meta: Meta = { title: "Components/Navigation/Accordion" };
5
+ export default meta;
6
+ type Story = StoryObj;
7
+
8
+ export const Default: Story = {
9
+ render: () => (
10
+ <div style={{ maxWidth: 480 }}>
11
+ <Accordion>
12
+ <AccordionItem title="What ships in the open core?" defaultOpen>
13
+ The substrate — schemas, specs, the content pipeline. MIT, free to fork.
14
+ </AccordionItem>
15
+ <AccordionItem title="How does voice context load?">
16
+ Per surface, via the MCP server — composed into the system prompt of any drafting call.
17
+ </AccordionItem>
18
+ <AccordionItem title="Where do the paid edges sit?">
19
+ Hosted version, premium features, guided courses — riding on top. The core is never paywalled.
20
+ </AccordionItem>
21
+ <AccordionItem title="Locked section (disabled)" disabled>
22
+ Not available.
23
+ </AccordionItem>
24
+ </Accordion>
25
+ </div>
26
+ ),
27
+ };
package/src/data.tsx CHANGED
@@ -124,3 +124,121 @@ export function Skeleton({
124
124
  export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
125
125
  return <div className={cx("pl-skel-group", className)} {...rest} />;
126
126
  }
127
+
128
+ /** Determinate or indeterminate progress bar. `status` tints the fill; pass
129
+ * `label` / `showValue` for a caption row. */
130
+ export function Progress({
131
+ value = 0,
132
+ max = 100,
133
+ status = "neutral",
134
+ label,
135
+ showValue,
136
+ indeterminate,
137
+ className,
138
+ }: {
139
+ value?: number;
140
+ max?: number;
141
+ status?: Status;
142
+ label?: ReactNode;
143
+ showValue?: boolean;
144
+ indeterminate?: boolean;
145
+ className?: string;
146
+ }) {
147
+ const pct = Math.max(0, Math.min(100, (value / max) * 100));
148
+ return (
149
+ <div className={cx("pl-progress", className)}>
150
+ {(label != null || showValue) && (
151
+ <div className="pl-progress__caption">
152
+ {label != null && <span>{label}</span>}
153
+ {showValue && !indeterminate && <span className="pl-progress__value">{Math.round(pct)}%</span>}
154
+ </div>
155
+ )}
156
+ <div
157
+ className="pl-progress__track"
158
+ role="progressbar"
159
+ aria-valuemin={0}
160
+ aria-valuemax={max}
161
+ aria-valuenow={indeterminate ? undefined : value}
162
+ >
163
+ <div
164
+ className={cx(
165
+ "pl-progress__fill",
166
+ status !== "neutral" && `pl-progress__fill--${status}`,
167
+ indeterminate && "pl-progress__fill--indeterminate",
168
+ )}
169
+ style={indeterminate ? undefined : { width: `${pct}%` }}
170
+ />
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function alertIcon(status: Status) {
177
+ switch (status) {
178
+ case "success":
179
+ return <path d="M3.5 8.5l2.8 2.8L12.5 5" />;
180
+ case "warning":
181
+ return (
182
+ <>
183
+ <path d="M8 2.5l5.8 10.5H2.2z" />
184
+ <path d="M8 6.5v3.2M8 11.6h.01" />
185
+ </>
186
+ );
187
+ case "error":
188
+ return (
189
+ <>
190
+ <circle cx="8" cy="8" r="6.2" />
191
+ <path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" />
192
+ </>
193
+ );
194
+ default:
195
+ return (
196
+ <>
197
+ <circle cx="8" cy="8" r="6.2" />
198
+ <path d="M8 7.4v3.4M8 5.2h.01" />
199
+ </>
200
+ );
201
+ }
202
+ }
203
+
204
+ /** Inline status banner — icon + title/body, optional `action` + dismiss. A
205
+ * prominent, persistent counterpart to the transient Toast (and to the prose
206
+ * Callout). `status` tints the whole surface. */
207
+ export function Alert({
208
+ status = "info",
209
+ title,
210
+ children,
211
+ action,
212
+ onDismiss,
213
+ className,
214
+ }: {
215
+ status?: Status;
216
+ title?: ReactNode;
217
+ children?: ReactNode;
218
+ action?: ReactNode;
219
+ onDismiss?: () => void;
220
+ className?: string;
221
+ }) {
222
+ return (
223
+ <div
224
+ className={cx("pl-alert", `pl-alert--${status}`, className)}
225
+ role={status === "error" || status === "warning" ? "alert" : "status"}
226
+ >
227
+ <svg className="pl-alert__icon" viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
228
+ {alertIcon(status)}
229
+ </svg>
230
+ <div className="pl-alert__body">
231
+ {title != null && <div className="pl-alert__title">{title}</div>}
232
+ {children != null && <div className="pl-alert__text">{children}</div>}
233
+ </div>
234
+ {action != null && <div className="pl-alert__action">{action}</div>}
235
+ {onDismiss && (
236
+ <button type="button" className="pl-alert__dismiss" aria-label="Dismiss" onClick={onDismiss}>
237
+ <svg viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
238
+ <path d="M4 4l8 8M12 4l-8 8" />
239
+ </svg>
240
+ </button>
241
+ )}
242
+ </div>
243
+ );
244
+ }
package/src/forms.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
2
+ import { createContext, useContext } from "react";
2
3
  import { cx } from "./internal";
3
4
 
4
5
  /** A labeled input/textarea bound to a string value (form fields, editors). */
@@ -105,3 +106,68 @@ export function Checkbox({
105
106
  </label>
106
107
  );
107
108
  }
109
+
110
+ type RadioCtx = {
111
+ name?: string;
112
+ value?: string;
113
+ onValueChange?: (value: string) => void;
114
+ disabled?: boolean;
115
+ };
116
+ const RadioContext = createContext<RadioCtx | null>(null);
117
+
118
+ /** Groups radios — owns the selected `value`, a shared `name`, and the change
119
+ * handler. Wrap Radio children. */
120
+ export function RadioGroup({
121
+ value,
122
+ onValueChange,
123
+ name,
124
+ disabled,
125
+ className,
126
+ children,
127
+ }: {
128
+ value?: string;
129
+ onValueChange?: (value: string) => void;
130
+ name?: string;
131
+ disabled?: boolean;
132
+ className?: string;
133
+ children: ReactNode;
134
+ }) {
135
+ return (
136
+ <div className={cx("pl-radiogroup", className)} role="radiogroup">
137
+ <RadioContext.Provider value={{ name, value, onValueChange, disabled }}>
138
+ {children}
139
+ </RadioContext.Provider>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ /** A single radio option. Reads selection + name from the enclosing RadioGroup. */
145
+ export function Radio({
146
+ value,
147
+ label,
148
+ disabled,
149
+ className,
150
+ }: {
151
+ value: string;
152
+ label?: ReactNode;
153
+ disabled?: boolean;
154
+ className?: string;
155
+ }) {
156
+ const ctx = useContext(RadioContext);
157
+ const isDisabled = disabled || ctx?.disabled;
158
+ return (
159
+ <label className={cx("pl-radio", isDisabled && "pl-radio--disabled", className)}>
160
+ <input
161
+ type="radio"
162
+ className="pl-radio__input"
163
+ name={ctx?.name}
164
+ value={value}
165
+ checked={ctx?.value === value}
166
+ disabled={isDisabled}
167
+ onChange={() => ctx?.onValueChange?.(value)}
168
+ />
169
+ <span className="pl-radio__control" aria-hidden />
170
+ {label != null && <span className="pl-radio__label">{label}</span>}
171
+ </label>
172
+ );
173
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
2
+ import { useId, useState } from "react";
2
3
  import { cx } from "./internal";
3
4
 
4
5
  export type TabItem = {
@@ -14,9 +15,25 @@ export type TabItem = {
14
15
 
15
16
  /** A horizontal tab strip with optional icon/badge slots + disabled/locked
16
17
  * support (gated workflows). */
17
- export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
18
- return (
19
- <div className="pl-tabs" role="tablist">
18
+ export function Tabs({
19
+ items,
20
+ active,
21
+ onSelect,
22
+ responsive = false,
23
+ ariaLabel,
24
+ }: {
25
+ items: TabItem[];
26
+ active: string;
27
+ onSelect: (id: string) => void;
28
+ /** Collapse the strip to a `<select>` dropdown when the Tabs' *container* is narrow
29
+ * (a CSS container query — responds to its own width, not the viewport, so it works
30
+ * inside a narrow panel/split). Opt-in; non-responsive output is unchanged. */
31
+ responsive?: boolean;
32
+ /** Accessible name for the tablist + the collapsed select. */
33
+ ariaLabel?: string;
34
+ }) {
35
+ const strip = (
36
+ <div className="pl-tabs" role="tablist" aria-label={ariaLabel}>
20
37
  {items.map((t) => (
21
38
  <button
22
39
  key={t.id}
@@ -43,6 +60,24 @@ export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: st
43
60
  ))}
44
61
  </div>
45
62
  );
63
+ if (!responsive) return strip;
64
+ return (
65
+ <div className="pl-tabs-wrap pl-tabs-wrap--responsive">
66
+ {strip}
67
+ <select
68
+ className="pl-tabs__select"
69
+ value={active}
70
+ aria-label={ariaLabel ?? "Select tab"}
71
+ onChange={(e) => onSelect(e.target.value)}
72
+ >
73
+ {items.map((t) => (
74
+ <option key={t.id} value={t.id} disabled={t.disabled || t.locked}>
75
+ {typeof t.label === "string" ? t.label : t.id}
76
+ </option>
77
+ ))}
78
+ </select>
79
+ </div>
80
+ );
46
81
  }
47
82
 
48
83
  /** A horizontal kanban board. Wrap BoardColumn children. */
@@ -92,3 +127,60 @@ export function PanelHeader({
92
127
  </div>
93
128
  );
94
129
  }
130
+
131
+ /** Vertical disclosure stack. Wrap AccordionItem children. */
132
+ export function Accordion({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
133
+ return <div className={cx("pl-accordion", className)} {...rest} />;
134
+ }
135
+
136
+ /** One collapsible section. Uncontrolled via `defaultOpen`, or controlled via
137
+ * `open` + `onOpenChange`. */
138
+ export function AccordionItem({
139
+ title,
140
+ children,
141
+ defaultOpen = false,
142
+ open: openProp,
143
+ onOpenChange,
144
+ disabled,
145
+ className,
146
+ }: {
147
+ title: ReactNode;
148
+ children: ReactNode;
149
+ defaultOpen?: boolean;
150
+ open?: boolean;
151
+ onOpenChange?: (open: boolean) => void;
152
+ disabled?: boolean;
153
+ className?: string;
154
+ }) {
155
+ const [openState, setOpenState] = useState(defaultOpen);
156
+ const open = openProp ?? openState;
157
+ const id = useId();
158
+ const toggle = () => {
159
+ if (disabled) return;
160
+ onOpenChange?.(!open);
161
+ if (openProp === undefined) setOpenState(!open);
162
+ };
163
+ return (
164
+ <div className={cx("pl-accordion__item", open && "pl-accordion__item--open", className)}>
165
+ <button
166
+ type="button"
167
+ id={`${id}-trigger`}
168
+ className="pl-accordion__trigger"
169
+ aria-expanded={open}
170
+ aria-controls={`${id}-panel`}
171
+ disabled={disabled}
172
+ onClick={toggle}
173
+ >
174
+ <span className="pl-accordion__title">{title}</span>
175
+ <svg className="pl-accordion__chevron" viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
176
+ <path d="M6 4l4 4-4 4" />
177
+ </svg>
178
+ </button>
179
+ {open && (
180
+ <div id={`${id}-panel`} className="pl-accordion__panel" role="region" aria-labelledby={`${id}-trigger`}>
181
+ {children}
182
+ </div>
183
+ )}
184
+ </div>
185
+ );
186
+ }
@@ -183,3 +183,142 @@
183
183
  background-image: none;
184
184
  }
185
185
  }
186
+
187
+ /* ── Progress ─────────────────────────────────────────────────────────────────── */
188
+ .pl-progress {
189
+ display: grid;
190
+ gap: var(--pl-space-2);
191
+ width: 100%;
192
+ }
193
+ .pl-progress__caption {
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: space-between;
197
+ gap: var(--pl-space-2);
198
+ font-size: 12px;
199
+ color: var(--pl-color-fg-muted);
200
+ }
201
+ .pl-progress__value {
202
+ font-family: var(--pl-font-mono);
203
+ color: var(--pl-color-fg);
204
+ }
205
+ .pl-progress__track {
206
+ position: relative;
207
+ height: 6px;
208
+ overflow: hidden;
209
+ background: var(--pl-color-bg-inset);
210
+ border-radius: 999px;
211
+ }
212
+ .pl-progress__fill {
213
+ height: 100%;
214
+ background: var(--pl-color-accent);
215
+ border-radius: inherit;
216
+ transition: width var(--pl-motion-base) var(--pl-motion-ease);
217
+ }
218
+ .pl-progress__fill--success {
219
+ background: var(--pl-color-status-success);
220
+ }
221
+ .pl-progress__fill--warning {
222
+ background: var(--pl-color-status-warning);
223
+ }
224
+ .pl-progress__fill--error {
225
+ background: var(--pl-color-status-error);
226
+ }
227
+ .pl-progress__fill--info {
228
+ background: var(--pl-color-status-info);
229
+ }
230
+ .pl-progress__fill--indeterminate {
231
+ width: 40%;
232
+ animation: pl-progress-slide 1.1s var(--pl-motion-ease-in-out) infinite;
233
+ }
234
+ @keyframes pl-progress-slide {
235
+ 0% {
236
+ transform: translateX(-120%);
237
+ }
238
+ 100% {
239
+ transform: translateX(320%);
240
+ }
241
+ }
242
+
243
+ /* ── Alert (inline status banner) ─────────────────────────────────────────────── */
244
+ .pl-alert {
245
+ display: flex;
246
+ align-items: flex-start;
247
+ gap: var(--pl-space-3);
248
+ padding: var(--pl-space-3);
249
+ background: var(--pl-color-bg-raised);
250
+ border: var(--pl-border-width) solid var(--pl-color-border);
251
+ border-left-width: 3px;
252
+ border-radius: var(--pl-radius);
253
+ color: var(--pl-color-fg);
254
+ }
255
+ .pl-alert__icon {
256
+ flex-shrink: 0;
257
+ width: 16px;
258
+ height: 16px;
259
+ margin-top: 1px;
260
+ }
261
+ .pl-alert__body {
262
+ flex: 1;
263
+ min-width: 0;
264
+ font-size: 13px;
265
+ line-height: 1.5;
266
+ }
267
+ .pl-alert__title {
268
+ font-weight: var(--pl-font-weight-medium);
269
+ }
270
+ .pl-alert__text {
271
+ color: var(--pl-color-fg-muted);
272
+ }
273
+ .pl-alert__title + .pl-alert__text {
274
+ margin-top: 2px;
275
+ }
276
+ .pl-alert__action {
277
+ flex-shrink: 0;
278
+ }
279
+ .pl-alert__dismiss {
280
+ flex-shrink: 0;
281
+ display: inline-flex;
282
+ padding: 2px;
283
+ margin: -2px -2px 0 0;
284
+ background: none;
285
+ border: none;
286
+ border-radius: var(--pl-radius);
287
+ color: var(--pl-color-fg-subtle);
288
+ cursor: pointer;
289
+ transition: color var(--pl-motion-fast) var(--pl-motion-ease);
290
+ }
291
+ .pl-alert__dismiss:hover {
292
+ color: var(--pl-color-fg);
293
+ }
294
+ .pl-alert__dismiss svg {
295
+ width: 14px;
296
+ height: 14px;
297
+ }
298
+ .pl-alert--success {
299
+ border-left-color: var(--pl-color-status-success);
300
+ }
301
+ .pl-alert--success .pl-alert__icon {
302
+ color: var(--pl-color-status-success);
303
+ }
304
+ .pl-alert--warning {
305
+ border-left-color: var(--pl-color-status-warning);
306
+ }
307
+ .pl-alert--warning .pl-alert__icon {
308
+ color: var(--pl-color-status-warning);
309
+ }
310
+ .pl-alert--error {
311
+ border-left-color: var(--pl-color-status-error);
312
+ }
313
+ .pl-alert--error .pl-alert__icon {
314
+ color: var(--pl-color-status-error);
315
+ }
316
+ .pl-alert--info {
317
+ border-left-color: var(--pl-color-status-info);
318
+ }
319
+ .pl-alert--info .pl-alert__icon {
320
+ color: var(--pl-color-status-info);
321
+ }
322
+ .pl-alert--neutral .pl-alert__icon {
323
+ color: var(--pl-color-fg-muted);
324
+ }
@@ -191,3 +191,64 @@
191
191
  font-size: 13px;
192
192
  color: var(--pl-color-fg);
193
193
  }
194
+
195
+ /* ── Radio ───────────────────────────────────────────────────────────────────── */
196
+ .pl-radiogroup {
197
+ display: grid;
198
+ gap: var(--pl-space-2);
199
+ }
200
+ .pl-radio {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ gap: 0.5rem;
204
+ cursor: pointer;
205
+ }
206
+ .pl-radio--disabled {
207
+ opacity: 0.5;
208
+ cursor: not-allowed;
209
+ }
210
+ .pl-radio__input {
211
+ position: absolute;
212
+ width: 0;
213
+ height: 0;
214
+ opacity: 0;
215
+ }
216
+ .pl-radio__control {
217
+ position: relative;
218
+ display: inline-block;
219
+ flex-shrink: 0;
220
+ width: 16px;
221
+ height: 16px;
222
+ background: var(--pl-color-bg-raised);
223
+ border: var(--pl-border-width) solid var(--pl-color-border-strong);
224
+ border-radius: 50%;
225
+ transition:
226
+ background var(--pl-motion-fast) var(--pl-motion-ease),
227
+ border-color var(--pl-motion-fast) var(--pl-motion-ease);
228
+ }
229
+ .pl-radio__control::after {
230
+ content: "";
231
+ position: absolute;
232
+ inset: 0;
233
+ width: 6px;
234
+ height: 6px;
235
+ margin: auto;
236
+ background: var(--pl-color-fg);
237
+ border-radius: 50%;
238
+ transform: scale(0);
239
+ transition: transform var(--pl-motion-fast) var(--pl-motion-ease);
240
+ }
241
+ .pl-radio__input:checked + .pl-radio__control {
242
+ border-color: var(--pl-color-fg);
243
+ }
244
+ .pl-radio__input:checked + .pl-radio__control::after {
245
+ transform: scale(1);
246
+ }
247
+ .pl-radio__input:focus-visible + .pl-radio__control {
248
+ outline: 2px solid var(--pl-color-border-strong);
249
+ outline-offset: 2px;
250
+ }
251
+ .pl-radio__label {
252
+ font-size: 13px;
253
+ color: var(--pl-color-fg);
254
+ }
@@ -71,6 +71,32 @@
71
71
  opacity: 0.6;
72
72
  }
73
73
 
74
+ /* Responsive Tabs (opt-in via `responsive`): below ~30rem of CONTAINER width the
75
+ strip collapses to a <select>. A container query responds to the Tabs' own
76
+ container — not the viewport — so it collapses inside a narrow panel/split too. */
77
+ .pl-tabs-wrap--responsive {
78
+ container-type: inline-size;
79
+ width: 100%;
80
+ }
81
+ .pl-tabs__select {
82
+ display: none;
83
+ width: 100%;
84
+ padding: 6px 10px;
85
+ font: inherit;
86
+ color: var(--pl-color-fg);
87
+ background: var(--pl-color-bg-inset);
88
+ border: var(--pl-border-width) solid var(--pl-color-border);
89
+ border-radius: var(--pl-radius);
90
+ }
91
+ @container (max-width: 30rem) {
92
+ .pl-tabs-wrap--responsive > .pl-tabs {
93
+ display: none;
94
+ }
95
+ .pl-tabs-wrap--responsive > .pl-tabs__select {
96
+ display: block;
97
+ }
98
+ }
99
+
74
100
  /* ── Board (kanban) ────────────────────────────────────────────────────────── */
75
101
  .pl-board {
76
102
  display: flex;
@@ -171,3 +197,61 @@
171
197
  .pl-panel-header--compact .pl-panel-header__title {
172
198
  font-size: 13px;
173
199
  }
200
+
201
+ /* ── Accordion (disclosure) ───────────────────────────────────────────────────── */
202
+ .pl-accordion {
203
+ display: flex;
204
+ flex-direction: column;
205
+ overflow: hidden;
206
+ border: var(--pl-border-width) solid var(--pl-color-border);
207
+ border-radius: var(--pl-radius);
208
+ }
209
+ .pl-accordion__item + .pl-accordion__item {
210
+ border-top: var(--pl-border-width) solid var(--pl-color-border);
211
+ }
212
+ .pl-accordion__trigger {
213
+ display: flex;
214
+ align-items: center;
215
+ justify-content: space-between;
216
+ gap: var(--pl-space-3);
217
+ width: 100%;
218
+ padding: var(--pl-space-3);
219
+ background: none;
220
+ border: none;
221
+ color: var(--pl-color-fg);
222
+ font: inherit;
223
+ font-size: 14px;
224
+ text-align: left;
225
+ cursor: pointer;
226
+ transition: background var(--pl-motion-fast) var(--pl-motion-ease);
227
+ }
228
+ .pl-accordion__trigger:hover {
229
+ background: var(--pl-color-bg-hover);
230
+ }
231
+ .pl-accordion__trigger:focus-visible {
232
+ outline: 2px solid var(--pl-color-focus);
233
+ outline-offset: -2px;
234
+ }
235
+ .pl-accordion__trigger:disabled {
236
+ opacity: 0.5;
237
+ cursor: not-allowed;
238
+ }
239
+ .pl-accordion__title {
240
+ font-weight: var(--pl-font-weight-medium);
241
+ }
242
+ .pl-accordion__chevron {
243
+ flex-shrink: 0;
244
+ width: 16px;
245
+ height: 16px;
246
+ color: var(--pl-color-fg-muted);
247
+ transition: transform var(--pl-motion-fast) var(--pl-motion-ease);
248
+ }
249
+ .pl-accordion__item--open .pl-accordion__chevron {
250
+ transform: rotate(90deg);
251
+ }
252
+ .pl-accordion__panel {
253
+ padding: 0 var(--pl-space-3) var(--pl-space-3);
254
+ font-size: 14px;
255
+ line-height: 1.55;
256
+ color: var(--pl-color-fg-muted);
257
+ }