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
|
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",
|
"import": "./dist/node.js",
|
||||||
"default": "./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": {
|
"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:js": "tsc -p tsconfig.build.json",
|
||||||
"build:css": "tailwindcss -i ./src/index.css -o ./dist/index.css --minify",
|
"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",
|
"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')\"",
|
"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 ."
|
"format": "pnpm exec prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@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",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.4.0",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react-day-picker": "^10.0.1",
|
|
||||||
"react-resizable-panels": "^4.11.2",
|
"react-resizable-panels": "^4.11.2",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"shadcn": "^4.11.0",
|
"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:
|
allowBuilds:
|
||||||
'@parcel/watcher': false
|
"@parcel/watcher": false
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ function Button({
|
||||||
return (
|
return (
|
||||||
<ButtonPrimitive
|
<ButtonPrimitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ function Card({
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import { Button } from "./button";
|
||||||
|
|
||||||
interface OnboardingStep {
|
interface OnboardingStep {
|
||||||
id: string;
|
id: string;
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
logo?: ReactNode;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
defaultCanContinue?: boolean;
|
defaultCanContinue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +54,9 @@ function OnboardingFlow({
|
||||||
steps: OnboardingStep[];
|
steps: OnboardingStep[];
|
||||||
onFinish: () => void | Promise<void>;
|
onFinish: () => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
if (steps.length === 0) {
|
||||||
|
throw new Error("OnboardingFlow requires at least one step");
|
||||||
|
}
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
const [controls, setControls] = useState<OnboardingStepControls>(() =>
|
const [controls, setControls] = useState<OnboardingStepControls>(() =>
|
||||||
defaultControls(steps[0]),
|
defaultControls(steps[0]),
|
||||||
|
|
@ -90,7 +96,7 @@ function OnboardingFlow({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const heading = document.querySelector<HTMLElement>(
|
const heading = document.querySelector<HTMLElement>(
|
||||||
`[data-onboarding-step="${activeStep.id}"] h1`,
|
`[data-onboarding-step="${activeStep.id}"] [data-onboarding-title]`,
|
||||||
);
|
);
|
||||||
heading?.focus();
|
heading?.focus();
|
||||||
}, [activeStep.id]);
|
}, [activeStep.id]);
|
||||||
|
|
@ -102,8 +108,27 @@ function OnboardingFlow({
|
||||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||||
data-onboarding-step={activeStep.id}
|
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}>
|
<ConfigureStepContext.Provider value={configure}>
|
||||||
{activeStep.content}
|
<div className="mx-auto w-full max-w-6xl px-6 pb-12 md:px-10">
|
||||||
|
{activeStep.content}
|
||||||
|
</div>
|
||||||
</ConfigureStepContext.Provider>
|
</ConfigureStepContext.Provider>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -111,6 +136,7 @@ function OnboardingFlow({
|
||||||
<div className="flex justify-center md:justify-end">
|
<div className="flex justify-center md:justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
|
className="w-full md:w-auto"
|
||||||
disabled={!controls.canContinue || pending}
|
disabled={!controls.canContinue || pending}
|
||||||
onClick={() => void advance()}
|
onClick={() => void advance()}
|
||||||
>
|
>
|
||||||
|
|
@ -119,7 +145,7 @@ function OnboardingFlow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<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"
|
aria-label="Onboarding steps"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ function Toggle({
|
||||||
return (
|
return (
|
||||||
<TogglePrimitive
|
<TogglePrimitive
|
||||||
data-slot="toggle"
|
data-slot="toggle"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
className={cn(toggleVariants({ variant, size, className }))}
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "@fontsource-variable/public-sans/wght.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-sans), sans-serif;
|
--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-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
|
@ -50,15 +51,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
: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);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.511 0.096 186.391);
|
--primary: oklch(0.24 0 0);
|
||||||
--primary-foreground: oklch(98.358% 0.0142 181.171);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--primary-foreground-alt: oklch(0.704 0.14 182.503);
|
--primary-foreground-alt: oklch(0.42 0 0);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
|
|
@ -68,7 +76,7 @@
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: 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-1: oklch(0.855 0.138 181.071);
|
||||||
--chart-2: oklch(0.704 0.14 182.503);
|
--chart-2: oklch(0.704 0.14 182.503);
|
||||||
--chart-3: oklch(0.6 0.118 184.704);
|
--chart-3: oklch(0.6 0.118 184.704);
|
||||||
|
|
@ -77,8 +85,8 @@
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.6 0.118 184.704);
|
--sidebar-primary: oklch(0.3 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
|
@ -92,9 +100,9 @@
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.437 0.078 188.216);
|
--primary: oklch(0.82 0 0);
|
||||||
--primary-foreground: oklch(98.358% 0.0142 181.171);
|
--primary-foreground: oklch(0.18 0 0);
|
||||||
--primary-foreground-alt: oklch(0.704 0.14 182.503);
|
--primary-foreground-alt: oklch(0.72 0 0);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
|
|
@ -112,8 +120,8 @@
|
||||||
--chart-5: oklch(0.437 0.078 188.216);
|
--chart-5: oklch(0.437 0.078 188.216);
|
||||||
--sidebar: oklch(0.19 0 0);
|
--sidebar: oklch(0.19 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.704 0.14 182.503);
|
--sidebar-primary: oklch(0.78 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
|
--sidebar-primary-foreground: oklch(0.18 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
|
@ -194,8 +202,58 @@
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@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 {
|
::selection {
|
||||||
background: var(--color-primary, var(--primary));
|
background: var(--color-primary, var(--primary));
|
||||||
color: var(--color-primary-foreground, var(--primary-foreground));
|
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 "./types";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./ThemeProvider";
|
export * from "./presets";
|
||||||
export * from "./pickers/ColorPicker";
|
export * from "./Provider";
|
||||||
export * from "./pickers/PolarityPicker";
|
export * from "./BaseThemeSelector";
|
||||||
export * from "./pickers/PrimaryPicker";
|
export * from "./OnboardingPage";
|
||||||
export * from "./pickers/Base16Picker";
|
|
||||||
export * from "./pickers/BorderRadiusPicker";
|
|
||||||
export * from "./pickers/CustomCssPicker";
|
|
||||||
export * from "./pickers/StylePicker";
|
export * from "./pickers/StylePicker";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Trash } from "lucide-react";
|
import { Trash } from "lucide-react";
|
||||||
|
|
||||||
import { Button, Textarea } from "../../index";
|
import { Button } from "../../cmp/button";
|
||||||
import { useTheme } from "../ThemeProvider";
|
import { Textarea } from "../../cmp/textarea";
|
||||||
|
import { useTheme } from "../Provider";
|
||||||
import { formatPaletteJson, parseBase16Palette } from "../utils";
|
import { formatPaletteJson, parseBase16Palette } from "../utils";
|
||||||
|
|
||||||
export function Base16Picker() {
|
export function Base16Picker() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { Slider, Button } from "../../index";
|
import { useId } from "react";
|
||||||
import { useTheme } from "../ThemeProvider";
|
|
||||||
import { Trash } from "lucide-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 {
|
import {
|
||||||
DEFAULT_BORDER_RADIUS,
|
DEFAULT_BORDER_RADIUS,
|
||||||
MAX_BORDER_RADIUS,
|
MAX_BORDER_RADIUS,
|
||||||
|
|
@ -8,6 +12,7 @@ import {
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
export function BorderRadiusPicker() {
|
export function BorderRadiusPicker() {
|
||||||
|
const id = useId();
|
||||||
const { themeBorderRadius, setThemeBorderRadius } = useTheme();
|
const { themeBorderRadius, setThemeBorderRadius } = useTheme();
|
||||||
const updateBorderRadius = (value: number | readonly number[]) => {
|
const updateBorderRadius = (value: number | readonly number[]) => {
|
||||||
setThemeBorderRadius(Array.isArray(value) ? (value[0] ?? 0) : value);
|
setThemeBorderRadius(Array.isArray(value) ? (value[0] ?? 0) : value);
|
||||||
|
|
@ -15,12 +20,19 @@ export function BorderRadiusPicker() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-65 flex-col gap-2">
|
<div className="flex w-65 flex-col gap-2">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex justify-between gap-4">
|
||||||
<div className="flex flex-col justify-start flex-1 gap-1">
|
<Label htmlFor={id}>Border radius</Label>
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<output
|
||||||
{themeBorderRadius.toFixed(3)}
|
htmlFor={id}
|
||||||
</span>
|
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
|
<Slider
|
||||||
|
id={id}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
min={MIN_BORDER_RADIUS}
|
min={MIN_BORDER_RADIUS}
|
||||||
max={MAX_BORDER_RADIUS}
|
max={MAX_BORDER_RADIUS}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Trash } from "lucide-react";
|
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";
|
import { COLOR_PICKER_FALLBACK } from "../utils";
|
||||||
|
|
||||||
export function ColorPicker({
|
export function ColorPicker({
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,59 @@
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { RotateCcw, Undo2 } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "../../cmp/button";
|
import { Button } from "../../cmp/button";
|
||||||
import { Textarea } from "../../cmp/textarea";
|
import { Textarea } from "../../cmp/textarea";
|
||||||
import { useTheme } from "../ThemeProvider";
|
import { useTheme } from "../Provider";
|
||||||
|
|
||||||
const CUSTOM_CSS_PLACEHOLDER = `* {
|
function hasBalancedBlocks(css: string) {
|
||||||
color: black;
|
return css.split("{").length === css.split("}").length;
|
||||||
}`;
|
}
|
||||||
|
|
||||||
export function CustomCssPicker() {
|
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 (
|
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
|
<Textarea
|
||||||
value={themeCustomCss}
|
value={themeCustomCss}
|
||||||
onChange={(event) => setThemeCustomCss(event.currentTarget.value)}
|
onFocus={() => {
|
||||||
placeholder={CUSTOM_CSS_PLACEHOLDER}
|
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}
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setThemeCustomCss("")}
|
disabled={!canUndo}
|
||||||
disabled={themeCustomCss === ""}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
import { Label } from "../../cmp/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -6,19 +9,20 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../../cmp/select";
|
} from "../../cmp/select";
|
||||||
import type { ThemePolarity } from "../types";
|
import type { ThemePolarity } from "../types";
|
||||||
import { useTheme } from "../ThemeProvider";
|
import { useTheme } from "../Provider";
|
||||||
|
|
||||||
export function PolarityPicker() {
|
export function PolarityPicker() {
|
||||||
|
const id = useId();
|
||||||
const { themePolarity, setThemePolarity } = useTheme();
|
const { themePolarity, setThemePolarity } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="flex flex-col gap-2 text-sm font-medium">
|
<div className="flex flex-col gap-2">
|
||||||
Color scheme
|
<Label htmlFor={id}>Polarity</Label>
|
||||||
<Select
|
<Select
|
||||||
value={themePolarity}
|
value={themePolarity}
|
||||||
onValueChange={(value) => setThemePolarity(value as ThemePolarity)}
|
onValueChange={(value) => setThemePolarity(value as ThemePolarity)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-65">
|
<SelectTrigger id={id} className="w-65">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{themePolarity === "system"
|
{themePolarity === "system"
|
||||||
? "System"
|
? "System"
|
||||||
|
|
@ -33,6 +37,6 @@ export function PolarityPicker() {
|
||||||
<SelectItem value="light">Light</SelectItem>
|
<SelectItem value="light">Light</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
|
import { Label } from "../../cmp/label";
|
||||||
import { ColorPicker } from "./ColorPicker";
|
import { ColorPicker } from "./ColorPicker";
|
||||||
import { useTheme } from "../ThemeProvider";
|
import { useTheme } from "../Provider";
|
||||||
|
|
||||||
export function PrimaryPicker() {
|
export function PrimaryPicker() {
|
||||||
const { themePrimaryColor, setThemePrimaryColor } = useTheme();
|
const { themePrimaryColor, setThemePrimaryColor } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<div className="flex flex-col gap-2">
|
||||||
label="Primary color"
|
<Label>Custom Primary Color</Label>
|
||||||
value={themePrimaryColor}
|
<ColorPicker
|
||||||
onChange={setThemePrimaryColor}
|
label="Primary color"
|
||||||
allowEmpty
|
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 { Base16Picker } from "./Base16Picker";
|
||||||
import { BorderRadiusPicker } from "./BorderRadiusPicker";
|
import { BorderRadiusPicker } from "./BorderRadiusPicker";
|
||||||
import { CustomCssPicker } from "./CustomCssPicker";
|
import { CustomCssPicker } from "./CustomCssPicker";
|
||||||
|
|
@ -7,52 +21,229 @@ import { PolarityPicker } from "./PolarityPicker";
|
||||||
import { PrimaryPicker } from "./PrimaryPicker";
|
import { PrimaryPicker } from "./PrimaryPicker";
|
||||||
import { TintPicker } from "./TintPicker";
|
import { TintPicker } from "./TintPicker";
|
||||||
|
|
||||||
type StyleCategoryProps = {
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function StyleCategory({ title, children }: StyleCategoryProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-sm font-semibold">{title}</h2>
|
<h2 className="font-heading font-semibold">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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="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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StylePicker() {
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5">
|
<div className="w-full min-w-0 space-y-7">
|
||||||
<PolarityPicker />
|
<div className="border border-dotted p-5 rounded-2xl bg-card/30 flex flex-col gap-5">
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),15rem))] gap-8">
|
<ThemeSelector value={value} onValueChange={onValueChange} />
|
||||||
<div className="flex shrink-0 flex-col gap-8">
|
<div className="grid gap-7 lg:grid-cols-2">
|
||||||
<StyleCategory title="Tint">
|
<Section title="Color scheme">
|
||||||
|
<PolarityPicker />
|
||||||
<TintPicker />
|
<TintPicker />
|
||||||
</StyleCategory>
|
|
||||||
|
|
||||||
<StyleCategory title="Primary Color">
|
|
||||||
<PrimaryPicker />
|
<PrimaryPicker />
|
||||||
</StyleCategory>
|
</Section>
|
||||||
|
<div className="space-y-7">
|
||||||
<StyleCategory title="Border Radius">
|
<Section title="Shape and spacing">
|
||||||
<BorderRadiusPicker />
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
</StyleCategory>
|
<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 />
|
||||||
|
<DesignSlider
|
||||||
|
label="Surface contrast"
|
||||||
|
field="surfaceContrast"
|
||||||
|
min={0}
|
||||||
|
max={8}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
</div>
|
||||||
|
<Section title="Typography">
|
||||||
<div className="shrink-0">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<StyleCategory title="Base16">
|
<div className="grid gap-2 text-sm">
|
||||||
<Base16Picker />
|
<Label htmlFor={fontId}>Font</Label>
|
||||||
</StyleCategory>
|
<Select
|
||||||
</div>
|
value={themeDesign.fontFamily}
|
||||||
|
onValueChange={(value) =>
|
||||||
<div className="shrink-0">
|
setThemeDesign((current) => ({
|
||||||
<StyleCategory title="Custom CSS">
|
...current,
|
||||||
<CustomCssPicker />
|
fontFamily: value as ThemeFontFamily,
|
||||||
</StyleCategory>
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
{advancedOpen && (
|
||||||
|
<div className="mt-6 space-y-7">
|
||||||
|
<Section title="Base16 colors">
|
||||||
|
<Base16Picker />
|
||||||
|
</Section>
|
||||||
|
<Section title="Custom CSS">
|
||||||
|
<CustomCssPicker />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const StylePicker = ThemeCustomizer;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../../cmp/select";
|
} from "../../cmp/select";
|
||||||
import { useTheme } from "../ThemeProvider";
|
import { Label } from "../../cmp/label";
|
||||||
|
import { useTheme } from "../Provider";
|
||||||
import type { ThemeTint } from "../types";
|
import type { ThemeTint } from "../types";
|
||||||
import { THEME_TINT_OPTIONS } from "../utils";
|
import { THEME_TINT_OPTIONS } from "../utils";
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ export function TintPicker() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Tint</Label>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
label="Theme color"
|
label="Theme color"
|
||||||
value={themeColor}
|
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 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 = {
|
export type ThemeProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
defaultTheme?: ThemePolarity;
|
defaultTheme?: ThemePolarity;
|
||||||
|
|
@ -32,6 +52,9 @@ export type ThemeProviderProps = {
|
||||||
defaultTint?: ThemeTint;
|
defaultTint?: ThemeTint;
|
||||||
defaultBorderRadius?: number;
|
defaultBorderRadius?: number;
|
||||||
defaultCustomCss?: string;
|
defaultCustomCss?: string;
|
||||||
|
themes?: readonly ThemePreset[];
|
||||||
|
defaultParentThemeId?: string | null;
|
||||||
|
defaultDesign?: ThemeDesign;
|
||||||
storageKey?: string | null;
|
storageKey?: string | null;
|
||||||
colorStorageKey?: string | null;
|
colorStorageKey?: string | null;
|
||||||
paletteStorageKey?: string | null;
|
paletteStorageKey?: string | null;
|
||||||
|
|
@ -39,6 +62,8 @@ export type ThemeProviderProps = {
|
||||||
tintStorageKey?: string | null;
|
tintStorageKey?: string | null;
|
||||||
borderRadiusStorageKey?: string | null;
|
borderRadiusStorageKey?: string | null;
|
||||||
customCssStorageKey?: string | null;
|
customCssStorageKey?: string | null;
|
||||||
|
parentThemeStorageKey?: string | null;
|
||||||
|
designStorageKey?: string | null;
|
||||||
disableTransitionOnChange?: boolean;
|
disableTransitionOnChange?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -60,6 +85,17 @@ export type ThemeProviderState = {
|
||||||
setThemeBorderRadius: (radius: number) => void;
|
setThemeBorderRadius: (radius: number) => void;
|
||||||
themeCustomCss: string;
|
themeCustomCss: string;
|
||||||
setThemeCustomCss: (css: string) => void;
|
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 = {
|
export type HslColor = {
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ export function formatPaletteJson(palette: Base16Palette | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSystemTheme(): ResolvedThemePolarity {
|
export function getSystemTheme(): ResolvedThemePolarity {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) return "dark";
|
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) return "dark";
|
||||||
return "light";
|
return "light";
|
||||||
}
|
}
|
||||||
|
|
@ -551,13 +552,13 @@ export function readStoredValue<T extends string>(
|
||||||
validate: (value: string | null) => value is T,
|
validate: (value: string | null) => value is T,
|
||||||
fallback: T,
|
fallback: T,
|
||||||
) {
|
) {
|
||||||
if (!key) return fallback;
|
if (!key || typeof window === "undefined") return fallback;
|
||||||
|
|
||||||
const value = localStorage.getItem(key);
|
const value = localStorage.getItem(key);
|
||||||
return validate(value) ? value : fallback;
|
return validate(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveStoredValue(key: string | null | undefined, value: string) {
|
export function saveStoredValue(key: string | null | undefined, value: string) {
|
||||||
if (!key) return;
|
if (!key || typeof window === "undefined") return;
|
||||||
localStorage.setItem(key, value);
|
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