@protolabsai/ui 0.10.0 → 0.12.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.10.0",
3
+ "version": "0.12.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -1,8 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Button } from "./primitives";
3
+ import { Badge, Button } from "./primitives";
4
4
  import { PanelHeader } from "./navigation";
5
- import { AppShell, MobileNav, SurfaceRail } from "./app-shell";
5
+ import { StatusDot } from "./data";
6
+ import { AppShell, MobileNav, SurfaceRail, UtilityBar } from "./app-shell";
6
7
  import type { MobileItem, RailItem } from "./app-shell";
7
8
 
8
9
  const meta: Meta = { title: "Components/AppShell" };
@@ -83,6 +84,19 @@ export const Full: Story = {
83
84
  {surfaceBody(activeRight)}
84
85
  </>
85
86
  }
87
+ utilityBar={
88
+ <UtilityBar
89
+ start={<StatusDot status="success" pulse label="connected · 3 agents" />}
90
+ end={
91
+ <>
92
+ <Button size="sm" variant="ghost">
93
+ Docs
94
+ </Button>
95
+ <Badge status="info">v0.11.0</Badge>
96
+ </>
97
+ }
98
+ />
99
+ }
86
100
  mobileItems={mobile}
87
101
  mobileActiveId={activeLeft}
88
102
  onMobileSelect={setActiveLeft}
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Changelog, ChangelogChange, ChangelogEntry, Heading, Lead } from "./marketing";
3
+ import { TextLink } from "./primitives";
4
+
5
+ const meta: Meta = { title: "Components/Marketing/Changelog", parameters: { layout: "fullscreen" } };
6
+ export default meta;
7
+ type Story = StoryObj;
8
+
9
+ const Page = ({ children }: { children: React.ReactNode }) => (
10
+ <div style={{ maxWidth: 680, margin: "0 auto", padding: "64px 24px" }}>
11
+ <Heading style={{ fontSize: "2rem" }}>Changelog</Heading>
12
+ <Lead>Every release, newest first.</Lead>
13
+ {children}
14
+ <div style={{ marginTop: 48, paddingTop: 24, borderTop: "1px solid var(--pl-color-border)" }}>
15
+ <p style={{ fontSize: 13, color: "var(--pl-color-fg-subtle)" }}>
16
+ Full release notes and source on <TextLink href="#" external>GitHub Releases</TextLink>.
17
+ </p>
18
+ </div>
19
+ </div>
20
+ );
21
+
22
+ /** User-facing — flat bullets, LLM-summarized voice. The marketing-site layout. */
23
+ export const UserFacing: Story = {
24
+ render: () => (
25
+ <Page>
26
+ <Changelog>
27
+ <ChangelogEntry version="@protolabsai/ui@0.11.0" date="Jun 9, 2026" latest href="#">
28
+ <ChangelogChange>Avatars and removable tags for identity and filters</ChangelogChange>
29
+ <ChangelogChange>A bottom utility bar in the app shell for global status and actions</ChangelogChange>
30
+ <ChangelogChange>Form toggles now associate with external labels</ChangelogChange>
31
+ </ChangelogEntry>
32
+ <ChangelogEntry version="@protolabsai/ui@0.10.0" date="Jun 9, 2026" href="#">
33
+ <ChangelogChange>Popover — anchored floating content for filters and pickers</ChangelogChange>
34
+ </ChangelogEntry>
35
+ <ChangelogEntry version="@protolabsai/ui@0.9.0" date="Jun 9, 2026" href="#">
36
+ <ChangelogChange>Drag a rail icon to reorder, or across to the other rail</ChangelogChange>
37
+ </ChangelogEntry>
38
+ <ChangelogEntry version="@protolabsai/design@0.5.0" date="Jun 9, 2026" href="#">
39
+ <ChangelogChange>Light mode, OS-driven, with a white-label token set</ChangelogChange>
40
+ </ChangelogEntry>
41
+ </Changelog>
42
+ </Page>
43
+ ),
44
+ };
45
+
46
+ /** Technical — typed entries (Keep-a-Changelog style) off the same layout. */
47
+ export const Technical: Story = {
48
+ render: () => (
49
+ <Page>
50
+ <Changelog>
51
+ <ChangelogEntry version="ui@0.11.0" date="2026-06-09" latest href="#">
52
+ <ChangelogChange tag="added">Avatar, AvatarGroup, Tag (removable chip)</ChangelogChange>
53
+ <ChangelogChange tag="added">AppShell utilityBar slot + UtilityBar container</ChangelogChange>
54
+ <ChangelogChange tag="fixed">Checkbox/Switch spread ...rest for external-label association (#155)</ChangelogChange>
55
+ </ChangelogEntry>
56
+ <ChangelogEntry version="ui@0.10.0" date="2026-06-09" href="#">
57
+ <ChangelogChange tag="added">Popover + PopoverClose (Radix)</ChangelogChange>
58
+ </ChangelogEntry>
59
+ <ChangelogEntry version="ui@0.9.0" date="2026-06-09" href="#">
60
+ <ChangelogChange tag="added">AppShell rail drag-and-drop + cross-rail (dnd-kit)</ChangelogChange>
61
+ <ChangelogChange tag="changed">Drag overlay reuses the rail-item renderer (keeps badge/dot)</ChangelogChange>
62
+ </ChangelogEntry>
63
+ </Changelog>
64
+ </Page>
65
+ ),
66
+ };
@@ -1,10 +1,46 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Callout, Divider, Kbd, TextLink } from "./primitives";
2
+ import { useState } from "react";
3
+ import { Avatar, AvatarGroup, Callout, Divider, Kbd, Tag, TextLink } from "./primitives";
3
4
 
4
5
  const meta: Meta = { title: "Components/Primitives/Atoms" };
5
6
  export default meta;
6
7
  type Story = StoryObj;
7
8
 
9
+ export const Avatars: Story = {
10
+ render: () => (
11
+ <div style={{ display: "flex", gap: 24, alignItems: "center" }}>
12
+ <Avatar name="Jon Reed" />
13
+ <Avatar name="Cindi Vox" size={40} />
14
+ <Avatar name="ORBIS" size={22} />
15
+ <AvatarGroup>
16
+ <Avatar name="Jon Reed" />
17
+ <Avatar name="Cindi Vox" />
18
+ <Avatar name="Beads" />
19
+ <Avatar name="+3" />
20
+ </AvatarGroup>
21
+ </div>
22
+ ),
23
+ };
24
+
25
+ export const Tags: Story = {
26
+ render: () => {
27
+ function Demo() {
28
+ const [tags, setTags] = useState(["streaming", "a2a", "voice", "router"]);
29
+ return (
30
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
31
+ <Tag>read-only</Tag>
32
+ {tags.map((t) => (
33
+ <Tag key={t} onRemove={() => setTags((ts) => ts.filter((x) => x !== t))}>
34
+ {t}
35
+ </Tag>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
40
+ return <Demo />;
41
+ },
42
+ };
43
+
8
44
  export const Callouts: Story = {
9
45
  render: () => (
10
46
  <div style={{ display: "grid", gap: 16, maxWidth: 560 }}>
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
+ /** Bottom 40px track — global utility actions / status / tickers. Presentation
284
+ * only; the host fills it (compose a `UtilityBar`). Desktop only. */
285
+ utilityBar?: ReactNode;
283
286
  className?: string;
284
287
  };
285
288
 
@@ -308,6 +311,7 @@ export function AppShell({
308
311
  onMobileSelect,
309
312
  quickBarIds,
310
313
  mobileBreakpoint = 768,
314
+ utilityBar,
311
315
  className,
312
316
  }: AppShellProps) {
313
317
  const isMobile = useIsMobile(mobileBreakpoint);
@@ -445,35 +449,38 @@ export function AppShell({
445
449
  const ctxRight = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("right", e, id) : undefined;
446
450
 
447
451
  const renderShell = (leftRail: ReactNode, rightRail: ReactNode) => (
448
- <div className={cx("pl-appshell", className)}>
449
- {leftRail}
450
- <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
451
- {showRight && (
452
- <div
453
- className="pl-appshell__handle"
454
- role="separator"
455
- aria-orientation="vertical"
456
- aria-label="Resize right panel"
457
- aria-valuenow={rightWidth}
458
- aria-valuemin={minRightWidth}
459
- aria-valuemax={maxRightWidth}
460
- tabIndex={0}
461
- onPointerDown={onPointerDown}
462
- onPointerMove={onPointerMove}
463
- onPointerUp={onPointerUp}
464
- onKeyDown={onKeyDown}
465
- onDoubleClick={() => onCollapse?.(true)}
466
- />
467
- )}
468
- {showRight && (
469
- <aside
470
- className="pl-appshell__col pl-appshell__col--right"
471
- style={{ flex: `0 0 ${rightWidth}px`, width: rightWidth }}
472
- >
473
- {rightContent}
474
- </aside>
475
- )}
476
- {rightRail}
452
+ <div className={cx("pl-appshell-frame", className)}>
453
+ <div className="pl-appshell">
454
+ {leftRail}
455
+ <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
456
+ {showRight && (
457
+ <div
458
+ className="pl-appshell__handle"
459
+ role="separator"
460
+ aria-orientation="vertical"
461
+ aria-label="Resize right panel"
462
+ aria-valuenow={rightWidth}
463
+ aria-valuemin={minRightWidth}
464
+ aria-valuemax={maxRightWidth}
465
+ tabIndex={0}
466
+ onPointerDown={onPointerDown}
467
+ onPointerMove={onPointerMove}
468
+ onPointerUp={onPointerUp}
469
+ onKeyDown={onKeyDown}
470
+ onDoubleClick={() => onCollapse?.(true)}
471
+ />
472
+ )}
473
+ {showRight && (
474
+ <aside
475
+ className="pl-appshell__col pl-appshell__col--right"
476
+ style={{ flex: `0 0 ${rightWidth}px`, width: rightWidth }}
477
+ >
478
+ {rightContent}
479
+ </aside>
480
+ )}
481
+ {rightRail}
482
+ </div>
483
+ {utilityBar != null && <div className="pl-appshell__utility">{utilityBar}</div>}
477
484
  </div>
478
485
  );
479
486
 
@@ -511,3 +518,24 @@ export function AppShell({
511
518
  </DndContext>
512
519
  );
513
520
  }
521
+
522
+ /** Bottom utility-bar chrome — start cluster · spacer · end cluster. Compose
523
+ * shipped primitives (StatusDot, Badge, Spinner, Menu) inside; pass to
524
+ * AppShell's `utilityBar` slot. Presentation only. */
525
+ export function UtilityBar({
526
+ start,
527
+ end,
528
+ className,
529
+ }: {
530
+ start?: ReactNode;
531
+ end?: ReactNode;
532
+ className?: string;
533
+ }) {
534
+ return (
535
+ <div className={cx("pl-utilitybar", className)}>
536
+ <div className="pl-utilitybar__cluster">{start}</div>
537
+ <div className="pl-utilitybar__spacer" />
538
+ <div className="pl-utilitybar__cluster">{end}</div>
539
+ </div>
540
+ );
541
+ }
package/src/forms.tsx CHANGED
@@ -48,27 +48,26 @@ export function Select({ className, children, ...rest }: SelectHTMLAttributes<HT
48
48
  );
49
49
  }
50
50
 
51
- /** Toggle switch. Controlled via `checked` / `onCheckedChange`. */
51
+ /** Toggle switch. Controlled via `checked` / `onCheckedChange`. Spreads the
52
+ * rest of the input attributes (id, name, aria-*, data-*) onto the inner input —
53
+ * so an external `<label htmlFor>` can associate. */
52
54
  export function Switch({
53
55
  checked,
54
56
  onCheckedChange,
55
- disabled,
56
57
  label,
57
58
  className,
58
- }: {
59
- checked?: boolean;
59
+ ...rest
60
+ }: Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> & {
60
61
  onCheckedChange?: (checked: boolean) => void;
61
- disabled?: boolean;
62
62
  label?: ReactNode;
63
- className?: string;
64
63
  }) {
65
64
  return (
66
- <label className={cx("pl-switch", disabled && "pl-switch--disabled", className)}>
65
+ <label className={cx("pl-switch", rest.disabled && "pl-switch--disabled", className)}>
67
66
  <input
68
67
  type="checkbox"
68
+ {...rest}
69
69
  className="pl-switch__input"
70
70
  checked={checked}
71
- disabled={disabled}
72
71
  onChange={(e) => onCheckedChange?.(e.target.checked)}
73
72
  />
74
73
  <span className="pl-switch__track" aria-hidden>
@@ -79,27 +78,26 @@ export function Switch({
79
78
  );
80
79
  }
81
80
 
82
- /** Checkbox. Controlled via `checked` / `onCheckedChange`. */
81
+ /** Checkbox. Controlled via `checked` / `onCheckedChange`. Spreads the rest of
82
+ * the input attributes (id, name, aria-*, data-*) onto the inner input, so an
83
+ * external `<label htmlFor>` can associate. */
83
84
  export function Checkbox({
84
85
  checked,
85
86
  onCheckedChange,
86
- disabled,
87
87
  label,
88
88
  className,
89
- }: {
90
- checked?: boolean;
89
+ ...rest
90
+ }: Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> & {
91
91
  onCheckedChange?: (checked: boolean) => void;
92
- disabled?: boolean;
93
92
  label?: ReactNode;
94
- className?: string;
95
93
  }) {
96
94
  return (
97
- <label className={cx("pl-checkbox", disabled && "pl-checkbox--disabled", className)}>
95
+ <label className={cx("pl-checkbox", rest.disabled && "pl-checkbox--disabled", className)}>
98
96
  <input
99
97
  type="checkbox"
98
+ {...rest}
100
99
  className="pl-checkbox__input"
101
100
  checked={checked}
102
- disabled={disabled}
103
101
  onChange={(e) => onCheckedChange?.(e.target.checked)}
104
102
  />
105
103
  <span className="pl-checkbox__box" aria-hidden />
package/src/marketing.tsx CHANGED
@@ -93,3 +93,70 @@ export function PostItem({ meta, title, excerpt, href }: PostItemProps) {
93
93
  export function Prose({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
94
94
  return <div className={cx("pl-prose", className)} {...rest} />;
95
95
  }
96
+
97
+ // ── Changelog (release timeline) — the standard marketing changelog layout ────
98
+
99
+ /** Changelog timeline — newest first. Wrap ChangelogEntry children. */
100
+ export function Changelog({ className, ...rest }: HTMLAttributes<HTMLOListElement>) {
101
+ return <ol className={cx("pl-changelog", className)} {...rest} />;
102
+ }
103
+
104
+ /** One release on the timeline. `latest` accents the dot + version pill. Put
105
+ * ChangelogChange children inside. */
106
+ export function ChangelogEntry({
107
+ version,
108
+ date,
109
+ latest,
110
+ href,
111
+ children,
112
+ }: {
113
+ version: ReactNode;
114
+ date?: ReactNode;
115
+ latest?: boolean;
116
+ /** Links the version pill (e.g. to the GitHub release / download). */
117
+ href?: string;
118
+ children: ReactNode;
119
+ }) {
120
+ return (
121
+ <li className={cx("pl-changelog__entry", latest && "pl-changelog__entry--latest")}>
122
+ <span className="pl-changelog__dot" aria-hidden />
123
+ <div className="pl-changelog__body">
124
+ <div className="pl-changelog__meta">
125
+ {href ? (
126
+ <a className="pl-changelog__version" href={href}>
127
+ {version}
128
+ </a>
129
+ ) : (
130
+ <span className="pl-changelog__version">{version}</span>
131
+ )}
132
+ {date != null && <span className="pl-changelog__date">{date}</span>}
133
+ {latest && <span className="pl-changelog__latest">latest</span>}
134
+ </div>
135
+ <div className="pl-changelog__changes">{children}</div>
136
+ </div>
137
+ </li>
138
+ );
139
+ }
140
+
141
+ /** A single change line. Flat (em-dash) for user-facing notes, or pass a `tag`
142
+ * (added / changed / fixed / removed) for a technical changelog. */
143
+ export function ChangelogChange({
144
+ tag,
145
+ children,
146
+ }: {
147
+ tag?: "added" | "changed" | "fixed" | "removed";
148
+ children: ReactNode;
149
+ }) {
150
+ return (
151
+ <div className="pl-changelog__change">
152
+ {tag ? (
153
+ <span className={cx("pl-changelog__tag", `pl-changelog__tag--${tag}`)}>{tag}</span>
154
+ ) : (
155
+ <span className="pl-changelog__bullet" aria-hidden>
156
+
157
+ </span>
158
+ )}
159
+ <span>{children}</span>
160
+ </div>
161
+ );
162
+ }
@@ -88,3 +88,69 @@ export function TextLink({
88
88
  />
89
89
  );
90
90
  }
91
+
92
+ /** Identity avatar — an image, or initials derived from `name`. */
93
+ export function Avatar({
94
+ src,
95
+ alt,
96
+ name,
97
+ size = 28,
98
+ className,
99
+ }: {
100
+ src?: string;
101
+ alt?: string;
102
+ name?: string;
103
+ size?: number;
104
+ className?: string;
105
+ }) {
106
+ const initials = name
107
+ ? name
108
+ .trim()
109
+ .split(/\s+/)
110
+ .slice(0, 2)
111
+ .map((w) => w[0])
112
+ .join("")
113
+ .toUpperCase()
114
+ : "";
115
+ return (
116
+ <span
117
+ className={cx("pl-avatar", className)}
118
+ style={{ width: size, height: size, fontSize: Math.round(size * 0.38) }}
119
+ role="img"
120
+ aria-label={alt ?? name}
121
+ >
122
+ {src ? (
123
+ <img className="pl-avatar__img" src={src} alt={alt ?? name ?? ""} />
124
+ ) : (
125
+ <span aria-hidden>{initials || "?"}</span>
126
+ )}
127
+ </span>
128
+ );
129
+ }
130
+
131
+ /** Overlapping avatar stack — wrap Avatar children. */
132
+ export function AvatarGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
133
+ return <div className={cx("pl-avatar-group", className)} {...rest} />;
134
+ }
135
+
136
+ /** Interactive token / chip. Pass `onRemove` for a removable chip. */
137
+ export function Tag({
138
+ children,
139
+ onRemove,
140
+ className,
141
+ }: {
142
+ children: ReactNode;
143
+ onRemove?: () => void;
144
+ className?: string;
145
+ }) {
146
+ return (
147
+ <span className={cx("pl-tag", className)}>
148
+ <span className="pl-tag__label">{children}</span>
149
+ {onRemove && (
150
+ <button type="button" className="pl-tag__remove" aria-label="Remove" onClick={onRemove}>
151
+ ×
152
+ </button>
153
+ )}
154
+ </span>
155
+ );
156
+ }
@@ -160,14 +160,50 @@
160
160
  }
161
161
 
162
162
  /* ── AppShell (dual-rail + 3-column) ─────────────────────────────────────────── */
163
+ /* Vertical frame: content row (1fr) + optional utility bar (40px). */
164
+ .pl-appshell-frame {
165
+ display: flex;
166
+ flex-direction: column;
167
+ height: 100%;
168
+ min-height: 0;
169
+ background: var(--pl-color-bg);
170
+ color: var(--pl-color-fg);
171
+ }
163
172
  .pl-appshell {
164
173
  display: flex;
165
174
  align-items: stretch;
166
- height: 100%;
175
+ flex: 1 1 auto;
167
176
  min-height: 0;
168
177
  background: var(--pl-color-bg);
169
178
  color: var(--pl-color-fg);
170
179
  }
180
+ /* Utility bar — the bottom 40px track (third grid row). Desktop only. */
181
+ .pl-appshell__utility {
182
+ flex: 0 0 40px;
183
+ height: 40px;
184
+ display: flex;
185
+ align-items: center;
186
+ border-top: var(--pl-border-width) solid var(--pl-color-border);
187
+ background: var(--pl-color-bg-raised);
188
+ }
189
+ .pl-utilitybar {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: var(--pl-space-2);
193
+ width: 100%;
194
+ height: 100%;
195
+ padding: 0 var(--pl-space-3);
196
+ font-size: 12px;
197
+ color: var(--pl-color-fg-muted);
198
+ }
199
+ .pl-utilitybar__cluster {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: var(--pl-space-3);
203
+ }
204
+ .pl-utilitybar__spacer {
205
+ flex: 1;
206
+ }
171
207
 
172
208
  .pl-appshell__col {
173
209
  display: flex;
@@ -247,3 +247,145 @@
247
247
  margin: 1.5rem 0;
248
248
  color: var(--pl-color-fg-muted);
249
249
  }
250
+
251
+ /* ── Changelog (release timeline) ─────────────────────────────────────────────── */
252
+ .pl-changelog {
253
+ position: relative;
254
+ display: grid;
255
+ gap: 40px;
256
+ margin: 0;
257
+ padding: 0;
258
+ list-style: none;
259
+ }
260
+ /* the timeline spine */
261
+ .pl-changelog::before {
262
+ content: "";
263
+ position: absolute;
264
+ left: 12px;
265
+ top: 8px;
266
+ bottom: 0;
267
+ width: 1px;
268
+ background: var(--pl-color-border);
269
+ }
270
+ .pl-changelog__entry {
271
+ position: relative;
272
+ display: flex;
273
+ gap: 20px;
274
+ }
275
+ .pl-changelog__dot {
276
+ position: relative;
277
+ z-index: 1;
278
+ flex-shrink: 0;
279
+ display: inline-flex;
280
+ align-items: center;
281
+ justify-content: center;
282
+ width: 25px;
283
+ height: 25px;
284
+ margin-top: 2px;
285
+ border-radius: 50%;
286
+ background: var(--pl-color-bg-raised);
287
+ border: var(--pl-border-width) solid var(--pl-color-border);
288
+ }
289
+ .pl-changelog__dot::after {
290
+ content: "";
291
+ width: 6px;
292
+ height: 6px;
293
+ border-radius: 50%;
294
+ background: var(--pl-color-fg-subtle);
295
+ }
296
+ .pl-changelog__entry--latest .pl-changelog__dot {
297
+ background: color-mix(in oklch, var(--pl-color-accent) 15%, transparent);
298
+ border-color: color-mix(in oklch, var(--pl-color-accent) 50%, transparent);
299
+ }
300
+ .pl-changelog__entry--latest .pl-changelog__dot::after {
301
+ background: var(--pl-color-accent);
302
+ }
303
+ .pl-changelog__body {
304
+ flex: 1;
305
+ min-width: 0;
306
+ padding-bottom: 8px;
307
+ }
308
+ .pl-changelog__meta {
309
+ display: flex;
310
+ flex-wrap: wrap;
311
+ align-items: center;
312
+ gap: 12px;
313
+ margin-bottom: 12px;
314
+ }
315
+ .pl-changelog__version {
316
+ padding: 2px 8px;
317
+ font-family: var(--pl-font-mono);
318
+ font-size: 13px;
319
+ color: var(--pl-color-fg-muted);
320
+ background: var(--pl-color-bg-raised);
321
+ border: var(--pl-border-width) solid var(--pl-color-border);
322
+ border-radius: var(--pl-radius);
323
+ text-decoration: none;
324
+ transition: border-color var(--pl-motion-fast) var(--pl-motion-ease);
325
+ }
326
+ a.pl-changelog__version:hover {
327
+ border-color: var(--pl-color-accent);
328
+ }
329
+ .pl-changelog__entry--latest .pl-changelog__version {
330
+ color: var(--pl-color-accent-fg);
331
+ background: color-mix(in oklch, var(--pl-color-accent) 10%, transparent);
332
+ border-color: color-mix(in oklch, var(--pl-color-accent) 35%, transparent);
333
+ }
334
+ .pl-changelog__date {
335
+ font-size: 13px;
336
+ color: var(--pl-color-fg-subtle);
337
+ }
338
+ .pl-changelog__latest {
339
+ padding: 1px 6px;
340
+ font-family: var(--pl-font-mono);
341
+ font-size: 11px;
342
+ color: var(--pl-color-accent-fg);
343
+ background: color-mix(in oklch, var(--pl-color-accent) 8%, transparent);
344
+ border: var(--pl-border-width) solid color-mix(in oklch, var(--pl-color-accent) 25%, transparent);
345
+ border-radius: var(--pl-radius);
346
+ }
347
+ .pl-changelog__changes {
348
+ display: grid;
349
+ gap: 6px;
350
+ }
351
+ .pl-changelog__change {
352
+ display: flex;
353
+ align-items: flex-start;
354
+ gap: 8px;
355
+ font-size: 14px;
356
+ line-height: 1.5;
357
+ color: var(--pl-color-fg);
358
+ }
359
+ .pl-changelog__bullet {
360
+ flex-shrink: 0;
361
+ margin-top: 1px;
362
+ color: var(--pl-color-fg-subtle);
363
+ }
364
+ .pl-changelog__tag {
365
+ flex-shrink: 0;
366
+ min-width: 52px;
367
+ padding: 0 6px;
368
+ font-family: var(--pl-font-mono);
369
+ font-size: 10px;
370
+ line-height: 18px;
371
+ text-align: center;
372
+ text-transform: lowercase;
373
+ border: var(--pl-border-width) solid var(--pl-color-border);
374
+ border-radius: var(--pl-radius);
375
+ }
376
+ .pl-changelog__tag--added {
377
+ color: var(--pl-color-status-success);
378
+ border-color: color-mix(in oklch, var(--pl-color-status-success) 35%, transparent);
379
+ }
380
+ .pl-changelog__tag--fixed {
381
+ color: var(--pl-color-status-info);
382
+ border-color: color-mix(in oklch, var(--pl-color-status-info) 35%, transparent);
383
+ }
384
+ .pl-changelog__tag--changed {
385
+ color: var(--pl-color-status-warning);
386
+ border-color: color-mix(in oklch, var(--pl-color-status-warning) 35%, transparent);
387
+ }
388
+ .pl-changelog__tag--removed {
389
+ color: var(--pl-color-status-error);
390
+ border-color: color-mix(in oklch, var(--pl-color-status-error) 35%, transparent);
391
+ }
@@ -217,3 +217,71 @@
217
217
  width: 16px;
218
218
  height: 16px;
219
219
  }
220
+
221
+ /* ── Avatar ──────────────────────────────────────────────────────────────────── */
222
+ .pl-avatar {
223
+ display: inline-flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ flex-shrink: 0;
227
+ overflow: hidden;
228
+ border-radius: 50%;
229
+ background: var(--pl-color-bg-subtle);
230
+ border: var(--pl-border-width) solid var(--pl-color-border);
231
+ color: var(--pl-color-fg-muted);
232
+ font-family: var(--pl-font-mono);
233
+ font-weight: var(--pl-font-weight-medium);
234
+ line-height: 1;
235
+ user-select: none;
236
+ }
237
+ .pl-avatar__img {
238
+ width: 100%;
239
+ height: 100%;
240
+ object-fit: cover;
241
+ }
242
+ .pl-avatar-group {
243
+ display: inline-flex;
244
+ align-items: center;
245
+ }
246
+ .pl-avatar-group > .pl-avatar {
247
+ margin-left: -8px;
248
+ box-shadow: 0 0 0 2px var(--pl-color-bg);
249
+ }
250
+ .pl-avatar-group > .pl-avatar:first-child {
251
+ margin-left: 0;
252
+ }
253
+
254
+ /* ── Tag / Chip ──────────────────────────────────────────────────────────────── */
255
+ .pl-tag {
256
+ display: inline-flex;
257
+ align-items: center;
258
+ gap: 4px;
259
+ height: 20px;
260
+ padding: 0 6px;
261
+ font-family: var(--pl-font-mono);
262
+ font-size: 11px;
263
+ color: var(--pl-color-fg);
264
+ background: var(--pl-color-bg-subtle);
265
+ border: var(--pl-border-width) solid var(--pl-color-border);
266
+ border-radius: var(--pl-radius);
267
+ }
268
+ .pl-tag__remove {
269
+ display: inline-flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ width: 14px;
273
+ height: 14px;
274
+ margin-right: -2px;
275
+ padding: 0;
276
+ font-size: 13px;
277
+ line-height: 1;
278
+ color: var(--pl-color-fg-muted);
279
+ background: none;
280
+ border: none;
281
+ border-radius: 50%;
282
+ cursor: pointer;
283
+ }
284
+ .pl-tag__remove:hover {
285
+ color: var(--pl-color-fg);
286
+ background: var(--pl-color-bg-hover);
287
+ }