@alt-t4b/pm-web 0.1.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/dist/assets/index-Bonqd4_2.js +49 -0
- package/dist/index.html +28 -0
- package/package.json +27 -0
- package/src/App.tsx +829 -0
- package/src/api.ts +1 -0
- package/src/components/Badge.tsx +54 -0
- package/src/components/Button.tsx +58 -0
- package/src/components/Card.tsx +40 -0
- package/src/components/Icon.tsx +22 -0
- package/src/components/IconButton.tsx +50 -0
- package/src/components/Input.tsx +47 -0
- package/src/components/Select.tsx +41 -0
- package/src/components/Stack.tsx +38 -0
- package/src/components/ThemeContext.tsx +53 -0
- package/src/components/ThemeSwitcher.tsx +49 -0
- package/src/components/TopBar.tsx +38 -0
- package/src/components/index.ts +12 -0
- package/src/components/theme.ts +185 -0
- package/src/main.tsx +12 -0
- package/src/serve.ts +71 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useTheme } from "./ThemeContext";
|
|
2
|
+
|
|
3
|
+
type BadgeVariant = "active" | "paused" | "completed" | "archived" | "default";
|
|
4
|
+
|
|
5
|
+
interface BadgeProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
variant?: BadgeVariant;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Badge({ children, variant = "default" }: BadgeProps) {
|
|
11
|
+
const { theme } = useTheme();
|
|
12
|
+
|
|
13
|
+
const variantStyles: Record<BadgeVariant, React.CSSProperties> = {
|
|
14
|
+
active: {
|
|
15
|
+
background: `${theme.color.primary}22`,
|
|
16
|
+
color: theme.color.primary,
|
|
17
|
+
},
|
|
18
|
+
completed: {
|
|
19
|
+
background: `${theme.color.success}22`,
|
|
20
|
+
color: theme.color.success,
|
|
21
|
+
},
|
|
22
|
+
paused: {
|
|
23
|
+
background: theme.color.surfaceContainerHighest,
|
|
24
|
+
color: theme.color.textMuted,
|
|
25
|
+
},
|
|
26
|
+
archived: {
|
|
27
|
+
background: theme.color.surfaceContainerHigh,
|
|
28
|
+
color: theme.color.textFaint,
|
|
29
|
+
},
|
|
30
|
+
default: {
|
|
31
|
+
background: theme.color.surfaceContainerHigh,
|
|
32
|
+
color: theme.color.textMuted,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<span
|
|
38
|
+
style={{
|
|
39
|
+
display: "inline-block",
|
|
40
|
+
padding: "0.2rem 0.5rem",
|
|
41
|
+
borderRadius: theme.radius.md,
|
|
42
|
+
fontSize: "0.625rem",
|
|
43
|
+
fontWeight: 700,
|
|
44
|
+
fontFamily: theme.font.body,
|
|
45
|
+
letterSpacing: "0.08em",
|
|
46
|
+
textTransform: "uppercase",
|
|
47
|
+
lineHeight: 1.4,
|
|
48
|
+
...variantStyles[variant],
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes } from "react";
|
|
2
|
+
import { type Theme } from "./theme";
|
|
3
|
+
import { useTheme } from "./ThemeContext";
|
|
4
|
+
|
|
5
|
+
type ButtonVariant = "primary" | "ghost";
|
|
6
|
+
type ButtonSize = "sm" | "md";
|
|
7
|
+
|
|
8
|
+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
variant?: ButtonVariant;
|
|
10
|
+
size?: ButtonSize;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getVariantStyles(theme: Theme): Record<ButtonVariant, React.CSSProperties> {
|
|
14
|
+
return {
|
|
15
|
+
primary: {
|
|
16
|
+
background: `linear-gradient(135deg, ${theme.color.primaryContainer}, ${theme.color.primary})`,
|
|
17
|
+
color: theme.color.onPrimary,
|
|
18
|
+
border: "none",
|
|
19
|
+
boxShadow: theme.shadow.sm,
|
|
20
|
+
},
|
|
21
|
+
ghost: {
|
|
22
|
+
background: "transparent",
|
|
23
|
+
color: theme.color.textMuted,
|
|
24
|
+
border: "1px solid transparent",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Button({
|
|
30
|
+
variant = "primary",
|
|
31
|
+
size = "md",
|
|
32
|
+
style,
|
|
33
|
+
...props
|
|
34
|
+
}: ButtonProps) {
|
|
35
|
+
const { theme } = useTheme();
|
|
36
|
+
|
|
37
|
+
const sizeStyles: Record<ButtonSize, React.CSSProperties> = {
|
|
38
|
+
sm: { padding: "0.25rem 0.625rem", fontSize: theme.font.size.sm },
|
|
39
|
+
md: { padding: "0.5rem 1rem", fontSize: theme.font.size.md },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
style={{
|
|
45
|
+
borderRadius: theme.radius.lg,
|
|
46
|
+
cursor: "pointer",
|
|
47
|
+
fontFamily: theme.font.body,
|
|
48
|
+
fontWeight: 500,
|
|
49
|
+
letterSpacing: "0.01em",
|
|
50
|
+
transition: "background 0.15s, opacity 0.15s",
|
|
51
|
+
...getVariantStyles(theme)[variant],
|
|
52
|
+
...sizeStyles[size],
|
|
53
|
+
...style,
|
|
54
|
+
}}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type HTMLAttributes } from "react";
|
|
2
|
+
import { type Theme } from "./theme";
|
|
3
|
+
import { useTheme } from "./ThemeContext";
|
|
4
|
+
|
|
5
|
+
type CardVariant = "default" | "flat";
|
|
6
|
+
|
|
7
|
+
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
padding?: keyof Theme["spacing"];
|
|
9
|
+
variant?: CardVariant;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Card({ padding = "lg", variant = "default", style, ...props }: CardProps) {
|
|
13
|
+
const { theme } = useTheme();
|
|
14
|
+
|
|
15
|
+
const variantStyles: Record<CardVariant, React.CSSProperties> = {
|
|
16
|
+
default: {
|
|
17
|
+
background: theme.color.surfaceContainer,
|
|
18
|
+
border: `1px solid ${theme.color.borderSubtle}`,
|
|
19
|
+
boxShadow: "none",
|
|
20
|
+
},
|
|
21
|
+
flat: {
|
|
22
|
+
background: theme.color.surfaceContainerLow,
|
|
23
|
+
border: "none",
|
|
24
|
+
boxShadow: "none",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
style={{
|
|
31
|
+
borderRadius: theme.radius.xl,
|
|
32
|
+
padding: theme.spacing[padding],
|
|
33
|
+
transition: "background 0.15s",
|
|
34
|
+
...variantStyles[variant],
|
|
35
|
+
...style,
|
|
36
|
+
}}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type HTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
interface IconProps extends HTMLAttributes<HTMLSpanElement> {
|
|
4
|
+
name: string;
|
|
5
|
+
size?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Icon({ name, size = 24, style, ...props }: IconProps) {
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
className="material-symbols-outlined"
|
|
12
|
+
style={{
|
|
13
|
+
fontSize: size,
|
|
14
|
+
lineHeight: 1,
|
|
15
|
+
...style,
|
|
16
|
+
}}
|
|
17
|
+
{...props}
|
|
18
|
+
>
|
|
19
|
+
{name}
|
|
20
|
+
</span>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes } from "react";
|
|
2
|
+
import { useTheme } from "./ThemeContext";
|
|
3
|
+
import { Icon } from "./Icon";
|
|
4
|
+
|
|
5
|
+
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
+
icon: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
badge?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function IconButton({ icon, size = 24, badge, style, ...props }: IconButtonProps) {
|
|
12
|
+
const { theme } = useTheme();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
style={{
|
|
17
|
+
position: "relative",
|
|
18
|
+
display: "flex",
|
|
19
|
+
alignItems: "center",
|
|
20
|
+
justifyContent: "center",
|
|
21
|
+
width: 36,
|
|
22
|
+
height: 36,
|
|
23
|
+
borderRadius: theme.radius.full,
|
|
24
|
+
border: "none",
|
|
25
|
+
background: "transparent",
|
|
26
|
+
color: theme.color.textMuted,
|
|
27
|
+
cursor: "pointer",
|
|
28
|
+
transition: "background 0.15s, color 0.15s",
|
|
29
|
+
...style,
|
|
30
|
+
}}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
<Icon name={icon} size={size} />
|
|
34
|
+
{badge && (
|
|
35
|
+
<span
|
|
36
|
+
style={{
|
|
37
|
+
position: "absolute",
|
|
38
|
+
top: 6,
|
|
39
|
+
right: 6,
|
|
40
|
+
width: 8,
|
|
41
|
+
height: 8,
|
|
42
|
+
borderRadius: theme.radius.full,
|
|
43
|
+
background: theme.color.danger,
|
|
44
|
+
border: `2px solid ${theme.color.surface}`,
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type InputHTMLAttributes } from "react";
|
|
2
|
+
import { useTheme } from "./ThemeContext";
|
|
3
|
+
|
|
4
|
+
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Input({ label, style, id, ...props }: InputProps) {
|
|
9
|
+
const { theme } = useTheme();
|
|
10
|
+
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div style={{ display: "flex", flexDirection: "column", gap: theme.spacing.xs }}>
|
|
14
|
+
{label && (
|
|
15
|
+
<label
|
|
16
|
+
htmlFor={inputId}
|
|
17
|
+
style={{
|
|
18
|
+
fontSize: theme.font.size.xs,
|
|
19
|
+
fontWeight: 700,
|
|
20
|
+
letterSpacing: "0.06em",
|
|
21
|
+
textTransform: "uppercase" as const,
|
|
22
|
+
color: theme.color.textFaint,
|
|
23
|
+
fontFamily: theme.font.body,
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
{label}
|
|
27
|
+
</label>
|
|
28
|
+
)}
|
|
29
|
+
<input
|
|
30
|
+
id={inputId}
|
|
31
|
+
style={{
|
|
32
|
+
padding: `${theme.spacing.sm} ${theme.spacing.md}`,
|
|
33
|
+
border: `1px solid ${theme.color.borderSubtle}`,
|
|
34
|
+
borderRadius: theme.radius.lg,
|
|
35
|
+
fontFamily: theme.font.body,
|
|
36
|
+
fontSize: theme.font.size.md,
|
|
37
|
+
outline: "none",
|
|
38
|
+
background: theme.color.surfaceContainerHigh,
|
|
39
|
+
color: theme.color.text,
|
|
40
|
+
transition: "border-color 0.15s",
|
|
41
|
+
...style,
|
|
42
|
+
}}
|
|
43
|
+
{...props}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type SelectHTMLAttributes } from "react";
|
|
2
|
+
import { useTheme } from "./ThemeContext";
|
|
3
|
+
|
|
4
|
+
interface SelectOption {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
10
|
+
options: SelectOption[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Select({ options, style, ...props }: SelectProps) {
|
|
14
|
+
const { theme } = useTheme();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div style={{ display: "flex", flexDirection: "column", gap: theme.spacing.xs }}>
|
|
18
|
+
<select
|
|
19
|
+
style={{
|
|
20
|
+
padding: `${theme.spacing.sm} ${theme.spacing.md}`,
|
|
21
|
+
border: `1px solid ${theme.color.borderSubtle}`,
|
|
22
|
+
borderRadius: theme.radius.lg,
|
|
23
|
+
fontFamily: theme.font.body,
|
|
24
|
+
fontSize: theme.font.size.sm,
|
|
25
|
+
background: theme.color.surfaceContainerHigh,
|
|
26
|
+
color: theme.color.text,
|
|
27
|
+
cursor: "pointer",
|
|
28
|
+
transition: "border-color 0.15s",
|
|
29
|
+
...style,
|
|
30
|
+
}}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
{options.map((opt) => (
|
|
34
|
+
<option key={opt.value} value={opt.value}>
|
|
35
|
+
{opt.label}
|
|
36
|
+
</option>
|
|
37
|
+
))}
|
|
38
|
+
</select>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type HTMLAttributes } from "react";
|
|
2
|
+
import { type Theme } from "./theme";
|
|
3
|
+
import { useTheme } from "./ThemeContext";
|
|
4
|
+
|
|
5
|
+
interface StackProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
direction?: "row" | "column";
|
|
7
|
+
gap?: keyof Theme["spacing"];
|
|
8
|
+
align?: React.CSSProperties["alignItems"];
|
|
9
|
+
justify?: React.CSSProperties["justifyContent"];
|
|
10
|
+
wrap?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Stack({
|
|
14
|
+
direction = "column",
|
|
15
|
+
gap = "md",
|
|
16
|
+
align,
|
|
17
|
+
justify,
|
|
18
|
+
wrap,
|
|
19
|
+
style,
|
|
20
|
+
...props
|
|
21
|
+
}: StackProps) {
|
|
22
|
+
const { theme } = useTheme();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
style={{
|
|
27
|
+
display: "flex",
|
|
28
|
+
flexDirection: direction,
|
|
29
|
+
gap: theme.spacing[gap],
|
|
30
|
+
alignItems: align,
|
|
31
|
+
justifyContent: justify,
|
|
32
|
+
flexWrap: wrap ? "wrap" : undefined,
|
|
33
|
+
...style,
|
|
34
|
+
}}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
2
|
+
import { type Theme, defaultThemeName, themes } from "./theme";
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = "pm-theme";
|
|
5
|
+
|
|
6
|
+
interface ThemeContextValue {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
themeName: string;
|
|
9
|
+
setTheme: (name: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
13
|
+
|
|
14
|
+
function loadSavedTheme(): string {
|
|
15
|
+
try {
|
|
16
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
17
|
+
if (saved && saved in themes) return saved;
|
|
18
|
+
} catch {}
|
|
19
|
+
return defaultThemeName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
23
|
+
const [themeName, setThemeName] = useState(loadSavedTheme);
|
|
24
|
+
|
|
25
|
+
const setTheme = useCallback((name: string) => {
|
|
26
|
+
if (name in themes) {
|
|
27
|
+
setThemeName(name);
|
|
28
|
+
localStorage.setItem(STORAGE_KEY, name);
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const t = themes[themeName]!;
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const s = document.body.style;
|
|
36
|
+
s.backgroundColor = t.color.surface;
|
|
37
|
+
s.color = t.color.text;
|
|
38
|
+
s.fontFamily = t.font.body;
|
|
39
|
+
s.margin = "0";
|
|
40
|
+
}, [t]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ThemeContext.Provider value={{ theme: t, themeName, setTheme }}>
|
|
44
|
+
{children}
|
|
45
|
+
</ThemeContext.Provider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useTheme(): ThemeContextValue {
|
|
50
|
+
const ctx = useContext(ThemeContext);
|
|
51
|
+
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
|
|
52
|
+
return ctx;
|
|
53
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { themes } from "./theme";
|
|
2
|
+
import { useTheme } from "./ThemeContext";
|
|
3
|
+
|
|
4
|
+
export function ThemeSwitcher() {
|
|
5
|
+
const { theme, themeName, setTheme } = useTheme();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div style={{ display: "flex", gap: theme.spacing.sm, alignItems: "center" }}>
|
|
9
|
+
{Object.values(themes).map((t) => {
|
|
10
|
+
const active = themeName === t.name;
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
key={t.name}
|
|
14
|
+
onClick={() => setTheme(t.name)}
|
|
15
|
+
title={t.label}
|
|
16
|
+
style={{
|
|
17
|
+
width: 28,
|
|
18
|
+
height: 28,
|
|
19
|
+
borderRadius: theme.radius.full,
|
|
20
|
+
border: active
|
|
21
|
+
? `2px solid ${theme.color.text}`
|
|
22
|
+
: `2px solid ${theme.color.border}`,
|
|
23
|
+
background: t.color.surfaceContainer,
|
|
24
|
+
cursor: "pointer",
|
|
25
|
+
padding: 0,
|
|
26
|
+
boxShadow: active ? `0 0 0 2px ${t.color.primary}` : "none",
|
|
27
|
+
position: "relative",
|
|
28
|
+
transition: "box-shadow 0.15s, border-color 0.15s",
|
|
29
|
+
}}
|
|
30
|
+
aria-label={`Switch to ${t.label} theme`}
|
|
31
|
+
>
|
|
32
|
+
<span
|
|
33
|
+
style={{
|
|
34
|
+
position: "absolute",
|
|
35
|
+
top: "50%",
|
|
36
|
+
left: "50%",
|
|
37
|
+
transform: "translate(-50%, -50%)",
|
|
38
|
+
width: 12,
|
|
39
|
+
height: 12,
|
|
40
|
+
borderRadius: theme.radius.full,
|
|
41
|
+
background: t.color.primary,
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
</button>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useTheme } from "./ThemeContext";
|
|
2
|
+
import { ThemeSwitcher } from "./ThemeSwitcher";
|
|
3
|
+
|
|
4
|
+
export function TopBar() {
|
|
5
|
+
const { theme } = useTheme();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<header
|
|
9
|
+
style={{
|
|
10
|
+
position: "sticky",
|
|
11
|
+
top: 0,
|
|
12
|
+
zIndex: 40,
|
|
13
|
+
display: "flex",
|
|
14
|
+
justifyContent: "center",
|
|
15
|
+
background: `${theme.color.surfaceContainer}cc`,
|
|
16
|
+
backdropFilter: "blur(20px)",
|
|
17
|
+
WebkitBackdropFilter: "blur(20px)",
|
|
18
|
+
boxShadow: theme.shadow.sm,
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
<div
|
|
22
|
+
style={{
|
|
23
|
+
display: "flex",
|
|
24
|
+
justifyContent: "flex-end",
|
|
25
|
+
alignItems: "center",
|
|
26
|
+
width: "100%",
|
|
27
|
+
maxWidth: 1400,
|
|
28
|
+
padding: `${theme.spacing.md} ${theme.spacing["2xl"]}`,
|
|
29
|
+
boxSizing: "border-box",
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
<div style={{ display: "flex", alignItems: "center" }}>
|
|
33
|
+
<ThemeSwitcher />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</header>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Button } from "./Button";
|
|
2
|
+
export { Input } from "./Input";
|
|
3
|
+
export { Select } from "./Select";
|
|
4
|
+
export { Card } from "./Card";
|
|
5
|
+
export { Stack } from "./Stack";
|
|
6
|
+
export { Icon } from "./Icon";
|
|
7
|
+
export { IconButton } from "./IconButton";
|
|
8
|
+
export { Badge } from "./Badge";
|
|
9
|
+
export { TopBar } from "./TopBar";
|
|
10
|
+
export { ThemeProvider, useTheme } from "./ThemeContext";
|
|
11
|
+
export { themes } from "./theme";
|
|
12
|
+
export type { Theme } from "./theme";
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export interface Theme {
|
|
2
|
+
name: string;
|
|
3
|
+
label: string;
|
|
4
|
+
color: {
|
|
5
|
+
// Text hierarchy
|
|
6
|
+
text: string;
|
|
7
|
+
textMuted: string;
|
|
8
|
+
textFaint: string;
|
|
9
|
+
// Surface layers (MD3-style tonal depth)
|
|
10
|
+
surface: string;
|
|
11
|
+
surfaceContainer: string;
|
|
12
|
+
surfaceContainerLow: string;
|
|
13
|
+
surfaceContainerHigh: string;
|
|
14
|
+
surfaceContainerHighest: string;
|
|
15
|
+
// Borders
|
|
16
|
+
border: string;
|
|
17
|
+
borderSubtle: string;
|
|
18
|
+
// Primary
|
|
19
|
+
primary: string;
|
|
20
|
+
primaryContainer: string;
|
|
21
|
+
onPrimary: string;
|
|
22
|
+
onPrimaryContainer: string;
|
|
23
|
+
// Tertiary accent
|
|
24
|
+
tertiary: string;
|
|
25
|
+
// Danger / error
|
|
26
|
+
danger: string;
|
|
27
|
+
// Semantic
|
|
28
|
+
success: string;
|
|
29
|
+
};
|
|
30
|
+
shadow: {
|
|
31
|
+
sm: string;
|
|
32
|
+
md: string;
|
|
33
|
+
lg: string;
|
|
34
|
+
};
|
|
35
|
+
radius: {
|
|
36
|
+
sm: number;
|
|
37
|
+
md: number;
|
|
38
|
+
lg: number;
|
|
39
|
+
xl: number;
|
|
40
|
+
full: number;
|
|
41
|
+
};
|
|
42
|
+
spacing: {
|
|
43
|
+
xs: string;
|
|
44
|
+
sm: string;
|
|
45
|
+
md: string;
|
|
46
|
+
lg: string;
|
|
47
|
+
xl: string;
|
|
48
|
+
"2xl": string;
|
|
49
|
+
"3xl": string;
|
|
50
|
+
};
|
|
51
|
+
font: {
|
|
52
|
+
headline: string;
|
|
53
|
+
body: string;
|
|
54
|
+
size: {
|
|
55
|
+
xs: string;
|
|
56
|
+
sm: string;
|
|
57
|
+
md: string;
|
|
58
|
+
lg: string;
|
|
59
|
+
xl: string;
|
|
60
|
+
"2xl": string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const shared: Pick<Theme, "radius" | "spacing" | "font"> = {
|
|
66
|
+
radius: { sm: 2, md: 4, lg: 8, xl: 12, full: 9999 },
|
|
67
|
+
spacing: {
|
|
68
|
+
xs: "0.25rem",
|
|
69
|
+
sm: "0.5rem",
|
|
70
|
+
md: "0.75rem",
|
|
71
|
+
lg: "1rem",
|
|
72
|
+
xl: "1.5rem",
|
|
73
|
+
"2xl": "2rem",
|
|
74
|
+
"3xl": "2.5rem",
|
|
75
|
+
},
|
|
76
|
+
font: {
|
|
77
|
+
headline: "'Manrope', system-ui, sans-serif",
|
|
78
|
+
body: "'Inter', system-ui, sans-serif",
|
|
79
|
+
size: {
|
|
80
|
+
xs: "0.75rem",
|
|
81
|
+
sm: "0.8125rem",
|
|
82
|
+
md: "0.875rem",
|
|
83
|
+
lg: "1rem",
|
|
84
|
+
xl: "1.25rem",
|
|
85
|
+
"2xl": "2.25rem",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const themes: Record<string, Theme> = {
|
|
91
|
+
// Dark teal — derived from the Stitch MD3 palette, inverted to dark
|
|
92
|
+
deepTeal: {
|
|
93
|
+
name: "deepTeal",
|
|
94
|
+
label: "Deep Teal",
|
|
95
|
+
...shared,
|
|
96
|
+
color: {
|
|
97
|
+
text: "#d4e5ea",
|
|
98
|
+
textMuted: "#8ba8b2",
|
|
99
|
+
textFaint: "#5a7580",
|
|
100
|
+
surface: "#0d1b1f",
|
|
101
|
+
surfaceContainer: "#12252a",
|
|
102
|
+
surfaceContainerLow: "#0f2025",
|
|
103
|
+
surfaceContainerHigh: "#172e34",
|
|
104
|
+
surfaceContainerHighest: "#1e383f",
|
|
105
|
+
border: "#1e383f",
|
|
106
|
+
borderSubtle: "#162d33",
|
|
107
|
+
primary: "#8bd1e8",
|
|
108
|
+
primaryContainer: "#005f73",
|
|
109
|
+
onPrimary: "#003642",
|
|
110
|
+
onPrimaryContainer: "#b2ebff",
|
|
111
|
+
tertiary: "#fcb97b",
|
|
112
|
+
danger: "#ffb4ab",
|
|
113
|
+
success: "#6dd58c",
|
|
114
|
+
},
|
|
115
|
+
shadow: {
|
|
116
|
+
sm: "0 1px 3px rgba(0,0,0,0.4)",
|
|
117
|
+
md: "0 4px 20px rgba(0,0,0,0.3)",
|
|
118
|
+
lg: "0 8px 40px rgba(0,0,0,0.4)",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// Warm ember — dark amber/orange
|
|
123
|
+
ember: {
|
|
124
|
+
name: "ember",
|
|
125
|
+
label: "Ember",
|
|
126
|
+
...shared,
|
|
127
|
+
color: {
|
|
128
|
+
text: "#ede0d4",
|
|
129
|
+
textMuted: "#a89280",
|
|
130
|
+
textFaint: "#6e5e50",
|
|
131
|
+
surface: "#141010",
|
|
132
|
+
surfaceContainer: "#1e1816",
|
|
133
|
+
surfaceContainerLow: "#1a1412",
|
|
134
|
+
surfaceContainerHigh: "#261e1a",
|
|
135
|
+
surfaceContainerHighest: "#2e2520",
|
|
136
|
+
border: "#2e2520",
|
|
137
|
+
borderSubtle: "#241c18",
|
|
138
|
+
primary: "#e87040",
|
|
139
|
+
primaryContainer: "#6e3518",
|
|
140
|
+
onPrimary: "#2d1600",
|
|
141
|
+
onPrimaryContainer: "#ffdcc0",
|
|
142
|
+
tertiary: "#d4bfff",
|
|
143
|
+
danger: "#ffb4ab",
|
|
144
|
+
success: "#a8d5a2",
|
|
145
|
+
},
|
|
146
|
+
shadow: {
|
|
147
|
+
sm: "0 1px 3px rgba(0,0,0,0.5)",
|
|
148
|
+
md: "0 4px 20px rgba(0,0,0,0.35)",
|
|
149
|
+
lg: "0 8px 40px rgba(0,0,0,0.45)",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// Cool nord — arctic dark
|
|
154
|
+
nord: {
|
|
155
|
+
name: "nord",
|
|
156
|
+
label: "Nord",
|
|
157
|
+
...shared,
|
|
158
|
+
color: {
|
|
159
|
+
text: "#d8dee9",
|
|
160
|
+
textMuted: "#8892a4",
|
|
161
|
+
textFaint: "#5c6478",
|
|
162
|
+
surface: "#242933",
|
|
163
|
+
surfaceContainer: "#2e3440",
|
|
164
|
+
surfaceContainerLow: "#292e39",
|
|
165
|
+
surfaceContainerHigh: "#353c4a",
|
|
166
|
+
surfaceContainerHighest: "#3d4556",
|
|
167
|
+
border: "#3d4556",
|
|
168
|
+
borderSubtle: "#353c4a",
|
|
169
|
+
primary: "#88c0d0",
|
|
170
|
+
primaryContainer: "#2e5a66",
|
|
171
|
+
onPrimary: "#1a3640",
|
|
172
|
+
onPrimaryContainer: "#b8e8f5",
|
|
173
|
+
tertiary: "#ebcb8b",
|
|
174
|
+
danger: "#bf616a",
|
|
175
|
+
success: "#a3be8c",
|
|
176
|
+
},
|
|
177
|
+
shadow: {
|
|
178
|
+
sm: "0 1px 3px rgba(0,0,0,0.3)",
|
|
179
|
+
md: "0 4px 20px rgba(0,0,0,0.25)",
|
|
180
|
+
lg: "0 8px 40px rgba(0,0,0,0.35)",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const defaultThemeName = "deepTeal";
|