This commit is contained in:
parent
5e811d4bb8
commit
29098d1f61
40 changed files with 3113 additions and 515 deletions
58
README.md
58
README.md
|
|
@ -1,5 +1,59 @@
|
|||
# Quick Start
|
||||
# Methanium UI
|
||||
|
||||
```
|
||||
```sh
|
||||
pnpm install https://git.methanium.net/methanium/ui/releases/download/latest/methanium-ui.tgz
|
||||
```
|
||||
|
||||
Import the stylesheet once, then add the provider at the application root:
|
||||
|
||||
```tsx
|
||||
import "@methanium/ui/index.css";
|
||||
import { ThemeProvider } from "@methanium/ui";
|
||||
|
||||
<ThemeProvider>{children}</ThemeProvider>;
|
||||
```
|
||||
|
||||
## App Default
|
||||
|
||||
Vite applications can choose their initial parent theme at build time:
|
||||
|
||||
```ts
|
||||
import { methaniumUi } from "@methanium/ui/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [methaniumUi({ defaultThemeId: "tensamin" })],
|
||||
});
|
||||
```
|
||||
|
||||
The built-in parent themes are `tensamin`, `methanium`, and `secret`. Without
|
||||
the plugin or a provider override, the neutral base theme is used.
|
||||
Public Sans is bundled as the default typeface; users can switch to their
|
||||
platform's system font in the customizer.
|
||||
|
||||
## Custom Themes
|
||||
|
||||
Pass a typed manifest to `ThemeProvider` to add themes. Presets are ordinary
|
||||
CSS and remain fully editable after selection.
|
||||
|
||||
```ts
|
||||
import { BUILT_IN_THEMES, defineThemes } from "@methanium/ui";
|
||||
|
||||
export const themes = defineThemes([
|
||||
...BUILT_IN_THEMES,
|
||||
{
|
||||
id: "my-theme",
|
||||
title: "My Theme",
|
||||
logo: "/themes/my-logo.svg",
|
||||
css: `:root[data-theme="my-theme"] { --primary: #ff4f91; }`,
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
Use `<ThemeCustomizer />` for all theme editing. `<ThemeSelector />` is its
|
||||
focused parent-theme selection control.
|
||||
|
||||
## Onboarding
|
||||
|
||||
Every `OnboardingStep` requires a `title`; the flow owns title rendering and
|
||||
focus. `<ThemeOnboardingPage />` applies choices immediately and prevents
|
||||
continuing until the user explicitly selects a theme.
|
||||
|
|
|
|||
19
apps/showcase/eslint.config.js
Normal file
19
apps/showcase/eslint.config.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: { ecmaVersion: 2020, globals: globals.browser },
|
||||
},
|
||||
);
|
||||
13
apps/showcase/index.html
Normal file
13
apps/showcase/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<title>Methanium UI Showcase</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
apps/showcase/package.json
Normal file
32
apps/showcase/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@methanium/ui-showcase",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@methanium/ui": "workspace:*",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
56
apps/showcase/src/App.tsx
Normal file
56
apps/showcase/src/App.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
OnboardingFlow,
|
||||
ThemeCustomizer,
|
||||
ThemeOnboardingPage,
|
||||
type OnboardingStep,
|
||||
} from "@methanium/ui";
|
||||
|
||||
export default function App() {
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [onboardingThemeId, setOnboardingThemeId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const steps: OnboardingStep[] = [
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Usually the legal stuff",
|
||||
description: "but this is a showcase, so continue",
|
||||
content: <></>,
|
||||
},
|
||||
{
|
||||
id: "theme",
|
||||
title: "Customisation",
|
||||
description: "Let's just hope you don't get into decision paralysis",
|
||||
content: (
|
||||
<ThemeOnboardingPage
|
||||
value={onboardingThemeId}
|
||||
onValueChange={setOnboardingThemeId}
|
||||
/>
|
||||
),
|
||||
defaultCanContinue: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (showOnboarding)
|
||||
return (
|
||||
<div className="h-dvh">
|
||||
<OnboardingFlow
|
||||
steps={steps}
|
||||
onFinish={() => setShowOnboarding(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex max-w-[1500px] flex-col gap-7 p-5 md:p-10">
|
||||
<div>
|
||||
<Button variant="outline" onClick={() => setShowOnboarding(true)}>
|
||||
Open onboarding
|
||||
</Button>
|
||||
</div>
|
||||
<ThemeCustomizer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
7
apps/showcase/src/index.css
Normal file
7
apps/showcase/src/index.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@import "@methanium/ui/index.css";
|
||||
@source ".";
|
||||
@source "../../../src";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
14
apps/showcase/src/main.tsx
Normal file
14
apps/showcase/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ThemeProvider } from "@methanium/ui";
|
||||
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
1
apps/showcase/src/vite-env.d.ts
vendored
Normal file
1
apps/showcase/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
21
apps/showcase/tsconfig.app.json
Normal file
21
apps/showcase/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/showcase/tsconfig.json
Normal file
7
apps/showcase/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
17
apps/showcase/tsconfig.node.json
Normal file
17
apps/showcase/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
30
apps/showcase/vite.config.ts
Normal file
30
apps/showcase/vite.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
import { methaniumUi } from "../../src/vite.ts";
|
||||
|
||||
const monorepoRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
const uiSrc = fileURLToPath(new URL("../../src", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@methanium\/ui\/index\.css$/,
|
||||
replacement: `${uiSrc}/index.css`,
|
||||
},
|
||||
{ find: /^@methanium\/ui$/, replacement: `${uiSrc}/index.ts` },
|
||||
],
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
optimizeDeps: { exclude: ["@methanium/ui"] },
|
||||
server: { fs: { allow: [monorepoRoot] } },
|
||||
plugins: [
|
||||
methaniumUi({ defaultThemeId: "tensamin" }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
});
|
||||
18
package.json
18
package.json
|
|
@ -20,29 +20,37 @@
|
|||
"import": "./dist/node.js",
|
||||
"default": "./dist/node.js"
|
||||
},
|
||||
"./index.css": "./dist/index.css"
|
||||
"./index.css": "./dist/index.css",
|
||||
"./vite": {
|
||||
"types": "./dist/vite.d.ts",
|
||||
"import": "./dist/vite.js",
|
||||
"default": "./dist/vite.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm run build:js && pnpm run build:css",
|
||||
"build": "pnpm run build:js && pnpm run build:css && pnpm run build:assets",
|
||||
"build:js": "tsc -p tsconfig.build.json",
|
||||
"build:css": "tailwindcss -i ./src/index.css -o ./dist/index.css --minify",
|
||||
"build:assets": "rm -rf dist/theme/assets dist/files && mkdir -p dist/theme dist/files && cp -R src/theme/assets dist/theme/assets && cp node_modules/@fontsource-variable/public-sans/files/public-sans-*-wght-normal.woff2 dist/files/",
|
||||
"build:all": "pnpm run build && pnpm --filter @methanium/ui-showcase build",
|
||||
"build:showcase": "pnpm run build && pnpm --filter @methanium/ui-showcase build",
|
||||
"dev": "pnpm --filter @methanium/ui-showcase dev",
|
||||
"lint": "pnpm --filter @methanium/ui-showcase lint",
|
||||
"prepack": "pnpm run build",
|
||||
"postpack": "node -e \"const fs=require('node:fs');const pkg=require('./package.json');const from=pkg.name.replace(/^@/,'').replace(/[\\\\/]/g,'-')+'-'+pkg.version+'.tgz';fs.renameSync(from,'methanium-ui.tgz')\"",
|
||||
"format": "pnpm exec prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/public-sans": "^5.2.7",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"recharts": "3.8.1",
|
||||
"shadcn": "^4.11.0",
|
||||
|
|
|
|||
1025
pnpm-lock.yaml
generated
1025
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,2 +1,5 @@
|
|||
packages:
|
||||
- "apps/*"
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
"@parcel/watcher": false
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ function Button({
|
|||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ function Card({
|
|||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground border has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl border border-dotted bg-card/30 py-4 text-sm text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import { Button } from "./button";
|
|||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
logo?: ReactNode;
|
||||
content: ReactNode;
|
||||
defaultCanContinue?: boolean;
|
||||
}
|
||||
|
|
@ -51,6 +54,9 @@ function OnboardingFlow({
|
|||
steps: OnboardingStep[];
|
||||
onFinish: () => void | Promise<void>;
|
||||
}) {
|
||||
if (steps.length === 0) {
|
||||
throw new Error("OnboardingFlow requires at least one step");
|
||||
}
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [controls, setControls] = useState<OnboardingStepControls>(() =>
|
||||
defaultControls(steps[0]),
|
||||
|
|
@ -90,7 +96,7 @@ function OnboardingFlow({
|
|||
|
||||
useEffect(() => {
|
||||
const heading = document.querySelector<HTMLElement>(
|
||||
`[data-onboarding-step="${activeStep.id}"] h1`,
|
||||
`[data-onboarding-step="${activeStep.id}"] [data-onboarding-title]`,
|
||||
);
|
||||
heading?.focus();
|
||||
}, [activeStep.id]);
|
||||
|
|
@ -102,8 +108,27 @@ function OnboardingFlow({
|
|||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
data-onboarding-step={activeStep.id}
|
||||
>
|
||||
<header className="mx-auto flex w-full max-w-6xl items-start gap-4 px-6 pt-12 pb-7 md:px-10 md:pt-16">
|
||||
{activeStep.logo}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1
|
||||
data-onboarding-title
|
||||
className="font-heading text-3xl font-bold md:text-4xl"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{activeStep.title}
|
||||
</h1>
|
||||
{activeStep.description && (
|
||||
<div className="mt-2 text-lg text-muted-foreground">
|
||||
{activeStep.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<ConfigureStepContext.Provider value={configure}>
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pb-12 md:px-10">
|
||||
{activeStep.content}
|
||||
</div>
|
||||
</ConfigureStepContext.Provider>
|
||||
</main>
|
||||
|
||||
|
|
@ -111,6 +136,7 @@ function OnboardingFlow({
|
|||
<div className="flex justify-center md:justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full md:w-auto"
|
||||
disabled={!controls.canContinue || pending}
|
||||
onClick={() => void advance()}
|
||||
>
|
||||
|
|
@ -119,7 +145,7 @@ function OnboardingFlow({
|
|||
</div>
|
||||
|
||||
<nav
|
||||
className="mt-4 flex items-center justify-center gap-2"
|
||||
className="mt-4 hidden items-center justify-center gap-2 md:flex"
|
||||
aria-label="Onboarding steps"
|
||||
>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ function Toggle({
|
|||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/public-sans/wght.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans), sans-serif;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-sans: "Public Sans Variable", system-ui, sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
@ -50,15 +51,22 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
--ui-density: 1;
|
||||
--ui-border-width: 1px;
|
||||
--ui-shadow-strength: 0;
|
||||
--ui-surface-contrast: 0;
|
||||
--ui-font-scale: 1;
|
||||
--ui-heading-weight: 600;
|
||||
--ui-motion: 1;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.511 0.096 186.391);
|
||||
--primary-foreground: oklch(98.358% 0.0142 181.171);
|
||||
--primary-foreground-alt: oklch(0.704 0.14 182.503);
|
||||
--primary: oklch(0.24 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-foreground-alt: oklch(0.42 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
|
|
@ -68,7 +76,7 @@
|
|||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--ring: oklch(0.55 0 0);
|
||||
--chart-1: oklch(0.855 0.138 181.071);
|
||||
--chart-2: oklch(0.704 0.14 182.503);
|
||||
--chart-3: oklch(0.6 0.118 184.704);
|
||||
|
|
@ -77,8 +85,8 @@
|
|||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.6 0.118 184.704);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
|
||||
--sidebar-primary: oklch(0.3 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
|
|
@ -92,9 +100,9 @@
|
|||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.437 0.078 188.216);
|
||||
--primary-foreground: oklch(98.358% 0.0142 181.171);
|
||||
--primary-foreground-alt: oklch(0.704 0.14 182.503);
|
||||
--primary: oklch(0.82 0 0);
|
||||
--primary-foreground: oklch(0.18 0 0);
|
||||
--primary-foreground-alt: oklch(0.72 0 0);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
|
|
@ -112,8 +120,8 @@
|
|||
--chart-5: oklch(0.437 0.078 188.216);
|
||||
--sidebar: oklch(0.19 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.704 0.14 182.503);
|
||||
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
|
||||
--sidebar-primary: oklch(0.78 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.18 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
|
|
@ -194,8 +202,58 @@
|
|||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
font-size: calc(16px * var(--ui-font-scale));
|
||||
}
|
||||
|
||||
html[data-font="system"] {
|
||||
--font-sans:
|
||||
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
:where(
|
||||
[data-slot="button"],
|
||||
[data-slot="toggle"],
|
||||
[data-slot="input"],
|
||||
[data-slot="textarea"],
|
||||
[data-slot="native-select"],
|
||||
[data-slot="select-trigger"],
|
||||
[data-slot="input-group"],
|
||||
[data-slot="card"],
|
||||
[data-slot="item"]
|
||||
) {
|
||||
border-width: var(--ui-border-width);
|
||||
transition-duration: calc(140ms * var(--ui-motion));
|
||||
}
|
||||
|
||||
:where([data-slot="button"], [data-slot="toggle"])[data-size="default"],
|
||||
[data-slot="input"],
|
||||
[data-slot="select-trigger"][data-size="default"] {
|
||||
height: calc(2rem * var(--ui-density));
|
||||
}
|
||||
|
||||
:where([data-slot="button"], [data-slot="toggle"]):not([data-size^="icon"]) {
|
||||
padding-inline: calc(0.625rem * var(--ui-density));
|
||||
}
|
||||
|
||||
[data-slot="item"] {
|
||||
padding: calc(0.625rem * var(--ui-density)) calc(0.75rem * var(--ui-density));
|
||||
}
|
||||
[data-slot="card"] {
|
||||
background: color-mix(
|
||||
in oklch,
|
||||
var(--card),
|
||||
var(--foreground) calc(var(--ui-surface-contrast) * 1%)
|
||||
);
|
||||
box-shadow: 0 calc(0.4rem * var(--ui-shadow-strength))
|
||||
calc(1.8rem * var(--ui-shadow-strength))
|
||||
rgb(0 0 0 / calc(0.12 * var(--ui-shadow-strength)));
|
||||
}
|
||||
:where(h1, h2, h3, [data-slot="card-title"], [data-slot="item-title"]) {
|
||||
font-weight: var(--ui-heading-weight);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
::selection {
|
||||
background: var(--color-primary, var(--primary));
|
||||
color: var(--color-primary-foreground, var(--primary-foreground));
|
||||
|
|
|
|||
118
src/theme/BaseThemeSelector.tsx
Normal file
118
src/theme/BaseThemeSelector.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../cmp/alert-dialog";
|
||||
import { Button } from "../cmp/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useTheme } from "./Provider";
|
||||
|
||||
export type ThemeSelectorProps = {
|
||||
value?: string | null;
|
||||
onValueChange?: (id: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ThemeSelector({
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
}: ThemeSelectorProps) {
|
||||
const { themes, parentThemeId, applyThemePreset, isThemeCustomized } =
|
||||
useTheme();
|
||||
const selected = value === undefined ? parentThemeId : value;
|
||||
const [pendingThemeId, setPendingThemeId] = useState<string | null>(null);
|
||||
|
||||
const apply = (id: string) => {
|
||||
applyThemePreset(id);
|
||||
onValueChange?.(id);
|
||||
};
|
||||
const choose = (id: string) => {
|
||||
if (id === selected) return;
|
||||
if (isThemeCustomized && parentThemeId && parentThemeId !== id) {
|
||||
setPendingThemeId(id);
|
||||
return;
|
||||
}
|
||||
apply(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Parent theme"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
>
|
||||
{themes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex w-16 flex-col items-center gap-1.5"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={theme.title}
|
||||
aria-pressed={selected === theme.id}
|
||||
onClick={() => choose(theme.id)}
|
||||
className={cn(
|
||||
"size-14 overflow-hidden rounded-md p-1.5",
|
||||
selected === theme.id &&
|
||||
"border-primary ring-2 ring-primary/20 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={theme.logo}
|
||||
alt=""
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
</Button>
|
||||
<span className="font-heading text-xs font-semibold">
|
||||
{theme.title}
|
||||
{selected === theme.id && isThemeCustomized && (
|
||||
<span className="ml-1 font-normal text-muted-foreground">
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingThemeId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingThemeId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Replace your custom theme?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Choosing another parent theme replaces your current visual
|
||||
settings and custom CSS. This cannot be undone after leaving this
|
||||
page.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (pendingThemeId) apply(pendingThemeId);
|
||||
setPendingThemeId(null);
|
||||
}}
|
||||
>
|
||||
Replace theme
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
src/theme/OnboardingPage.tsx
Normal file
29
src/theme/OnboardingPage.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { useOnboardingStep } from "../cmp/onboarding-flow";
|
||||
import { ThemeCustomizer } from "./pickers/StylePicker";
|
||||
|
||||
export function ThemeOnboardingPage({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value?: string | null;
|
||||
onValueChange?: (id: string) => void;
|
||||
}) {
|
||||
const [localThemeId, setLocalThemeId] = useState<string | null>(null);
|
||||
const chosenThemeId = value === undefined ? localThemeId : value;
|
||||
useOnboardingStep({ canContinue: chosenThemeId !== null });
|
||||
|
||||
const choose = (id: string) => {
|
||||
setLocalThemeId(id);
|
||||
onValueChange?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeCustomizer
|
||||
value={chosenThemeId}
|
||||
onValueChange={choose}
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
502
src/theme/Provider.tsx
Normal file
502
src/theme/Provider.tsx
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { BUILT_IN_THEMES, DEFAULT_THEME_DESIGN, findTheme } from "./presets";
|
||||
import type {
|
||||
Base16Palette,
|
||||
ResolvedThemePolarity,
|
||||
ThemeDesign,
|
||||
ThemeFontFamily,
|
||||
ThemeProviderProps,
|
||||
ThemeProviderState,
|
||||
ThemeTint,
|
||||
} from "./types";
|
||||
import {
|
||||
COLOR_SCHEME_QUERY,
|
||||
DEFAULT_BORDER_RADIUS,
|
||||
DEFAULT_THEME_COLOR,
|
||||
THEME_VARIABLE_NAMES,
|
||||
disableTransitionsTemporarily,
|
||||
getBase16ThemeVariables,
|
||||
getPrimaryThemeVariables,
|
||||
getSystemTheme,
|
||||
getThemeVariables,
|
||||
isTheme,
|
||||
isThemeColor,
|
||||
isTint,
|
||||
parseBorderRadius,
|
||||
parseStoredBase16Palette,
|
||||
readStoredValue,
|
||||
saveStoredValue,
|
||||
serializeBase16Palette,
|
||||
} from "./utils";
|
||||
|
||||
declare const __METHANIUM_UI_DEFAULT_THEME_ID__: string | undefined;
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const CUSTOM_CSS_STYLE_ID = "methanium-theme-custom-css";
|
||||
const FONT_FAMILIES: ThemeFontFamily[] = ["public-sans", "system"];
|
||||
const DESIGN_NUMBER_FIELDS = [
|
||||
"density",
|
||||
"borderWidth",
|
||||
"shadowStrength",
|
||||
"surfaceContrast",
|
||||
"fontScale",
|
||||
"headingWeight",
|
||||
"motion",
|
||||
] as const;
|
||||
const useIsomorphicLayoutEffect =
|
||||
typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
function configuredDefaultThemeId() {
|
||||
return typeof __METHANIUM_UI_DEFAULT_THEME_ID__ === "string"
|
||||
? __METHANIUM_UI_DEFAULT_THEME_ID__
|
||||
: null;
|
||||
}
|
||||
|
||||
function storageValue(key: string | null) {
|
||||
return key && typeof window !== "undefined"
|
||||
? localStorage.getItem(key)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isThemeDesign(value: unknown): value is ThemeDesign {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const design = value as Record<string, unknown>;
|
||||
return (
|
||||
DESIGN_NUMBER_FIELDS.every((field) => typeof design[field] === "number") &&
|
||||
FONT_FAMILIES.includes(design.fontFamily as ThemeFontFamily)
|
||||
);
|
||||
}
|
||||
|
||||
function parseDesign(value: string | null, fallback: ThemeDesign) {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return isThemeDesign(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function storedDesign(key: string | null, fallback: ThemeDesign) {
|
||||
return parseDesign(storageValue(key), fallback);
|
||||
}
|
||||
|
||||
function designsEqual(left: ThemeDesign, right: ThemeDesign) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
defaultColor = DEFAULT_THEME_COLOR,
|
||||
defaultPalette = null,
|
||||
defaultPrimaryColor = "",
|
||||
defaultTint = "soft",
|
||||
defaultBorderRadius = DEFAULT_BORDER_RADIUS,
|
||||
defaultCustomCss = "",
|
||||
themes = BUILT_IN_THEMES,
|
||||
defaultParentThemeId = configuredDefaultThemeId(),
|
||||
defaultDesign = DEFAULT_THEME_DESIGN,
|
||||
storageKey = "theme",
|
||||
colorStorageKey = "theme_color",
|
||||
paletteStorageKey = "theme_palette",
|
||||
primaryColorStorageKey = "theme_primary_color",
|
||||
tintStorageKey = "theme_tint",
|
||||
borderRadiusStorageKey = "theme_border_radius",
|
||||
customCssStorageKey = "theme_custom_css",
|
||||
parentThemeStorageKey = "theme_parent",
|
||||
designStorageKey = "theme_design",
|
||||
disableTransitionOnChange = true,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const configuredParent = findTheme(themes, defaultParentThemeId);
|
||||
const [parentThemeId, setParentThemeIdState] = useState<string | null>(() => {
|
||||
const stored = storageValue(parentThemeStorageKey);
|
||||
return findTheme(themes, stored)?.id ?? configuredParent?.id ?? null;
|
||||
});
|
||||
const initialParent = findTheme(themes, parentThemeId);
|
||||
const [themePolarity, setThemePolarityState] = useState(() =>
|
||||
readStoredValue(storageKey, isTheme, defaultTheme),
|
||||
);
|
||||
const [themeColor, setThemeColorState] = useState<string>(() =>
|
||||
readStoredValue(colorStorageKey, isThemeColor, defaultColor),
|
||||
);
|
||||
const [themePalette, setThemePaletteState] = useState<Base16Palette | null>(
|
||||
() => {
|
||||
const stored = storageValue(paletteStorageKey);
|
||||
return stored === null
|
||||
? defaultPalette
|
||||
: parseStoredBase16Palette(stored);
|
||||
},
|
||||
);
|
||||
const [themePrimaryColor, setThemePrimaryColorState] = useState(() =>
|
||||
readStoredValue(primaryColorStorageKey, isThemeColor, defaultPrimaryColor),
|
||||
);
|
||||
const [themeTint, setThemeTintState] = useState<ThemeTint>(() =>
|
||||
readStoredValue(tintStorageKey, isTint, defaultTint),
|
||||
);
|
||||
const [themeBorderRadius, setThemeBorderRadiusState] = useState(
|
||||
() =>
|
||||
parseBorderRadius(storageValue(borderRadiusStorageKey)) ??
|
||||
defaultBorderRadius,
|
||||
);
|
||||
const [themeCustomCss, setThemeCustomCssState] = useState(() => {
|
||||
const storedCss = storageValue(customCssStorageKey);
|
||||
const hadStoredParent = storageValue(parentThemeStorageKey) !== null;
|
||||
if (storedCss === null || (storedCss === "" && !hadStoredParent)) {
|
||||
return initialParent?.css ?? defaultCustomCss;
|
||||
}
|
||||
return storedCss;
|
||||
});
|
||||
const [themeDesign, setThemeDesignState] = useState(() =>
|
||||
storedDesign(designStorageKey, defaultDesign),
|
||||
);
|
||||
const [systemPolarity, setSystemPolarity] = useState<ResolvedThemePolarity>(
|
||||
() => getSystemTheme(),
|
||||
);
|
||||
|
||||
const parentTheme = findTheme(themes, parentThemeId);
|
||||
const resolvedPolarity =
|
||||
themePolarity === "system" ? systemPolarity : themePolarity;
|
||||
|
||||
const setParentThemeId = useCallback(
|
||||
(id: string | null) => {
|
||||
const next = findTheme(themes, id)?.id ?? null;
|
||||
saveStoredValue(parentThemeStorageKey, next ?? "");
|
||||
setParentThemeIdState(next);
|
||||
},
|
||||
[parentThemeStorageKey, themes],
|
||||
);
|
||||
|
||||
const setThemePolarity = useCallback(
|
||||
(next: typeof themePolarity) => {
|
||||
saveStoredValue(storageKey, next);
|
||||
setThemePolarityState(next);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
const setThemeColor = useCallback(
|
||||
(next: string) => {
|
||||
if (!isThemeColor(next)) return;
|
||||
saveStoredValue(colorStorageKey, next);
|
||||
setThemeColorState(next);
|
||||
},
|
||||
[colorStorageKey],
|
||||
);
|
||||
const setThemePalette = useCallback(
|
||||
(next: Base16Palette | null) => {
|
||||
saveStoredValue(paletteStorageKey, serializeBase16Palette(next));
|
||||
setThemePaletteState(next);
|
||||
},
|
||||
[paletteStorageKey],
|
||||
);
|
||||
const setThemePrimaryColor = useCallback(
|
||||
(next: string) => {
|
||||
if (!isThemeColor(next)) return;
|
||||
saveStoredValue(primaryColorStorageKey, next);
|
||||
setThemePrimaryColorState(next);
|
||||
},
|
||||
[primaryColorStorageKey],
|
||||
);
|
||||
const setThemeTint = useCallback(
|
||||
(next: ThemeTint) => {
|
||||
saveStoredValue(tintStorageKey, next);
|
||||
setThemeTintState(next);
|
||||
},
|
||||
[tintStorageKey],
|
||||
);
|
||||
const setThemeBorderRadius = useCallback(
|
||||
(next: number) => {
|
||||
const radius = parseBorderRadius(String(next));
|
||||
if (radius === null) return;
|
||||
saveStoredValue(borderRadiusStorageKey, String(radius));
|
||||
setThemeBorderRadiusState(radius);
|
||||
},
|
||||
[borderRadiusStorageKey],
|
||||
);
|
||||
const setThemeCustomCss = useCallback(
|
||||
(next: string) => {
|
||||
saveStoredValue(customCssStorageKey, next);
|
||||
setThemeCustomCssState(next);
|
||||
},
|
||||
[customCssStorageKey],
|
||||
);
|
||||
const setThemeDesign = useCallback(
|
||||
(next: ThemeDesign | ((current: ThemeDesign) => ThemeDesign)) => {
|
||||
setThemeDesignState((current) => {
|
||||
const value = typeof next === "function" ? next(current) : next;
|
||||
saveStoredValue(designStorageKey, JSON.stringify(value));
|
||||
return value;
|
||||
});
|
||||
},
|
||||
[designStorageKey],
|
||||
);
|
||||
|
||||
const applyThemePreset = useCallback(
|
||||
(id: string) => {
|
||||
const preset = findTheme(themes, id);
|
||||
if (!preset) throw new Error(`Unknown theme preset: ${id}`);
|
||||
setParentThemeId(preset.id);
|
||||
setThemeColor("");
|
||||
setThemePalette(null);
|
||||
setThemePrimaryColor("");
|
||||
setThemeTint("soft");
|
||||
setThemeBorderRadius(DEFAULT_BORDER_RADIUS);
|
||||
setThemeDesign(defaultDesign);
|
||||
setThemeCustomCss(preset.css);
|
||||
},
|
||||
[
|
||||
defaultDesign,
|
||||
setParentThemeId,
|
||||
setThemeBorderRadius,
|
||||
setThemeColor,
|
||||
setThemeCustomCss,
|
||||
setThemeDesign,
|
||||
setThemePalette,
|
||||
setThemePrimaryColor,
|
||||
setThemeTint,
|
||||
themes,
|
||||
],
|
||||
);
|
||||
const resetThemePreset = useCallback(() => {
|
||||
if (parentTheme) applyThemePreset(parentTheme.id);
|
||||
}, [applyThemePreset, parentTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY);
|
||||
const handleChange = () => setSystemPolarity(getSystemTheme());
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const restoreTransitions = disableTransitionOnChange
|
||||
? disableTransitionsTemporarily()
|
||||
: null;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolvedPolarity);
|
||||
if (parentThemeId) root.dataset.theme = parentThemeId;
|
||||
else delete root.dataset.theme;
|
||||
|
||||
for (const name of THEME_VARIABLE_NAMES) root.style.removeProperty(name);
|
||||
const variables = themePalette
|
||||
? getBase16ThemeVariables(themePalette)
|
||||
: themeColor
|
||||
? getThemeVariables(themeColor, resolvedPolarity, themeTint)
|
||||
: null;
|
||||
if (variables) {
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
if (themePrimaryColor) {
|
||||
for (const [name, value] of Object.entries(
|
||||
getPrimaryThemeVariables(themePrimaryColor),
|
||||
)) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
root.style.setProperty("--radius", `${themeBorderRadius}rem`);
|
||||
root.style.setProperty("--ui-density", String(themeDesign.density));
|
||||
root.style.setProperty("--ui-border-width", `${themeDesign.borderWidth}px`);
|
||||
root.style.setProperty(
|
||||
"--ui-shadow-strength",
|
||||
String(themeDesign.shadowStrength),
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--ui-surface-contrast",
|
||||
String(themeDesign.surfaceContrast),
|
||||
);
|
||||
root.style.setProperty("--ui-font-scale", String(themeDesign.fontScale));
|
||||
root.style.setProperty(
|
||||
"--ui-heading-weight",
|
||||
String(themeDesign.headingWeight),
|
||||
);
|
||||
root.style.setProperty("--ui-motion", String(themeDesign.motion));
|
||||
root.dataset.font = themeDesign.fontFamily;
|
||||
restoreTransitions?.();
|
||||
}, [
|
||||
disableTransitionOnChange,
|
||||
parentThemeId,
|
||||
resolvedPolarity,
|
||||
themeBorderRadius,
|
||||
themeColor,
|
||||
themeDesign,
|
||||
themePalette,
|
||||
themePrimaryColor,
|
||||
themeTint,
|
||||
]);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const existing = document.getElementById(CUSTOM_CSS_STYLE_ID);
|
||||
if (!themeCustomCss.trim()) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
const style = existing ?? document.createElement("style");
|
||||
style.id = CUSTOM_CSS_STYLE_ID;
|
||||
style.textContent = themeCustomCss;
|
||||
if (!existing) document.head.appendChild(style);
|
||||
}, [themeCustomCss]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handlers = new Map<string, (value: string | null) => void>();
|
||||
const register = (
|
||||
key: string | null,
|
||||
handler: (value: string | null) => void,
|
||||
) => {
|
||||
if (key) handlers.set(key, handler);
|
||||
};
|
||||
register(storageKey, (stored) =>
|
||||
setThemePolarityState(isTheme(stored) ? stored : defaultTheme),
|
||||
);
|
||||
register(colorStorageKey, (stored) =>
|
||||
setThemeColorState(isThemeColor(stored) ? stored : defaultColor),
|
||||
);
|
||||
register(paletteStorageKey, (stored) =>
|
||||
setThemePaletteState(parseStoredBase16Palette(stored)),
|
||||
);
|
||||
register(primaryColorStorageKey, (stored) =>
|
||||
setThemePrimaryColorState(
|
||||
isThemeColor(stored) ? stored : defaultPrimaryColor,
|
||||
),
|
||||
);
|
||||
register(tintStorageKey, (stored) =>
|
||||
setThemeTintState(isTint(stored) ? stored : defaultTint),
|
||||
);
|
||||
register(borderRadiusStorageKey, (stored) =>
|
||||
setThemeBorderRadiusState(
|
||||
parseBorderRadius(stored) ?? defaultBorderRadius,
|
||||
),
|
||||
);
|
||||
register(customCssStorageKey, (stored) =>
|
||||
setThemeCustomCssState(stored ?? defaultCustomCss),
|
||||
);
|
||||
register(parentThemeStorageKey, (stored) =>
|
||||
setParentThemeIdState(findTheme(themes, stored)?.id ?? null),
|
||||
);
|
||||
register(designStorageKey, (stored) =>
|
||||
setThemeDesignState(parseDesign(stored, defaultDesign)),
|
||||
);
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.storageArea !== localStorage) return;
|
||||
if (event.key) handlers.get(event.key)?.(event.newValue);
|
||||
};
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, [
|
||||
borderRadiusStorageKey,
|
||||
colorStorageKey,
|
||||
customCssStorageKey,
|
||||
defaultBorderRadius,
|
||||
defaultColor,
|
||||
defaultCustomCss,
|
||||
defaultDesign,
|
||||
defaultPrimaryColor,
|
||||
defaultTheme,
|
||||
defaultTint,
|
||||
designStorageKey,
|
||||
paletteStorageKey,
|
||||
parentThemeStorageKey,
|
||||
primaryColorStorageKey,
|
||||
storageKey,
|
||||
themes,
|
||||
tintStorageKey,
|
||||
]);
|
||||
|
||||
const isThemeCustomized = Boolean(
|
||||
parentTheme &&
|
||||
(themeCustomCss !== parentTheme.css ||
|
||||
themeColor !== "" ||
|
||||
themePalette !== null ||
|
||||
themePrimaryColor !== "" ||
|
||||
themeTint !== "soft" ||
|
||||
themeBorderRadius !== DEFAULT_BORDER_RADIUS ||
|
||||
!designsEqual(themeDesign, defaultDesign)),
|
||||
);
|
||||
|
||||
const value = useMemo<ThemeProviderState>(
|
||||
() => ({
|
||||
theme: themePolarity,
|
||||
setTheme: setThemePolarity,
|
||||
themeColor,
|
||||
setThemeColor,
|
||||
themePalette,
|
||||
setThemePalette,
|
||||
themePrimaryColor,
|
||||
setThemePrimaryColor,
|
||||
themePolarity,
|
||||
setThemePolarity,
|
||||
resolvedPolarity,
|
||||
themeTint,
|
||||
setThemeTint,
|
||||
themeBorderRadius,
|
||||
setThemeBorderRadius,
|
||||
themeCustomCss,
|
||||
setThemeCustomCss,
|
||||
themes,
|
||||
parentThemeId,
|
||||
parentTheme,
|
||||
setParentThemeId,
|
||||
applyThemePreset,
|
||||
resetThemePreset,
|
||||
isThemeCustomized,
|
||||
themeDesign,
|
||||
setThemeDesign,
|
||||
}),
|
||||
[
|
||||
applyThemePreset,
|
||||
isThemeCustomized,
|
||||
parentTheme,
|
||||
parentThemeId,
|
||||
resetThemePreset,
|
||||
resolvedPolarity,
|
||||
setParentThemeId,
|
||||
setThemeBorderRadius,
|
||||
setThemeColor,
|
||||
setThemeCustomCss,
|
||||
setThemeDesign,
|
||||
setThemePalette,
|
||||
setThemePolarity,
|
||||
setThemePrimaryColor,
|
||||
setThemeTint,
|
||||
themeBorderRadius,
|
||||
themeColor,
|
||||
themeCustomCss,
|
||||
themeDesign,
|
||||
themePalette,
|
||||
themePolarity,
|
||||
themePrimaryColor,
|
||||
themeTint,
|
||||
themes,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
if (!context) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
Base16Palette,
|
||||
ResolvedThemePolarity,
|
||||
ThemeProviderProps,
|
||||
ThemeProviderState,
|
||||
ThemeTint,
|
||||
} from "./types";
|
||||
import {
|
||||
COLOR_SCHEME_QUERY,
|
||||
DEFAULT_BORDER_RADIUS,
|
||||
DEFAULT_THEME_COLOR,
|
||||
THEME_VARIABLE_NAMES,
|
||||
disableTransitionsTemporarily,
|
||||
getBase16ThemeVariables,
|
||||
getPrimaryThemeVariables,
|
||||
getSystemTheme,
|
||||
getThemeVariables,
|
||||
isTheme,
|
||||
isThemeColor,
|
||||
isTint,
|
||||
parseBorderRadius,
|
||||
parseStoredBase16Palette,
|
||||
readStoredValue,
|
||||
saveStoredValue,
|
||||
serializeBase16Palette,
|
||||
} from "./utils";
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const CUSTOM_CSS_STYLE_ID = "tensamin-theme-custom-css";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
defaultColor = DEFAULT_THEME_COLOR,
|
||||
defaultPalette = null,
|
||||
defaultPrimaryColor = "",
|
||||
defaultTint = "soft",
|
||||
defaultBorderRadius = DEFAULT_BORDER_RADIUS,
|
||||
defaultCustomCss = "",
|
||||
storageKey = "theme",
|
||||
colorStorageKey = "theme_color",
|
||||
paletteStorageKey = "theme_palette",
|
||||
primaryColorStorageKey = "theme_primary_color",
|
||||
tintStorageKey = "theme_tint",
|
||||
borderRadiusStorageKey = "theme_border_radius",
|
||||
customCssStorageKey = "theme_custom_css",
|
||||
disableTransitionOnChange = true,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [themePolarity, setThemePolarityState] = useState(() =>
|
||||
readStoredValue(storageKey, isTheme, defaultTheme),
|
||||
);
|
||||
const [themeColor, setThemeColorState] = useState<string>(() =>
|
||||
readStoredValue(colorStorageKey, isThemeColor, defaultColor),
|
||||
);
|
||||
const [themePalette, setThemePaletteState] = useState<Base16Palette | null>(
|
||||
() => {
|
||||
if (!paletteStorageKey) return defaultPalette;
|
||||
return parseStoredBase16Palette(localStorage.getItem(paletteStorageKey));
|
||||
},
|
||||
);
|
||||
const [themePrimaryColor, setThemePrimaryColorState] = useState(() =>
|
||||
readStoredValue(primaryColorStorageKey, isThemeColor, defaultPrimaryColor),
|
||||
);
|
||||
const [themeTint, setThemeTintState] = useState<ThemeTint>(() =>
|
||||
readStoredValue(tintStorageKey, isTint, defaultTint),
|
||||
);
|
||||
const [themeBorderRadius, setThemeBorderRadiusState] = useState(
|
||||
() =>
|
||||
parseBorderRadius(
|
||||
borderRadiusStorageKey
|
||||
? localStorage.getItem(borderRadiusStorageKey)
|
||||
: null,
|
||||
) ?? defaultBorderRadius,
|
||||
);
|
||||
const [themeCustomCss, setThemeCustomCssState] = useState(() =>
|
||||
customCssStorageKey
|
||||
? (localStorage.getItem(customCssStorageKey) ?? defaultCustomCss)
|
||||
: defaultCustomCss,
|
||||
);
|
||||
const [systemPolarity, setSystemPolarity] = useState<ResolvedThemePolarity>(
|
||||
() => getSystemTheme(),
|
||||
);
|
||||
|
||||
const resolvedPolarity =
|
||||
themePolarity === "system" ? systemPolarity : themePolarity;
|
||||
|
||||
const setThemePolarity = useCallback(
|
||||
(nextPolarity: typeof themePolarity) => {
|
||||
saveStoredValue(storageKey, nextPolarity);
|
||||
setThemePolarityState(nextPolarity);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const setThemeColor = useCallback(
|
||||
(nextColor: string) => {
|
||||
if (!isThemeColor(nextColor)) return;
|
||||
|
||||
saveStoredValue(colorStorageKey, nextColor);
|
||||
setThemeColorState(nextColor);
|
||||
},
|
||||
[colorStorageKey],
|
||||
);
|
||||
|
||||
const setThemePalette = useCallback(
|
||||
(nextPalette: Base16Palette | null) => {
|
||||
saveStoredValue(paletteStorageKey, serializeBase16Palette(nextPalette));
|
||||
setThemePaletteState(nextPalette);
|
||||
},
|
||||
[paletteStorageKey],
|
||||
);
|
||||
|
||||
const setThemePrimaryColor = useCallback(
|
||||
(nextColor: string) => {
|
||||
if (!isThemeColor(nextColor)) return;
|
||||
|
||||
saveStoredValue(primaryColorStorageKey, nextColor);
|
||||
setThemePrimaryColorState(nextColor);
|
||||
},
|
||||
[primaryColorStorageKey],
|
||||
);
|
||||
|
||||
const setThemeTint = useCallback(
|
||||
(nextTint: ThemeTint) => {
|
||||
saveStoredValue(tintStorageKey, nextTint);
|
||||
setThemeTintState(nextTint);
|
||||
},
|
||||
[tintStorageKey],
|
||||
);
|
||||
|
||||
const setThemeBorderRadius = useCallback(
|
||||
(nextRadius: number) => {
|
||||
const radius = parseBorderRadius(String(nextRadius));
|
||||
if (radius === null) return;
|
||||
|
||||
saveStoredValue(borderRadiusStorageKey, String(radius));
|
||||
setThemeBorderRadiusState(radius);
|
||||
},
|
||||
[borderRadiusStorageKey],
|
||||
);
|
||||
|
||||
const setThemeCustomCss = useCallback(
|
||||
(nextCss: string) => {
|
||||
saveStoredValue(customCssStorageKey, nextCss);
|
||||
setThemeCustomCssState(nextCss);
|
||||
},
|
||||
[customCssStorageKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY);
|
||||
const handleChange = () => {
|
||||
setSystemPolarity(getSystemTheme());
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const restoreTransitions = disableTransitionOnChange
|
||||
? disableTransitionsTemporarily()
|
||||
: null;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolvedPolarity);
|
||||
|
||||
for (const name of THEME_VARIABLE_NAMES) {
|
||||
root.style.removeProperty(name);
|
||||
}
|
||||
|
||||
const variables = themePalette
|
||||
? getBase16ThemeVariables(themePalette)
|
||||
: themeColor === ""
|
||||
? null
|
||||
: getThemeVariables(themeColor, resolvedPolarity, themeTint);
|
||||
|
||||
if (variables) {
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (themePrimaryColor !== "") {
|
||||
for (const [name, value] of Object.entries(
|
||||
getPrimaryThemeVariables(themePrimaryColor),
|
||||
)) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (restoreTransitions) restoreTransitions();
|
||||
}, [
|
||||
disableTransitionOnChange,
|
||||
resolvedPolarity,
|
||||
themeColor,
|
||||
themePalette,
|
||||
themePrimaryColor,
|
||||
themeTint,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--radius",
|
||||
`${themeBorderRadius}rem`,
|
||||
);
|
||||
}, [themeBorderRadius]);
|
||||
|
||||
useEffect(() => {
|
||||
const existingStyle = document.getElementById(CUSTOM_CSS_STYLE_ID);
|
||||
|
||||
if (themeCustomCss.trim() === "") {
|
||||
existingStyle?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const style = existingStyle ?? document.createElement("style");
|
||||
style.id = CUSTOM_CSS_STYLE_ID;
|
||||
style.textContent = themeCustomCss;
|
||||
|
||||
if (!existingStyle) document.head.appendChild(style);
|
||||
}, [themeCustomCss]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.storageArea !== localStorage) return;
|
||||
|
||||
if (event.key === storageKey) {
|
||||
setThemePolarityState(
|
||||
isTheme(event.newValue) ? event.newValue : defaultTheme,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === colorStorageKey) {
|
||||
setThemeColorState(
|
||||
isThemeColor(event.newValue) ? event.newValue : defaultColor,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === tintStorageKey) {
|
||||
setThemeTintState(
|
||||
isTint(event.newValue) ? event.newValue : defaultTint,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === borderRadiusStorageKey) {
|
||||
setThemeBorderRadiusState(
|
||||
parseBorderRadius(event.newValue) ?? defaultBorderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === customCssStorageKey) {
|
||||
setThemeCustomCssState(event.newValue ?? defaultCustomCss);
|
||||
}
|
||||
|
||||
if (event.key === paletteStorageKey) {
|
||||
setThemePaletteState(parseStoredBase16Palette(event.newValue));
|
||||
}
|
||||
|
||||
if (event.key === primaryColorStorageKey) {
|
||||
setThemePrimaryColorState(
|
||||
isThemeColor(event.newValue) ? event.newValue : defaultPrimaryColor,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
};
|
||||
}, [
|
||||
borderRadiusStorageKey,
|
||||
colorStorageKey,
|
||||
customCssStorageKey,
|
||||
defaultBorderRadius,
|
||||
defaultColor,
|
||||
defaultCustomCss,
|
||||
defaultPrimaryColor,
|
||||
defaultTheme,
|
||||
defaultTint,
|
||||
paletteStorageKey,
|
||||
primaryColorStorageKey,
|
||||
storageKey,
|
||||
tintStorageKey,
|
||||
]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme: themePolarity,
|
||||
setTheme: setThemePolarity,
|
||||
themeColor,
|
||||
setThemeColor,
|
||||
themePalette,
|
||||
setThemePalette,
|
||||
themePrimaryColor,
|
||||
setThemePrimaryColor,
|
||||
themePolarity,
|
||||
setThemePolarity,
|
||||
resolvedPolarity,
|
||||
themeTint,
|
||||
setThemeTint,
|
||||
themeBorderRadius,
|
||||
setThemeBorderRadius,
|
||||
themeCustomCss,
|
||||
setThemeCustomCss,
|
||||
}),
|
||||
[
|
||||
resolvedPolarity,
|
||||
setThemeBorderRadius,
|
||||
setThemeColor,
|
||||
setThemeCustomCss,
|
||||
setThemePalette,
|
||||
setThemePolarity,
|
||||
setThemePrimaryColor,
|
||||
setThemeTint,
|
||||
themeColor,
|
||||
themeBorderRadius,
|
||||
themeCustomCss,
|
||||
themePalette,
|
||||
themePolarity,
|
||||
themePrimaryColor,
|
||||
themeTint,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
27
src/theme/assets/methanium-logo.svg
Normal file
27
src/theme/assets/methanium-logo.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="4441.4" height="6468.8" shape-rendering="geometricPrecision" viewBox="0 0 4441 6469">
|
||||
<path fill="#331c4e" d="m1714 6033-33-248-5-676 69-1110-247-2069-388-302 196 3614Z"/>
|
||||
<path fill="#6a438a" d="m1864 2038-214 3093 31 640 820-1486 125-2262Z"/>
|
||||
<path fill="#291641" d="m3130 1586-504 437-125 2262 305 315Z"/>
|
||||
<path fill="#0e0619" d="m2501 4285 305 315-120 1018-384 830h-56Z"/>
|
||||
<path fill="#211136" d="m1762 6196 247 254 237-2 255-2163-822 1476 17 221Z"/>
|
||||
<path fill="#3f245b" d="m740 4907 255-267 719 1393 48 163-340-112Z"/>
|
||||
<path fill="#05020d" d="m1407 6080-147 198 414 172h335l-247-254Z"/>
|
||||
<path fill="#030106" d="m1273 6283-85 173 486-6Z"/>
|
||||
<path fill="#0a0413" d="m10 5229 297-41 966 1095-85 173-100 3Z"/>
|
||||
<path fill="#5b3878" d="m740 4907 682 1177-149 199-966-1095Z"/>
|
||||
<path fill="#794f9a" d="M159 4369 10 5229l297-41Z"/>
|
||||
<path fill="#b984c5" d="m307 5188 433-281-581-538Z"/>
|
||||
<path fill="#d4abda" d="m159 4369 581 538 255-267Z"/>
|
||||
<path d="m2686 5604 312 564-179 263-517 17Z"/>
|
||||
<path fill="#130922" d="m2686 5604 294-421 588-268 48 443-618 810Z"/>
|
||||
<path fill="#975dac" d="m2686 5604 1115-1590-379-274-666 1279Z"/>
|
||||
<path fill="#6a438a" d="m2980 5183 588-268 511-814-429-152Z"/>
|
||||
<path fill="#190c2b" d="m4079 4101 352 111-815 1146-48-443Z"/>
|
||||
<path fill="#f0d7f2" d="m2045 11-547 1919 366 108 762-15 70-59Z"/>
|
||||
<path fill="#a558aa" d="m2045 11 651 1953 434-378Z"/>
|
||||
<path fill="#b06ab6" d="m2045 11-547 1919-388-302Z"/>
|
||||
<path fill="#885ca8" d="m1498 1930 238 1992 128-1884Z"/>
|
||||
<path fill="#c698d0" d="m3650 3949 429 152 273-790Z"/>
|
||||
<path fill="#4c2d68" d="m4079 4101 352 111-79-901Z"/>
|
||||
<path fill="#e2c1e6" d="m3422 3740 228 209 702-638Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
60
src/theme/assets/secret-logo.svg
Normal file
60
src/theme/assets/secret-logo.svg
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
sodipodi:docname="secret.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1.0574425"
|
||||
inkscape:cx="362.66748"
|
||||
inkscape:cy="372.12426"
|
||||
inkscape:window-width="2500"
|
||||
inkscape:window-height="1403"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-26.522326,-57.048019)">
|
||||
<rect
|
||||
style="fill:#000000;stroke-width:0.379397"
|
||||
id="rect1"
|
||||
width="200"
|
||||
height="200"
|
||||
x="26.522326"
|
||||
y="57.048019" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:3.175px;font-family:'Public Sans';-inkscape-font-specification:'Public Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;white-space:pre;inline-size:11.1898;fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
|
||||
x="119.35046"
|
||||
y="151.87784"
|
||||
id="text2"
|
||||
transform="matrix(32.473549,0,0,32.473181,-3775.2425,-4737.1215)"><tspan
|
||||
x="119.35046"
|
||||
y="151.87784"
|
||||
id="tspan1">?</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
236
src/theme/assets/tensamin-logo.svg
Normal file
236
src/theme/assets/tensamin-logo.svg
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="84.12928mm"
|
||||
height="88.726425mm"
|
||||
viewBox="0 0 84.12928 88.726425"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
sodipodi:docname="tensamin.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="2.8284271"
|
||||
inkscape:cx="133.4664"
|
||||
inkscape:cy="169.52885"
|
||||
inkscape:window-width="2466"
|
||||
inkscape:window-height="1369"
|
||||
inkscape:window-x="26"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs1"><linearGradient
|
||||
id="swatch15"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop15" /></linearGradient><linearGradient
|
||||
id="swatch10"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop10" /></linearGradient><linearGradient
|
||||
id="swatch3"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop3" /></linearGradient><linearGradient
|
||||
id="swatch2"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop2" /></linearGradient><linearGradient
|
||||
id="swatch1"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#031616;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1" /></linearGradient><inkscape:path-effect
|
||||
effect="fillet_chamfer"
|
||||
id="path-effect2"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
nodesatellites_param="F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1"
|
||||
radius="3"
|
||||
unit="px"
|
||||
method="auto"
|
||||
mode="F"
|
||||
chamfer_steps="1"
|
||||
flexible="false"
|
||||
use_knot_distance="true"
|
||||
apply_no_radius="true"
|
||||
apply_with_radius="true"
|
||||
only_selected="false"
|
||||
hide_knots="false" /><inkscape:path-effect
|
||||
effect="bspline"
|
||||
id="path-effect6"
|
||||
is_visible="true"
|
||||
lpeversion="1.3"
|
||||
weight="33.333333"
|
||||
steps="2"
|
||||
helper_size="0"
|
||||
apply_no_weight="true"
|
||||
apply_with_weight="true"
|
||||
only_selected="false"
|
||||
uniform="false" /><inkscape:path-effect
|
||||
effect="spiro"
|
||||
id="path-effect5"
|
||||
is_visible="true"
|
||||
lpeversion="1" /><clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath1"><path
|
||||
id="path3"
|
||||
style="fill:#043b3c;stroke-width:0.320821"
|
||||
inkscape:label="arrow"
|
||||
d="m 244.98648,261.60864 -9.19072,26.71639 23.35523,-11.66051 c -4.69011,-5.05439 -10.12762,-9.5023 -14.16451,-15.05588 z m 40.12749,-74.6916 50.72412,18.76364 c 6.70121,39.09457 -10.8946,70.48625 -50.72412,85.29337 z m 0,0 -50.72412,18.76364 c -6.7012,39.09457 10.89461,70.48625 50.72412,85.29337 z"
|
||||
sodipodi:nodetypes="cccccccccccc" /></clipPath><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient15"
|
||||
x1="232.99823"
|
||||
y1="238.94554"
|
||||
x2="337.22971"
|
||||
y2="238.94554"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient16"
|
||||
x1="63.191292"
|
||||
y1="148.5"
|
||||
x2="146.82355"
|
||||
y2="148.5"
|
||||
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="232.99823"
|
||||
y1="238.94554"
|
||||
x2="337.22971"
|
||||
y2="238.94554" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient18"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="232.99823"
|
||||
y1="238.94554"
|
||||
x2="337.22971"
|
||||
y2="238.94554" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient19"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="232.99823"
|
||||
y1="238.94554"
|
||||
x2="337.22971"
|
||||
y2="238.94554" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="232.99823"
|
||||
y1="238.94554"
|
||||
x2="337.22971"
|
||||
y2="238.94554" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient21"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="232.99823"
|
||||
y1="238.94554"
|
||||
x2="337.22971"
|
||||
y2="238.94554" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient22"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="63.191292"
|
||||
y1="148.5"
|
||||
x2="146.82355"
|
||||
y2="148.5" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient23"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="63.191292"
|
||||
y1="148.5"
|
||||
x2="146.82355"
|
||||
y2="148.5" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="63.191292"
|
||||
y1="148.5"
|
||||
x2="146.82355"
|
||||
y2="148.5" /><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#swatch1"
|
||||
id="linearGradient25"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="63.191292"
|
||||
y1="148.5"
|
||||
x2="146.82355"
|
||||
y2="148.5" /></defs><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-62.941767,-104.13688)"><g
|
||||
id="g2"
|
||||
transform="matrix(0.78671048,0,0,0.83608014,-119.72576,-51.488345)"
|
||||
inkscape:label="background"
|
||||
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
|
||||
clip-path="url(#clipPath1)"><path
|
||||
id="rect1"
|
||||
style="fill:#004b4a;stroke:url(#linearGradient17);stroke-width:0.616508;stroke-dasharray:none"
|
||||
transform="rotate(-75)"
|
||||
d="m -227.83395,273.59631 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||
id="rect1-1"
|
||||
style="fill:#00524e;stroke:url(#linearGradient18);stroke-width:0.616508;stroke-dasharray:none"
|
||||
transform="rotate(-75)"
|
||||
d="m -227.83395,297.99268 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||
id="rect1-1-3"
|
||||
style="fill:#006560;stroke:url(#linearGradient19);stroke-width:0.616508;stroke-dasharray:none"
|
||||
transform="rotate(-75)"
|
||||
d="m -227.83395,322.3891 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||
id="rect1-1-3-6"
|
||||
style="fill:#02857d;stroke:url(#linearGradient20);stroke-width:0.616508;stroke-dasharray:none"
|
||||
transform="rotate(-75)"
|
||||
d="m -227.83395,346.78546 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||
id="rect1-1-3-6-2"
|
||||
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
|
||||
transform="rotate(-75)"
|
||||
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><g
|
||||
id="g3"
|
||||
inkscape:label="foreground"
|
||||
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"><g
|
||||
id="g1"
|
||||
inkscape:label="outline"
|
||||
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path
|
||||
id="path15-3-5"
|
||||
style="opacity:0.352;fill:#000000;fill-opacity:1;stroke:url(#linearGradient22);stroke-width:0.5;stroke-dasharray:none"
|
||||
inkscape:label="filler"
|
||||
d="m -54.535435,65.443954 -38.869976,15.14533 c -2.902661,17.83718 0.143161,33.687786 8.852689,46.257116 l -5.823934,18.52239 15.016655,-8.2765 c 5.699447,4.98296 12.651108,9.14382 20.824566,12.34447 30.52177,-11.95203 44.00571,-37.29104 38.8705,-68.847476 z m -0.01808,2.85099 36.24585,14.11438 c 4.61111,37.589746 -14.17784,55.293626 -36.24585,64.158876 -21.698042,-8.5864 -40.914886,-26.53641 -36.245337,-64.158876 z"
|
||||
transform="translate(159.54375,40.87617)" /></g><path
|
||||
style="opacity:1;fill:#b8f8ff;fill-opacity:1;stroke:url(#linearGradient24);stroke-width:0.5;stroke-dasharray:none"
|
||||
d="m 98.103726,126.47621 21.412544,0.16758 a 0.41189664,0.41189664 63.022469 0 1 0.33171,0.65164 l -8.29596,11.58914 a 0.41184923,0.41184923 63.019773 0 0 0.33171,0.65157 l 10.05254,0.0777 a 0.3104736,0.3104736 69.080154 0 1 0.20649,0.54017 l -30.495516,27.73194 a 0.15466019,0.15466019 36.691517 0 1 -0.243455,-0.18141 l 10.172421,-21.16912 a 0.50438266,0.50438266 58.099348 0 0 -0.44993,-0.72282 l -11.312564,-0.10524 a 0.52748832,0.52748832 56.926855 0 1 -0.479488,-0.73628 l 7.661544,-17.7722 a 1.1963277,1.1963277 146.88457 0 1 1.107954,-0.72269 z"
|
||||
id="path2"
|
||||
sodipodi:nodetypes="cccccccc"
|
||||
inkscape:label="bolt" /><path
|
||||
id="path1"
|
||||
style="fill:#043b3c;stroke:url(#linearGradient25);stroke-width:0.5;stroke-dasharray:none"
|
||||
inkscape:label="dark_outline"
|
||||
d="m 104.99968,104.40528 -40.706558,15.90239 c -2.948156,18.16379 0.05742,34.31077 8.566402,47.3046 l -7.438306,22.73763 18.798853,-9.90998 c 5.772204,4.86637 12.68201,8.97564 20.779609,12.15481 31.96374,-12.54919 46.08489,-39.15399 40.70708,-72.28706 z m 0,1.91926 38.88755,15.13603 c 4.9985,39.45937 -15.37196,59.52899 -38.88755,68.8573 -7.461219,-2.95976 -14.605741,-7.00102 -20.723799,-12.39976 l -1.849499,0.95188 -13.267924,7.37991 5.196582,-16.46463 0.704866,-2.3027 c 0.02259,0.0331 0.04422,0.065 0,-5.2e-4 -0.02016,-0.0296 -0.04036,-0.0594 -0.01292,-0.0196 -7.707836,-11.18647 -11.48502,-25.8661 -8.934338,-46.00184 z m 5.2e-4,2.85151 -36.245852,14.11438 c -4.788427,29.4077 7.785188,53.02119 36.245852,64.15939 28.46066,-11.1382 41.03376,-34.75169 36.24533,-64.15939 z m 0,1.70377 34.62527,13.43381 c 4.45069,35.0227 -13.68688,52.83618 -34.62527,61.11564 -20.938395,-8.27946 -39.076477,-26.09294 -34.625796,-61.11564 z" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -1,10 +1,7 @@
|
|||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./ThemeProvider";
|
||||
export * from "./pickers/ColorPicker";
|
||||
export * from "./pickers/PolarityPicker";
|
||||
export * from "./pickers/PrimaryPicker";
|
||||
export * from "./pickers/Base16Picker";
|
||||
export * from "./pickers/BorderRadiusPicker";
|
||||
export * from "./pickers/CustomCssPicker";
|
||||
export * from "./presets";
|
||||
export * from "./Provider";
|
||||
export * from "./BaseThemeSelector";
|
||||
export * from "./OnboardingPage";
|
||||
export * from "./pickers/StylePicker";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import * as React from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
|
||||
import { Button, Textarea } from "../../index";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { Button } from "../../cmp/button";
|
||||
import { Textarea } from "../../cmp/textarea";
|
||||
import { useTheme } from "../Provider";
|
||||
import { formatPaletteJson, parseBase16Palette } from "../utils";
|
||||
|
||||
export function Base16Picker() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { Slider, Button } from "../../index";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { useId } from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
|
||||
import { Button } from "../../cmp/button";
|
||||
import { Label } from "../../cmp/label";
|
||||
import { Slider } from "../../cmp/slider";
|
||||
import { useTheme } from "../Provider";
|
||||
import {
|
||||
DEFAULT_BORDER_RADIUS,
|
||||
MAX_BORDER_RADIUS,
|
||||
|
|
@ -8,6 +12,7 @@ import {
|
|||
} from "../utils";
|
||||
|
||||
export function BorderRadiusPicker() {
|
||||
const id = useId();
|
||||
const { themeBorderRadius, setThemeBorderRadius } = useTheme();
|
||||
const updateBorderRadius = (value: number | readonly number[]) => {
|
||||
setThemeBorderRadius(Array.isArray(value) ? (value[0] ?? 0) : value);
|
||||
|
|
@ -15,12 +20,19 @@ export function BorderRadiusPicker() {
|
|||
|
||||
return (
|
||||
<div className="flex w-65 flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col justify-start flex-1 gap-1">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{themeBorderRadius.toFixed(3)}
|
||||
</span>
|
||||
<div className="flex justify-between gap-4">
|
||||
<Label htmlFor={id}>Border radius</Label>
|
||||
<output
|
||||
htmlFor={id}
|
||||
className="font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{themeBorderRadius.toFixed(3)}rem
|
||||
</output>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 flex-col justify-start gap-1">
|
||||
<Slider
|
||||
id={id}
|
||||
className="w-full"
|
||||
min={MIN_BORDER_RADIUS}
|
||||
max={MAX_BORDER_RADIUS}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Trash } from "lucide-react";
|
||||
import { Button, Input } from "../../index";
|
||||
import { Button } from "../../cmp/button";
|
||||
import { Input } from "../../cmp/input";
|
||||
import { COLOR_PICKER_FALLBACK } from "../utils";
|
||||
|
||||
export function ColorPicker({
|
||||
|
|
|
|||
|
|
@ -1,32 +1,59 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { RotateCcw, Undo2 } from "lucide-react";
|
||||
|
||||
import { Button } from "../../cmp/button";
|
||||
import { Textarea } from "../../cmp/textarea";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { useTheme } from "../Provider";
|
||||
|
||||
const CUSTOM_CSS_PLACEHOLDER = `* {
|
||||
color: black;
|
||||
}`;
|
||||
function hasBalancedBlocks(css: string) {
|
||||
return css.split("{").length === css.split("}").length;
|
||||
}
|
||||
|
||||
export function CustomCssPicker() {
|
||||
const { themeCustomCss, setThemeCustomCss } = useTheme();
|
||||
const { themeCustomCss, setThemeCustomCss, parentTheme, resetThemePreset } =
|
||||
useTheme();
|
||||
const editStart = useRef(themeCustomCss);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const valid = hasBalancedBlocks(themeCustomCss);
|
||||
|
||||
return (
|
||||
<div className="flex w-90 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{!valid && <p className="text-destructive">Invalid Syntax</p>}
|
||||
<Textarea
|
||||
value={themeCustomCss}
|
||||
onChange={(event) => setThemeCustomCss(event.currentTarget.value)}
|
||||
placeholder={CUSTOM_CSS_PLACEHOLDER}
|
||||
onFocus={() => {
|
||||
editStart.current = themeCustomCss;
|
||||
setCanUndo(false);
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setThemeCustomCss(event.currentTarget.value);
|
||||
setCanUndo(event.currentTarget.value !== editStart.current);
|
||||
}}
|
||||
placeholder={":root {\n --primary: oklch(0.6 0.2 260);\n}"}
|
||||
spellCheck={false}
|
||||
className="h-80 resize-none font-mono text-xs leading-relaxed"
|
||||
className="min-h-80 resize-y font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setThemeCustomCss("")}
|
||||
disabled={themeCustomCss === ""}
|
||||
disabled={!canUndo}
|
||||
onClick={() => {
|
||||
setThemeCustomCss(editStart.current);
|
||||
setCanUndo(false);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
<Undo2 data-icon="inline-start" /> Undo edits
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!parentTheme}
|
||||
onClick={resetThemePreset}
|
||||
>
|
||||
<RotateCcw data-icon="inline-start" /> Reset to parent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { useId } from "react";
|
||||
|
||||
import { Label } from "../../cmp/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -6,19 +9,20 @@ import {
|
|||
SelectValue,
|
||||
} from "../../cmp/select";
|
||||
import type { ThemePolarity } from "../types";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { useTheme } from "../Provider";
|
||||
|
||||
export function PolarityPicker() {
|
||||
const id = useId();
|
||||
const { themePolarity, setThemePolarity } = useTheme();
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-2 text-sm font-medium">
|
||||
Color scheme
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={id}>Polarity</Label>
|
||||
<Select
|
||||
value={themePolarity}
|
||||
onValueChange={(value) => setThemePolarity(value as ThemePolarity)}
|
||||
>
|
||||
<SelectTrigger className="w-65">
|
||||
<SelectTrigger id={id} className="w-65">
|
||||
<SelectValue>
|
||||
{themePolarity === "system"
|
||||
? "System"
|
||||
|
|
@ -33,6 +37,6 @@ export function PolarityPicker() {
|
|||
<SelectItem value="light">Light</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { Label } from "../../cmp/label";
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { useTheme } from "../Provider";
|
||||
|
||||
export function PrimaryPicker() {
|
||||
const { themePrimaryColor, setThemePrimaryColor } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Custom Primary Color</Label>
|
||||
<ColorPicker
|
||||
label="Primary color"
|
||||
value={themePrimaryColor}
|
||||
onChange={setThemePrimaryColor}
|
||||
allowEmpty
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
import * as React from "react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, RotateCcw } from "lucide-react";
|
||||
|
||||
import { Button } from "../../cmp/button";
|
||||
import { Label } from "../../cmp/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../cmp/select";
|
||||
import { Slider } from "../../cmp/slider";
|
||||
import { ThemeSelector } from "../BaseThemeSelector";
|
||||
import { useTheme } from "../Provider";
|
||||
import type { ThemeDesign, ThemeFontFamily } from "../types";
|
||||
import { Base16Picker } from "./Base16Picker";
|
||||
import { BorderRadiusPicker } from "./BorderRadiusPicker";
|
||||
import { CustomCssPicker } from "./CustomCssPicker";
|
||||
|
|
@ -7,52 +21,229 @@ import { PolarityPicker } from "./PolarityPicker";
|
|||
import { PrimaryPicker } from "./PrimaryPicker";
|
||||
import { TintPicker } from "./TintPicker";
|
||||
|
||||
type StyleCategoryProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function StyleCategory({ title, children }: StyleCategoryProps) {
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="font-heading font-semibold">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StylePicker() {
|
||||
function DesignSlider({
|
||||
label,
|
||||
field,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
format = (value) => String(value),
|
||||
}: {
|
||||
label: string;
|
||||
field: keyof Pick<
|
||||
ThemeDesign,
|
||||
| "density"
|
||||
| "borderWidth"
|
||||
| "shadowStrength"
|
||||
| "surfaceContrast"
|
||||
| "fontScale"
|
||||
| "headingWeight"
|
||||
| "motion"
|
||||
>;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
format?: (value: number) => string;
|
||||
}) {
|
||||
const { themeDesign, setThemeDesign } = useTheme();
|
||||
const value = themeDesign[field];
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<output
|
||||
htmlFor={id}
|
||||
className="font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{format(value)}
|
||||
</output>
|
||||
</div>
|
||||
<Slider
|
||||
id={id}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={(next) =>
|
||||
setThemeDesign((current) => ({
|
||||
...current,
|
||||
[field]: Array.isArray(next) ? (next[0] ?? value) : next,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeCustomizer({
|
||||
value,
|
||||
onValueChange,
|
||||
showHeader = true,
|
||||
}: {
|
||||
value?: string | null;
|
||||
onValueChange?: (id: string) => void;
|
||||
showHeader?: boolean;
|
||||
}) {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const fontId = useId();
|
||||
const {
|
||||
themeDesign,
|
||||
setThemeDesign,
|
||||
parentTheme,
|
||||
isThemeCustomized,
|
||||
resetThemePreset,
|
||||
} = useTheme();
|
||||
return (
|
||||
<div className="w-full min-w-0 space-y-7">
|
||||
<div className="border border-dotted p-5 rounded-2xl bg-card/30 flex flex-col gap-5">
|
||||
<ThemeSelector value={value} onValueChange={onValueChange} />
|
||||
<div className="grid gap-7 lg:grid-cols-2">
|
||||
<Section title="Color scheme">
|
||||
<PolarityPicker />
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),15rem))] gap-8">
|
||||
<div className="flex shrink-0 flex-col gap-8">
|
||||
<StyleCategory title="Tint">
|
||||
<TintPicker />
|
||||
</StyleCategory>
|
||||
|
||||
<StyleCategory title="Primary Color">
|
||||
<PrimaryPicker />
|
||||
</StyleCategory>
|
||||
|
||||
<StyleCategory title="Border Radius">
|
||||
</Section>
|
||||
<div className="space-y-7">
|
||||
<Section title="Shape and spacing">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<DesignSlider
|
||||
label="Density"
|
||||
field="density"
|
||||
min={0.8}
|
||||
max={1.3}
|
||||
step={0.025}
|
||||
format={(v) => `${Math.round(v * 100)}%`}
|
||||
/>
|
||||
<DesignSlider
|
||||
label="Border width"
|
||||
field="borderWidth"
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.25}
|
||||
format={(v) => `${v}px`}
|
||||
/>
|
||||
<BorderRadiusPicker />
|
||||
</StyleCategory>
|
||||
<DesignSlider
|
||||
label="Surface contrast"
|
||||
field="surfaceContrast"
|
||||
min={0}
|
||||
max={8}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<StyleCategory title="Base16">
|
||||
</Section>
|
||||
<Section title="Depth and motion">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<DesignSlider
|
||||
label="Shadow strength"
|
||||
field="shadowStrength"
|
||||
min={0}
|
||||
max={1.5}
|
||||
step={0.05}
|
||||
format={(v) => `${Math.round(v * 100)}%`}
|
||||
/>
|
||||
<DesignSlider
|
||||
label="Motion"
|
||||
field="motion"
|
||||
min={0}
|
||||
max={1.75}
|
||||
step={0.05}
|
||||
format={(v) =>
|
||||
v === 0 ? "Reduced" : `${Math.round(v * 100)}%`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<Section title="Typography">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="grid gap-2 text-sm">
|
||||
<Label htmlFor={fontId}>Font</Label>
|
||||
<Select
|
||||
value={themeDesign.fontFamily}
|
||||
onValueChange={(value) =>
|
||||
setThemeDesign((current) => ({
|
||||
...current,
|
||||
fontFamily: value as ThemeFontFamily,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={fontId} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public-sans">Public Sans</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DesignSlider
|
||||
label="Font scale"
|
||||
field="fontScale"
|
||||
min={0.875}
|
||||
max={1.2}
|
||||
step={0.025}
|
||||
format={(v) => `${Math.round(v * 100)}%`}
|
||||
/>
|
||||
<DesignSlider
|
||||
label="Heading boldness"
|
||||
field="headingWeight"
|
||||
min={400}
|
||||
max={800}
|
||||
step={50}
|
||||
format={(v) => `${Math.round((v - 400) / 4)}%`}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
className="flex-1/2 md:flex-none"
|
||||
variant="outline"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
>
|
||||
Advanced Options
|
||||
<ChevronDown
|
||||
data-icon="inline-end"
|
||||
className={advancedOpen ? "rotate-180" : undefined}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1/2 md:flex-none"
|
||||
variant="outline"
|
||||
disabled={!parentTheme || !isThemeCustomized}
|
||||
onClick={resetThemePreset}
|
||||
>
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
Reset to {parentTheme?.title ?? "parent"}
|
||||
</Button>
|
||||
</div>
|
||||
{advancedOpen && (
|
||||
<div className="mt-6 space-y-7">
|
||||
<Section title="Base16 colors">
|
||||
<Base16Picker />
|
||||
</StyleCategory>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<StyleCategory title="Custom CSS">
|
||||
</Section>
|
||||
<Section title="Custom CSS">
|
||||
<CustomCssPicker />
|
||||
</StyleCategory>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const StylePicker = ThemeCustomizer;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../cmp/select";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { Label } from "../../cmp/label";
|
||||
import { useTheme } from "../Provider";
|
||||
import type { ThemeTint } from "../types";
|
||||
import { THEME_TINT_OPTIONS } from "../utils";
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ export function TintPicker() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Tint</Label>
|
||||
<ColorPicker
|
||||
label="Theme color"
|
||||
value={themeColor}
|
||||
|
|
|
|||
388
src/theme/presets.ts
Normal file
388
src/theme/presets.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import type { ThemeDesign, ThemePreset } from "./types";
|
||||
|
||||
export const DEFAULT_THEME_DESIGN: ThemeDesign = {
|
||||
density: 1,
|
||||
borderWidth: 1,
|
||||
shadowStrength: 0,
|
||||
surfaceContrast: 0,
|
||||
fontScale: 1,
|
||||
headingWeight: 600,
|
||||
motion: 1,
|
||||
fontFamily: "public-sans",
|
||||
};
|
||||
|
||||
const TENSAMIN_LOGO = new URL("./assets/tensamin-logo.svg", import.meta.url)
|
||||
.href;
|
||||
const METHANIUM_LOGO = new URL("./assets/methanium-logo.svg", import.meta.url)
|
||||
.href;
|
||||
const SECRET_LOGO = new URL("./assets/secret-logo.svg", import.meta.url).href;
|
||||
|
||||
export function defineThemes<const T extends readonly ThemePreset[]>(
|
||||
themes: T,
|
||||
): T {
|
||||
const ids = new Set<string>();
|
||||
for (const theme of themes) {
|
||||
if (!theme.id || ids.has(theme.id)) {
|
||||
throw new Error(`Theme ids must be non-empty and unique: ${theme.id}`);
|
||||
}
|
||||
ids.add(theme.id);
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
export const BUILT_IN_THEMES = defineThemes([
|
||||
{
|
||||
id: "tensamin",
|
||||
title: "Tensamin",
|
||||
logo: TENSAMIN_LOGO,
|
||||
css: `:root[data-theme="tensamin"] {
|
||||
--card: oklch(0.97 0 0);
|
||||
--primary: oklch(0.54 0.105 186);
|
||||
--primary-foreground: oklch(0.985 0.012 184);
|
||||
--primary-foreground-alt: oklch(0.58 0.13 185);
|
||||
--ring: oklch(0.62 0.12 185);
|
||||
--sidebar-primary: oklch(0.58 0.12 185);
|
||||
}
|
||||
:root.dark[data-theme="tensamin"] {
|
||||
--card: oklch(0.2 0 0);
|
||||
--primary: oklch(0.68 0.12 184);
|
||||
--primary-foreground: oklch(0.16 0.025 190);
|
||||
--primary-foreground-alt: oklch(0.72 0.13 183);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot] {
|
||||
--tensamin-edge: var(--border);
|
||||
border-left-width: 0;
|
||||
border-right-width: 0;
|
||||
border-top-color: color-mix(in oklch, var(--tensamin-edge) 98%, white);
|
||||
border-bottom-color: color-mix(in oklch, var(--tensamin-edge) 88%, black);
|
||||
transition: border-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
:root[data-theme="tensamin"] :where([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"], [data-slot="native-select"], [data-slot="input-group"]) {
|
||||
--tensamin-edge: var(--input);
|
||||
}
|
||||
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="default"] {
|
||||
--tensamin-edge: var(--primary);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"] {
|
||||
background-color: color-mix(in oklch, var(--background) 94%, white);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"]:hover {
|
||||
background-color: color-mix(in oklch, var(--background) 88%, var(--foreground));
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="default"] {
|
||||
background-color: color-mix(in oklch, var(--primary) 93%, white);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="default"]:hover {
|
||||
background-color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
|
||||
}
|
||||
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="subtleDefault"] {
|
||||
--tensamin-edge: var(--primary-foreground-alt);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="subtleDefault"] {
|
||||
background-color: color-mix(in oklch, var(--background) 88%, var(--primary-foreground-alt));
|
||||
border-top-color: color-mix(in oklch, color-mix(in oklch, var(--background) 88%, var(--primary-foreground-alt)) 82%, white);
|
||||
border-bottom-color: color-mix(in oklch, color-mix(in oklch, var(--background) 88%, var(--primary-foreground-alt)) 90%, white);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="subtleDefault"]:hover {
|
||||
background-color: color-mix(in oklch, var(--background) 80%, var(--primary-foreground-alt));
|
||||
}
|
||||
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="secondary"] {
|
||||
--tensamin-edge: var(--secondary);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="secondary"] {
|
||||
background-color: color-mix(in oklch, var(--secondary) 94%, white);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="secondary"]:hover {
|
||||
background-color: color-mix(in oklch, var(--secondary) 86%, var(--secondary-foreground));
|
||||
}
|
||||
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="destructive"] {
|
||||
--tensamin-edge: var(--destructive);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="destructive"] {
|
||||
background-color: color-mix(in oklch, var(--background) 86%, var(--destructive));
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot="button"][data-variant="destructive"]:hover {
|
||||
background-color: color-mix(in oklch, var(--background) 76%, var(--destructive));
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot][data-variant="destructive"] {
|
||||
border-top-color: color-mix(in oklch, color-mix(in oklch, var(--background) 86%, var(--destructive)) 82%, white);
|
||||
border-bottom-color: color-mix(in oklch, color-mix(in oklch, var(--background) 86%, var(--destructive)) 90%, white);
|
||||
}
|
||||
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="outline"] {
|
||||
--tensamin-edge: color-mix(in oklch, var(--primary) 16%, var(--border));
|
||||
}
|
||||
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"]):where([data-variant="ghost"], [data-variant="link"]) {
|
||||
--tensamin-edge: color-mix(in oklch, currentColor 12%, var(--border));
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot][data-checked],
|
||||
:root[data-theme="tensamin"] [data-slot][data-active] {
|
||||
--tensamin-edge: var(--primary);
|
||||
}
|
||||
:root[data-theme="tensamin"] [data-slot][aria-invalid="true"] {
|
||||
--tensamin-edge: var(--destructive);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "methanium",
|
||||
title: "Methanium",
|
||||
logo: METHANIUM_LOGO,
|
||||
css: `:root[data-theme="methanium"] {
|
||||
--background: #d2e0f0;
|
||||
--foreground: #10161d;
|
||||
--card: #c0cfe0;
|
||||
--card-foreground: #10161d;
|
||||
--popover: #d2e0f0;
|
||||
--popover-foreground: #10161d;
|
||||
--primary: #8c47ad;
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-foreground-alt: #7b3d98;
|
||||
--secondary: #aebed0;
|
||||
--secondary-foreground: #171e26;
|
||||
--muted: #c0cfe0;
|
||||
--muted-foreground: #515f70;
|
||||
--accent: #cf8fe633;
|
||||
--accent-foreground: #5a2a6d;
|
||||
--destructive: #b91c1c;
|
||||
--border: #8c9caf;
|
||||
--input: #9dadc0;
|
||||
--ring: #9d53bc;
|
||||
--chart-1: #8c47ad;
|
||||
--chart-2: #2563eb;
|
||||
--chart-3: #15803d;
|
||||
--chart-4: #ca8a04;
|
||||
--chart-5: #db2777;
|
||||
--sidebar: #aebed0;
|
||||
--sidebar-foreground: #10161d;
|
||||
--sidebar-primary: #8c47ad;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #cf8fe633;
|
||||
--sidebar-accent-foreground: #5a2a6d;
|
||||
--sidebar-border: #8c9caf;
|
||||
--sidebar-ring: #9d53bc;
|
||||
}
|
||||
:root.dark[data-theme="methanium"] {
|
||||
--background: #10161d;
|
||||
--foreground: #d2e0f0;
|
||||
--card: #242d38;
|
||||
--card-foreground: #d2e0f0;
|
||||
--popover: #242d38;
|
||||
--popover-foreground: #d2e0f0;
|
||||
--primary: #cf8fe6;
|
||||
--primary-foreground: #000000;
|
||||
--primary-foreground-alt: #bb6fd6;
|
||||
--secondary: #374351;
|
||||
--secondary-foreground: #d2e0f0;
|
||||
--muted: #1d262f;
|
||||
--muted-foreground: #9dadc0;
|
||||
--accent: #cf8fe633;
|
||||
--accent-foreground: #faecfc;
|
||||
--destructive: #cc4848;
|
||||
--border: #374351;
|
||||
--input: #445161;
|
||||
--ring: #cf8fe6;
|
||||
--chart-1: #cf8fe6;
|
||||
--chart-2: #4e96cc;
|
||||
--chart-3: #93b373;
|
||||
--chart-4: #eaaf03;
|
||||
--chart-5: #d74397;
|
||||
--sidebar: #10161d;
|
||||
--sidebar-foreground: #d2e0f0;
|
||||
--sidebar-primary: #cf8fe6;
|
||||
--sidebar-primary-foreground: #000000;
|
||||
--sidebar-accent: #2b3642;
|
||||
--sidebar-accent-foreground: #d2e0f0;
|
||||
--sidebar-border: #374351;
|
||||
--sidebar-ring: #cf8fe6;
|
||||
}
|
||||
:root[data-theme="methanium"] body {
|
||||
background-color: var(--background);
|
||||
}
|
||||
:root[data-theme="methanium"] :where([data-slot="card"], [data-slot="button"], [data-slot="input"], [data-slot="textarea"], [data-slot="native-select"], [data-slot="select-trigger"], [data-slot="input-group"], [data-slot="dialog-content"], [data-slot="alert-dialog-content"], [data-slot="popover-content"]) {
|
||||
border-radius: 0.25rem;
|
||||
border-style: solid;
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"] {
|
||||
min-height: 0;
|
||||
font-weight: 500;
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-size="xs"] {
|
||||
height: calc(2rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] :where([data-slot="button"], [data-slot="select-trigger"])[data-size="sm"] {
|
||||
height: calc(2.125rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] :where([data-slot="button"], [data-slot="select-trigger"])[data-size="default"],
|
||||
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="native-select"], [data-slot="input-group"]) {
|
||||
height: calc(2.25rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-size="lg"] {
|
||||
height: calc(2.5rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"]:not([data-size^="icon"]) {
|
||||
padding-inline: calc(1.35rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-size="sm"]:not([data-size^="icon"]),
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-size="xs"]:not([data-size^="icon"]) {
|
||||
padding-inline: calc(1.25rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="native-select"], [data-slot="select-trigger"]) {
|
||||
padding-inline: calc(1rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="textarea"] {
|
||||
padding: calc(0.785rem * var(--ui-density)) calc(1rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] :where([data-slot="card-header"], [data-slot="card-content"], [data-slot="card-footer"]) {
|
||||
padding-inline: calc(1rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="card"] {
|
||||
padding-block: calc(1rem * var(--ui-density));
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-variant="default"] {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-variant="default"]:hover {
|
||||
background-color: #bb6fd6;
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-variant="secondary"] {
|
||||
background-color: var(--secondary);
|
||||
border-color: color-mix(in srgb, white 16%, transparent);
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-variant="secondary"]:hover,
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-variant="outline"]:hover {
|
||||
background-color: color-mix(in srgb, var(--secondary) 80%, var(--foreground) 20%);
|
||||
}
|
||||
:root[data-theme="methanium"] [data-slot="button"][data-variant="outline"] {
|
||||
background-color: transparent;
|
||||
border-color: var(--input);
|
||||
color: var(--foreground);
|
||||
}
|
||||
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="textarea"], [data-slot="native-select"], [data-slot="select-trigger"], [data-slot="input-group"]) {
|
||||
background-color: color-mix(in srgb, var(--input) 55%, var(--background));
|
||||
border-color: var(--input);
|
||||
transition: background-color 100ms ease, border-color 100ms ease;
|
||||
}
|
||||
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="textarea"], [data-slot="native-select"], [data-slot="select-trigger"], [data-slot="input-group"]):not(:disabled):not([data-disabled]):not(:has(:disabled)):hover {
|
||||
background-color: color-mix(in srgb, var(--input) 72%, var(--background));
|
||||
border-color: color-mix(in srgb, var(--input) 75%, var(--foreground) 25%);
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "secret",
|
||||
title: "Secret",
|
||||
logo: SECRET_LOGO,
|
||||
css: `:root[data-theme="secret"] {
|
||||
--background: oklch(0.93 0.04 150);
|
||||
--foreground: oklch(0.2 0.025 150);
|
||||
--card: oklch(0.84 0.1 84);
|
||||
--card-foreground: oklch(0.2 0.025 150);
|
||||
--popover: oklch(0.975 0.012 135);
|
||||
--popover-foreground: oklch(0.2 0.025 150);
|
||||
--primary: oklch(0.72 0.14 80);
|
||||
--primary-foreground: oklch(0.19 0.025 75);
|
||||
--primary-foreground-alt: oklch(0.59 0.13 76);
|
||||
--secondary: oklch(0.925 0.025 145);
|
||||
--secondary-foreground: oklch(0.25 0.035 150);
|
||||
--muted: oklch(0.935 0.016 105);
|
||||
--muted-foreground: oklch(0.48 0.025 125);
|
||||
--accent: oklch(0.91 0.035 145);
|
||||
--accent-foreground: oklch(0.3 0.06 150);
|
||||
--destructive: oklch(0.52 0.11 95);
|
||||
--border: oklch(0.79 0.045 78);
|
||||
--input: oklch(0.86 0.025 110);
|
||||
--ring: oklch(0.7 0.13 80);
|
||||
--sidebar: oklch(0.945 0.018 145);
|
||||
--sidebar-foreground: oklch(0.22 0.03 150);
|
||||
--sidebar-primary: oklch(0.7 0.13 80);
|
||||
--sidebar-primary-foreground: oklch(0.19 0.025 75);
|
||||
--sidebar-accent: oklch(0.9 0.03 145);
|
||||
--sidebar-accent-foreground: oklch(0.3 0.06 150);
|
||||
--sidebar-border: oklch(0.78 0.04 78);
|
||||
}
|
||||
:root.dark[data-theme="secret"] {
|
||||
--background: oklch(0.16 0.025 155);
|
||||
--foreground: oklch(0.92 0.018 88);
|
||||
--card: oklch(0.38 0.08 82);
|
||||
--card-foreground: oklch(0.92 0.018 88);
|
||||
--popover: oklch(0.205 0.035 150);
|
||||
--popover-foreground: oklch(0.93 0.018 88);
|
||||
--primary: oklch(0.78 0.13 82);
|
||||
--primary-foreground: oklch(0.2 0.025 75);
|
||||
--primary-foreground-alt: oklch(0.8 0.12 84);
|
||||
--secondary: oklch(0.235 0.035 150);
|
||||
--secondary-foreground: oklch(0.9 0.018 88);
|
||||
--muted: oklch(0.225 0.025 145);
|
||||
--muted-foreground: oklch(0.7 0.025 95);
|
||||
--accent: oklch(0.235 0.055 150);
|
||||
--accent-foreground: oklch(0.91 0.025 82);
|
||||
--destructive: oklch(0.66 0.13 95);
|
||||
--border: oklch(0.34 0.045 76);
|
||||
--input: oklch(0.275 0.035 145);
|
||||
--ring: oklch(0.76 0.12 82);
|
||||
--sidebar: oklch(0.175 0.03 150);
|
||||
--sidebar-foreground: oklch(0.92 0.018 88);
|
||||
--sidebar-primary: oklch(0.76 0.12 82);
|
||||
--sidebar-primary-foreground: oklch(0.2 0.025 75);
|
||||
--sidebar-accent: oklch(0.225 0.05 150);
|
||||
--sidebar-accent-foreground: oklch(0.91 0.025 82);
|
||||
--sidebar-border: oklch(0.32 0.04 75);
|
||||
}
|
||||
:root[data-theme="secret"] body {
|
||||
background-color: var(--background);
|
||||
background-image: radial-gradient(circle at 12% 8%, oklch(0.3 0.07 150 / 9%), transparent 34%), radial-gradient(circle at 90% 5%, oklch(0.58 0.09 165 / 8%), transparent 30%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
:root.dark[data-theme="secret"] body {
|
||||
background-image: radial-gradient(circle at 12% 8%, oklch(0.29 0.075 150 / 52%), transparent 38%), radial-gradient(circle at 92% 4%, oklch(0.25 0.08 165 / 42%), transparent 34%);
|
||||
}
|
||||
:root[data-theme="secret"] [data-slot="button"][data-variant="default"] {
|
||||
background-color: var(--primary);
|
||||
background-image: linear-gradient(115deg, oklch(0.57 0.12 72) 0%, oklch(0.76 0.15 82) 30%, oklch(0.96 0.035 92) 47%, oklch(0.79 0.14 84) 58%, oklch(0.62 0.13 74) 100%);
|
||||
border-color: oklch(0.62 0.12 76);
|
||||
color: var(--primary-foreground);
|
||||
text-shadow: 0 1px rgb(255 255 255 / 35%);
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 45%), 0 0.45rem 1.2rem oklch(0.35 0.07 75 / 16%);
|
||||
}
|
||||
:root[data-theme="secret"] :where([data-slot="card"], [data-slot="dialog-content"], [data-slot="alert-dialog-content"]) {
|
||||
border-color: color-mix(in oklch, var(--border) 72%, var(--primary));
|
||||
box-shadow: inset 0 1px color-mix(in oklch, white 8%, transparent), 0 1rem 3rem oklch(0.1 0.03 150 / 12%);
|
||||
}
|
||||
:root[data-theme="secret"] [data-slot="card"] {
|
||||
background: linear-gradient(115deg, oklch(0.68 0.1 74) 0%, oklch(0.86 0.12 84) 30%, oklch(0.98 0.025 94) 47%, oklch(0.88 0.11 86) 58%, oklch(0.72 0.1 76) 100%);
|
||||
}
|
||||
:root.dark[data-theme="secret"] [data-slot="card"] {
|
||||
background: linear-gradient(115deg, oklch(0.27 0.055 72) 0%, oklch(0.43 0.09 82) 30%, oklch(0.68 0.075 91) 47%, oklch(0.46 0.09 84) 58%, oklch(0.3 0.06 74) 100%);
|
||||
}
|
||||
:root[data-theme="secret"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="outline"] {
|
||||
background-color: oklch(0.86 0.055 150);
|
||||
border-color: oklch(0.5 0.09 150);
|
||||
color: oklch(0.27 0.07 150);
|
||||
}
|
||||
:root[data-theme="secret"] [data-slot="button"][data-variant="outline"]:hover {
|
||||
background-color: oklch(0.8 0.075 150);
|
||||
}
|
||||
:root.dark[data-theme="secret"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="outline"] {
|
||||
background-color: oklch(0.22 0.055 150);
|
||||
border-color: oklch(0.52 0.1 150);
|
||||
color: oklch(0.82 0.08 150);
|
||||
}
|
||||
:root.dark[data-theme="secret"] [data-slot="button"][data-variant="outline"]:hover {
|
||||
background-color: oklch(0.3 0.07 150);
|
||||
}
|
||||
:root[data-theme="secret"] :where([data-slot="card-title"], [data-slot="item-media"][data-variant="icon"], [data-slot="badge"][data-variant="outline"]) svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
:root[data-theme="secret"] [data-slot="separator"] {
|
||||
background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--primary) 42%, var(--border)), transparent);
|
||||
}`,
|
||||
},
|
||||
] as const);
|
||||
|
||||
export function findTheme(
|
||||
themes: readonly ThemePreset[],
|
||||
id: string | null | undefined,
|
||||
) {
|
||||
return id ? (themes.find((theme) => theme.id === id) ?? null) : null;
|
||||
}
|
||||
|
|
@ -23,6 +23,26 @@ export type Base16Key =
|
|||
|
||||
export type Base16Palette = Record<Base16Key, string>;
|
||||
|
||||
export type ThemeFontFamily = "public-sans" | "system";
|
||||
|
||||
export type ThemeDesign = {
|
||||
density: number;
|
||||
borderWidth: number;
|
||||
shadowStrength: number;
|
||||
surfaceContrast: number;
|
||||
fontScale: number;
|
||||
headingWeight: number;
|
||||
motion: number;
|
||||
fontFamily: ThemeFontFamily;
|
||||
};
|
||||
|
||||
export type ThemePreset = {
|
||||
id: string;
|
||||
title: string;
|
||||
logo: string;
|
||||
css: string;
|
||||
};
|
||||
|
||||
export type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: ThemePolarity;
|
||||
|
|
@ -32,6 +52,9 @@ export type ThemeProviderProps = {
|
|||
defaultTint?: ThemeTint;
|
||||
defaultBorderRadius?: number;
|
||||
defaultCustomCss?: string;
|
||||
themes?: readonly ThemePreset[];
|
||||
defaultParentThemeId?: string | null;
|
||||
defaultDesign?: ThemeDesign;
|
||||
storageKey?: string | null;
|
||||
colorStorageKey?: string | null;
|
||||
paletteStorageKey?: string | null;
|
||||
|
|
@ -39,6 +62,8 @@ export type ThemeProviderProps = {
|
|||
tintStorageKey?: string | null;
|
||||
borderRadiusStorageKey?: string | null;
|
||||
customCssStorageKey?: string | null;
|
||||
parentThemeStorageKey?: string | null;
|
||||
designStorageKey?: string | null;
|
||||
disableTransitionOnChange?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -60,6 +85,17 @@ export type ThemeProviderState = {
|
|||
setThemeBorderRadius: (radius: number) => void;
|
||||
themeCustomCss: string;
|
||||
setThemeCustomCss: (css: string) => void;
|
||||
themes: readonly ThemePreset[];
|
||||
parentThemeId: string | null;
|
||||
parentTheme: ThemePreset | null;
|
||||
setParentThemeId: (id: string | null) => void;
|
||||
applyThemePreset: (id: string) => void;
|
||||
resetThemePreset: () => void;
|
||||
isThemeCustomized: boolean;
|
||||
themeDesign: ThemeDesign;
|
||||
setThemeDesign: (
|
||||
design: ThemeDesign | ((current: ThemeDesign) => ThemeDesign),
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type HslColor = {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export function formatPaletteJson(palette: Base16Palette | null) {
|
|||
}
|
||||
|
||||
export function getSystemTheme(): ResolvedThemePolarity {
|
||||
if (typeof window === "undefined") return "light";
|
||||
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) return "dark";
|
||||
return "light";
|
||||
}
|
||||
|
|
@ -551,13 +552,13 @@ export function readStoredValue<T extends string>(
|
|||
validate: (value: string | null) => value is T,
|
||||
fallback: T,
|
||||
) {
|
||||
if (!key) return fallback;
|
||||
if (!key || typeof window === "undefined") return fallback;
|
||||
|
||||
const value = localStorage.getItem(key);
|
||||
return validate(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function saveStoredValue(key: string | null | undefined, value: string) {
|
||||
if (!key) return;
|
||||
if (!key || typeof window === "undefined") return;
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
|
|
|||
31
src/vite.ts
Normal file
31
src/vite.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export type MethaniumUiPluginOptions = {
|
||||
defaultThemeId: string;
|
||||
};
|
||||
|
||||
export type MethaniumUiPlugin = {
|
||||
name: "methanium-ui";
|
||||
config: () => {
|
||||
define: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export function methaniumUi(
|
||||
options: MethaniumUiPluginOptions,
|
||||
): MethaniumUiPlugin {
|
||||
if (!options.defaultThemeId.trim()) {
|
||||
throw new Error("methaniumUi defaultThemeId must not be empty");
|
||||
}
|
||||
|
||||
return {
|
||||
name: "methanium-ui",
|
||||
config() {
|
||||
return {
|
||||
define: {
|
||||
__METHANIUM_UI_DEFAULT_THEME_ID__: JSON.stringify(
|
||||
options.defaultThemeId,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue