feat: improve app ui and project metadata
This commit is contained in:
46
apps/web/lib/ui/theme.test.ts
Normal file
46
apps/web/lib/ui/theme.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_THEME_MODE,
|
||||
isThemeMode,
|
||||
nextThemeMode,
|
||||
normalizeThemeMode,
|
||||
themeBootstrapScript,
|
||||
themeModeLabel,
|
||||
themeToggleLabel,
|
||||
} from "./theme";
|
||||
|
||||
describe("theme mode", () => {
|
||||
it("accepts only supported theme modes", () => {
|
||||
expect(isThemeMode("paper")).toBe(true);
|
||||
expect(isThemeMode("night")).toBe(true);
|
||||
expect(isThemeMode("dark")).toBe(false);
|
||||
expect(isThemeMode(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to paper for unknown stored values", () => {
|
||||
expect(normalizeThemeMode("night")).toBe("night");
|
||||
expect(normalizeThemeMode("")).toBe(DEFAULT_THEME_MODE);
|
||||
expect(normalizeThemeMode("auto")).toBe(DEFAULT_THEME_MODE);
|
||||
});
|
||||
|
||||
it("toggles between paper and night", () => {
|
||||
expect(nextThemeMode("paper")).toBe("night");
|
||||
expect(nextThemeMode("night")).toBe("paper");
|
||||
});
|
||||
|
||||
it("returns labels for the current mode and the next action", () => {
|
||||
expect(themeModeLabel("paper")).toBe("纸感模式");
|
||||
expect(themeModeLabel("night")).toBe("夜读模式");
|
||||
expect(themeToggleLabel("paper")).toBe("切换到夜读模式");
|
||||
expect(themeToggleLabel("night")).toBe("切换到纸感模式");
|
||||
});
|
||||
|
||||
it("builds a bootstrap script that applies the stored mode before hydration", () => {
|
||||
const script = themeBootstrapScript();
|
||||
|
||||
expect(script).toContain("localStorage.getItem(\"ww.theme_mode\")");
|
||||
expect(script).toContain("document.documentElement.dataset.theme");
|
||||
expect(script).toContain("mode !== \"paper\" && mode !== \"night\"");
|
||||
});
|
||||
});
|
||||
36
apps/web/lib/ui/theme.ts
Normal file
36
apps/web/lib/ui/theme.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type ThemeMode = "paper" | "night";
|
||||
|
||||
export const DEFAULT_THEME_MODE: ThemeMode = "paper";
|
||||
export const THEME_STORAGE_KEY = "ww.theme_mode";
|
||||
|
||||
export function isThemeMode(value: unknown): value is ThemeMode {
|
||||
return value === "paper" || value === "night";
|
||||
}
|
||||
|
||||
export function normalizeThemeMode(value: unknown): ThemeMode {
|
||||
return isThemeMode(value) ? value : DEFAULT_THEME_MODE;
|
||||
}
|
||||
|
||||
export function nextThemeMode(current: ThemeMode): ThemeMode {
|
||||
return current === "night" ? "paper" : "night";
|
||||
}
|
||||
|
||||
export function themeModeLabel(mode: ThemeMode): string {
|
||||
return mode === "night" ? "夜读模式" : "纸感模式";
|
||||
}
|
||||
|
||||
export function themeToggleLabel(mode: ThemeMode): string {
|
||||
return mode === "night" ? "切换到纸感模式" : "切换到夜读模式";
|
||||
}
|
||||
|
||||
export function themeBootstrapScript(): string {
|
||||
return `
|
||||
try {
|
||||
var mode = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
|
||||
if (mode !== "paper" && mode !== "night") mode = ${JSON.stringify(DEFAULT_THEME_MODE)};
|
||||
document.documentElement.dataset.theme = mode;
|
||||
} catch (error) {
|
||||
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME_MODE)};
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
52
apps/web/lib/ui/variants.test.ts
Normal file
52
apps/web/lib/ui/variants.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
badgeClass,
|
||||
buttonClass,
|
||||
cn,
|
||||
inputClass,
|
||||
segmentedClass,
|
||||
statusNoteClass,
|
||||
} from "./variants";
|
||||
|
||||
describe("ui variants", () => {
|
||||
it("joins classes without falsey values", () => {
|
||||
expect(cn("a", false, undefined, null, "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("builds stable primary button classes", () => {
|
||||
const klass = buttonClass({ variant: "primary", size: "sm" });
|
||||
|
||||
expect(klass).toContain("bg-cinnabar");
|
||||
expect(klass).toContain("px-3");
|
||||
expect(klass).toContain("focus-visible:ring-2");
|
||||
});
|
||||
|
||||
it("keeps warning badges readable on the paper background", () => {
|
||||
const klass = badgeClass({ variant: "warning" });
|
||||
|
||||
expect(klass).toContain("bg-overdue/10");
|
||||
expect(klass).toContain("text-ink");
|
||||
});
|
||||
|
||||
it("marks invalid inputs with conflict styling", () => {
|
||||
const klass = inputClass({ state: "error" });
|
||||
|
||||
expect(klass).toContain("border-conflict");
|
||||
expect(klass).toContain("focus-visible:ring-conflict/25");
|
||||
});
|
||||
|
||||
it("gives warning status notes a non-color icon slot friendly shell", () => {
|
||||
const klass = statusNoteClass({ variant: "warning" });
|
||||
|
||||
expect(klass).toContain("border-overdue");
|
||||
expect(klass).toContain("px-3");
|
||||
});
|
||||
|
||||
it("builds segmented control chrome", () => {
|
||||
const klass = segmentedClass();
|
||||
|
||||
expect(klass).toContain("inline-flex");
|
||||
expect(klass).toContain("border-line");
|
||||
});
|
||||
});
|
||||
141
apps/web/lib/ui/variants.ts
Normal file
141
apps/web/lib/ui/variants.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export type ButtonVariant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "ghost"
|
||||
| "danger";
|
||||
|
||||
export type ButtonSize = "sm" | "md" | "icon";
|
||||
|
||||
export type BadgeVariant =
|
||||
| "neutral"
|
||||
| "accent"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "info";
|
||||
|
||||
export type InputState = "default" | "error";
|
||||
|
||||
export type StatusNoteVariant = "info" | "success" | "warning" | "danger";
|
||||
|
||||
export function cn(...classes: Array<string | false | null | undefined>): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
|
||||
|
||||
const buttonVariants: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95",
|
||||
secondary:
|
||||
"border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar",
|
||||
outline:
|
||||
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]",
|
||||
ghost:
|
||||
"border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar",
|
||||
danger:
|
||||
"border-conflict bg-transparent text-conflict hover:bg-conflict/10",
|
||||
};
|
||||
|
||||
const buttonSizes: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
md: "px-4 py-2",
|
||||
icon: "h-9 w-9 p-0",
|
||||
};
|
||||
|
||||
export function buttonClass({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
className,
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(buttonBase, buttonVariants[variant], buttonSizes[size], className);
|
||||
}
|
||||
|
||||
const badgeBase =
|
||||
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs leading-5";
|
||||
|
||||
const badgeVariants: Record<BadgeVariant, string> = {
|
||||
neutral: "border-line bg-bg text-ink-soft",
|
||||
accent:
|
||||
"border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar",
|
||||
success: "border-pass/25 bg-pass/10 text-pass",
|
||||
warning: "border-overdue/35 bg-overdue/10 text-ink",
|
||||
danger: "border-conflict/25 bg-conflict/10 text-conflict",
|
||||
info: "border-info/25 bg-info/10 text-info",
|
||||
};
|
||||
|
||||
export function badgeClass({
|
||||
variant = "neutral",
|
||||
className,
|
||||
}: {
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(badgeBase, badgeVariants[variant], className);
|
||||
}
|
||||
|
||||
export function cardClass(className?: string): string {
|
||||
return cn("rounded border border-line bg-panel shadow-paper", className);
|
||||
}
|
||||
|
||||
const fieldTextBase = "block text-sm font-medium text-ink";
|
||||
|
||||
export function fieldLabelClass(className?: string): string {
|
||||
return cn(fieldTextBase, className);
|
||||
}
|
||||
|
||||
export function fieldHelpClass(className?: string): string {
|
||||
return cn("mt-1 text-xs leading-5 text-ink-soft", className);
|
||||
}
|
||||
|
||||
export function fieldErrorClass(className?: string): string {
|
||||
return cn("mt-1 text-xs leading-5 text-conflict", className);
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
"w-full rounded border bg-bg text-ink transition-colors placeholder:text-ink-soft/65 focus:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft";
|
||||
|
||||
const inputStates: Record<InputState, string> = {
|
||||
default: "border-line focus:border-cinnabar",
|
||||
error: "border-conflict focus:border-conflict focus-visible:ring-conflict/25",
|
||||
};
|
||||
|
||||
export function inputClass({
|
||||
state = "default",
|
||||
className,
|
||||
}: {
|
||||
state?: InputState;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(inputBase, inputStates[state], className);
|
||||
}
|
||||
|
||||
const statusNoteBase =
|
||||
"rounded border px-3 py-2 text-sm leading-6";
|
||||
|
||||
const statusNoteVariants: Record<StatusNoteVariant, string> = {
|
||||
info: "border-info/25 bg-info/10 text-info",
|
||||
success: "border-pass/25 bg-pass/10 text-pass",
|
||||
warning: "border-overdue/35 bg-overdue/10 text-ink",
|
||||
danger: "border-conflict/25 bg-conflict/10 text-conflict",
|
||||
};
|
||||
|
||||
export function statusNoteClass({
|
||||
variant = "info",
|
||||
className,
|
||||
}: {
|
||||
variant?: StatusNoteVariant;
|
||||
className?: string;
|
||||
} = {}): string {
|
||||
return cn(statusNoteBase, statusNoteVariants[variant], className);
|
||||
}
|
||||
|
||||
export function segmentedClass(className?: string): string {
|
||||
return cn("inline-flex rounded border border-line bg-bg p-1", className);
|
||||
}
|
||||
Reference in New Issue
Block a user