@protolabsai/ui 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -1,11 +1,15 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Badge, Button } from "./primitives";
3
+ import { Badge, Button, Logo } from "./primitives";
4
4
  import { PanelHeader } from "./navigation";
5
5
  import { StatusDot } from "./data";
6
- import { AppShell, MobileNav, SurfaceRail, UtilityBar } from "./app-shell";
6
+ import { AppShell, Header, MobileNav, SurfaceRail, UtilityBar } from "./app-shell";
7
7
  import type { MobileItem, RailItem } from "./app-shell";
8
8
 
9
+ const LOGO =
10
+ "data:image/svg+xml," +
11
+ encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#9b87f2"/></svg>');
12
+
9
13
  const meta: Meta = { title: "Components/AppShell" };
10
14
  export default meta;
11
15
  type Story = StoryObj;
@@ -84,6 +88,19 @@ export const Full: Story = {
84
88
  {surfaceBody(activeRight)}
85
89
  </>
86
90
  }
91
+ header={
92
+ <Header
93
+ logo={<Logo src={LOGO} alt="" />}
94
+ name="protoAgent"
95
+ org="protoLabs.studio"
96
+ status={<StatusDot status="success" pulse />}
97
+ actions={
98
+ <Button size="sm" variant="ghost">
99
+ Settings
100
+ </Button>
101
+ }
102
+ />
103
+ }
87
104
  utilityBar={
88
105
  <UtilityBar
89
106
  start={<StatusDot status="success" pulse label="connected · 3 agents" />}
@@ -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/app-shell.tsx CHANGED
@@ -280,6 +280,9 @@ export type AppShellProps = {
280
280
  onMobileSelect?: (id: string) => void;
281
281
  quickBarIds?: string[];
282
282
  mobileBreakpoint?: number;
283
+ /** Top 48px bar — brand lockup + status/actions. Presentation only; the host
284
+ * fills it (compose a `Header`). Renders on both desktop and mobile. */
285
+ header?: ReactNode;
283
286
  /** Bottom 40px track — global utility actions / status / tickers. Presentation
284
287
  * only; the host fills it (compose a `UtilityBar`). Desktop only. */
285
288
  utilityBar?: ReactNode;
@@ -311,6 +314,7 @@ export function AppShell({
311
314
  onMobileSelect,
312
315
  quickBarIds,
313
316
  mobileBreakpoint = 768,
317
+ header,
314
318
  utilityBar,
315
319
  className,
316
320
  }: AppShellProps) {
@@ -433,6 +437,7 @@ export function AppShell({
433
437
  if (isMobile && mobileItems && onMobileSelect && quickBarIds) {
434
438
  return (
435
439
  <div className={cx("pl-appshell", "pl-appshell--mobile", className)}>
440
+ {header != null && <div className="pl-appshell__header">{header}</div>}
436
441
  <div className="pl-appshell__mobile-stage">{leftContent}</div>
437
442
  <MobileNav
438
443
  items={mobileItems}
@@ -450,6 +455,7 @@ export function AppShell({
450
455
 
451
456
  const renderShell = (leftRail: ReactNode, rightRail: ReactNode) => (
452
457
  <div className={cx("pl-appshell-frame", className)}>
458
+ {header != null && <div className="pl-appshell__header">{header}</div>}
453
459
  <div className="pl-appshell">
454
460
  {leftRail}
455
461
  <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
@@ -539,3 +545,41 @@ export function UtilityBar({
539
545
  </div>
540
546
  );
541
547
  }
548
+
549
+ /** White-label top-bar chrome (48px header row) — brand lockup (logo · name ·
550
+ * org) on the left, status + actions on the right. Pass to AppShell's `header`
551
+ * slot. `dragRegion` marks it as the desktop window drag region (Tauri). */
552
+ export function Header({
553
+ logo,
554
+ name,
555
+ org,
556
+ status,
557
+ actions,
558
+ dragRegion,
559
+ className,
560
+ }: {
561
+ logo?: ReactNode;
562
+ name?: ReactNode;
563
+ org?: ReactNode;
564
+ status?: ReactNode;
565
+ actions?: ReactNode;
566
+ dragRegion?: boolean;
567
+ className?: string;
568
+ }) {
569
+ return (
570
+ <div className={cx("pl-header", className)} {...(dragRegion ? { "data-tauri-drag-region": "" } : {})}>
571
+ <div className="pl-header__brand">
572
+ {logo}
573
+ {(name != null || org != null) && (
574
+ <div className="pl-header__lockup">
575
+ {name != null && <span className="pl-header__name">{name}</span>}
576
+ {org != null && <span className="pl-header__org">{org}</span>}
577
+ </div>
578
+ )}
579
+ </div>
580
+ <div className="pl-header__spacer" />
581
+ {status != null && <div className="pl-header__status">{status}</div>}
582
+ {actions != null && <div className="pl-header__actions">{actions}</div>}
583
+ </div>
584
+ );
585
+ }
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 = {
@@ -92,3 +93,60 @@ export function PanelHeader({
92
93
  </div>
93
94
  );
94
95
  }
96
+
97
+ /** Vertical disclosure stack. Wrap AccordionItem children. */
98
+ export function Accordion({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
99
+ return <div className={cx("pl-accordion", className)} {...rest} />;
100
+ }
101
+
102
+ /** One collapsible section. Uncontrolled via `defaultOpen`, or controlled via
103
+ * `open` + `onOpenChange`. */
104
+ export function AccordionItem({
105
+ title,
106
+ children,
107
+ defaultOpen = false,
108
+ open: openProp,
109
+ onOpenChange,
110
+ disabled,
111
+ className,
112
+ }: {
113
+ title: ReactNode;
114
+ children: ReactNode;
115
+ defaultOpen?: boolean;
116
+ open?: boolean;
117
+ onOpenChange?: (open: boolean) => void;
118
+ disabled?: boolean;
119
+ className?: string;
120
+ }) {
121
+ const [openState, setOpenState] = useState(defaultOpen);
122
+ const open = openProp ?? openState;
123
+ const id = useId();
124
+ const toggle = () => {
125
+ if (disabled) return;
126
+ onOpenChange?.(!open);
127
+ if (openProp === undefined) setOpenState(!open);
128
+ };
129
+ return (
130
+ <div className={cx("pl-accordion__item", open && "pl-accordion__item--open", className)}>
131
+ <button
132
+ type="button"
133
+ id={`${id}-trigger`}
134
+ className="pl-accordion__trigger"
135
+ aria-expanded={open}
136
+ aria-controls={`${id}-panel`}
137
+ disabled={disabled}
138
+ onClick={toggle}
139
+ >
140
+ <span className="pl-accordion__title">{title}</span>
141
+ <svg className="pl-accordion__chevron" viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
142
+ <path d="M6 4l4 4-4 4" />
143
+ </svg>
144
+ </button>
145
+ {open && (
146
+ <div id={`${id}-panel`} className="pl-accordion__panel" role="region" aria-labelledby={`${id}-trigger`}>
147
+ {children}
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
@@ -154,3 +154,19 @@ export function Tag({
154
154
  </span>
155
155
  );
156
156
  }
157
+
158
+ /** Brand mark — a token-sized, contained `<img>` (square by default). The
159
+ * white-label logo primitive (vs Avatar, which is a person/initials affordance). */
160
+ export function Logo({
161
+ src,
162
+ alt = "",
163
+ size = 22,
164
+ className,
165
+ }: {
166
+ src: string;
167
+ alt?: string;
168
+ size?: number;
169
+ className?: string;
170
+ }) {
171
+ return <img className={cx("pl-logo", className)} src={src} alt={alt} width={size} height={size} />;
172
+ }
@@ -274,3 +274,59 @@
274
274
  box-shadow: var(--pl-shadow-popover);
275
275
  cursor: grabbing;
276
276
  }
277
+
278
+ /* ── Header (top bar) ─────────────────────────────────────────────────────────── */
279
+ .pl-appshell__header {
280
+ flex: 0 0 48px;
281
+ height: 48px;
282
+ display: flex;
283
+ align-items: center;
284
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
285
+ background: var(--pl-color-bg-raised);
286
+ }
287
+ .pl-header {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: var(--pl-space-3);
291
+ width: 100%;
292
+ height: 100%;
293
+ padding: 0 var(--pl-space-3);
294
+ }
295
+ .pl-header__brand {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: var(--pl-space-2);
299
+ min-width: 0;
300
+ }
301
+ .pl-header__lockup {
302
+ display: flex;
303
+ flex-direction: column;
304
+ justify-content: center;
305
+ min-width: 0;
306
+ line-height: 1.15;
307
+ }
308
+ .pl-header__name {
309
+ overflow: hidden;
310
+ font-size: 13px;
311
+ font-weight: var(--pl-font-weight-medium);
312
+ color: var(--pl-color-fg);
313
+ text-overflow: ellipsis;
314
+ white-space: nowrap;
315
+ }
316
+ .pl-header__org {
317
+ font-family: var(--pl-font-mono);
318
+ font-size: 10px;
319
+ color: var(--pl-color-fg-muted);
320
+ }
321
+ .pl-header__spacer {
322
+ flex: 1;
323
+ }
324
+ .pl-header__status {
325
+ display: flex;
326
+ align-items: center;
327
+ }
328
+ .pl-header__actions {
329
+ display: flex;
330
+ align-items: center;
331
+ gap: var(--pl-space-2);
332
+ }
@@ -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
+ }
@@ -171,3 +171,61 @@
171
171
  .pl-panel-header--compact .pl-panel-header__title {
172
172
  font-size: 13px;
173
173
  }
174
+
175
+ /* ── Accordion (disclosure) ───────────────────────────────────────────────────── */
176
+ .pl-accordion {
177
+ display: flex;
178
+ flex-direction: column;
179
+ overflow: hidden;
180
+ border: var(--pl-border-width) solid var(--pl-color-border);
181
+ border-radius: var(--pl-radius);
182
+ }
183
+ .pl-accordion__item + .pl-accordion__item {
184
+ border-top: var(--pl-border-width) solid var(--pl-color-border);
185
+ }
186
+ .pl-accordion__trigger {
187
+ display: flex;
188
+ align-items: center;
189
+ justify-content: space-between;
190
+ gap: var(--pl-space-3);
191
+ width: 100%;
192
+ padding: var(--pl-space-3);
193
+ background: none;
194
+ border: none;
195
+ color: var(--pl-color-fg);
196
+ font: inherit;
197
+ font-size: 14px;
198
+ text-align: left;
199
+ cursor: pointer;
200
+ transition: background var(--pl-motion-fast) var(--pl-motion-ease);
201
+ }
202
+ .pl-accordion__trigger:hover {
203
+ background: var(--pl-color-bg-hover);
204
+ }
205
+ .pl-accordion__trigger:focus-visible {
206
+ outline: 2px solid var(--pl-color-focus);
207
+ outline-offset: -2px;
208
+ }
209
+ .pl-accordion__trigger:disabled {
210
+ opacity: 0.5;
211
+ cursor: not-allowed;
212
+ }
213
+ .pl-accordion__title {
214
+ font-weight: var(--pl-font-weight-medium);
215
+ }
216
+ .pl-accordion__chevron {
217
+ flex-shrink: 0;
218
+ width: 16px;
219
+ height: 16px;
220
+ color: var(--pl-color-fg-muted);
221
+ transition: transform var(--pl-motion-fast) var(--pl-motion-ease);
222
+ }
223
+ .pl-accordion__item--open .pl-accordion__chevron {
224
+ transform: rotate(90deg);
225
+ }
226
+ .pl-accordion__panel {
227
+ padding: 0 var(--pl-space-3) var(--pl-space-3);
228
+ font-size: 14px;
229
+ line-height: 1.55;
230
+ color: var(--pl-color-fg-muted);
231
+ }
@@ -285,3 +285,10 @@
285
285
  color: var(--pl-color-fg);
286
286
  background: var(--pl-color-bg-hover);
287
287
  }
288
+
289
+ /* ── Logo ────────────────────────────────────────────────────────────────────── */
290
+ .pl-logo {
291
+ display: block;
292
+ flex-shrink: 0;
293
+ object-fit: contain;
294
+ }