@protolabsai/ui 0.14.0 → 0.16.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 +2 -1
- package/src/AppShell.stories.tsx +28 -0
- package/src/ThemePanel.stories.tsx +56 -0
- package/src/navigation.tsx +37 -3
- package/src/styles/navigation.css +26 -0
- package/src/styles/theming.css +149 -0
- package/src/styles.css +1 -0
- package/src/theming.tsx +330 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protolabsai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./data": "./src/data.tsx",
|
|
22
22
|
"./menu": "./src/menu.tsx",
|
|
23
23
|
"./app-shell": "./src/app-shell.tsx",
|
|
24
|
+
"./theming": "./src/theming.tsx",
|
|
24
25
|
"./styles.css": "./src/styles.css"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { ThemePanel } from "./theming";
|
|
4
|
+
import { Badge, Button, Callout, Tag, TextLink } from "./primitives";
|
|
5
|
+
import { Alert, Progress, StatusDot } from "./data";
|
|
6
|
+
import { Tabs } from "./navigation";
|
|
7
|
+
|
|
8
|
+
const meta: Meta = { title: "Components/Theming/ThemePanel", parameters: { layout: "fullscreen" } };
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj;
|
|
11
|
+
|
|
12
|
+
/** A spread of real components — watch them all repaint as you drag a picker. */
|
|
13
|
+
function Sample() {
|
|
14
|
+
const [tab, setTab] = useState("overview");
|
|
15
|
+
return (
|
|
16
|
+
<div style={{ display: "grid", gap: 16, maxWidth: 520 }}>
|
|
17
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
18
|
+
<Button>Primary</Button>
|
|
19
|
+
<Button variant="ghost">Ghost</Button>
|
|
20
|
+
<Button variant="danger">Danger</Button>
|
|
21
|
+
<Badge status="info">v0.15.0</Badge>
|
|
22
|
+
<Tag>filter</Tag>
|
|
23
|
+
<StatusDot status="success" pulse label="connected" />
|
|
24
|
+
</div>
|
|
25
|
+
<Tabs
|
|
26
|
+
ariaLabel="Section"
|
|
27
|
+
items={[
|
|
28
|
+
{ id: "overview", label: "Overview" },
|
|
29
|
+
{ id: "activity", label: "Activity", badge: 12 },
|
|
30
|
+
{ id: "settings", label: "Settings" },
|
|
31
|
+
]}
|
|
32
|
+
active={tab}
|
|
33
|
+
onSelect={setTab}
|
|
34
|
+
/>
|
|
35
|
+
<Progress value={64} label="Indexing corpus" showValue />
|
|
36
|
+
<Alert status="info" title="Live theming">
|
|
37
|
+
Drag any swatch — every token resolves through <TextLink href="#">--pl-*</TextLink>, so the whole surface
|
|
38
|
+
repaints in real time.
|
|
39
|
+
</Alert>
|
|
40
|
+
<Callout tone="success" title="on brand">
|
|
41
|
+
Keep the lavender + indigo, or dial in a white-label accent and save it as a preset.
|
|
42
|
+
</Callout>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const LiveEditor: Story = {
|
|
48
|
+
render: () => (
|
|
49
|
+
<div style={{ display: "flex", gap: 24, alignItems: "flex-start", padding: 24, flexWrap: "wrap" }}>
|
|
50
|
+
<ThemePanel />
|
|
51
|
+
<div style={{ flex: "1 1 360px" }}>
|
|
52
|
+
<Sample />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
};
|
package/src/navigation.tsx
CHANGED
|
@@ -15,9 +15,25 @@ export type TabItem = {
|
|
|
15
15
|
|
|
16
16
|
/** A horizontal tab strip with optional icon/badge slots + disabled/locked
|
|
17
17
|
* support (gated workflows). */
|
|
18
|
-
export function Tabs({
|
|
19
|
-
|
|
20
|
-
|
|
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}>
|
|
21
37
|
{items.map((t) => (
|
|
22
38
|
<button
|
|
23
39
|
key={t.id}
|
|
@@ -44,6 +60,24 @@ export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: st
|
|
|
44
60
|
))}
|
|
45
61
|
</div>
|
|
46
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
|
+
);
|
|
47
81
|
}
|
|
48
82
|
|
|
49
83
|
/** A horizontal kanban board. Wrap BoardColumn children. */
|
|
@@ -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;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/* @protolabsai/ui — ThemePanel (live token editor). All --pl-* driven, so the
|
|
2
|
+
panel itself re-skins live as you edit. */
|
|
3
|
+
|
|
4
|
+
.pl-theme-panel {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
width: 100%;
|
|
8
|
+
max-width: 440px;
|
|
9
|
+
color: var(--pl-color-fg);
|
|
10
|
+
font-size: 13px;
|
|
11
|
+
background: var(--pl-color-bg-raised);
|
|
12
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
13
|
+
border-radius: var(--pl-radius);
|
|
14
|
+
}
|
|
15
|
+
.pl-theme-panel__bar {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: var(--pl-space-3);
|
|
19
|
+
padding: var(--pl-space-3);
|
|
20
|
+
border-bottom: var(--pl-border-width) solid var(--pl-color-border);
|
|
21
|
+
}
|
|
22
|
+
.pl-theme-panel__title {
|
|
23
|
+
font-weight: var(--pl-font-weight-medium);
|
|
24
|
+
}
|
|
25
|
+
.pl-theme-panel__spacer {
|
|
26
|
+
flex: 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* segmented light/dark control */
|
|
30
|
+
.pl-segmented {
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
padding: 2px;
|
|
33
|
+
background: var(--pl-color-bg-inset);
|
|
34
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
35
|
+
border-radius: var(--pl-radius);
|
|
36
|
+
}
|
|
37
|
+
.pl-segmented__btn {
|
|
38
|
+
padding: 3px 12px;
|
|
39
|
+
font: inherit;
|
|
40
|
+
font-size: 12px;
|
|
41
|
+
text-transform: capitalize;
|
|
42
|
+
color: var(--pl-color-fg-muted);
|
|
43
|
+
background: none;
|
|
44
|
+
border: none;
|
|
45
|
+
border-radius: calc(var(--pl-radius) - 1px);
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
transition:
|
|
48
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
49
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
50
|
+
}
|
|
51
|
+
.pl-segmented__btn--active {
|
|
52
|
+
color: var(--pl-color-fg);
|
|
53
|
+
background: var(--pl-color-bg-raised);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.pl-theme-panel__presets {
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-wrap: wrap;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: var(--pl-space-2);
|
|
61
|
+
padding: var(--pl-space-3);
|
|
62
|
+
border-bottom: var(--pl-border-width) solid var(--pl-color-border);
|
|
63
|
+
}
|
|
64
|
+
.pl-theme-panel__preset-select {
|
|
65
|
+
flex: 1 1 140px;
|
|
66
|
+
}
|
|
67
|
+
.pl-theme-panel__preset-name {
|
|
68
|
+
flex: 1 1 120px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.pl-theme-panel__groups {
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
gap: var(--pl-space-4);
|
|
75
|
+
max-height: 460px;
|
|
76
|
+
padding: var(--pl-space-3);
|
|
77
|
+
overflow-y: auto;
|
|
78
|
+
}
|
|
79
|
+
.pl-theme-group {
|
|
80
|
+
margin: 0;
|
|
81
|
+
padding: 0;
|
|
82
|
+
border: none;
|
|
83
|
+
}
|
|
84
|
+
.pl-theme-group__legend {
|
|
85
|
+
padding: 0 0 var(--pl-space-2);
|
|
86
|
+
font-size: 10px;
|
|
87
|
+
font-weight: var(--pl-font-weight-medium);
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
letter-spacing: 0.06em;
|
|
90
|
+
color: var(--pl-color-fg-subtle);
|
|
91
|
+
}
|
|
92
|
+
.pl-theme-row {
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: var(--pl-space-2);
|
|
96
|
+
padding: 3px 0;
|
|
97
|
+
}
|
|
98
|
+
.pl-theme-row__swatch {
|
|
99
|
+
flex-shrink: 0;
|
|
100
|
+
width: 22px;
|
|
101
|
+
height: 22px;
|
|
102
|
+
padding: 0;
|
|
103
|
+
background: none;
|
|
104
|
+
border: var(--pl-border-width) solid var(--pl-color-border-strong);
|
|
105
|
+
border-radius: var(--pl-radius);
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
.pl-theme-row__swatch::-webkit-color-swatch-wrapper {
|
|
109
|
+
padding: 0;
|
|
110
|
+
}
|
|
111
|
+
.pl-theme-row__swatch::-webkit-color-swatch {
|
|
112
|
+
border: none;
|
|
113
|
+
border-radius: calc(var(--pl-radius) - 1px);
|
|
114
|
+
}
|
|
115
|
+
.pl-theme-row__swatch::-moz-color-swatch {
|
|
116
|
+
border: none;
|
|
117
|
+
border-radius: calc(var(--pl-radius) - 1px);
|
|
118
|
+
}
|
|
119
|
+
.pl-theme-row__swatch--static {
|
|
120
|
+
cursor: default;
|
|
121
|
+
}
|
|
122
|
+
.pl-theme-row__label {
|
|
123
|
+
flex: 1;
|
|
124
|
+
overflow: hidden;
|
|
125
|
+
color: var(--pl-color-fg-muted);
|
|
126
|
+
text-overflow: ellipsis;
|
|
127
|
+
white-space: nowrap;
|
|
128
|
+
}
|
|
129
|
+
.pl-theme-row__value {
|
|
130
|
+
flex-shrink: 0;
|
|
131
|
+
width: 116px;
|
|
132
|
+
padding: 3px 6px;
|
|
133
|
+
font-family: var(--pl-font-mono);
|
|
134
|
+
font-size: 11px;
|
|
135
|
+
color: var(--pl-color-fg);
|
|
136
|
+
background: var(--pl-color-bg-inset);
|
|
137
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
138
|
+
border-radius: var(--pl-radius);
|
|
139
|
+
}
|
|
140
|
+
.pl-theme-row__value:focus-visible {
|
|
141
|
+
outline: 2px solid var(--pl-color-focus);
|
|
142
|
+
outline-offset: -1px;
|
|
143
|
+
}
|
|
144
|
+
.pl-theme-row--changed .pl-theme-row__label {
|
|
145
|
+
color: var(--pl-color-fg);
|
|
146
|
+
}
|
|
147
|
+
.pl-theme-row--changed .pl-theme-row__value {
|
|
148
|
+
border-color: var(--pl-color-accent);
|
|
149
|
+
}
|
package/src/styles.css
CHANGED
package/src/theming.tsx
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import tokens from "@protolabsai/design/tokens.json";
|
|
3
|
+
import { Button } from "./primitives";
|
|
4
|
+
import { cx } from "./internal";
|
|
5
|
+
|
|
6
|
+
// ── Token registry, derived from @protolabsai/design so it stays in sync ────────
|
|
7
|
+
// Mirrors the design package's build flatten (kebab + `--pl` prefix) exactly, so
|
|
8
|
+
// the var names we write match the ones the tokens.css emits.
|
|
9
|
+
const kebab = (s: string) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
10
|
+
function flatten(obj: Record<string, unknown>, prefix: string, acc: Record<string, string>) {
|
|
11
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
12
|
+
const name = `${prefix}-${kebab(k)}`;
|
|
13
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
14
|
+
flatten(v as Record<string, unknown>, name, acc);
|
|
15
|
+
} else {
|
|
16
|
+
acc[name] = String(v);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return acc;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const T = tokens as {
|
|
23
|
+
color: Record<string, unknown>;
|
|
24
|
+
radius: unknown;
|
|
25
|
+
borderWidth: unknown;
|
|
26
|
+
light?: { color?: Record<string, unknown> };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Default (dark) values for every editable token, keyed by CSS var. */
|
|
30
|
+
const DARK = flatten({ color: T.color, radius: T.radius, borderWidth: T.borderWidth }, "--pl", {});
|
|
31
|
+
/** Light values = dark base with the design package's light overrides layered on. */
|
|
32
|
+
const LIGHT = { ...DARK, ...flatten({ color: T.light?.color ?? {} }, "--pl", {}) };
|
|
33
|
+
|
|
34
|
+
export type ThemeMode = "light" | "dark";
|
|
35
|
+
|
|
36
|
+
type TokenGroup = { label: string; match: (v: string) => boolean };
|
|
37
|
+
// Colors + shape — the meaningful white-label knobs. Order = panel order.
|
|
38
|
+
const GROUPS: TokenGroup[] = [
|
|
39
|
+
{ label: "Brand", match: (v) => v.startsWith("--pl-color-brand-") },
|
|
40
|
+
{ label: "Accent", match: (v) => v.startsWith("--pl-color-accent") || v === "--pl-color-focus" },
|
|
41
|
+
{ label: "Surfaces", match: (v) => v.startsWith("--pl-color-bg") || v === "--pl-color-overlay" },
|
|
42
|
+
{ label: "Text", match: (v) => v.startsWith("--pl-color-fg") },
|
|
43
|
+
{ label: "Status", match: (v) => v.startsWith("--pl-color-status-") },
|
|
44
|
+
{ label: "Borders", match: (v) => v.startsWith("--pl-color-border") },
|
|
45
|
+
{ label: "Shape", match: (v) => v === "--pl-radius" || v === "--pl-border-width" },
|
|
46
|
+
];
|
|
47
|
+
const ALL_VARS = Object.keys(DARK);
|
|
48
|
+
|
|
49
|
+
const prettyLabel = (v: string) =>
|
|
50
|
+
v
|
|
51
|
+
.replace(/^--pl-(color-)?/, "")
|
|
52
|
+
.replace(/^(brand|status|accent|bg|fg|border)-?/, "")
|
|
53
|
+
.replace(/-/g, " ")
|
|
54
|
+
.trim()
|
|
55
|
+
.replace(/\b\w/g, (c) => c.toUpperCase()) || v.replace(/^--pl-/, "");
|
|
56
|
+
|
|
57
|
+
const isHex6 = (val: string) => /^#[0-9a-f]{6}$/i.test(val.trim());
|
|
58
|
+
|
|
59
|
+
// ── Presets ─────────────────────────────────────────────────────────────────────
|
|
60
|
+
export type ThemePreset = {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
mode: ThemeMode;
|
|
64
|
+
/** CSS-var → value overrides layered on the base theme. */
|
|
65
|
+
overrides: Record<string, string>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const BUILTIN_PRESETS: ThemePreset[] = [
|
|
69
|
+
{ id: "pl-dark", name: "protoLabs Dark", mode: "dark", overrides: {} },
|
|
70
|
+
{ id: "pl-light", name: "protoLabs Light", mode: "light", overrides: {} },
|
|
71
|
+
{
|
|
72
|
+
id: "ex-amber",
|
|
73
|
+
name: "Amber (example)",
|
|
74
|
+
mode: "dark",
|
|
75
|
+
overrides: {
|
|
76
|
+
"--pl-color-accent": "#f59e0b",
|
|
77
|
+
"--pl-color-accent-hover": "#d97706",
|
|
78
|
+
"--pl-color-accent-fg": "#fbbf24",
|
|
79
|
+
"--pl-color-focus": "#f59e0b",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "ex-teal",
|
|
84
|
+
name: "Teal (example)",
|
|
85
|
+
mode: "dark",
|
|
86
|
+
overrides: {
|
|
87
|
+
"--pl-color-accent": "#14b8a6",
|
|
88
|
+
"--pl-color-accent-hover": "#0d9488",
|
|
89
|
+
"--pl-color-accent-fg": "#2dd4bf",
|
|
90
|
+
"--pl-color-focus": "#14b8a6",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const LS_THEME = "pl-theme";
|
|
96
|
+
const LS_PRESETS = "pl-theme-presets";
|
|
97
|
+
|
|
98
|
+
const root = () => document.documentElement;
|
|
99
|
+
const applyMode = (mode: ThemeMode) => root().setAttribute("data-theme", mode);
|
|
100
|
+
const applyOverrides = (overrides: Record<string, string>) => {
|
|
101
|
+
for (const [k, v] of Object.entries(overrides)) root().style.setProperty(k, v);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function readStored(): { mode: ThemeMode; overrides: Record<string, string> } | null {
|
|
105
|
+
try {
|
|
106
|
+
const raw = localStorage.getItem(LS_THEME);
|
|
107
|
+
return raw ? (JSON.parse(raw) as { mode: ThemeMode; overrides: Record<string, string> }) : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Apply the persisted theme (mode + overrides). Call once on app boot — ideally
|
|
114
|
+
* before first paint — so a user's saved theme survives reloads without a flash. */
|
|
115
|
+
export function applyStoredTheme() {
|
|
116
|
+
const t = readStored();
|
|
117
|
+
if (!t) return;
|
|
118
|
+
applyMode(t.mode);
|
|
119
|
+
applyOverrides(t.overrides);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Live theme editor — maps every color + shape token to a control, repaints the
|
|
123
|
+
* document in real time as you drag a picker, toggles light/dark, and saves named
|
|
124
|
+
* presets (localStorage) you can switch back to. White-label control surface. */
|
|
125
|
+
export function ThemePanel({
|
|
126
|
+
className,
|
|
127
|
+
presets: extraPresets = [],
|
|
128
|
+
}: {
|
|
129
|
+
className?: string;
|
|
130
|
+
/** Extra read-only presets to offer alongside the built-ins. */
|
|
131
|
+
presets?: ThemePreset[];
|
|
132
|
+
}) {
|
|
133
|
+
const [mode, setMode] = useState<ThemeMode>("dark");
|
|
134
|
+
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
|
135
|
+
const [userPresets, setUserPresets] = useState<ThemePreset[]>([]);
|
|
136
|
+
const [presetName, setPresetName] = useState("");
|
|
137
|
+
const [copied, setCopied] = useState("");
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const t = readStored();
|
|
141
|
+
if (t) {
|
|
142
|
+
setMode(t.mode);
|
|
143
|
+
setOverrides(t.overrides);
|
|
144
|
+
applyMode(t.mode);
|
|
145
|
+
applyOverrides(t.overrides);
|
|
146
|
+
} else {
|
|
147
|
+
const cur = root().getAttribute("data-theme");
|
|
148
|
+
setMode(cur === "light" ? "light" : "dark");
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const p = localStorage.getItem(LS_PRESETS);
|
|
152
|
+
if (p) setUserPresets(JSON.parse(p) as ThemePreset[]);
|
|
153
|
+
} catch {
|
|
154
|
+
/* ignore */
|
|
155
|
+
}
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
const persist = useCallback((m: ThemeMode, o: Record<string, string>) => {
|
|
159
|
+
try {
|
|
160
|
+
localStorage.setItem(LS_THEME, JSON.stringify({ mode: m, overrides: o }));
|
|
161
|
+
} catch {
|
|
162
|
+
/* ignore */
|
|
163
|
+
}
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const baseFor = (cssVar: string) => (mode === "light" ? LIGHT : DARK)[cssVar];
|
|
167
|
+
const valueOf = (cssVar: string) => overrides[cssVar] ?? baseFor(cssVar);
|
|
168
|
+
|
|
169
|
+
const setVar = (cssVar: string, value: string) => {
|
|
170
|
+
root().style.setProperty(cssVar, value);
|
|
171
|
+
setOverrides((prev) => {
|
|
172
|
+
const next = { ...prev, [cssVar]: value };
|
|
173
|
+
persist(mode, next);
|
|
174
|
+
return next;
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const changeMode = (m: ThemeMode) => {
|
|
179
|
+
setMode(m);
|
|
180
|
+
applyMode(m);
|
|
181
|
+
persist(m, overrides);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const reset = () => {
|
|
185
|
+
for (const k of Object.keys(overrides)) root().style.removeProperty(k);
|
|
186
|
+
setOverrides({});
|
|
187
|
+
persist(mode, {});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const applyPreset = (id: string) => {
|
|
191
|
+
if (!id) return;
|
|
192
|
+
const p = [...BUILTIN_PRESETS, ...extraPresets, ...userPresets].find((x) => x.id === id);
|
|
193
|
+
if (!p) return;
|
|
194
|
+
for (const k of Object.keys(overrides)) root().style.removeProperty(k);
|
|
195
|
+
setMode(p.mode);
|
|
196
|
+
applyMode(p.mode);
|
|
197
|
+
applyOverrides(p.overrides);
|
|
198
|
+
setOverrides(p.overrides);
|
|
199
|
+
persist(p.mode, p.overrides);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const savePreset = () => {
|
|
203
|
+
const name = presetName.trim();
|
|
204
|
+
if (!name) return;
|
|
205
|
+
const p: ThemePreset = { id: `user-${name.toLowerCase().replace(/\s+/g, "-")}`, name, mode, overrides };
|
|
206
|
+
const next = [...userPresets.filter((x) => x.id !== p.id), p];
|
|
207
|
+
setUserPresets(next);
|
|
208
|
+
setPresetName("");
|
|
209
|
+
try {
|
|
210
|
+
localStorage.setItem(LS_PRESETS, JSON.stringify(next));
|
|
211
|
+
} catch {
|
|
212
|
+
/* ignore */
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const copy = (text: string, what: string) => {
|
|
217
|
+
navigator.clipboard?.writeText(text);
|
|
218
|
+
setCopied(what);
|
|
219
|
+
setTimeout(() => setCopied(""), 1400);
|
|
220
|
+
};
|
|
221
|
+
const exportCss = () => {
|
|
222
|
+
const body = Object.entries(overrides)
|
|
223
|
+
.map(([k, v]) => ` ${k}: ${v};`)
|
|
224
|
+
.join("\n");
|
|
225
|
+
copy(`:root {\n${body}\n}`, "css");
|
|
226
|
+
};
|
|
227
|
+
const exportJson = () => copy(JSON.stringify({ mode, overrides }, null, 2), "json");
|
|
228
|
+
|
|
229
|
+
const dirty = Object.keys(overrides).length;
|
|
230
|
+
const allPresets = [...BUILTIN_PRESETS, ...extraPresets, ...userPresets];
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className={cx("pl-theme-panel", className)}>
|
|
234
|
+
<div className="pl-theme-panel__bar">
|
|
235
|
+
<span className="pl-theme-panel__title">Theme</span>
|
|
236
|
+
<div className="pl-segmented" role="group" aria-label="Color scheme">
|
|
237
|
+
{(["dark", "light"] as const).map((m) => (
|
|
238
|
+
<button
|
|
239
|
+
key={m}
|
|
240
|
+
type="button"
|
|
241
|
+
className={cx("pl-segmented__btn", mode === m && "pl-segmented__btn--active")}
|
|
242
|
+
aria-pressed={mode === m}
|
|
243
|
+
onClick={() => changeMode(m)}
|
|
244
|
+
>
|
|
245
|
+
{m}
|
|
246
|
+
</button>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
<span className="pl-theme-panel__spacer" />
|
|
250
|
+
<Button size="sm" variant="ghost" onClick={reset} disabled={!dirty}>
|
|
251
|
+
Reset{dirty ? ` (${dirty})` : ""}
|
|
252
|
+
</Button>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div className="pl-theme-panel__presets">
|
|
256
|
+
<select
|
|
257
|
+
className="pl-input pl-select pl-theme-panel__preset-select"
|
|
258
|
+
aria-label="Apply preset"
|
|
259
|
+
value=""
|
|
260
|
+
onChange={(e) => applyPreset(e.target.value)}
|
|
261
|
+
>
|
|
262
|
+
<option value="">Apply preset…</option>
|
|
263
|
+
{allPresets.map((p) => (
|
|
264
|
+
<option key={p.id} value={p.id}>
|
|
265
|
+
{p.name}
|
|
266
|
+
</option>
|
|
267
|
+
))}
|
|
268
|
+
</select>
|
|
269
|
+
<input
|
|
270
|
+
className="pl-input pl-theme-panel__preset-name"
|
|
271
|
+
placeholder="Save current as…"
|
|
272
|
+
value={presetName}
|
|
273
|
+
onChange={(e) => setPresetName(e.target.value)}
|
|
274
|
+
onKeyDown={(e) => e.key === "Enter" && savePreset()}
|
|
275
|
+
/>
|
|
276
|
+
<Button size="sm" onClick={savePreset} disabled={!presetName.trim()}>
|
|
277
|
+
Save
|
|
278
|
+
</Button>
|
|
279
|
+
<span className="pl-theme-panel__spacer" />
|
|
280
|
+
<Button size="sm" variant="ghost" onClick={exportCss} disabled={!dirty}>
|
|
281
|
+
{copied === "css" ? "Copied" : "Copy CSS"}
|
|
282
|
+
</Button>
|
|
283
|
+
<Button size="sm" variant="ghost" onClick={exportJson} disabled={!dirty}>
|
|
284
|
+
{copied === "json" ? "Copied" : "Copy JSON"}
|
|
285
|
+
</Button>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div className="pl-theme-panel__groups">
|
|
289
|
+
{GROUPS.map((g) => {
|
|
290
|
+
const vars = ALL_VARS.filter(g.match);
|
|
291
|
+
if (!vars.length) return null;
|
|
292
|
+
return (
|
|
293
|
+
<fieldset key={g.label} className="pl-theme-group">
|
|
294
|
+
<legend className="pl-theme-group__legend">{g.label}</legend>
|
|
295
|
+
{vars.map((v) => {
|
|
296
|
+
const val = valueOf(v);
|
|
297
|
+
const hex = isHex6(val);
|
|
298
|
+
const changed = v in overrides;
|
|
299
|
+
return (
|
|
300
|
+
<label key={v} className={cx("pl-theme-row", changed && "pl-theme-row--changed")}>
|
|
301
|
+
{hex ? (
|
|
302
|
+
<input
|
|
303
|
+
type="color"
|
|
304
|
+
className="pl-theme-row__swatch"
|
|
305
|
+
value={val}
|
|
306
|
+
onInput={(e) => setVar(v, (e.target as HTMLInputElement).value)}
|
|
307
|
+
aria-label={prettyLabel(v)}
|
|
308
|
+
/>
|
|
309
|
+
) : (
|
|
310
|
+
<span className="pl-theme-row__swatch pl-theme-row__swatch--static" style={{ background: val }} aria-hidden />
|
|
311
|
+
)}
|
|
312
|
+
<span className="pl-theme-row__label" title={v}>
|
|
313
|
+
{prettyLabel(v)}
|
|
314
|
+
</span>
|
|
315
|
+
<input
|
|
316
|
+
className="pl-theme-row__value"
|
|
317
|
+
value={val}
|
|
318
|
+
spellCheck={false}
|
|
319
|
+
onChange={(e) => setVar(v, e.target.value)}
|
|
320
|
+
/>
|
|
321
|
+
</label>
|
|
322
|
+
);
|
|
323
|
+
})}
|
|
324
|
+
</fieldset>
|
|
325
|
+
);
|
|
326
|
+
})}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|