@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/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";