├── .nvmrc
├── .husky
└── pre-commit
├── tsconfig.json
├── src
├── preferences.ts
├── components
│ ├── OutputCard.module.css
│ ├── CardTitle.tsx
│ ├── hueslider
│ │ ├── ColorPicker.context.ts
│ │ ├── Thumb.tsx
│ │ ├── LICENSE.txt
│ │ ├── OKHueSlider.tsx
│ │ ├── ColorPicker.module.css
│ │ └── ColorSlider.tsx
│ ├── ThemeDisplay.module.css
│ ├── TextFormat.tsx
│ ├── PreviewCard.module.css
│ ├── PreviewCard.tsx
│ ├── SingleColorEditorCard.tsx
│ ├── AboutModal.tsx
│ ├── ThemeDisplay.tsx
│ ├── OutputCard.tsx
│ └── SyntaxPreviewCard.tsx
├── main.tsx
├── outputs
│ ├── types.ts
│ ├── index.ts
│ ├── alacritty.ts
│ ├── kitty.ts
│ ├── ghostty.ts
│ ├── wezterm.ts
│ ├── json.ts
│ ├── iterm2.ts
│ └── vscode.ts
├── App.module.css
├── mantineTheme.ts
├── App.tsx
├── colorconversion.d.ts
├── index.css
├── state.ts
├── serialization.ts
├── apcach.d.ts
├── solarized.ts
└── colorconversion.js
├── .prettierrc
├── .vscode
└── extensions.json
├── .prettierignore
├── vite.config.ts
├── README.md
├── postcss.config.js
├── .gitignore
├── tsconfig.node.json
├── tsconfig.app.json
├── .editorconfig
├── index.html
├── package.json
├── eslint.config.js
└── CLAUDE.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm run lint
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/src/preferences.ts:
--------------------------------------------------------------------------------
1 | export interface Preferences {
2 | linkDarkHues: boolean;
3 | linkLightHues: boolean;
4 | linkColorLightness: boolean;
5 | fontSize?: number;
6 | }
7 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "trailingComma": "es5",
6 | "printWidth": 80,
7 | "arrowParens": "always",
8 | "endOfLine": "lf"
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "esbenp.prettier-vscode",
4 | "dbaeumer.vscode-eslint",
5 | "vunguyentuan.vscode-css-variables",
6 | "EditorConfig.EditorConfig"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Build output
2 | dist
3 | build
4 | coverage
5 |
6 | # Dependencies
7 | node_modules
8 |
9 | # Cache
10 | .cache
11 | .vite
12 | .turbo
13 |
14 | # Logs
15 | *.log
16 |
17 | # OS
18 | .DS_Store
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | allowedHosts: true,
9 | },
10 | clearScreen: false,
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/OutputCard.module.css:
--------------------------------------------------------------------------------
1 | .codeBlock {
2 | font-size: 12px;
3 | line-height: 1.5;
4 | font-family: var(--mantine-font-family-monospace);
5 | white-space: pre;
6 | overflow-x: auto;
7 |
8 | @media (min-width: $mantine-breakpoint-xl) {
9 | width: 600px;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SolarSystem
2 |
3 | Build Solarized-inspired color palettes, using OKHSL and APCA to ensure
4 | perceptual uniformity and accessibility.
5 |
6 | ## Installation
7 |
8 | ```bash
9 | $ nvm install && nvm use # use the configured node version
10 | $ npm install
11 | $ npm run dev # run the dev server
12 | ```
13 |
--------------------------------------------------------------------------------
/src/components/CardTitle.tsx:
--------------------------------------------------------------------------------
1 | import { Title } from '@mantine/core';
2 |
3 | export default function CardTitle({ children }: { children: React.ReactNode }) {
4 | return (
5 |
11 | {children}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import App from './App.tsx';
5 | import '@mantine/core/styles.css';
6 | import '@mantine/dates/styles.css';
7 | import './index.css';
8 |
9 | createRoot(document.getElementById('root')!).render(
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | 'postcss-preset-mantine': {},
4 | 'postcss-simple-vars': {
5 | variables: {
6 | 'mantine-breakpoint-xs': '36em',
7 | 'mantine-breakpoint-sm': '48em',
8 | 'mantine-breakpoint-md': '62em',
9 | 'mantine-breakpoint-lg': '75em',
10 | 'mantine-breakpoint-xl': '88em',
11 | },
12 | },
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/src/components/hueslider/ColorPicker.context.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createOptionalContext,
3 | type GetStylesApi,
4 | type ColorPickerFactory,
5 | } from '@mantine/core';
6 |
7 | interface ColorPickerContextValue {
8 | getStyles: GetStylesApi;
9 | unstyled: boolean | undefined;
10 | }
11 |
12 | export const [ColorPickerProvider, useColorPickerContext] =
13 | createOptionalContext(null);
14 |
--------------------------------------------------------------------------------
/src/outputs/types.ts:
--------------------------------------------------------------------------------
1 | import type { SolarizedTheme } from '../solarized';
2 |
3 | export interface ExporterConfig {
4 | id: string;
5 | name: string;
6 | description: string;
7 | fileExtension?: string;
8 | colorScheme?: 'dark' | 'light';
9 | export: (theme: SolarizedTheme, themeName: string) => string;
10 | }
11 |
12 | export interface ExporterCategory {
13 | id: string;
14 | name: string;
15 | exporters: ExporterConfig[];
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # amplify
27 | .amplify
28 | amplify_outputs*
29 | amplifyconfiguration*
30 |
31 | # visualizer output
32 | stats.html
33 |
--------------------------------------------------------------------------------
/src/components/hueslider/Thumb.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@mantine/core';
2 |
3 | export interface ThumbProps extends React.ComponentProps<'div'> {
4 | variant?: string;
5 | position: { x: number; y: number };
6 | }
7 |
8 | export function Thumb({ position, ref, ...others }: ThumbProps) {
9 | return (
10 |
18 | );
19 | }
20 |
21 | Thumb.displayName = 'ColorPickerThumb';
22 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2023",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "types": ["node"],
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "erasableSyntaxOnly": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["vite.config.ts"]
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2022",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "types": ["vite/client"],
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "baseUrl": ".",
15 | "paths": {
16 | "@/*": ["./*"]
17 | },
18 | "allowImportingTsExtensions": true,
19 | "verbatimModuleSyntax": true,
20 | "moduleDetection": "force",
21 | "noEmit": true,
22 | "jsx": "react-jsx",
23 |
24 | /* Linting */
25 | "strict": true,
26 | "noUnusedLocals": true,
27 | "noUnusedParameters": true,
28 | "erasableSyntaxOnly": true,
29 | "noFallthroughCasesInSwitch": true,
30 | "noUncheckedSideEffectImports": true
31 | },
32 | "include": ["src"]
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/hueslider/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Portions of this were adapted from Mantine (https://mantine.dev/)
2 |
3 | MIT License
4 |
5 | Copyright (c) 2021 Vitaly Rtishchev
6 |
7 | Permission is hereby granted, free of charge, to any person obtaining a copy
8 | of this software and associated documentation files (the "Software"), to deal
9 | in the Software without restriction, including without limitation the rights
10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the Software is
12 | furnished to do so, subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in all
15 | copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 | SOFTWARE.
24 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # Top-most EditorConfig file
4 | root = true
5 |
6 | # Unix-style newlines with a newline ending every file
7 | [*]
8 | charset = utf-8
9 | end_of_line = lf
10 | insert_final_newline = true
11 | trim_trailing_whitespace = true
12 |
13 | # TypeScript, JavaScript, JSX, TSX
14 | [*.{ts,tsx,js,jsx,mjs,cjs}]
15 | indent_style = space
16 | indent_size = 2
17 | max_line_length = 80
18 |
19 | # JSON files
20 | [*.json]
21 | indent_style = space
22 | indent_size = 2
23 |
24 | # JSON5 files (like tsconfig)
25 | [*.json5]
26 | indent_style = space
27 | indent_size = 2
28 |
29 | # YAML files
30 | [*.{yml,yaml}]
31 | indent_style = space
32 | indent_size = 2
33 |
34 | # Markdown files
35 | [*.md]
36 | indent_style = space
37 | indent_size = 2
38 | trim_trailing_whitespace = false
39 |
40 | # CSS, SCSS, and PostCSS files
41 | [*.{css,scss,pcss}]
42 | indent_style = space
43 | indent_size = 2
44 |
45 | # HTML files
46 | [*.html]
47 | indent_style = space
48 | indent_size = 2
49 |
50 | # Package.json - specific formatting
51 | [package.json]
52 | indent_style = space
53 | indent_size = 2
54 |
55 | # Makefiles
56 | [Makefile]
57 | indent_style = tab
58 |
59 | # Shell scripts
60 | [*.sh]
61 | indent_style = space
62 | indent_size = 2
63 |
--------------------------------------------------------------------------------
/src/outputs/index.ts:
--------------------------------------------------------------------------------
1 | import { alacrittyDarkExporter, alacrittyLightExporter } from './alacritty';
2 | import { ghosttyDarkExporter, ghosttyLightExporter } from './ghostty';
3 | import { iterm2DarkExporter, iterm2LightExporter } from './iterm2';
4 | import { jsonSimpleExporter, jsonDetailedExporter } from './json';
5 | import { kittyDarkExporter, kittyLightExporter } from './kitty';
6 | import type { ExporterCategory } from './types';
7 | import { vscodeDarkExporter, vscodeLightExporter } from './vscode';
8 | import { weztermDarkExporter, weztermLightExporter } from './wezterm';
9 |
10 | export * from './types';
11 |
12 | export const EXPORTER_CATEGORIES: ExporterCategory[] = [
13 | {
14 | id: 'data',
15 | name: 'Data Formats',
16 | exporters: [jsonSimpleExporter, jsonDetailedExporter],
17 | },
18 | {
19 | id: 'editors',
20 | name: 'Code Editors',
21 | exporters: [vscodeDarkExporter, vscodeLightExporter],
22 | },
23 | {
24 | id: 'terminals',
25 | name: 'Terminal Emulators',
26 | exporters: [
27 | ghosttyDarkExporter,
28 | ghosttyLightExporter,
29 | iterm2DarkExporter,
30 | iterm2LightExporter,
31 | alacrittyDarkExporter,
32 | alacrittyLightExporter,
33 | kittyDarkExporter,
34 | kittyLightExporter,
35 | weztermDarkExporter,
36 | weztermLightExporter,
37 | ],
38 | },
39 | ];
40 |
--------------------------------------------------------------------------------
/src/App.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: fit-content;
3 | margin-inline: auto;
4 | padding-inline: var(--mantine-spacing-lg);
5 | margin-block: var(--mantine-spacing-sm);
6 | gap: var(--mantine-spacing-sm);
7 | display: grid;
8 | grid-template-columns: repeat(2, 1fr);
9 | grid-template-rows: auto auto auto 1fr auto;
10 | grid-template-areas:
11 | 'header header'
12 | 'ThemeDisplay EditorCard'
13 | 'ThemeDisplay PreviewCard'
14 | 'ThemeDisplay SyntaxPreviewCard'
15 | 'OutputCard OutputCard';
16 |
17 | .header {
18 | grid-area: header;
19 | justify-self: center;
20 | }
21 |
22 | @media (min-width: $mantine-breakpoint-xl) {
23 | grid-template-columns: auto auto 1fr;
24 | grid-template-rows: auto auto auto 1fr;
25 | grid-template-areas:
26 | 'header header header'
27 | 'ThemeDisplay EditorCard OutputCard'
28 | 'ThemeDisplay PreviewCard OutputCard'
29 | 'ThemeDisplay SyntaxPreviewCard OutputCard';
30 | }
31 |
32 | @media (max-width: $mantine-breakpoint-md) {
33 | width: 100%;
34 | padding-inline: var(--mantine-spacing-sm);
35 | grid-template-columns: auto;
36 | grid-template-areas:
37 | 'header'
38 | 'ThemeDisplay'
39 | 'EditorCard'
40 | 'PreviewCard'
41 | 'SyntaxPreviewCard'
42 | 'OutputCard';
43 |
44 | h1 {
45 | grid-column: 1 / span 1;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
14 |
19 |
20 | SolarSystem
21 |
22 |
23 |
24 |
28 |
29 |
33 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/mantineTheme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mantine/core';
2 | import { apcach, apcachToCss, crToBg } from 'apcach';
3 |
4 | const darkHue = 300;
5 |
6 | const backgroundColor = `oklch(0.10 0.0236 ${darkHue})`;
7 |
8 | export const theme = createTheme({
9 | primaryShade: { light: 7, dark: 7 },
10 | primaryColor: 'green',
11 | fontFamily:
12 | '"Stack Sans Text", -apple-system, "system-ui", "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
13 | fontFamilyMonospace: '"JetBrains Mono", monospace',
14 | colors: {
15 | dark: [
16 | apcachToCss(apcach(crToBg(backgroundColor, 90), 0.0236, darkHue)),
17 | apcachToCss(apcach(crToBg(backgroundColor, 75), 0.0236, darkHue)),
18 | apcachToCss(apcach(crToBg(backgroundColor, 70), 0.0236, darkHue)),
19 | apcachToCss(apcach(crToBg(backgroundColor, 60), 0.0236, darkHue)),
20 | `oklch(0.50 0.0236 ${darkHue})`,
21 | `oklch(0.42 0.0236 ${darkHue})`,
22 | `oklch(0.34 0.0136 ${darkHue})`,
23 | `oklch(0.26 0.0445 ${darkHue})`,
24 | `oklch(0.18 0.0445 ${darkHue})`,
25 | `oklch(0.10 0.0236 ${darkHue})`,
26 | ],
27 | },
28 | headings: {
29 | fontFamily: '"Stack Sans Text", sans-serif',
30 | fontWeight: '400',
31 | },
32 | components: {
33 | Card: {
34 | defaultProps: {
35 | shadow: 'md',
36 | bdrs: 'md',
37 | },
38 | },
39 | Divider: {
40 | classNames: {
41 | root: 'divider',
42 | },
43 | },
44 | Paper: {
45 | classNames: {
46 | root: 'paper-transparent',
47 | },
48 | },
49 | Input: {
50 | classNames: {
51 | input: 'input-transparent',
52 | },
53 | },
54 | Tabs: {
55 | classNames: {
56 | tab: 'tab-hover',
57 | },
58 | },
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "solarsystem",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint . && prettier --check .",
10 | "lint:fix": "eslint . --fix && prettier --write .",
11 | "preview": "vite preview",
12 | "prepare": "husky"
13 | },
14 | "dependencies": {
15 | "@hookform/resolvers": "^5.2.2",
16 | "@mantine/core": "^8.3.6",
17 | "@mantine/dates": "^8.3.6",
18 | "@mantine/form": "^8.3.6",
19 | "@mantine/hooks": "^8.3.6",
20 | "@tabler/icons-react": "^3.35.0",
21 | "@types/prismjs": "^1.26.5",
22 | "apcach": "^0.6.4",
23 | "colorjs.io": "^0.6.0-beta.3",
24 | "jotai": "^2.15.1",
25 | "prismjs": "^1.30.0",
26 | "react": "^19.1.1",
27 | "react-dom": "^19.1.1",
28 | "react-hook-form": "^7.66.0",
29 | "usehooks-ts": "^3.1.1",
30 | "zod": "^4.1.12"
31 | },
32 | "devDependencies": {
33 | "@eslint/js": "^9.36.0",
34 | "@hookform/devtools": "^4.4.0",
35 | "@types/node": "^24.6.0",
36 | "@types/react": "^19.1.16",
37 | "@types/react-dom": "^19.1.9",
38 | "@vitejs/plugin-react": "^5.0.4",
39 | "eslint": "^9.36.0",
40 | "eslint-config-prettier": "^10.1.8",
41 | "eslint-import-resolver-typescript": "^4.4.4",
42 | "eslint-plugin-import-x": "^4.16.1",
43 | "eslint-plugin-prettier": "^5.5.4",
44 | "eslint-plugin-react-hooks": "^5.2.0",
45 | "eslint-plugin-react-refresh": "^0.4.22",
46 | "globals": "^16.4.0",
47 | "husky": "^9.1.7",
48 | "postcss": "^8.5.6",
49 | "postcss-preset-mantine": "^1.18.0",
50 | "postcss-simple-vars": "^7.0.1",
51 | "prettier": "^3.6.2",
52 | "tsx": "^4.20.6",
53 | "typescript": "^5.9.3",
54 | "typescript-eslint": "^8.45.0",
55 | "vite": "^7.1.7"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/ThemeDisplay.module.css:
--------------------------------------------------------------------------------
1 | .colorBox {
2 | font-family: var(--mantine-font-family-monospace);
3 | line-height: 1.1;
4 | font-size: 12px;
5 | font-weight: 500;
6 | box-sizing: content-box;
7 | padding: 16px;
8 | border: none;
9 | border-radius: var(--mantine-radius-sm);
10 | isolation: isolate;
11 |
12 | display: grid;
13 | grid-template-columns: 1fr auto;
14 | grid-template-rows: auto 1fr;
15 | grid-template-areas:
16 | 'name uses'
17 | 'hex contrasts';
18 | column-gap: var(--mantine-spacing-md);
19 | row-gap: var(--mantine-spacing-sm);
20 |
21 | &.selected {
22 | outline: 1px solid var(--mantine-color-blue-7);
23 | box-shadow: 0 0 3px 2px var(--mantine-color-blue-7);
24 | }
25 |
26 | [data-mantine-color-scheme='dark'] &.selected {
27 | outline: 1px solid var(--mantine-color-blue-3);
28 | box-shadow: 0 0 3px 2px var(--mantine-color-blue-3);
29 | }
30 |
31 | .name {
32 | grid-area: name;
33 | font-weight: bold;
34 | font-size: 14px;
35 | }
36 |
37 | .hex {
38 | grid-area: hex;
39 | align-self: end;
40 | }
41 |
42 | .uses,
43 | .contrasts {
44 | font-family: var(--mantine-font-family);
45 | display: flex;
46 | flex-flow: column nowrap;
47 | margin: 0;
48 | padding: 0;
49 | li {
50 | display: block;
51 | }
52 | }
53 |
54 | .uses {
55 | grid-area: uses;
56 | text-align: right;
57 | li {
58 | white-space: nowrap;
59 | }
60 | }
61 |
62 | .contrasts {
63 | grid-area: contrasts;
64 | justify-self: end;
65 | align-self: end;
66 | font-size: 14px;
67 | font-weight: 400;
68 | text-align: right;
69 | font-feature-settings: 'tnum';
70 | font-variant-numeric: tabular-nums;
71 | li {
72 | display: flex;
73 | justify-content: space-between;
74 | gap: var(--mantine-spacing-xs);
75 | .contrastLabel {
76 | color: color-mix(in srgb, var(--fg-color), transparent 25%);
77 | text-transform: lowercase;
78 | }
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { ActionIcon, Group, MantineProvider, Title } from '@mantine/core';
3 | import { IconInfoCircle } from '@tabler/icons-react';
4 | import { useSetAtom } from 'jotai';
5 |
6 | import classes from './App.module.css';
7 | import AboutModal from './components/AboutModal';
8 | import OutputCard from './components/OutputCard';
9 | import PreviewCard from './components/PreviewCard';
10 | import SingleColorEditorCard from './components/SingleColorEditorCard';
11 | import SyntaxPreviewCard from './components/SyntaxPreviewCard';
12 | import ThemeDisplay from './components/ThemeDisplay';
13 | import { theme as mantineTheme } from './mantineTheme';
14 | import { parseThemeFromURL, clearURLParams } from './serialization';
15 | import { themeAtom, themeNameAtom } from './state';
16 |
17 | function App() {
18 | const setTheme = useSetAtom(themeAtom);
19 | const setThemeName = useSetAtom(themeNameAtom);
20 | const [aboutModalOpened, setAboutModalOpened] = useState(false);
21 |
22 | // Load theme from URL on mount
23 | useEffect(() => {
24 | const { theme, name } = parseThemeFromURL();
25 |
26 | if (theme) {
27 | setTheme(theme);
28 | if (name) {
29 | setThemeName(name);
30 | }
31 | // Remove query params from URL without navigation
32 | clearURLParams();
33 | }
34 | }, [setTheme, setThemeName]);
35 |
36 | return (
37 |
38 |
39 |
40 | SolarSystem
41 | setAboutModalOpened(true)}
45 | aria-label="About SolarSystem"
46 | >
47 |
48 |
49 |
50 |
51 |
setAboutModalOpened(false)}
54 | />
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | export default App;
67 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import reactHooks from 'eslint-plugin-react-hooks';
4 | import reactRefresh from 'eslint-plugin-react-refresh';
5 | import tseslint from 'typescript-eslint';
6 | import importX from 'eslint-plugin-import-x';
7 | import eslintConfigPrettier from 'eslint-config-prettier';
8 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
9 | import { defineConfig, globalIgnores } from 'eslint/config';
10 |
11 | export default defineConfig([
12 | globalIgnores(['dist']),
13 | {
14 | files: ['**/*.{ts,tsx}'],
15 | extends: [
16 | js.configs.recommended,
17 | tseslint.configs.recommended,
18 | reactHooks.configs['recommended-latest'],
19 | reactRefresh.configs.vite,
20 | importX.flatConfigs.recommended,
21 | importX.flatConfigs.typescript,
22 | eslintConfigPrettier,
23 | eslintPluginPrettierRecommended,
24 | ],
25 | languageOptions: {
26 | ecmaVersion: 2020,
27 | globals: globals.browser,
28 | },
29 | settings: {
30 | 'import-x/resolver': {
31 | typescript: {
32 | alwaysTryTypes: true,
33 | project: './tsconfig.app.json',
34 | },
35 | },
36 | },
37 | rules: {
38 | // Import sorting and organization
39 | 'import-x/order': [
40 | 'error',
41 | {
42 | groups: [
43 | ['builtin', 'external'],
44 | 'internal',
45 | ['parent', 'sibling', 'index', 'object'],
46 | ],
47 | pathGroups: [
48 | {
49 | pattern: 'react',
50 | group: 'builtin',
51 | position: 'before',
52 | },
53 | {
54 | pattern: '@/**',
55 | group: 'internal',
56 | position: 'before',
57 | },
58 | ],
59 | pathGroupsExcludedImportTypes: [],
60 | distinctGroup: false,
61 | 'newlines-between': 'always',
62 | alphabetize: {
63 | order: 'asc',
64 | caseInsensitive: true,
65 | },
66 | },
67 | ],
68 | 'import-x/no-duplicates': 'error',
69 | 'import-x/first': 'error',
70 | 'import-x/newline-after-import': 'error',
71 | 'import-x/no-unresolved': 'error',
72 | },
73 | },
74 | ]);
75 |
--------------------------------------------------------------------------------
/src/colorconversion.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Type declarations for colorconversion module
3 | * Color conversion utilities for OkHSL, OkHSV, and RGB color spaces
4 | */
5 |
6 | /**
7 | * RGB color tuple [r, g, b] where values are in range 0-255
8 | */
9 | export type RGB = [number, number, number];
10 |
11 | /**
12 | * HSL color tuple [h, s, l] where:
13 | * - h (hue) is in range 0-1 (representing 0-360 degrees)
14 | * - s (saturation) is in range 0-1
15 | * - l (lightness) is in range 0-1
16 | */
17 | export type HSL = [number, number, number];
18 |
19 | /**
20 | * HSV color tuple [h, s, v] where:
21 | * - h (hue) is in range 0-1 (representing 0-360 degrees)
22 | * - s (saturation) is in range 0-1
23 | * - v (value) is in range 0-1
24 | */
25 | export type HSV = [number, number, number];
26 |
27 | /**
28 | * Converts OkHSL color to sRGB
29 | * @param h - Hue (0-1)
30 | * @param s - Saturation (0-1)
31 | * @param l - Lightness (0-1)
32 | * @returns RGB tuple [r, g, b] with values 0-255
33 | */
34 | export function okhsl_to_srgb(h: number, s: number, l: number): RGB;
35 |
36 | /**
37 | * Converts sRGB color to OkHSL
38 | * @param r - Red component (0-255)
39 | * @param g - Green component (0-255)
40 | * @param b - Blue component (0-255)
41 | * @returns HSL tuple [h, s, l] with values 0-1
42 | */
43 | export function srgb_to_okhsl(r: number, g: number, b: number): HSL;
44 |
45 | /**
46 | * Converts OkHSV color to sRGB
47 | * @param h - Hue (0-1)
48 | * @param s - Saturation (0-1)
49 | * @param v - Value (0-1)
50 | * @returns RGB tuple [r, g, b] with values 0-255
51 | */
52 | export function okhsv_to_srgb(h: number, s: number, v: number): RGB;
53 |
54 | /**
55 | * Converts sRGB color to OkHSV
56 | * @param r - Red component (0-255)
57 | * @param g - Green component (0-255)
58 | * @param b - Blue component (0-255)
59 | * @returns HSV tuple [h, s, v] with values 0-1
60 | */
61 | export function srgb_to_okhsv(r: number, g: number, b: number): HSV;
62 |
63 | /**
64 | * Converts hexadecimal color string to RGB
65 | * @param hex - Hex color string (with or without #, supports 1, 2, 3, or 6 digit formats)
66 | * @returns RGB tuple [r, g, b] with values 0-255, or null if invalid format
67 | */
68 | export function hex_to_rgb(hex: string): RGB | null;
69 |
70 | /**
71 | * Converts RGB color to hexadecimal string
72 | * @param r - Red component (0-255)
73 | * @param g - Green component (0-255)
74 | * @param b - Blue component (0-255)
75 | * @returns Hex color string with # prefix
76 | */
77 | export function rgb_to_hex(r: number, g: number, b: number): string;
78 |
--------------------------------------------------------------------------------
/src/components/TextFormat.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import { Group, NativeSelect, TextInput } from '@mantine/core';
3 | import Color from 'colorjs.io';
4 |
5 | const TextFormats = {
6 | hex: 'sRGB Hex',
7 | srgb: 'sRGB',
8 | p3: 'Display P3',
9 | okhsl: 'OKHSL',
10 | oklch: 'OKLCH',
11 | } as const;
12 |
13 | type TextFormat = keyof typeof TextFormats;
14 |
15 | export default function TextFormat({
16 | color,
17 | setColor,
18 | }: {
19 | color: Color;
20 | setColor: (color: Color) => void;
21 | }) {
22 | const [format, setFormat] = useState('hex');
23 |
24 | const [colorString, setColorString] = useState(
25 | formatColor(color, format)
26 | );
27 | const colorUpdatedFromInput = useRef(false);
28 |
29 | useEffect(() => {
30 | if (!colorUpdatedFromInput.current) {
31 | setColorString(formatColor(color, format));
32 | }
33 | colorUpdatedFromInput.current = false;
34 | }, [color, format]);
35 |
36 | return (
37 |
38 | {
46 | const colorString = ev.target.value;
47 | setColorString(colorString);
48 | let newColor: Color;
49 | try {
50 | newColor = new Color(colorString.trim());
51 | } catch {
52 | return;
53 | }
54 | if (
55 | newColor.spaceId === 'srgb' &&
56 | (colorString.startsWith('#') ||
57 | colorString.match(/^[a-f0-9]{3}|[a-f0-9]{6}$/i))
58 | ) {
59 | setFormat('hex');
60 | colorUpdatedFromInput.current = true;
61 | } else if (Object.keys(TextFormats).includes(newColor.spaceId)) {
62 | setFormat(newColor.spaceId as TextFormat);
63 | colorUpdatedFromInput.current = true;
64 | }
65 | setColor(newColor);
66 | }}
67 | />
68 | {
72 | const format = event.currentTarget.value as TextFormat;
73 | setFormat(format);
74 | setColorString(formatColor(color, format));
75 | }}
76 | data={Object.entries(TextFormats).map(([value, label]) => ({
77 | value,
78 | label,
79 | }))}
80 | />
81 |
82 | );
83 | }
84 |
85 | function formatColor(color: Color, format: TextFormat) {
86 | if (format === 'hex') {
87 | return color.to('srgb').toString({ format: 'hex' });
88 | } else {
89 | return color.to(format).toString();
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/components/PreviewCard.module.css:
--------------------------------------------------------------------------------
1 | .previewBlock {
2 | margin: 0;
3 | border-radius: var(--mantine-radius-sm);
4 | padding: var(--mantine-spacing-md);
5 | isolation: isolate;
6 |
7 | @media (min-width: $mantine-breakpoint-md) {
8 | width: 480px;
9 | }
10 |
11 | &::selection {
12 | background-color: var(--selection-bg-color);
13 | }
14 | }
15 |
16 | .syntaxPreviewBlock {
17 | margin: 0;
18 | border-radius: var(--mantine-radius-sm);
19 | padding: 0;
20 | isolation: isolate;
21 | background-color: var(--prism-background);
22 | color: var(--prism-foreground);
23 | overflow: auto;
24 | max-height: 500px;
25 | width: 100%;
26 | font-family: var(--mantine-font-family-monospace);
27 |
28 | /* Prism token styling */
29 | :global(.token.comment),
30 | :global(.token.prolog),
31 | :global(.token.doctype),
32 | :global(.token.cdata) {
33 | color: var(--prism-comment);
34 | font-style: italic;
35 | }
36 |
37 | :global(.token.keyword),
38 | :global(.token.operator),
39 | :global(.token.boolean) {
40 | color: var(--prism-keyword);
41 | }
42 |
43 | :global(.token.string),
44 | :global(.token.char),
45 | :global(.token.attr-value) {
46 | color: var(--prism-string);
47 | }
48 |
49 | :global(.token.function),
50 | :global(.token.function-variable) {
51 | color: var(--prism-function);
52 | }
53 |
54 | :global(.token.number) {
55 | color: var(--prism-number);
56 | }
57 |
58 | :global(.token.punctuation) {
59 | color: var(--prism-punctuation);
60 | }
61 |
62 | :global(.token.class-name) {
63 | color: var(--prism-class-name);
64 | }
65 |
66 | :global(.token.constant),
67 | :global(.token.symbol) {
68 | color: var(--prism-constant);
69 | }
70 |
71 | :global(.token.builtin) {
72 | color: var(--prism-builtin);
73 | }
74 |
75 | :global(.token.variable),
76 | :global(.token.parameter),
77 | :global(.token.key) {
78 | color: var(--prism-variable);
79 | }
80 |
81 | :global(.token.tag) {
82 | color: var(--prism-tag);
83 | }
84 |
85 | :global(.token.attr-name),
86 | :global(.token.property) {
87 | color: var(--prism-attr-name);
88 | }
89 |
90 | :global(.token.decorator),
91 | :global(.token.annotation) {
92 | color: var(--prism-decorator);
93 | }
94 |
95 | :global(.token.regex) {
96 | color: var(--prism-regex);
97 | }
98 |
99 | pre {
100 | padding: var(--mantine-spacing-md);
101 | margin: 0;
102 | overflow: auto;
103 | isolation: isolate;
104 | }
105 |
106 | code {
107 | background: none;
108 | border: none;
109 | padding: 0;
110 | font-size: inherit;
111 | line-height: 1.5;
112 | font-family: var(--mantine-font-family-monospace);
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/hueslider/OKHueSlider.tsx:
--------------------------------------------------------------------------------
1 | import { rem, useProps } from '@mantine/core';
2 | import type Color from 'colorjs.io';
3 |
4 | import { ColorSlider, type ColorSliderProps } from './ColorSlider';
5 | import { clampAndRoundComponent, getFgColor } from '../../solarized';
6 |
7 | export interface OKComponentSliderProps
8 | extends Omit {
9 | baseColor: Color;
10 | component: 'hue' | 'saturation' | 'lightness';
11 | }
12 |
13 | export function OKComponentSlider(
14 | props: OKComponentSliderProps & { ref?: React.Ref }
15 | ) {
16 | const {
17 | value,
18 | onChange,
19 | onChangeEnd,
20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
21 | color,
22 | ref,
23 | baseColor,
24 | component,
25 | ...others
26 | } = useProps('HueSlider', {}, props);
27 |
28 | return (
29 |
38 | onChange?.(
39 | component === 'hue'
40 | ? clampAndRoundComponent(component, v)
41 | : clampAndRoundComponent(component, v / 1000)
42 | )
43 | }
44 | onChangeEnd={(v) =>
45 | onChangeEnd?.(
46 | component === 'hue'
47 | ? clampAndRoundComponent(component, v)
48 | : clampAndRoundComponent(component, v / 1000)
49 | )
50 | }
51 | maxValue={component === 'hue' ? 360 : 1000}
52 | thumbColor={baseColor.toString({ format: 'oklch' })}
53 | thumbBorderColor={getFgColor(baseColor).toString({ format: 'oklch' })}
54 | size="xl"
55 | round
56 | overlays={[
57 | {
58 | backgroundImage: generateGradient(
59 | baseColor,
60 | component,
61 | component === 'hue' ? 36 : 100
62 | ),
63 | },
64 | {
65 | boxShadow: `rgba(0, 0, 0, .1) 0 0 0 ${rem(1)} inset, rgb(0, 0, 0, .15) 0 0 ${rem(
66 | 4
67 | )} inset`,
68 | },
69 | ]}
70 | />
71 | );
72 | }
73 |
74 | function generateGradient(
75 | baseColor: Color,
76 | component: 'hue' | 'saturation' | 'lightness',
77 | steps: number
78 | ): string {
79 | let c = baseColor.clone();
80 | return `linear-gradient(to right in oklch shorter hue,${Array.from(
81 | { length: steps + 1 },
82 | (_, i) => {
83 | c = c.set(
84 | component === 'hue'
85 | ? 'h'
86 | : component === 'saturation'
87 | ? 's'
88 | : component === 'lightness'
89 | ? 'l'
90 | : 'v',
91 | component === 'hue' ? (i * 360) / steps : i / steps
92 | );
93 | return c.toString({ format: 'oklch' });
94 | }
95 | ).join(',')})`;
96 | }
97 |
--------------------------------------------------------------------------------
/src/outputs/alacritty.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | SolarizedTheme,
3 | SolarizedThemeParams,
4 | SolarizedThemeSlot,
5 | } from '../solarized';
6 | import type { ExporterConfig } from './types';
7 |
8 | function hexColor(
9 | theme: SolarizedTheme,
10 | colorName: keyof SolarizedThemeParams | SolarizedThemeSlot
11 | ): string {
12 | return theme.get(colorName).to('srgb').toString({ format: 'hex' });
13 | }
14 |
15 | function exportAlacrittyDark(theme: SolarizedTheme, themeName: string): string {
16 | return `# ${themeName} theme for Alacritty
17 | # Generated by SolarSystem
18 |
19 | [colors.primary]
20 | background = '${hexColor(theme, 'base03')}'
21 | foreground = '${hexColor(theme, 'base0')}'
22 |
23 | [colors.cursor]
24 | cursor = '${hexColor(theme, 'base1')}'
25 | text = '${hexColor(theme, 'base03')}'
26 |
27 | [colors.selection]
28 | background = '${hexColor(theme, 'base02')}'
29 | text = '${hexColor(theme, 'base1')}'
30 |
31 | [colors.normal]
32 | black = '${hexColor(theme, 'base02')}'
33 | red = '${hexColor(theme, 'red')}'
34 | green = '${hexColor(theme, 'green')}'
35 | yellow = '${hexColor(theme, 'yellow')}'
36 | blue = '${hexColor(theme, 'blue')}'
37 | magenta = '${hexColor(theme, 'magenta')}'
38 | cyan = '${hexColor(theme, 'cyan')}'
39 | white = '${hexColor(theme, 'base2')}'
40 |
41 | [colors.bright]
42 | black = '${hexColor(theme, 'base03')}'
43 | red = '${hexColor(theme, 'orange')}'
44 | green = '${hexColor(theme, 'base01')}'
45 | yellow = '${hexColor(theme, 'base00')}'
46 | blue = '${hexColor(theme, 'base0')}'
47 | magenta = '${hexColor(theme, 'violet')}'
48 | cyan = '${hexColor(theme, 'base1')}'
49 | white = '${hexColor(theme, 'base3')}'
50 | `;
51 | }
52 |
53 | function exportAlacrittyLight(
54 | theme: SolarizedTheme,
55 | themeName: string
56 | ): string {
57 | return `# ${themeName} theme for Alacritty
58 | # Generated by SolarSystem
59 |
60 | [colors.primary]
61 | background = '${hexColor(theme, 'base3')}'
62 | foreground = '${hexColor(theme, 'base00')}'
63 |
64 | [colors.cursor]
65 | cursor = '${hexColor(theme, 'base01')}'
66 | text = '${hexColor(theme, 'base3')}'
67 |
68 | [colors.selection]
69 | background = '${hexColor(theme, 'base2')}'
70 | text = '${hexColor(theme, 'base01')}'
71 |
72 | [colors.normal]
73 | black = '${hexColor(theme, 'base2')}'
74 | red = '${hexColor(theme, 'red')}'
75 | green = '${hexColor(theme, 'green')}'
76 | yellow = '${hexColor(theme, 'yellow')}'
77 | blue = '${hexColor(theme, 'blue')}'
78 | magenta = '${hexColor(theme, 'magenta')}'
79 | cyan = '${hexColor(theme, 'cyan')}'
80 | white = '${hexColor(theme, 'base02')}'
81 |
82 | [colors.bright]
83 | black = '${hexColor(theme, 'base3')}'
84 | red = '${hexColor(theme, 'orange')}'
85 | green = '${hexColor(theme, 'base1')}'
86 | yellow = '${hexColor(theme, 'base00')}'
87 | blue = '${hexColor(theme, 'base0')}'
88 | magenta = '${hexColor(theme, 'violet')}'
89 | cyan = '${hexColor(theme, 'base01')}'
90 | white = '${hexColor(theme, 'base03')}'
91 | `;
92 | }
93 |
94 | export const alacrittyDarkExporter: ExporterConfig = {
95 | id: 'alacritty-dark',
96 | name: 'Alacritty (Dark)',
97 | description: 'Alacritty terminal config for dark mode',
98 | fileExtension: 'toml',
99 | colorScheme: 'dark',
100 | export: exportAlacrittyDark,
101 | };
102 |
103 | export const alacrittyLightExporter: ExporterConfig = {
104 | id: 'alacritty-light',
105 | name: 'Alacritty (Light)',
106 | description: 'Alacritty terminal config for light mode',
107 | fileExtension: 'toml',
108 | colorScheme: 'light',
109 | export: exportAlacrittyLight,
110 | };
111 |
--------------------------------------------------------------------------------
/src/outputs/kitty.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | SolarizedTheme,
3 | SolarizedThemeParams,
4 | SolarizedThemeSlot,
5 | } from '../solarized';
6 | import type { ExporterConfig } from './types';
7 |
8 | function hexColor(
9 | theme: SolarizedTheme,
10 | colorName: keyof SolarizedThemeParams | SolarizedThemeSlot
11 | ): string {
12 | return theme.get(colorName).to('srgb').toString({ format: 'hex' });
13 | }
14 |
15 | function exportKittyDark(theme: SolarizedTheme, themeName: string): string {
16 | return `# ${themeName} theme for Kitty
17 | # Generated by SolarSystem
18 |
19 | # Basic colors
20 | foreground ${hexColor(theme, 'base0')}
21 | background ${hexColor(theme, 'base03')}
22 | selection_foreground ${hexColor(theme, 'base1')}
23 | selection_background ${hexColor(theme, 'base02')}
24 |
25 | # Cursor colors
26 | cursor ${hexColor(theme, 'base1')}
27 | cursor_text_color ${hexColor(theme, 'base03')}
28 |
29 | # URL underline color when hovering with mouse
30 | url_color ${hexColor(theme, 'blue')}
31 |
32 | # Black
33 | color0 ${hexColor(theme, 'base02')}
34 | color8 ${hexColor(theme, 'base03')}
35 |
36 | # Red
37 | color1 ${hexColor(theme, 'red')}
38 | color9 ${hexColor(theme, 'orange')}
39 |
40 | # Green
41 | color2 ${hexColor(theme, 'green')}
42 | color10 ${hexColor(theme, 'base01')}
43 |
44 | # Yellow
45 | color3 ${hexColor(theme, 'yellow')}
46 | color11 ${hexColor(theme, 'base00')}
47 |
48 | # Blue
49 | color4 ${hexColor(theme, 'blue')}
50 | color12 ${hexColor(theme, 'base0')}
51 |
52 | # Magenta
53 | color5 ${hexColor(theme, 'magenta')}
54 | color13 ${hexColor(theme, 'violet')}
55 |
56 | # Cyan
57 | color6 ${hexColor(theme, 'cyan')}
58 | color14 ${hexColor(theme, 'base1')}
59 |
60 | # White
61 | color7 ${hexColor(theme, 'base2')}
62 | color15 ${hexColor(theme, 'base3')}
63 | `;
64 | }
65 |
66 | function exportKittyLight(theme: SolarizedTheme, themeName: string): string {
67 | return `# ${themeName} theme for Kitty
68 | # Generated by SolarSystem
69 |
70 | # Basic colors
71 | foreground ${hexColor(theme, 'base00')}
72 | background ${hexColor(theme, 'base3')}
73 | selection_foreground ${hexColor(theme, 'base01')}
74 | selection_background ${hexColor(theme, 'base2')}
75 |
76 | # Cursor colors
77 | cursor ${hexColor(theme, 'base01')}
78 | cursor_text_color ${hexColor(theme, 'base3')}
79 |
80 | # URL underline color when hovering with mouse
81 | url_color ${hexColor(theme, 'blue')}
82 |
83 | # Black
84 | color0 ${hexColor(theme, 'base2')}
85 | color8 ${hexColor(theme, 'base3')}
86 |
87 | # Red
88 | color1 ${hexColor(theme, 'red')}
89 | color9 ${hexColor(theme, 'orange')}
90 |
91 | # Green
92 | color2 ${hexColor(theme, 'green')}
93 | color10 ${hexColor(theme, 'base1')}
94 |
95 | # Yellow
96 | color3 ${hexColor(theme, 'yellow')}
97 | color11 ${hexColor(theme, 'base00')}
98 |
99 | # Blue
100 | color4 ${hexColor(theme, 'blue')}
101 | color12 ${hexColor(theme, 'base0')}
102 |
103 | # Magenta
104 | color5 ${hexColor(theme, 'magenta')}
105 | color13 ${hexColor(theme, 'violet')}
106 |
107 | # Cyan
108 | color6 ${hexColor(theme, 'cyan')}
109 | color14 ${hexColor(theme, 'base01')}
110 |
111 | # White
112 | color7 ${hexColor(theme, 'base02')}
113 | color15 ${hexColor(theme, 'base03')}
114 | `;
115 | }
116 |
117 | export const kittyDarkExporter: ExporterConfig = {
118 | id: 'kitty-dark',
119 | name: 'Kitty (Dark)',
120 | description: 'Kitty terminal config for dark mode',
121 | fileExtension: 'conf',
122 | colorScheme: 'dark',
123 | export: exportKittyDark,
124 | };
125 |
126 | export const kittyLightExporter: ExporterConfig = {
127 | id: 'kitty-light',
128 | name: 'Kitty (Light)',
129 | description: 'Kitty terminal config for light mode',
130 | fileExtension: 'conf',
131 | colorScheme: 'light',
132 | export: exportKittyLight,
133 | };
134 |
--------------------------------------------------------------------------------
/src/outputs/ghostty.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | SolarizedTheme,
3 | SolarizedThemeParams,
4 | SolarizedThemeSlot,
5 | } from '../solarized';
6 | import type { ExporterConfig } from './types';
7 |
8 | function hexColor(
9 | theme: SolarizedTheme,
10 | colorName: keyof SolarizedThemeParams | SolarizedThemeSlot
11 | ): string {
12 | return theme.get(colorName).to('srgb').toString({ format: 'hex' });
13 | }
14 |
15 | function exportGhosTTYDark(theme: SolarizedTheme, themeName: string): string {
16 | return `# ${themeName} theme for Ghostty
17 | # Generated by SolarSystem
18 |
19 | # Background and foreground
20 | background = ${hexColor(theme, 'base03')}
21 | foreground = ${hexColor(theme, 'base0')}
22 |
23 | # Cursor
24 | cursor-color = ${hexColor(theme, 'base1')}
25 | cursor-text = ${hexColor(theme, 'base03')}
26 |
27 | # Selection
28 | selection-background = ${hexColor(theme, 'base02')}
29 | selection-foreground = ${hexColor(theme, 'base1')}
30 |
31 | # Black
32 | palette = 0=${hexColor(theme, 'base02')}
33 | palette = 8=${hexColor(theme, 'base03')}
34 |
35 | # Red
36 | palette = 1=${hexColor(theme, 'red')}
37 | palette = 9=${hexColor(theme, 'orange')}
38 |
39 | # Green
40 | palette = 2=${hexColor(theme, 'green')}
41 | palette = 10=${hexColor(theme, 'base01')}
42 |
43 | # Yellow
44 | palette = 3=${hexColor(theme, 'yellow')}
45 | palette = 11=${hexColor(theme, 'base00')}
46 |
47 | # Blue
48 | palette = 4=${hexColor(theme, 'blue')}
49 | palette = 12=${hexColor(theme, 'base0')}
50 |
51 | # Magenta
52 | palette = 5=${hexColor(theme, 'magenta')}
53 | palette = 13=${hexColor(theme, 'violet')}
54 |
55 | # Cyan
56 | palette = 6=${hexColor(theme, 'cyan')}
57 | palette = 14=${hexColor(theme, 'base1')}
58 |
59 | # White
60 | palette = 7=${hexColor(theme, 'base2')}
61 | palette = 15=${hexColor(theme, 'base3')}
62 | `;
63 | }
64 |
65 | function exportGhosTTYLight(theme: SolarizedTheme, themeName: string): string {
66 | return `# ${themeName} theme for Ghostty
67 | # Generated by SolarSystem
68 |
69 | # Background and foreground
70 | background = ${hexColor(theme, 'base3')}
71 | foreground = ${hexColor(theme, 'base00')}
72 |
73 | # Cursor
74 | cursor-color = ${hexColor(theme, 'base01')}
75 | cursor-text = ${hexColor(theme, 'base3')}
76 |
77 | # Selection
78 | selection-background = ${hexColor(theme, 'base2')}
79 | selection-foreground = ${hexColor(theme, 'base01')}
80 |
81 | # Black
82 | palette = 0=${hexColor(theme, 'base2')}
83 | palette = 8=${hexColor(theme, 'base3')}
84 |
85 | # Red
86 | palette = 1=${hexColor(theme, 'red')}
87 | palette = 9=${hexColor(theme, 'orange')}
88 |
89 | # Green
90 | palette = 2=${hexColor(theme, 'green')}
91 | palette = 10=${hexColor(theme, 'base1')}
92 |
93 | # Yellow
94 | palette = 3=${hexColor(theme, 'yellow')}
95 | palette = 11=${hexColor(theme, 'base00')}
96 |
97 | # Blue
98 | palette = 4=${hexColor(theme, 'blue')}
99 | palette = 12=${hexColor(theme, 'base0')}
100 |
101 | # Magenta
102 | palette = 5=${hexColor(theme, 'magenta')}
103 | palette = 13=${hexColor(theme, 'violet')}
104 |
105 | # Cyan
106 | palette = 6=${hexColor(theme, 'cyan')}
107 | palette = 14=${hexColor(theme, 'base01')}
108 |
109 | # White
110 | palette = 7=${hexColor(theme, 'base02')}
111 | palette = 15=${hexColor(theme, 'base03')}
112 | `;
113 | }
114 |
115 | export const ghosttyDarkExporter: ExporterConfig = {
116 | id: 'ghostty-dark',
117 | name: 'Ghostty (Dark)',
118 | description: 'Ghostty terminal config for dark mode',
119 | fileExtension: 'conf',
120 | colorScheme: 'dark',
121 | export: exportGhosTTYDark,
122 | };
123 |
124 | export const ghosttyLightExporter: ExporterConfig = {
125 | id: 'ghostty-light',
126 | name: 'Ghostty (Light)',
127 | description: 'Ghostty terminal config for light mode',
128 | fileExtension: 'conf',
129 | colorScheme: 'light',
130 | export: exportGhosTTYLight,
131 | };
132 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | min-height: 100vh;
3 | --overlay-filter: blur(2px);
4 | }
5 |
6 | body {
7 | min-height: 100vh;
8 | background-image:
9 | radial-gradient(
10 | circle at 20% 30%,
11 | color-mix(in srgb, var(--mantine-color-grape-3) 40%, transparent),
12 | transparent 40%
13 | ),
14 | radial-gradient(
15 | circle at 80% 20%,
16 | color-mix(in srgb, var(--mantine-color-orange-3) 40%, transparent),
17 | transparent 40%
18 | ),
19 | radial-gradient(
20 | circle at 40% 80%,
21 | color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent),
22 | transparent 40%
23 | );
24 | background-attachment: fixed;
25 | background-size: cover;
26 | padding-top: env(safe-area-inset-top);
27 | padding-left: env(safe-area-inset-left);
28 | padding-right: env(safe-area-inset-right);
29 | padding-bottom: env(safe-area-inset-bottom);
30 | }
31 |
32 | :root[data-mantine-color-scheme='dark'] body {
33 | background-image:
34 | radial-gradient(
35 | circle at 20% 30%,
36 | color-mix(in srgb, var(--mantine-color-grape-9) 40%, transparent),
37 | transparent 60%
38 | ),
39 | radial-gradient(
40 | circle at 80% 20%,
41 | color-mix(in srgb, var(--mantine-color-orange-9) 40%, transparent),
42 | transparent 50%
43 | ),
44 | radial-gradient(
45 | circle at 50% 80%,
46 | color-mix(in srgb, var(--mantine-color-blue-9) 40%, transparent),
47 | transparent 50%
48 | );
49 | background-color: color-mix(
50 | in srgb,
51 | var(--mantine-color-dark-8),
52 | transparent 30%
53 | );
54 | }
55 |
56 | h1 {
57 | font-family: 'Stack Sans Notch', sans-serif !important;
58 | text-transform: lowercase;
59 | }
60 |
61 | h2,
62 | h3,
63 | h4,
64 | h5,
65 | h6 {
66 | text-transform: uppercase;
67 | }
68 |
69 | .paper-transparent {
70 | background-color: color-mix(
71 | in srgb,
72 | var(--mantine-color-body),
73 | transparent 50%
74 | );
75 |
76 | &[role='dialog'] {
77 | background-color: color-mix(
78 | in srgb,
79 | var(--mantine-color-body),
80 | transparent 10%
81 | );
82 |
83 | > header {
84 | background: none;
85 | }
86 | }
87 |
88 | backdrop-filter: blur(10px) saturate(180%);
89 |
90 | @media (prefers-color-scheme: dark) {
91 | background-color: color-mix(
92 | in srgb,
93 | var(--mantine-color-body),
94 | transparent 92%
95 | );
96 | backdrop-filter: blur(10px) saturate(140%);
97 | }
98 |
99 | &::before {
100 | content: '';
101 | position: absolute;
102 | top: 0;
103 | left: 0;
104 | width: 100%;
105 | height: 100%;
106 | background: linear-gradient(
107 | 145deg,
108 | rgba(255, 255, 255, 0.1) 0%,
109 | rgba(255, 255, 255, 0) 50%,
110 | rgba(255, 255, 255, 0.1) 100%
111 | ); /* Gradient for a reflective shine */
112 | opacity: 0.5;
113 | border-radius: var(--mantine-radius-md);
114 | pointer-events: none; /* Allows interaction with content underneath */
115 | }
116 | }
117 |
118 | .input-transparent {
119 | background-color: color-mix(in srgb, var(--input-bg), transparent 50%);
120 | }
121 |
122 | .tab-hover:hover {
123 | background-color: color-mix(
124 | in srgb,
125 | var(--mantine-color-body),
126 | transparent 50%
127 | );
128 | backdrop-filter: blur(10px) saturate(180%);
129 |
130 | /* @media (prefers-color-scheme: dark) {
131 | background-color: color-mix(
132 | in srgb,
133 | var(--mantine-color-body),
134 | transparent 65%
135 | );
136 | backdrop-filter: blur(10px) saturate(140%);
137 | } */
138 | }
139 |
140 | .divider {
141 | border: none;
142 | height: 1px;
143 | background-color: color-mix(in srgb, #000, transparent 90%);
144 | backdrop-filter: saturate(180%);
145 |
146 | [data-mantine-color-scheme='dark'] & {
147 | background-color: color-mix(in srgb, #fff, transparent 90%);
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/state.ts:
--------------------------------------------------------------------------------
1 | import Color, { type Coords } from 'colorjs.io';
2 | import { atom, type SetStateAction } from 'jotai';
3 | import { atomFamily, atomWithStorage, createJSONStorage } from 'jotai/utils';
4 |
5 | import type { Preferences } from './preferences';
6 | import {
7 | SOLARIZED_DEFAULT,
8 | SolarizedTheme,
9 | type SolarizedThemeColorName,
10 | type SolarizedThemeParams,
11 | } from './solarized';
12 |
13 | // Custom storage for SolarizedTheme that handles Color object serialization
14 | export const ThemeStorage = createJSONStorage(
15 | () => localStorage,
16 | {
17 | reviver: (_key, value) => {
18 | // Check if this is a color property by looking at the value structure
19 | if (
20 | value &&
21 | typeof value === 'object' &&
22 | 'spaceId' in value &&
23 | 'coords' in value &&
24 | 'alpha' in value &&
25 | !('base03' in value) // Make sure it's not the root theme object
26 | ) {
27 | // Reconstruct Color object from serialized data
28 | return new Color(
29 | value.spaceId as string,
30 | value.coords as Coords,
31 | value.alpha as number
32 | );
33 | }
34 |
35 | // Check if this is the root theme object (has all color properties)
36 | if (
37 | value &&
38 | typeof value === 'object' &&
39 | 'base03' in value &&
40 | 'base02' in value &&
41 | 'yellow' in value
42 | ) {
43 | // All color properties should already be revived by this point
44 | // So we can construct a SolarizedTheme instance
45 | return new SolarizedTheme(value as SolarizedThemeParams);
46 | }
47 |
48 | return value;
49 | },
50 | replacer: (_key, value) => {
51 | // Serialize Color objects to a plain object format
52 | if (value instanceof Color) {
53 | return value.toJSON();
54 | }
55 | // Handle SolarizedTheme instances
56 | if (value instanceof SolarizedTheme) {
57 | const params: SolarizedThemeParams = {
58 | base03: value.base03,
59 | base02: value.base02,
60 | base01: value.base01,
61 | base00: value.base00,
62 | base0: value.base0,
63 | base1: value.base1,
64 | base2: value.base2,
65 | base3: value.base3,
66 | yellow: value.yellow,
67 | orange: value.orange,
68 | red: value.red,
69 | magenta: value.magenta,
70 | violet: value.violet,
71 | blue: value.blue,
72 | cyan: value.cyan,
73 | green: value.green,
74 | };
75 | return params;
76 | }
77 | return value;
78 | },
79 | }
80 | );
81 |
82 | export const themeAtom = atomWithStorage(
83 | 'theme',
84 | SOLARIZED_DEFAULT,
85 | ThemeStorage,
86 | { getOnInit: true }
87 | );
88 |
89 | export const singleColorAtomFamily = atomFamily(
90 | (colorName: SolarizedThemeColorName) =>
91 | atom(
92 | (get) => get(themeAtom).get(colorName),
93 | (get, set, newColor: SetStateAction) => {
94 | const preferences = get(preferencesAtom);
95 | set(themeAtom, (theme) =>
96 | theme.set(
97 | colorName,
98 | typeof newColor === 'function'
99 | ? newColor(theme.get(colorName))
100 | : newColor,
101 | preferences
102 | )
103 | );
104 | }
105 | )
106 | );
107 |
108 | export const selectedColorAtom = atom(null);
109 |
110 | export const preferencesAtom = atomWithStorage(
111 | 'preferences',
112 | {
113 | linkDarkHues: false,
114 | linkLightHues: false,
115 | linkColorLightness: false,
116 | },
117 | undefined,
118 | { getOnInit: true }
119 | );
120 |
121 | export const themeNameAtom = atomWithStorage(
122 | 'themeName',
123 | 'SolarSystem',
124 | undefined,
125 | { getOnInit: true }
126 | );
127 |
--------------------------------------------------------------------------------
/src/components/PreviewCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, Code, Input, Slider, Stack } from '@mantine/core';
2 | import { useAtom } from 'jotai';
3 |
4 | import { preferencesAtom, selectedColorAtom, themeAtom } from '../state';
5 | import CardTitle from './CardTitle';
6 | import {
7 | resolveSlotToColorName,
8 | type SolarizedThemeColorName,
9 | type SolarizedThemeSlot,
10 | } from '../solarized';
11 | import classes from './PreviewCard.module.css';
12 |
13 | export default function PreviewCard() {
14 | const [preferences, setPreferences] = useAtom(preferencesAtom);
15 | return (
16 |
17 |
18 | Quick Preview
19 |
20 |
21 |
22 | {
36 | setPreferences({ ...preferences, fontSize: value });
37 | }}
38 | />
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | function PreviewBlock({ mode }: { mode: 'dark' | 'light' }) {
46 | const [theme] = useAtom(themeAtom);
47 | const [preferences] = useAtom(preferencesAtom);
48 | const [, setSelectedColor] = useAtom(selectedColorAtom);
49 | return (
50 | setSelectedColor(mode === 'dark' ? 'base03' : 'base3')}
63 | >
64 |
65 |
68 |
69 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 | }
84 |
85 | function PreviewChunk({
86 | slot,
87 | }: {
88 | slot: SolarizedThemeColorName | SolarizedThemeSlot;
89 | }) {
90 | const [theme] = useAtom(themeAtom);
91 | const [, setSelectedColor] = useAtom(selectedColorAtom);
92 | return (
93 | <>
94 | {
96 | ev.stopPropagation();
97 | setSelectedColor(resolveSlotToColorName(slot));
98 | }}
99 | style={{
100 | fontWeight: slot.endsWith('Emphasis') ? 'bold' : 'normal',
101 | color: slot.endsWith('Highlight')
102 | ? 'inherit'
103 | : theme.get(slot).to('srgb').toString({ format: 'hex' }),
104 | backgroundColor: slot.endsWith('Highlight')
105 | ? theme.get(slot).to('srgb').toString({ format: 'hex' })
106 | : undefined,
107 | }}
108 | >
109 | {slot}
110 | {' '}
111 | >
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/SingleColorEditorCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, Divider, Group, Stack, Text } from '@mantine/core';
2 | import type Color from 'colorjs.io';
3 | import { useAtom } from 'jotai';
4 |
5 | import { clampAndRound, type SolarizedThemeColorName } from '../solarized';
6 | import { selectedColorAtom, singleColorAtomFamily } from '../state';
7 | import CardTitle from './CardTitle';
8 | import { OKComponentSlider } from './hueslider/OKHueSlider';
9 | import TextFormat from './TextFormat';
10 |
11 | export default function SingleColorEditorCard() {
12 | const [selectedColor] = useAtom(selectedColorAtom);
13 |
14 | if (!selectedColor) {
15 | return (
16 |
17 |
18 | Edit
19 |
20 | Select a color to edit
21 |
22 |
23 |
24 | );
25 | } else {
26 | return ;
27 | }
28 | }
29 |
30 | function EditorCard({
31 | selectedColor: selectedColorName,
32 | }: {
33 | selectedColor: SolarizedThemeColorName;
34 | }) {
35 | const [selectedColor, setSelectedColor] = useAtom(
36 | singleColorAtomFamily(selectedColorName)
37 | );
38 |
39 | const color = clampAndRound(selectedColor.to('okhsl'));
40 |
41 | return (
42 |
43 |
44 |
45 | Edit{' '}
46 |
53 | {selectedColorName}
54 |
55 |
56 |
57 | {
62 | setSelectedColor((c) => setOkHslComponent(c, 'hue', h));
63 | }}
64 | />
65 |
66 |
67 | {
72 | setSelectedColor((c) => setOkHslComponent(c, 'saturation', s));
73 | }}
74 | />
75 |
76 |
77 | {
82 | setSelectedColor((c) => setOkHslComponent(c, 'lightness', l));
83 | }}
84 | />
85 |
86 |
87 |
88 |
89 | setSelectedColor(clampAndRound(c.to('okhsl')))}
92 | />
93 |
94 |
95 | );
96 | }
97 |
98 | function SliderWrapper({
99 | label,
100 | value,
101 | children,
102 | }: {
103 | label: string;
104 | value: string;
105 | children: React.ReactNode;
106 | }) {
107 | return (
108 |
109 |
110 |
111 | {label}
112 |
113 |
114 | {value}
115 |
116 |
117 | {children}
118 |
119 | );
120 | }
121 |
122 | function setOkHslComponent(
123 | color: Color,
124 | component: 'hue' | 'saturation' | 'lightness',
125 | value: number
126 | ) {
127 | return clampAndRound(color.to('okhsl')).set(
128 | component === 'hue' ? 'h' : component === 'saturation' ? 's' : 'l',
129 | value
130 | );
131 | }
132 |
--------------------------------------------------------------------------------
/src/components/AboutModal.tsx:
--------------------------------------------------------------------------------
1 | import { Anchor, List, Modal, Stack, Text } from '@mantine/core';
2 |
3 | interface AboutModalProps {
4 | opened: boolean;
5 | onClose: () => void;
6 | }
7 |
8 | export default function AboutModal({ opened, onClose }: AboutModalProps) {
9 | return (
10 |
16 |
17 |
18 | SolarSystem is an extension of{' '}
19 |
24 | Ethan Schoonover's Solarized colorscheme
25 |
26 | , designed to give you more control over your color palette while
27 | maintaining the perceptual uniformity and accessibility that makes
28 | Solarized great.
29 |
30 |
31 |
32 |
33 | Key Features:
34 |
35 |
36 |
37 | Customizable hues: Tweak base and accent color
38 | hues to your preference.
39 |
40 |
46 | Hint: Use the toggle buttons below the palette
47 | display to link similar hues together as you change them, so you
48 | can maintain harmony across light/dark themes.
49 |
50 |
51 |
52 | OKHSL color space: All editing happens in the
53 | perceptually uniform{' '}
54 |
59 | OKHSL color space
60 | {' '}
61 | for predictable results.
62 |
63 |
64 | APCA contrast algorithm: Ensures proper
65 | accessibility with{' '}
66 |
71 | modern contrast calculations
72 | {' '}
73 | for all foreground/background combinations.
74 |
75 |
76 | Share your themes: Easily share your custom
77 | themes with others via shareable links that encode the entire
78 | theme in the URL.
79 |
80 |
81 | Multiple export formats: Export your custom
82 | themes to VSCode, terminal emulators (Ghostty, iTerm2, Alacritty,
83 | Kitty, WezTerm), and more.
84 |
85 |
86 | Real-time preview: See your changes instantly in
87 | both dark and light modes with syntax highlighting.
88 |
89 |
90 |
91 |
92 |
93 | Built with care by{' '}
94 |
99 | Zack Voase
100 |
101 | . Source code and feedback via{' '}
102 |
107 | GitHub
108 |
109 | .
110 |
111 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/src/outputs/wezterm.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | SolarizedTheme,
3 | SolarizedThemeParams,
4 | SolarizedThemeSlot,
5 | } from '../solarized';
6 | import type { ExporterConfig } from './types';
7 |
8 | function hexColor(
9 | theme: SolarizedTheme,
10 | colorName: keyof SolarizedThemeParams | SolarizedThemeSlot
11 | ): string {
12 | return theme.get(colorName).to('srgb').toString({ format: 'hex' });
13 | }
14 |
15 | function exportWeztermDark(theme: SolarizedTheme, themeName: string): string {
16 | return `-- ${themeName} theme for WezTerm
17 | -- Generated by SolarSystem
18 | -- Add this to your wezterm.lua config
19 |
20 | return {
21 | foreground = '${hexColor(theme, 'base0')}',
22 | background = '${hexColor(theme, 'base03')}',
23 |
24 | cursor_bg = '${hexColor(theme, 'base1')}',
25 | cursor_fg = '${hexColor(theme, 'base03')}',
26 | cursor_border = '${hexColor(theme, 'base1')}',
27 |
28 | selection_fg = '${hexColor(theme, 'base1')}',
29 | selection_bg = '${hexColor(theme, 'base02')}',
30 |
31 | scrollbar_thumb = '${hexColor(theme, 'base01')}',
32 | split = '${hexColor(theme, 'base01')}',
33 |
34 | ansi = {
35 | '${hexColor(theme, 'base02')}', -- black
36 | '${hexColor(theme, 'red')}', -- red
37 | '${hexColor(theme, 'green')}', -- green
38 | '${hexColor(theme, 'yellow')}', -- yellow
39 | '${hexColor(theme, 'blue')}', -- blue
40 | '${hexColor(theme, 'magenta')}', -- magenta
41 | '${hexColor(theme, 'cyan')}', -- cyan
42 | '${hexColor(theme, 'base2')}', -- white
43 | },
44 |
45 | brights = {
46 | '${hexColor(theme, 'base03')}', -- bright black
47 | '${hexColor(theme, 'orange')}', -- bright red
48 | '${hexColor(theme, 'base01')}', -- bright green
49 | '${hexColor(theme, 'base00')}', -- bright yellow
50 | '${hexColor(theme, 'base0')}', -- bright blue
51 | '${hexColor(theme, 'violet')}', -- bright magenta
52 | '${hexColor(theme, 'base1')}', -- bright cyan
53 | '${hexColor(theme, 'base3')}', -- bright white
54 | },
55 | }
56 | `;
57 | }
58 |
59 | function exportWeztermLight(theme: SolarizedTheme, themeName: string): string {
60 | return `-- ${themeName} theme for WezTerm
61 | -- Generated by SolarSystem
62 | -- Add this to your wezterm.lua config
63 |
64 | return {
65 | foreground = '${hexColor(theme, 'base00')}',
66 | background = '${hexColor(theme, 'base3')}',
67 |
68 | cursor_bg = '${hexColor(theme, 'base01')}',
69 | cursor_fg = '${hexColor(theme, 'base3')}',
70 | cursor_border = '${hexColor(theme, 'base01')}',
71 |
72 | selection_fg = '${hexColor(theme, 'base01')}',
73 | selection_bg = '${hexColor(theme, 'base2')}',
74 |
75 | scrollbar_thumb = '${hexColor(theme, 'base1')}',
76 | split = '${hexColor(theme, 'base1')}',
77 |
78 | ansi = {
79 | '${hexColor(theme, 'base2')}', -- black
80 | '${hexColor(theme, 'red')}', -- red
81 | '${hexColor(theme, 'green')}', -- green
82 | '${hexColor(theme, 'yellow')}', -- yellow
83 | '${hexColor(theme, 'blue')}', -- blue
84 | '${hexColor(theme, 'magenta')}', -- magenta
85 | '${hexColor(theme, 'cyan')}', -- cyan
86 | '${hexColor(theme, 'base02')}', -- white
87 | },
88 |
89 | brights = {
90 | '${hexColor(theme, 'base3')}', -- bright black
91 | '${hexColor(theme, 'orange')}', -- bright red
92 | '${hexColor(theme, 'base1')}', -- bright green
93 | '${hexColor(theme, 'base00')}', -- bright yellow
94 | '${hexColor(theme, 'base0')}', -- bright blue
95 | '${hexColor(theme, 'violet')}', -- bright magenta
96 | '${hexColor(theme, 'base01')}', -- bright cyan
97 | '${hexColor(theme, 'base03')}', -- bright white
98 | },
99 | }
100 | `;
101 | }
102 |
103 | export const weztermDarkExporter: ExporterConfig = {
104 | id: 'wezterm-dark',
105 | name: 'WezTerm (Dark)',
106 | description: 'WezTerm config for dark mode',
107 | fileExtension: 'lua',
108 | colorScheme: 'dark',
109 | export: exportWeztermDark,
110 | };
111 |
112 | export const weztermLightExporter: ExporterConfig = {
113 | id: 'wezterm-light',
114 | name: 'WezTerm (Light)',
115 | description: 'WezTerm config for light mode',
116 | fileExtension: 'lua',
117 | colorScheme: 'light',
118 | export: exportWeztermLight,
119 | };
120 |
--------------------------------------------------------------------------------
/src/outputs/json.ts:
--------------------------------------------------------------------------------
1 | import type { SolarizedTheme, SolarizedThemeColorName } from '../solarized';
2 | import type { ExporterConfig } from './types';
3 |
4 | const COLOR_NAMES: SolarizedThemeColorName[] = [
5 | 'base03',
6 | 'base02',
7 | 'base01',
8 | 'base00',
9 | 'base0',
10 | 'base1',
11 | 'base2',
12 | 'base3',
13 | 'yellow',
14 | 'orange',
15 | 'red',
16 | 'magenta',
17 | 'violet',
18 | 'blue',
19 | 'cyan',
20 | 'green',
21 | ];
22 |
23 | function exportSimpleJSON(theme: SolarizedTheme): string {
24 | const colors: Record = {};
25 | for (const name of COLOR_NAMES) {
26 | colors[name] = theme.get(name).to('srgb').toString({ format: 'hex' });
27 | }
28 | return JSON.stringify(colors, null, 2);
29 | }
30 |
31 | function exportDetailedJSON(theme: SolarizedTheme, themeName: string): string {
32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
33 | const result: Record = {
34 | name: themeName,
35 | colors: {},
36 | ansiColors: {
37 | dark: {},
38 | light: {},
39 | },
40 | };
41 |
42 | // All colors with multiple formats
43 | for (const name of COLOR_NAMES) {
44 | const color = theme.get(name);
45 | result.colors[name] = {
46 | hex: color.to('srgb').toString({ format: 'hex' }),
47 | rgb: color.to('srgb').toString(),
48 | oklch: color.to('oklch').toString(),
49 | };
50 | }
51 |
52 | // ANSI color mappings for terminals
53 | result.ansiColors.dark = {
54 | black: theme.base02.to('srgb').toString({ format: 'hex' }),
55 | red: theme.red.to('srgb').toString({ format: 'hex' }),
56 | green: theme.green.to('srgb').toString({ format: 'hex' }),
57 | yellow: theme.yellow.to('srgb').toString({ format: 'hex' }),
58 | blue: theme.blue.to('srgb').toString({ format: 'hex' }),
59 | magenta: theme.magenta.to('srgb').toString({ format: 'hex' }),
60 | cyan: theme.cyan.to('srgb').toString({ format: 'hex' }),
61 | white: theme.base2.to('srgb').toString({ format: 'hex' }),
62 | brightBlack: theme.base03.to('srgb').toString({ format: 'hex' }),
63 | brightRed: theme.orange.to('srgb').toString({ format: 'hex' }),
64 | brightGreen: theme.base01.to('srgb').toString({ format: 'hex' }),
65 | brightYellow: theme.base00.to('srgb').toString({ format: 'hex' }),
66 | brightBlue: theme.base0.to('srgb').toString({ format: 'hex' }),
67 | brightMagenta: theme.violet.to('srgb').toString({ format: 'hex' }),
68 | brightCyan: theme.base1.to('srgb').toString({ format: 'hex' }),
69 | brightWhite: theme.base3.to('srgb').toString({ format: 'hex' }),
70 | };
71 |
72 | result.ansiColors.light = {
73 | black: theme.base2.to('srgb').toString({ format: 'hex' }),
74 | red: theme.red.to('srgb').toString({ format: 'hex' }),
75 | green: theme.green.to('srgb').toString({ format: 'hex' }),
76 | yellow: theme.yellow.to('srgb').toString({ format: 'hex' }),
77 | blue: theme.blue.to('srgb').toString({ format: 'hex' }),
78 | magenta: theme.magenta.to('srgb').toString({ format: 'hex' }),
79 | cyan: theme.cyan.to('srgb').toString({ format: 'hex' }),
80 | white: theme.base02.to('srgb').toString({ format: 'hex' }),
81 | brightBlack: theme.base3.to('srgb').toString({ format: 'hex' }),
82 | brightRed: theme.orange.to('srgb').toString({ format: 'hex' }),
83 | brightGreen: theme.base1.to('srgb').toString({ format: 'hex' }),
84 | brightYellow: theme.base00.to('srgb').toString({ format: 'hex' }),
85 | brightBlue: theme.base0.to('srgb').toString({ format: 'hex' }),
86 | brightMagenta: theme.violet.to('srgb').toString({ format: 'hex' }),
87 | brightCyan: theme.base01.to('srgb').toString({ format: 'hex' }),
88 | brightWhite: theme.base03.to('srgb').toString({ format: 'hex' }),
89 | };
90 |
91 | return JSON.stringify(result, null, 2);
92 | }
93 |
94 | export const jsonSimpleExporter: ExporterConfig = {
95 | id: 'json-simple',
96 | name: 'JSON (Simple)',
97 | description: 'Simple color name to hex mapping',
98 | fileExtension: 'json',
99 | export: exportSimpleJSON,
100 | };
101 |
102 | export const jsonDetailedExporter: ExporterConfig = {
103 | id: 'json-detailed',
104 | name: 'JSON (Detailed)',
105 | description: 'Complete color data with multiple formats and ANSI mappings',
106 | fileExtension: 'json',
107 | export: exportDetailedJSON,
108 | };
109 |
--------------------------------------------------------------------------------
/src/outputs/iterm2.ts:
--------------------------------------------------------------------------------
1 | import Color from 'colorjs.io';
2 |
3 | import type { SolarizedTheme } from '../solarized';
4 | import type { ExporterConfig } from './types';
5 |
6 | interface RGB {
7 | r: number;
8 | g: number;
9 | b: number;
10 | }
11 |
12 | function toRGB(color: Color): RGB {
13 | const srgb = color.to('srgb');
14 | return {
15 | r: srgb.r ?? 0,
16 | g: srgb.g ?? 0,
17 | b: srgb.b ?? 0,
18 | };
19 | }
20 |
21 | function colorToXML(name: string, rgb: RGB): string {
22 | return `\t${name}
23 | \t
24 | \t\tAlpha Component
25 | \t\t1
26 | \t\tBlue Component
27 | \t\t${rgb.b.toFixed(6)}
28 | \t\tColor Space
29 | \t\tsRGB
30 | \t\tGreen Component
31 | \t\t${rgb.g.toFixed(6)}
32 | \t\tRed Component
33 | \t\t${rgb.r.toFixed(6)}
34 | \t `;
35 | }
36 |
37 | function exportITerm2Dark(theme: SolarizedTheme): string {
38 | const colors = {
39 | 'Background Color': toRGB(theme.base03),
40 | 'Foreground Color': toRGB(theme.base0),
41 | 'Bold Color': toRGB(theme.base1),
42 | 'Cursor Color': toRGB(theme.base1),
43 | 'Cursor Text Color': toRGB(theme.base03),
44 | 'Selection Color': toRGB(theme.base02),
45 | 'Selected Text Color': toRGB(theme.base1),
46 | 'Ansi 0 Color': toRGB(theme.base02),
47 | 'Ansi 1 Color': toRGB(theme.red),
48 | 'Ansi 2 Color': toRGB(theme.green),
49 | 'Ansi 3 Color': toRGB(theme.yellow),
50 | 'Ansi 4 Color': toRGB(theme.blue),
51 | 'Ansi 5 Color': toRGB(theme.magenta),
52 | 'Ansi 6 Color': toRGB(theme.cyan),
53 | 'Ansi 7 Color': toRGB(theme.base2),
54 | 'Ansi 8 Color': toRGB(theme.base03),
55 | 'Ansi 9 Color': toRGB(theme.orange),
56 | 'Ansi 10 Color': toRGB(theme.base01),
57 | 'Ansi 11 Color': toRGB(theme.base00),
58 | 'Ansi 12 Color': toRGB(theme.base0),
59 | 'Ansi 13 Color': toRGB(theme.violet),
60 | 'Ansi 14 Color': toRGB(theme.base1),
61 | 'Ansi 15 Color': toRGB(theme.base3),
62 | };
63 |
64 | const entries = Object.entries(colors)
65 | .map(([name, rgb]) => colorToXML(name, rgb))
66 | .join('\n');
67 |
68 | return `
69 |
70 |
71 |
72 | ${entries}
73 |
74 |
75 | `;
76 | }
77 |
78 | function exportITerm2Light(theme: SolarizedTheme): string {
79 | const colors = {
80 | 'Background Color': toRGB(theme.base3),
81 | 'Foreground Color': toRGB(theme.base00),
82 | 'Bold Color': toRGB(theme.base01),
83 | 'Cursor Color': toRGB(theme.base01),
84 | 'Cursor Text Color': toRGB(theme.base3),
85 | 'Selection Color': toRGB(theme.base2),
86 | 'Selected Text Color': toRGB(theme.base01),
87 | 'Ansi 0 Color': toRGB(theme.base2),
88 | 'Ansi 1 Color': toRGB(theme.red),
89 | 'Ansi 2 Color': toRGB(theme.green),
90 | 'Ansi 3 Color': toRGB(theme.yellow),
91 | 'Ansi 4 Color': toRGB(theme.blue),
92 | 'Ansi 5 Color': toRGB(theme.magenta),
93 | 'Ansi 6 Color': toRGB(theme.cyan),
94 | 'Ansi 7 Color': toRGB(theme.base02),
95 | 'Ansi 8 Color': toRGB(theme.base3),
96 | 'Ansi 9 Color': toRGB(theme.orange),
97 | 'Ansi 10 Color': toRGB(theme.base1),
98 | 'Ansi 11 Color': toRGB(theme.base00),
99 | 'Ansi 12 Color': toRGB(theme.base0),
100 | 'Ansi 13 Color': toRGB(theme.violet),
101 | 'Ansi 14 Color': toRGB(theme.base01),
102 | 'Ansi 15 Color': toRGB(theme.base03),
103 | };
104 |
105 | const entries = Object.entries(colors)
106 | .map(([name, rgb]) => colorToXML(name, rgb))
107 | .join('\n');
108 |
109 | return `
110 |
111 |
112 |
113 | ${entries}
114 |
115 |
116 | `;
117 | }
118 |
119 | export const iterm2DarkExporter: ExporterConfig = {
120 | id: 'iterm2-dark',
121 | name: 'iTerm2 (Dark)',
122 | description: 'iTerm2 color preset for dark mode',
123 | fileExtension: 'itermcolors',
124 | colorScheme: 'dark',
125 | export: exportITerm2Dark,
126 | };
127 |
128 | export const iterm2LightExporter: ExporterConfig = {
129 | id: 'iterm2-light',
130 | name: 'iTerm2 (Light)',
131 | description: 'iTerm2 color preset for light mode',
132 | fileExtension: 'itermcolors',
133 | colorScheme: 'light',
134 | export: exportITerm2Light,
135 | };
136 |
--------------------------------------------------------------------------------
/src/components/hueslider/ColorPicker.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | --cp-width-xs: 180px;
3 | --cp-width-sm: 200px;
4 | --cp-width-md: 240px;
5 | --cp-width-lg: 280px;
6 | --cp-width-xl: 320px;
7 |
8 | --cp-preview-size-xs: 26px;
9 | --cp-preview-size-sm: 34px;
10 | --cp-preview-size-md: 42px;
11 | --cp-preview-size-lg: 50px;
12 | --cp-preview-size-xl: 54px;
13 |
14 | --cp-thumb-size-xs: 8px;
15 | --cp-thumb-size-sm: 12px;
16 | --cp-thumb-size-md: 16px;
17 | --cp-thumb-size-lg: 20px;
18 | --cp-thumb-size-xl: 22px;
19 |
20 | --cp-saturation-height-xs: 100px;
21 | --cp-saturation-height-sm: 110px;
22 | --cp-saturation-height-md: 120px;
23 | --cp-saturation-height-lg: 140px;
24 | --cp-saturation-height-xl: 160px;
25 |
26 | --cp-preview-size: var(--cp-preview-size-sm);
27 | --cp-thumb-size: var(--cp-thumb-size-sm);
28 | --cp-saturation-height: var(--cp-saturation-height-sm);
29 | --cp-width: var(--cp-width-sm);
30 | --cp-body-spacing: var(--mantine-spacing-sm);
31 |
32 | width: var(--cp-width);
33 | padding: 1px;
34 |
35 | &:where([data-full-width]) {
36 | width: 100%;
37 | }
38 | }
39 |
40 | .preview {
41 | width: var(--cp-preview-size);
42 | height: var(--cp-preview-size);
43 | }
44 |
45 | .body {
46 | display: flex;
47 | padding-top: calc(var(--cp-body-spacing) / 2);
48 | }
49 |
50 | .sliders {
51 | flex: 1;
52 |
53 | &:not(:only-child) {
54 | margin-inline-end: var(--mantine-spacing-xs);
55 | }
56 | }
57 |
58 | .thumb {
59 | overflow: hidden;
60 | position: absolute;
61 | box-shadow: 0 0 1px rgba(0, 0, 0, 0.6);
62 | border: 2px solid var(--mantine-color-white);
63 | width: var(--cp-thumb-size);
64 | height: var(--cp-thumb-size);
65 | border-radius: var(--cp-thumb-size);
66 | left: calc(var(--thumb-x-offset) - var(--cp-thumb-size) / 2);
67 | top: calc(var(--thumb-y-offset) - var(--cp-thumb-size) / 2);
68 | }
69 |
70 | .swatch {
71 | height: unset !important;
72 | width: unset !important;
73 | min-width: 0 !important;
74 | min-height: 0 !important;
75 | margin: 2px;
76 | cursor: pointer;
77 | padding-bottom: calc(var(--cp-swatch-size) - rem(4px));
78 | flex: 0 0 calc(var(--cp-swatch-size) - rem(4px));
79 | }
80 |
81 | .swatches {
82 | margin-top: 5px;
83 | margin-inline: -2px;
84 | display: flex;
85 | flex-wrap: wrap;
86 |
87 | &:only-child {
88 | margin-top: 0;
89 | }
90 | }
91 |
92 | .saturation {
93 | --cp-thumb-size-xs: 8px;
94 | --cp-thumb-size-sm: 12px;
95 | --cp-thumb-size-md: 16px;
96 | --cp-thumb-size-lg: 20px;
97 | --cp-thumb-size-xl: 22px;
98 |
99 | -webkit-tap-highlight-color: transparent;
100 | position: relative;
101 | height: var(--cp-saturation-height);
102 | border-radius: var(--mantine-radius-sm);
103 | margin: calc(var(--cp-thumb-size) / 2);
104 |
105 | &:where([data-focus-ring='auto']) {
106 | &:focus:focus-visible {
107 | & .thumb {
108 | outline: 2px solid var(--mantine-color-blue-filled);
109 | }
110 | }
111 | }
112 |
113 | &:where([data-focus-ring='always']) {
114 | &:focus {
115 | & .thumb {
116 | outline: 2px solid var(--mantine-color-blue-filled);
117 | }
118 | }
119 | }
120 | }
121 |
122 | .saturationOverlay {
123 | position: absolute;
124 | border-radius: var(--mantine-radius-sm);
125 | inset: calc(var(--cp-thumb-size) * -1 / 2 - rem(1px));
126 | }
127 |
128 | .slider {
129 | --cp-thumb-size-xs: 8px;
130 | --cp-thumb-size-sm: 12px;
131 | --cp-thumb-size-md: 16px;
132 | --cp-thumb-size-lg: 20px;
133 | --cp-thumb-size-xl: 22px;
134 | --cp-thumb-size: var(--cp-thumb-size, rem(12px));
135 |
136 | position: relative;
137 | height: calc(var(--cp-thumb-size) + rem(2px));
138 | margin-inline: calc(var(--cp-thumb-size) / 2);
139 | outline: none;
140 |
141 | & + & {
142 | margin-top: 6px;
143 | }
144 |
145 | &:where([data-focus-ring='auto']) {
146 | &:focus:focus-visible {
147 | & .thumb {
148 | outline: 2px solid var(--mantine-color-blue-filled);
149 | }
150 | }
151 | }
152 |
153 | &:where([data-focus-ring='always']) {
154 | &:focus {
155 | & .thumb {
156 | outline: 2px solid var(--mantine-color-blue-filled);
157 | }
158 | }
159 | }
160 |
161 | @mixin where-light {
162 | --slider-checkers: var(--mantine-color-gray-3);
163 | }
164 |
165 | @mixin where-dark {
166 | --slider-checkers: var(--mantine-color-dark-4);
167 | }
168 | }
169 |
170 | .sliderOverlay {
171 | position: absolute;
172 | top: 0;
173 | bottom: 0;
174 | inset-inline: calc(var(--cp-thumb-size) * -1 / 2 - rem(1px));
175 | border-radius: 10000rem;
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/hueslider/ColorSlider.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from 'react';
2 | import {
3 | Box,
4 | type BoxProps,
5 | type ElementProps,
6 | factory,
7 | type Factory,
8 | type MantineSize,
9 | rem,
10 | type StylesApiProps,
11 | useMantineTheme,
12 | useProps,
13 | useStyles,
14 | } from '@mantine/core';
15 | import {
16 | clampUseMovePosition,
17 | useDidUpdate,
18 | useMergedRef,
19 | useMove,
20 | type UseMovePosition,
21 | } from '@mantine/hooks';
22 |
23 | import { useColorPickerContext } from './ColorPicker.context';
24 | import classes from './ColorPicker.module.css';
25 | import { Thumb } from './Thumb';
26 |
27 | export type ColorSliderStylesNames = 'slider' | 'sliderOverlay' | 'thumb';
28 |
29 | export interface __ColorSliderProps extends ElementProps<'div', 'onChange'> {
30 | value: number;
31 | onChange?: (value: number) => void;
32 | onChangeEnd?: (value: number) => void;
33 | onScrubStart?: () => void;
34 | onScrubEnd?: () => void;
35 | size?: MantineSize | (string & {});
36 | focusable?: boolean;
37 | }
38 |
39 | export interface ColorSliderProps
40 | extends BoxProps,
41 | StylesApiProps,
42 | __ColorSliderProps,
43 | ElementProps<'div', 'onChange'> {
44 | __staticSelector?: string;
45 | maxValue: number;
46 | overlays: React.CSSProperties[];
47 | round: boolean;
48 | thumbColor?: string;
49 | thumbBorderColor?: string;
50 | }
51 |
52 | export type ColorSliderFactory = Factory<{
53 | props: ColorSliderProps;
54 | ref: HTMLDivElement;
55 | stylesNames: ColorSliderStylesNames;
56 | }>;
57 |
58 | export const ColorSlider = factory((_props, ref) => {
59 | const props = useProps('ColorSlider', null, _props);
60 | const {
61 | classNames,
62 | className,
63 | style,
64 | styles,
65 | unstyled,
66 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
67 | vars,
68 | onChange,
69 | onChangeEnd,
70 | maxValue,
71 | round,
72 | size = 'md',
73 | focusable = true,
74 | value,
75 | overlays,
76 | thumbColor = 'transparent',
77 | thumbBorderColor = '#fff',
78 | onScrubStart,
79 | onScrubEnd,
80 | __staticSelector = 'ColorPicker',
81 | attributes,
82 | ...others
83 | } = props;
84 |
85 | const _getStyles = useStyles({
86 | name: __staticSelector,
87 | classes,
88 | props,
89 | className,
90 | style,
91 | classNames,
92 | styles,
93 | unstyled,
94 | attributes,
95 | });
96 |
97 | const ctxGetStyles = useColorPickerContext()?.getStyles;
98 | const getStyles = ctxGetStyles || _getStyles;
99 |
100 | const theme = useMantineTheme();
101 | const [position, setPosition] = useState({ y: 0, x: value / maxValue });
102 | const positionRef = useRef(position);
103 | const getChangeValue = (val: number) =>
104 | round ? Math.round(val * maxValue) : val * maxValue;
105 | const { ref: sliderRef } = useMove(
106 | ({ x, y }) => {
107 | positionRef.current = { x, y };
108 | onChange?.(getChangeValue(x));
109 | },
110 | {
111 | onScrubEnd: () => {
112 | const { x } = positionRef.current;
113 | onChangeEnd?.(getChangeValue(x));
114 | onScrubEnd?.();
115 | },
116 | onScrubStart,
117 | }
118 | );
119 |
120 | useDidUpdate(() => {
121 | setPosition({ y: 0, x: value / maxValue });
122 | }, [value]);
123 |
124 | const handleArrow = (
125 | event: React.KeyboardEvent,
126 | pos: UseMovePosition
127 | ) => {
128 | event.preventDefault();
129 | const _position = clampUseMovePosition(pos);
130 | onChange?.(getChangeValue(_position.x));
131 | onChangeEnd?.(getChangeValue(_position.x));
132 | };
133 |
134 | const handleKeyDown = (event: React.KeyboardEvent) => {
135 | switch (event.key) {
136 | case 'ArrowRight': {
137 | handleArrow(event, { x: position.x + 0.05, y: position.y });
138 | break;
139 | }
140 |
141 | case 'ArrowLeft': {
142 | handleArrow(event, { x: position.x - 0.05, y: position.y });
143 | break;
144 | }
145 | }
146 | };
147 |
148 | const layers = overlays.map((overlay, index) => (
149 |
150 | ));
151 |
152 | return (
153 |
168 | {layers}
169 |
170 |
182 |
183 | );
184 | });
185 |
186 | ColorSlider.displayName = '@mantine/core/ColorSlider';
187 |
--------------------------------------------------------------------------------
/src/serialization.ts:
--------------------------------------------------------------------------------
1 | import Color from 'colorjs.io';
2 |
3 | import { SolarizedTheme, type SolarizedThemeColorName } from './solarized';
4 |
5 | const COLOR_ORDER: SolarizedThemeColorName[] = [
6 | 'base03',
7 | 'base02',
8 | 'base01',
9 | 'base00',
10 | 'base0',
11 | 'base1',
12 | 'base2',
13 | 'base3',
14 | 'yellow',
15 | 'orange',
16 | 'red',
17 | 'magenta',
18 | 'violet',
19 | 'blue',
20 | 'cyan',
21 | 'green',
22 | ];
23 |
24 | // Use native base64url encoding with URL-safe alphabet
25 | function base64urlEncode(bytes: Uint8Array): string {
26 | // Convert bytes to binary string
27 | let binary = '';
28 | for (let i = 0; i < bytes.length; i++) {
29 | binary += String.fromCharCode(bytes[i]);
30 | }
31 |
32 | // Use native btoa and convert to URL-safe format
33 | return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); // Remove padding
34 | }
35 |
36 | function base64urlDecode(str: string): Uint8Array {
37 | // Convert URL-safe format back to standard base64
38 | let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
39 |
40 | // Add back padding
41 | while (base64.length % 4) {
42 | base64 += '=';
43 | }
44 |
45 | // Decode using native atob
46 | const binary = atob(base64);
47 | const bytes = new Uint8Array(binary.length);
48 |
49 | for (let i = 0; i < binary.length; i++) {
50 | bytes[i] = binary.charCodeAt(i);
51 | }
52 |
53 | return bytes;
54 | }
55 |
56 | // Pack bits into bytes efficiently
57 | class BitPacker {
58 | private bytes: number[] = [];
59 | private currentByte = 0;
60 | private bitPosition = 0;
61 |
62 | writeBits(value: number, numBits: number) {
63 | for (let i = numBits - 1; i >= 0; i--) {
64 | const bit = (value >> i) & 1;
65 | this.currentByte = (this.currentByte << 1) | bit;
66 | this.bitPosition++;
67 |
68 | if (this.bitPosition === 8) {
69 | this.bytes.push(this.currentByte);
70 | this.currentByte = 0;
71 | this.bitPosition = 0;
72 | }
73 | }
74 | }
75 |
76 | getBytes(): Uint8Array {
77 | if (this.bitPosition > 0) {
78 | // Pad the last byte
79 | this.currentByte <<= 8 - this.bitPosition;
80 | this.bytes.push(this.currentByte);
81 | }
82 | return new Uint8Array(this.bytes);
83 | }
84 | }
85 |
86 | class BitUnpacker {
87 | private bytes: Uint8Array;
88 | private byteIndex = 0;
89 | private bitPosition = 0;
90 |
91 | constructor(bytes: Uint8Array) {
92 | this.bytes = bytes;
93 | }
94 |
95 | readBits(numBits: number): number {
96 | let value = 0;
97 |
98 | for (let i = 0; i < numBits; i++) {
99 | if (this.byteIndex >= this.bytes.length) return 0;
100 |
101 | const bit = (this.bytes[this.byteIndex] >> (7 - this.bitPosition)) & 1;
102 | value = (value << 1) | bit;
103 | this.bitPosition++;
104 |
105 | if (this.bitPosition === 8) {
106 | this.byteIndex++;
107 | this.bitPosition = 0;
108 | }
109 | }
110 |
111 | return value;
112 | }
113 | }
114 |
115 | export function serializeTheme(theme: SolarizedTheme): string {
116 | const packer = new BitPacker();
117 |
118 | for (const colorName of COLOR_ORDER) {
119 | const color = theme.get(colorName).to('okhsl');
120 |
121 | // Hue: 0-360 → 9 bits (0-511, wrapping at 360)
122 | let h = color.h ?? 0;
123 | if (h < 0) h += 360;
124 | h = Math.round(h) % 360;
125 | packer.writeBits(h, 9);
126 |
127 | // Saturation: 0-1 → 10 bits (0-1023, use 0-1000 for precision)
128 | const s = Math.round(Math.max(0, Math.min(1, color.s ?? 0)) * 1000);
129 | packer.writeBits(s, 10);
130 |
131 | // Lightness: 0-1 → 10 bits (0-1023, use 0-1000 for precision)
132 | const l = Math.round(Math.max(0, Math.min(1, color.l ?? 0)) * 1000);
133 | packer.writeBits(l, 10);
134 | }
135 |
136 | return base64urlEncode(packer.getBytes());
137 | }
138 |
139 | export function deserializeTheme(encoded: string): SolarizedTheme | null {
140 | try {
141 | const bytes = base64urlDecode(encoded);
142 | const unpacker = new BitUnpacker(bytes);
143 |
144 | const colors: Partial> = {};
145 |
146 | for (const colorName of COLOR_ORDER) {
147 | const h = unpacker.readBits(9);
148 | const s = unpacker.readBits(10) / 1000;
149 | const l = unpacker.readBits(10) / 1000;
150 |
151 | colors[colorName] = new Color('okhsl', [h, s, l]);
152 | }
153 |
154 | return new SolarizedTheme(colors as Record);
155 | } catch (error) {
156 | console.error('Failed to deserialize theme:', error);
157 | return null;
158 | }
159 | }
160 |
161 | export function createShareableURL(
162 | theme: SolarizedTheme,
163 | themeName?: string
164 | ): string {
165 | const url = new URL(window.location.href);
166 | url.search = ''; // Clear existing params
167 | url.searchParams.set('t', serializeTheme(theme));
168 |
169 | if (themeName && themeName !== 'SolarSystem') {
170 | url.searchParams.set('n', themeName);
171 | }
172 |
173 | return url.toString();
174 | }
175 |
176 | export function parseThemeFromURL(): {
177 | theme: SolarizedTheme | null;
178 | name: string | null;
179 | } {
180 | const params = new URLSearchParams(window.location.search);
181 | const themeParam = params.get('t');
182 | const nameParam = params.get('n');
183 |
184 | const theme = themeParam ? deserializeTheme(themeParam) : null;
185 | const name = nameParam || null;
186 |
187 | return { theme, name };
188 | }
189 |
190 | export function clearURLParams() {
191 | const url = new URL(window.location.href);
192 | url.search = '';
193 | window.history.replaceState({}, '', url.toString());
194 | }
195 |
--------------------------------------------------------------------------------
/src/components/ThemeDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Card, Stack, Switch, Divider } from '@mantine/core';
2 | import { useAtom } from 'jotai';
3 |
4 | import {
5 | SOLARIZED_DEFAULT,
6 | type SolarizedThemeColorName,
7 | getFgColor,
8 | } from '../solarized';
9 | import { themeAtom, selectedColorAtom, preferencesAtom } from '../state';
10 | import classes from './ThemeDisplay.module.css';
11 |
12 | export default function ThemeDisplay() {
13 | const [preferences, setPreferences] = useAtom(preferencesAtom);
14 | const [, setTheme] = useAtom(themeAtom);
15 |
16 | return (
17 |
18 |
19 |
28 |
29 |
30 |
36 |
37 |
38 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | {
62 | setPreferences((prev) => ({
63 | ...prev,
64 | linkDarkHues: event.currentTarget.checked,
65 | }));
66 | }}
67 | />
68 | {
72 | setPreferences((prev) => ({
73 | ...prev,
74 | linkLightHues: event.currentTarget.checked,
75 | }));
76 | }}
77 | />
78 | {
82 | setPreferences((prev) => ({
83 | ...prev,
84 | linkColorLightness: event.currentTarget.checked,
85 | }));
86 | }}
87 | />
88 |
89 |
90 |
91 | setTheme(SOLARIZED_DEFAULT)}
95 | >
96 | Reset to Solarized Default
97 |
98 |
99 |
100 | );
101 | }
102 |
103 | function ColorBox({
104 | name,
105 | uses,
106 | contrastDark,
107 | contrastLight,
108 | }: {
109 | name: SolarizedThemeColorName;
110 | uses?: string[];
111 | contrastDark?: boolean;
112 | contrastLight?: boolean;
113 | }) {
114 | const [theme] = useAtom(themeAtom);
115 | const [selectedColor, setSelectedColor] = useAtom(selectedColorAtom);
116 | const selected = selectedColor === name;
117 | const onClick = () => {
118 | setSelectedColor(selected ? null : name);
119 | };
120 | const color = theme.get(name);
121 | const fgColor = getFgColor(color).toString({ format: 'oklch' });
122 | return (
123 |
132 |
136 | {name}
137 |
138 |
139 | {color.to('srgb').toString({ format: 'hex' })}
140 |
141 | {uses ? (
142 |
143 | {uses.map((use) => (
144 | {use}
145 | ))}
146 |
147 | ) : null}
148 | {contrastDark || contrastLight ? (
149 |
150 | {contrastDark && (
151 |
152 | Dark
153 |
154 | {theme.darkBg.contrast(color, 'APCA').toFixed(1)}
155 |
156 |
157 | )}
158 | {contrastLight && (
159 |
160 | Light
161 |
162 | {theme.lightBg.contrast(color, 'APCA').toFixed(1)}
163 |
164 |
165 | )}
166 |
167 | ) : null}
168 |
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/src/apcach.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Type declarations for apcach module
3 | * https://www.npmjs.com/package/apcach
4 | */
5 |
6 | declare module 'apcach' {
7 | // ============ Types ============
8 |
9 | export type ColorSpace = 'p3' | 'srgb';
10 | export type ContrastModel = 'apca' | 'wcag';
11 | export type SearchDirection = 'auto' | 'lighter' | 'darker';
12 | export type CSSFormat = 'oklch' | 'rgb' | 'hex' | 'p3' | 'figma-p3';
13 |
14 | /**
15 | * A CSS color string (e.g., "oklch(0.5 0.1 180)", "#ff0000", "white", "black")
16 | */
17 | export type CSSColor = string;
18 |
19 | /**
20 | * Configuration for contrast calculation
21 | */
22 | export interface ContrastConfig {
23 | bgColor: CSSColor | 'apcach';
24 | fgColor: CSSColor | 'apcach';
25 | cr: number;
26 | contrastModel: ContrastModel;
27 | searchDirection: SearchDirection;
28 | }
29 |
30 | /**
31 | * The apcach color object returned by the main apcach function
32 | */
33 | export interface ApcachColor {
34 | alpha: number;
35 | chroma: number;
36 | colorSpace: ColorSpace;
37 | contrastConfig: ContrastConfig;
38 | hue: number;
39 | lightness: number;
40 | }
41 |
42 | /**
43 | * Antagonist configuration for cssToApcach
44 | */
45 | export interface AntagonistConfig {
46 | bg?: CSSColor;
47 | fg?: CSSColor;
48 | }
49 |
50 | /**
51 | * Max chroma function type
52 | */
53 | export type MaxChromaFunction = (
54 | contrastConfig: ContrastConfig,
55 | hue: number,
56 | alpha: number,
57 | colorSpace: ColorSpace
58 | ) => ApcachColor;
59 |
60 | // ============ Main Function ============
61 |
62 | /**
63 | * Creates an apcach color with the specified contrast, chroma, hue, alpha, and color space
64 | * @param contrast - Contrast ratio (number) or ContrastConfig object
65 | * @param chroma - Chroma value (0-0.37) or maxChroma function
66 | * @param hue - Hue value (0-360) or undefined
67 | * @param alpha - Alpha value (0-100), default 100
68 | * @param colorSpace - Color space ('p3' or 'srgb'), default 'p3'
69 | */
70 | export function apcach(
71 | contrast: number | ContrastConfig,
72 | chroma: number | MaxChromaFunction,
73 | hue?: number | null,
74 | alpha?: number,
75 | colorSpace?: ColorSpace
76 | ): ApcachColor;
77 |
78 | // ============ Conversion Functions ============
79 |
80 | /**
81 | * Converts a CSS color to an apcach color
82 | * @param color - CSS color string
83 | * @param antagonist - Object with bg or fg antagonist color
84 | * @param colorSpace - Color space, default 'p3'
85 | * @param contrastModel - Contrast model, default 'apca'
86 | */
87 | export function cssToApcach(
88 | color: CSSColor,
89 | antagonist: AntagonistConfig,
90 | colorSpace?: ColorSpace,
91 | contrastModel?: ContrastModel
92 | ): ApcachColor;
93 |
94 | /**
95 | * Converts an apcach color to CSS format
96 | * @param color - Apcach color object
97 | * @param format - Output format, default 'oklch'
98 | */
99 | export function apcachToCss(color: ApcachColor, format?: CSSFormat): string;
100 |
101 | // ============ Contrast Config Helpers ============
102 |
103 | /**
104 | * Creates contrast config with apcach as foreground and specified background
105 | */
106 | export function crToBg(
107 | bgColor: CSSColor,
108 | cr: number,
109 | contrastModel?: ContrastModel,
110 | searchDirection?: SearchDirection
111 | ): ContrastConfig;
112 |
113 | /**
114 | * Alias for crToBg
115 | */
116 | export function crTo(
117 | bgColor: CSSColor,
118 | cr: number,
119 | contrastModel?: ContrastModel,
120 | searchDirection?: SearchDirection
121 | ): ContrastConfig;
122 |
123 | /**
124 | * Creates contrast config with apcach as foreground and white background
125 | */
126 | export function crToBgWhite(
127 | cr: number,
128 | contrastModel?: ContrastModel,
129 | searchDirection?: SearchDirection
130 | ): ContrastConfig;
131 |
132 | /**
133 | * Creates contrast config with apcach as foreground and black background
134 | */
135 | export function crToBgBlack(
136 | cr: number,
137 | contrastModel?: ContrastModel,
138 | searchDirection?: SearchDirection
139 | ): ContrastConfig;
140 |
141 | /**
142 | * Creates contrast config with apcach as background and specified foreground
143 | */
144 | export function crToFg(
145 | fgColor: CSSColor,
146 | cr: number,
147 | contrastModel?: ContrastModel,
148 | searchDirection?: SearchDirection
149 | ): ContrastConfig;
150 |
151 | /**
152 | * Creates contrast config with apcach as background and white foreground
153 | */
154 | export function crToFgWhite(
155 | cr: number,
156 | contrastModel?: ContrastModel,
157 | searchDirection?: SearchDirection
158 | ): ContrastConfig;
159 |
160 | /**
161 | * Creates contrast config with apcach as background and black foreground
162 | */
163 | export function crToFgBlack(
164 | cr: number,
165 | contrastModel?: ContrastModel,
166 | searchDirection?: SearchDirection
167 | ): ContrastConfig;
168 |
169 | // ============ Color Manipulation Functions ============
170 |
171 | /**
172 | * Sets or updates the contrast of an apcach color
173 | * @param colorInApcach - Apcach color object
174 | * @param cr - New contrast value or function to calculate from current value
175 | */
176 | export function setContrast(
177 | colorInApcach: ApcachColor,
178 | cr: number | ((currentCr: number) => number)
179 | ): ApcachColor;
180 |
181 | /**
182 | * Sets or updates the chroma of an apcach color
183 | * @param colorInApcach - Apcach color object
184 | * @param c - New chroma value or function to calculate from current value
185 | */
186 | export function setChroma(
187 | colorInApcach: ApcachColor,
188 | c: number | ((currentChroma: number) => number)
189 | ): ApcachColor;
190 |
191 | /**
192 | * Sets or updates the hue of an apcach color
193 | * @param colorInApcach - Apcach color object
194 | * @param h - New hue value or function to calculate from current value
195 | */
196 | export function setHue(
197 | colorInApcach: ApcachColor,
198 | h: number | ((currentHue: number) => number)
199 | ): ApcachColor;
200 |
201 | /**
202 | * Returns a max chroma function with optional chroma cap
203 | * @param chromaCap - Maximum chroma value, default 0.4
204 | */
205 | export function maxChroma(chromaCap?: number): MaxChromaFunction;
206 |
207 | // ============ Utility Functions ============
208 |
209 | /**
210 | * Calculates the contrast between foreground and background colors
211 | * @param fgColor - Foreground color (CSS string)
212 | * @param bgColor - Background color (CSS string)
213 | * @param contrastModel - Contrast model, default 'apca'
214 | * @param colorSpace - Color space, default 'p3'
215 | */
216 | export function calcContrast(
217 | fgColor: CSSColor,
218 | bgColor: CSSColor,
219 | contrastModel?: ContrastModel,
220 | colorSpace?: ColorSpace
221 | ): number;
222 |
223 | /**
224 | * Checks if a color is within the specified color space gamut
225 | * @param color - Color to check (ApcachColor or CSS string)
226 | * @param colorSpace - Color space, default 'p3'
227 | */
228 | export function inColorSpace(
229 | color: ApcachColor | CSSColor,
230 | colorSpace?: ColorSpace
231 | ): boolean;
232 | }
233 |
--------------------------------------------------------------------------------
/src/components/OutputCard.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import {
3 | Card,
4 | Stack,
5 | Tabs,
6 | Text,
7 | Group,
8 | Code,
9 | ScrollArea,
10 | CopyButton,
11 | ActionIcon,
12 | Tooltip,
13 | NativeSelect,
14 | TextInput,
15 | Input,
16 | Button,
17 | } from '@mantine/core';
18 | import {
19 | IconCheck,
20 | IconCopy,
21 | IconDownload,
22 | IconShare,
23 | } from '@tabler/icons-react';
24 | import { useAtom } from 'jotai';
25 | import { useDarkMode } from 'usehooks-ts';
26 |
27 | import { EXPORTER_CATEGORIES } from '../outputs';
28 | import { themeAtom, themeNameAtom } from '../state';
29 | import CardTitle from './CardTitle';
30 | import classes from './OutputCard.module.css';
31 | import type { ExporterConfig } from '../outputs/types';
32 | import { createShareableURL } from '../serialization';
33 |
34 | export default function OutputCard() {
35 | const { isDarkMode } = useDarkMode();
36 | const [theme] = useAtom(themeAtom);
37 | const [themeName, setThemeName] = useAtom(themeNameAtom);
38 | const [selectedExporter, setSelectedExporter] = useState(
39 | EXPORTER_CATEGORIES[0].exporters[0]
40 | );
41 |
42 | // Compute full theme name with mode suffix
43 | const fullThemeName = selectedExporter.colorScheme
44 | ? `${themeName.trim() || 'SolarSystem'} (${selectedExporter.colorScheme === 'dark' ? 'Dark' : 'Light'})`
45 | : themeName.trim() || 'SolarSystem';
46 |
47 | const shareableURL = createShareableURL(theme, themeName);
48 |
49 | const output = selectedExporter.export(theme, fullThemeName);
50 |
51 | // Determine colors based on the current color scheme
52 | const bgColor = theme
53 | .bg(isDarkMode ? 'dark' : 'light')
54 | .to('srgb')
55 | .toString({ format: 'hex' });
56 | const fgColor = theme
57 | .fg(isDarkMode ? 'dark' : 'light')
58 | .to('srgb')
59 | .toString({ format: 'hex' });
60 |
61 | const handleDownload = () => {
62 | const blob = new Blob([output], { type: 'text/plain' });
63 | const url = URL.createObjectURL(blob);
64 | const a = document.createElement('a');
65 | a.href = url;
66 |
67 | // Generate filename using theme name
68 | const defaultFilename = `solarsystem-${selectedExporter.colorScheme === 'dark' ? 'dark' : 'light'}`;
69 | const filename = `${slugify(fullThemeName) || defaultFilename}.${selectedExporter.fileExtension || 'txt'}`;
70 | a.download = filename;
71 |
72 | document.body.appendChild(a);
73 | a.click();
74 | document.body.removeChild(a);
75 | URL.revokeObjectURL(url);
76 | };
77 |
78 | return (
79 |
80 |
81 | Output
82 |
83 |
84 |
85 | setThemeName(event.currentTarget.value)}
89 | placeholder="Enter theme name"
90 | />
91 |
92 | {({ copied, copy }) => (
93 |
99 | :
106 | }
107 | >
108 | Copy Shareable Link
109 |
110 |
111 | )}
112 |
113 |
114 |
115 |
116 | {
119 | const category = EXPORTER_CATEGORIES.find(
120 | (cat) => cat.id === value
121 | );
122 | if (category && category.exporters.length > 0) {
123 | setSelectedExporter(category.exporters[0]);
124 | }
125 | }}
126 | >
127 |
128 | {EXPORTER_CATEGORIES.map((category) => (
129 |
130 | {category.name}
131 |
132 | ))}
133 |
134 |
135 | {EXPORTER_CATEGORIES.map((category) => (
136 |
137 | {
140 | const exporter = category.exporters.find(
141 | (exp) => exp.id === event.currentTarget.value
142 | );
143 | if (exporter) {
144 | setSelectedExporter(exporter);
145 | }
146 | }}
147 | data={category.exporters.map((exporter) => ({
148 | value: exporter.id,
149 | label: exporter.name,
150 | }))}
151 | />
152 |
153 | ))}
154 |
155 |
156 |
157 |
158 |
159 | {selectedExporter.description}
160 |
161 |
162 |
163 | {({ copied, copy }) => (
164 |
169 |
174 | {copied ? (
175 |
176 | ) : (
177 |
178 | )}
179 |
180 |
181 | )}
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
201 | {output}
202 |
203 |
204 |
205 |
206 | );
207 | }
208 |
209 | function slugify(name: string): string {
210 | return name
211 | .toString() // Ensure input is a string
212 | .normalize('NFD') // Split accented characters into base + accent
213 | .replace(/[\u0300-\u036f]/g, '') // Remove the accent marks
214 | .toLowerCase() // Convert to lowercase
215 | .trim() // Remove leading/trailing whitespace
216 | .replace(/\s+/g, '-') // Replace spaces with hyphens
217 | .replace(/[^\w-]+/g, '') // Remove all non-word characters (except hyphens)
218 | .replace(/--+/g, '-'); // Replace multiple hyphens with a single one
219 | }
220 |
--------------------------------------------------------------------------------
/src/solarized.ts:
--------------------------------------------------------------------------------
1 | import { apcachToCss, apcach, crToBg } from 'apcach';
2 | import Color from 'colorjs.io';
3 |
4 | import type { Preferences } from './preferences';
5 |
6 | export interface SolarizedThemeParams {
7 | base03: Color;
8 | base02: Color;
9 | base01: Color;
10 | base00: Color;
11 | base0: Color;
12 | base1: Color;
13 | base2: Color;
14 | base3: Color;
15 | yellow: Color;
16 | orange: Color;
17 | red: Color;
18 | magenta: Color;
19 | violet: Color;
20 | blue: Color;
21 | cyan: Color;
22 | green: Color;
23 | }
24 |
25 | export type SolarizedThemeColorName = keyof SolarizedThemeParams;
26 |
27 | export type SolarizedThemeSlot =
28 | | 'darkBg'
29 | | 'darkFg'
30 | | 'darkHighlight'
31 | | 'darkEmphasis'
32 | | 'darkSecondary'
33 | | 'lightBg'
34 | | 'lightFg'
35 | | 'lightHighlight'
36 | | 'lightEmphasis'
37 | | 'lightSecondary';
38 |
39 | export function resolveSlotToColorName(
40 | slot: SolarizedThemeSlot | SolarizedThemeColorName
41 | ): SolarizedThemeColorName {
42 | switch (slot) {
43 | case 'darkBg':
44 | return 'base03';
45 | case 'darkFg':
46 | return 'base0';
47 | case 'darkHighlight':
48 | return 'base02';
49 | case 'darkEmphasis':
50 | return 'base1';
51 | case 'darkSecondary':
52 | return 'base01';
53 | case 'lightBg':
54 | return 'base3';
55 | case 'lightFg':
56 | return 'base00';
57 | case 'lightHighlight':
58 | return 'base2';
59 | case 'lightEmphasis':
60 | return 'base01';
61 | case 'lightSecondary':
62 | return 'base1';
63 | default:
64 | return slot;
65 | }
66 | }
67 |
68 | export const DARK_NAMES: SolarizedThemeColorName[] = [
69 | 'base03',
70 | 'base02',
71 | 'base01',
72 | 'base00',
73 | 'base0',
74 | 'base1',
75 | ];
76 |
77 | export const LIGHT_NAMES: SolarizedThemeColorName[] = ['base3', 'base2'];
78 |
79 | export const COLOR_NAMES: SolarizedThemeColorName[] = [
80 | 'yellow',
81 | 'orange',
82 | 'red',
83 | 'magenta',
84 | 'violet',
85 | 'blue',
86 | 'cyan',
87 | 'green',
88 | ];
89 |
90 | export class SolarizedTheme implements SolarizedThemeParams {
91 | public readonly base03: Color;
92 | public readonly base02: Color;
93 | public readonly base01: Color;
94 | public readonly base00: Color;
95 | public readonly base0: Color;
96 | public readonly base1: Color;
97 | public readonly base2: Color;
98 | public readonly base3: Color;
99 | public readonly yellow: Color;
100 | public readonly orange: Color;
101 | public readonly red: Color;
102 | public readonly magenta: Color;
103 | public readonly violet: Color;
104 | public readonly blue: Color;
105 | public readonly cyan: Color;
106 | public readonly green: Color;
107 |
108 | constructor(params: SolarizedThemeParams) {
109 | this.base03 = params.base03;
110 | this.base02 = params.base02;
111 | this.base01 = params.base01;
112 | this.base00 = params.base00;
113 | this.base0 = params.base0;
114 | this.base1 = params.base1;
115 | this.base2 = params.base2;
116 | this.base3 = params.base3;
117 | this.yellow = params.yellow;
118 | this.orange = params.orange;
119 | this.red = params.red;
120 | this.magenta = params.magenta;
121 | this.violet = params.violet;
122 | this.blue = params.blue;
123 | this.cyan = params.cyan;
124 | this.green = params.green;
125 | }
126 |
127 | get darkBg() {
128 | return this.base03;
129 | }
130 | get darkFg() {
131 | return this.base0;
132 | }
133 | get darkHighlight() {
134 | return this.base02;
135 | }
136 | get darkEmphasis() {
137 | return this.base1;
138 | }
139 | get darkSecondary() {
140 | return this.base01;
141 | }
142 |
143 | get lightBg() {
144 | return this.base3;
145 | }
146 | get lightFg() {
147 | return this.base00;
148 | }
149 | get lightHighlight() {
150 | return this.base2;
151 | }
152 | get lightEmphasis() {
153 | return this.base01;
154 | }
155 | get lightSecondary() {
156 | return this.base1;
157 | }
158 |
159 | bg(mode: 'dark' | 'light'): Color {
160 | return mode === 'dark' ? this.darkBg : this.lightBg;
161 | }
162 |
163 | fg(mode: 'dark' | 'light'): Color {
164 | return mode === 'dark' ? this.darkFg : this.lightFg;
165 | }
166 |
167 | highlight(mode: 'dark' | 'light'): Color {
168 | return mode === 'dark' ? this.darkHighlight : this.lightHighlight;
169 | }
170 |
171 | secondary(mode: 'dark' | 'light'): Color {
172 | return mode === 'dark' ? this.darkSecondary : this.lightSecondary;
173 | }
174 |
175 | emphasis(mode: 'dark' | 'light'): Color {
176 | return mode === 'dark' ? this.darkEmphasis : this.lightEmphasis;
177 | }
178 |
179 | get(colorName: keyof SolarizedThemeParams | SolarizedThemeSlot): Color {
180 | return this[colorName] as Color;
181 | }
182 |
183 | set(
184 | colorName: SolarizedThemeColorName,
185 | newColor: Color,
186 | preferences?: Preferences
187 | ): SolarizedTheme {
188 | const newTheme = { ...this, [colorName]: newColor } as SolarizedThemeParams;
189 | if (preferences?.linkDarkHues && DARK_NAMES.includes(colorName)) {
190 | for (const darkHue of DARK_NAMES) {
191 | if (darkHue !== colorName) {
192 | newTheme[darkHue] = (newTheme[darkHue] as Color)
193 | .to(newColor.space)
194 | .set('h', newColor.h!);
195 | }
196 | }
197 | } else if (preferences?.linkLightHues && LIGHT_NAMES.includes(colorName)) {
198 | for (const lightHue of LIGHT_NAMES) {
199 | if (lightHue !== colorName) {
200 | newTheme[lightHue] = (newTheme[lightHue] as Color)
201 | .to(newColor.space)
202 | .set('h', newColor.h!);
203 | }
204 | }
205 | } else if (
206 | preferences?.linkColorLightness &&
207 | COLOR_NAMES.includes(colorName)
208 | ) {
209 | for (const colorHue of COLOR_NAMES) {
210 | if (colorHue !== colorName) {
211 | newTheme[colorHue] = (newTheme[colorHue] as Color)
212 | .to(newColor.space)
213 | .set('l', newColor.l!);
214 | }
215 | }
216 | }
217 | return new SolarizedTheme(newTheme);
218 | }
219 | }
220 |
221 | // The L*A*B* values from Solarized itself
222 | export const SOLARIZED_DEFAULT: SolarizedTheme = new SolarizedTheme({
223 | base03: new Color('lab', [15, -12, -12]),
224 | base02: new Color('lab', [20, -12, -12]),
225 | base01: new Color('lab', [45, -7, -7]),
226 | base00: new Color('lab', [50, -7, -7]),
227 | base0: new Color('lab', [60, -6, -3]),
228 | base1: new Color('lab', [65, -5, -2]),
229 | base2: new Color('lab', [92, 0, 10]),
230 | base3: new Color('lab', [97, 0, 10]),
231 | yellow: new Color('lab', [60, 10, 65]),
232 | orange: new Color('lab', [50, 50, 55]),
233 | red: new Color('lab', [50, 65, 45]),
234 | magenta: new Color('lab', [50, 65, -5]),
235 | violet: new Color('lab', [50, 15, -45]),
236 | blue: new Color('lab', [55, -10, -45]),
237 | cyan: new Color('lab', [60, -35, -5]),
238 | green: new Color('lab', [60, -20, 65]),
239 | });
240 |
241 | export function clampAndRoundComponent(
242 | component: 'hue' | 'saturation' | 'lightness' | 'value',
243 | v: number
244 | ) {
245 | if (component === 'hue') {
246 | let hv = v % 360;
247 | if (hv < 0) {
248 | hv += 360;
249 | }
250 | return Math.round(hv);
251 | } else {
252 | return Math.round(Math.min(Math.max(v, 0), 1) * 1000) / 1000;
253 | }
254 | }
255 |
256 | export function clampAndRound(color: Color) {
257 | if (color.spaceId === 'okhsl') {
258 | return new Color('okhsl', [
259 | clampAndRoundComponent('hue', color.h!),
260 | clampAndRoundComponent('saturation', color.s!),
261 | clampAndRoundComponent('lightness', color.l!),
262 | ]);
263 | } else if (color.spaceId === 'okhsv') {
264 | return new Color('okhsv', [
265 | clampAndRoundComponent('hue', color.h!),
266 | clampAndRoundComponent('saturation', color.s!),
267 | clampAndRoundComponent('value', color.v!),
268 | ]);
269 | } else {
270 | return color;
271 | }
272 | }
273 |
274 | export function getFgColor(bgColor: Color): Color {
275 | return new Color(
276 | apcachToCss(apcach(crToBg(bgColor.to('srgb').toString(), 80), 0, 0))
277 | );
278 | }
279 |
--------------------------------------------------------------------------------
/src/outputs/vscode.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | SolarizedTheme,
3 | SolarizedThemeParams,
4 | SolarizedThemeSlot,
5 | } from '../solarized';
6 | import type { ExporterConfig } from './types';
7 |
8 | function hexColor(
9 | theme: SolarizedTheme,
10 | colorName: keyof SolarizedThemeParams | SolarizedThemeSlot
11 | ): string {
12 | return theme.get(colorName).to('srgb').toString({ format: 'hex' });
13 | }
14 |
15 | function interpolateHexColor(
16 | theme: SolarizedTheme,
17 | colorName1: keyof SolarizedThemeParams | SolarizedThemeSlot,
18 | colorName2: keyof SolarizedThemeParams | SolarizedThemeSlot,
19 | t: number
20 | ): string {
21 | const color1 = theme.get(colorName1).to('okhsl');
22 | const color2 = theme.get(colorName2).to('okhsl');
23 |
24 | const h = color1.h! + t * (color2.h! - color1.h!);
25 | const s = color1.s! + t * (color2.s! - color1.s!);
26 | const l = color1.l! + t * (color2.l! - color1.l!);
27 |
28 | return theme
29 | .get(colorName1)
30 | .to('okhsl')
31 | .set('h', h)
32 | .set('s', s)
33 | .set('l', l)
34 | .to('srgb')
35 | .toString({ format: 'hex' });
36 | }
37 |
38 | function exportVSCodeDark(theme: SolarizedTheme, themeName: string): string {
39 | const result = {
40 | name: themeName,
41 | type: 'dark',
42 | ...getTheme(theme, 'dark'),
43 | };
44 |
45 | return JSON.stringify(result, null, 2);
46 | }
47 |
48 | function exportVSCodeLight(theme: SolarizedTheme, themeName: string): string {
49 | const result = {
50 | name: themeName,
51 | type: 'light',
52 | ...getTheme(theme, 'light'),
53 | };
54 |
55 | return JSON.stringify(result, null, 2);
56 | }
57 |
58 | export const vscodeDarkExporter: ExporterConfig = {
59 | id: 'vscode-dark',
60 | name: 'VSCode Theme (Dark)',
61 | description: 'Complete VSCode color theme for dark mode',
62 | fileExtension: 'json',
63 | colorScheme: 'dark',
64 | export: exportVSCodeDark,
65 | };
66 |
67 | export const vscodeLightExporter: ExporterConfig = {
68 | id: 'vscode-light',
69 | name: 'VSCode Theme (Light)',
70 | description: 'Complete VSCode color theme for light mode',
71 | fileExtension: 'json',
72 | colorScheme: 'light',
73 | export: exportVSCodeLight,
74 | };
75 |
76 | function getBracketHighlights(theme: SolarizedTheme) {
77 | return Object.fromEntries(
78 | ['magenta', 'orange', 'yellow', 'green', 'cyan', 'violet'].map(
79 | (color, i) => [
80 | `editorBracketHighlight.foreground${i + 1}`,
81 | hexColor(theme, color as keyof SolarizedThemeParams),
82 | ]
83 | )
84 | );
85 | }
86 |
87 | function getTheme(theme: SolarizedTheme, mode: 'dark' | 'light') {
88 | return {
89 | colors: {
90 | 'editor.background': hexColor(theme, `${mode}Bg`),
91 | 'editor.foreground': hexColor(theme, `${mode}Fg`),
92 | 'editorLineNumber.foreground': hexColor(theme, `${mode}Secondary`),
93 | 'editorLineNumber.activeForeground': hexColor(theme, `${mode}Emphasis`),
94 | 'editorCursor.foreground': hexColor(theme, `${mode}Emphasis`),
95 | 'editor.selectionBackground': hexColor(theme, `${mode}Highlight`),
96 | 'editor.lineHighlightBackground': hexColor(theme, `${mode}Highlight`),
97 | 'editor.wordHighlightBackground':
98 | hexColor(theme, `${mode}Highlight`) + 'a7',
99 | 'editor.hoverHighlightBackground':
100 | hexColor(theme, `${mode}Highlight`) + 'a7',
101 | 'editor.findMatchBackground': hexColor(theme, `${mode}Highlight`) + 'a7',
102 | 'editorWhitespace.foreground': hexColor(theme, `${mode}Secondary`),
103 | 'editorIndentGuide.background1':
104 | hexColor(theme, `${mode}Secondary`) + 'a7',
105 | 'editorIndentGuide.activeBackground1': hexColor(
106 | theme,
107 | `${mode}Secondary`
108 | ),
109 | ...getBracketHighlights(theme),
110 | 'editorBracketHighlight.unexpectedBracket.foreground': hexColor(
111 | theme,
112 | 'red'
113 | ),
114 | 'sideBar.background': interpolateHexColor(
115 | theme,
116 | `${mode}Bg`,
117 | `${mode}Highlight`,
118 | 0.3
119 | ),
120 | 'sideBar.foreground': hexColor(theme, `${mode}Fg`),
121 | 'sideBarSectionHeader.background': hexColor(theme, `${mode}Highlight`),
122 | 'list.activeSelectionBackground': hexColor(theme, `${mode}Highlight`),
123 | 'list.activeSelectionForeground': hexColor(theme, `${mode}Fg`),
124 | 'list.inactiveSelectionBackground': hexColor(theme, `${mode}Highlight`),
125 | 'list.hoverBackground': hexColor(theme, `${mode}Highlight`),
126 | 'statusBar.background': hexColor(theme, `${mode}Highlight`),
127 | 'statusBar.debuggingBackground': hexColor(theme, 'yellow'),
128 | 'statusBar.foreground': hexColor(theme, `${mode}Fg`),
129 | 'titleBar.activeBackground': hexColor(theme, `${mode}Bg`),
130 | 'titleBar.activeForeground': hexColor(theme, `${mode}Fg`),
131 | 'activityBar.background': hexColor(theme, `${mode}Bg`),
132 | 'activityBar.foreground': hexColor(theme, `${mode}Secondary`),
133 | 'panel.background': hexColor(theme, `${mode}Bg`),
134 | 'panel.border': hexColor(theme, `${mode}Highlight`),
135 | 'terminal.background': hexColor(theme, `${mode}Bg`),
136 | 'terminal.foreground': hexColor(theme, `${mode}Fg`),
137 | 'terminal.ansiBlack': hexColor(
138 | theme,
139 | mode === 'dark' ? 'base02' : 'base2'
140 | ),
141 | 'terminal.ansiRed': hexColor(theme, 'red'),
142 | 'terminal.ansiGreen': hexColor(theme, 'green'),
143 | 'terminal.ansiYellow': hexColor(theme, 'yellow'),
144 | 'terminal.ansiBlue': hexColor(theme, 'blue'),
145 | 'terminal.ansiMagenta': hexColor(theme, 'magenta'),
146 | 'terminal.ansiCyan': hexColor(theme, 'cyan'),
147 | 'terminal.ansiWhite': hexColor(
148 | theme,
149 | mode === 'dark' ? 'base2' : 'base02'
150 | ),
151 | 'terminal.ansiBrightBlack': hexColor(
152 | theme,
153 | mode === 'dark' ? 'base03' : 'base3'
154 | ),
155 | 'terminal.ansiBrightRed': hexColor(theme, 'orange'),
156 | 'terminal.ansiBrightGreen': hexColor(
157 | theme,
158 | mode === 'dark' ? 'base01' : 'base1'
159 | ),
160 | 'terminal.ansiBrightYellow': hexColor(
161 | theme,
162 | mode === 'dark' ? 'base0' : 'base00'
163 | ),
164 | 'terminal.ansiBrightBlue': hexColor(
165 | theme,
166 | mode === 'dark' ? 'base00' : 'base0'
167 | ),
168 | 'terminal.ansiBrightMagenta': hexColor(theme, 'violet'),
169 | 'terminal.ansiBrightCyan': hexColor(
170 | theme,
171 | mode === 'dark' ? 'base1' : 'base01'
172 | ),
173 | 'terminal.ansiBrightWhite': hexColor(
174 | theme,
175 | mode === 'dark' ? 'base3' : 'base03'
176 | ),
177 | },
178 | semanticHighlighting: true,
179 | semanticTokenColors: {
180 | property: hexColor(theme, `${mode}Fg`),
181 | 'property.declaration': hexColor(theme, 'blue'),
182 | parameter: hexColor(theme, 'blue'),
183 | 'parameter.declaration': hexColor(theme, 'blue'),
184 | },
185 | tokenColors: [
186 | {
187 | scope: ['comment', 'punctuation.definition.comment'],
188 | settings: {
189 | foreground: hexColor(theme, `${mode}Secondary`),
190 | fontStyle: 'italic',
191 | },
192 | },
193 | {
194 | scope: ['string', 'constant.other.symbol'],
195 | settings: {
196 | foreground: hexColor(theme, 'cyan'),
197 | },
198 | },
199 | {
200 | scope: ['constant.numeric', 'constant.language', 'constant.character'],
201 | settings: {
202 | foreground: hexColor(theme, 'cyan'),
203 | },
204 | },
205 | {
206 | scope: ['string.regexp'],
207 | settings: {
208 | foreground: hexColor(theme, 'orange'),
209 | fontStyle: 'underline',
210 | },
211 | },
212 | {
213 | scope: ['variable'],
214 | settings: {
215 | foreground: hexColor(theme, `${mode}Fg`),
216 | },
217 | },
218 | {
219 | scope: ['keyword', 'storage.type', 'storage.modifier'],
220 | settings: {
221 | foreground: hexColor(theme, 'green'),
222 | },
223 | },
224 | {
225 | scope: ['entity.name.function', 'support.function'],
226 | settings: {
227 | foreground: hexColor(theme, 'blue'),
228 | },
229 | },
230 | {
231 | scope: [
232 | 'entity.name.type',
233 | 'entity.name.class',
234 | 'support.type',
235 | 'support.class',
236 | ],
237 | settings: {
238 | foreground: hexColor(theme, 'yellow'),
239 | },
240 | },
241 | {
242 | scope: ['keyword.operator'],
243 | settings: {
244 | foreground: hexColor(theme, 'orange'),
245 | },
246 | },
247 | {
248 | scope: ['punctuation', 'keyword.operator.assignment'],
249 | settings: {
250 | foreground: hexColor(theme, `${mode}Fg`),
251 | },
252 | },
253 | {
254 | scope: ['entity.name.tag'],
255 | settings: {
256 | foreground: hexColor(theme, 'blue'),
257 | },
258 | },
259 | {
260 | scope: ['entity.other.attribute-name'],
261 | settings: {
262 | foreground: hexColor(theme, 'cyan'),
263 | },
264 | },
265 | {
266 | scope: ['support.constant'],
267 | settings: {
268 | foreground: hexColor(theme, 'orange'),
269 | },
270 | },
271 | {
272 | scope: ['meta.property-name'],
273 | settings: {
274 | foreground: hexColor(theme, 'blue'),
275 | },
276 | },
277 | {
278 | scope: ['invalid'],
279 | settings: {
280 | foreground: hexColor(theme, 'red'),
281 | },
282 | },
283 | {
284 | scope: ['markup.heading'],
285 | settings: {
286 | foreground: hexColor(theme, 'yellow'),
287 | fontStyle: 'bold',
288 | },
289 | },
290 | {
291 | scope: ['markup.italic'],
292 | settings: {
293 | foreground: hexColor(theme, `${mode}Fg`),
294 | fontStyle: 'italic',
295 | },
296 | },
297 | {
298 | scope: ['markup.bold'],
299 | settings: {
300 | foreground: hexColor(theme, `${mode}Fg`),
301 | fontStyle: 'bold',
302 | },
303 | },
304 | {
305 | scope: ['markup.underline'],
306 | settings: {
307 | foreground: hexColor(theme, 'violet'),
308 | fontStyle: 'underline',
309 | },
310 | },
311 | {
312 | scope: ['markup.quote'],
313 | settings: {
314 | foreground: hexColor(theme, 'green'),
315 | },
316 | },
317 | {
318 | scope: ['markup.inline.raw', 'markup.fenced_code'],
319 | settings: {
320 | foreground: hexColor(theme, 'cyan'),
321 | },
322 | },
323 | ],
324 | };
325 | }
326 |
--------------------------------------------------------------------------------
/src/components/SyntaxPreviewCard.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import { Card, Code, SegmentedControl, Stack } from '@mantine/core';
3 | import { useAtom } from 'jotai';
4 | import * as Prism from 'prismjs';
5 | import { useDarkMode } from 'usehooks-ts';
6 |
7 | import { preferencesAtom, themeAtom } from '../state';
8 | import CardTitle from './CardTitle';
9 | import classes from './PreviewCard.module.css';
10 |
11 | import 'prismjs/components/prism-typescript';
12 | import 'prismjs/components/prism-python';
13 | import 'prismjs/components/prism-rust';
14 | import 'prismjs/components/prism-go';
15 | import 'prismjs/components/prism-java';
16 | import 'prismjs/components/prism-jsx';
17 | import 'prismjs/components/prism-bash';
18 | import 'prismjs/components/prism-json';
19 | import 'prismjs/components/prism-sql';
20 | import 'prismjs/components/prism-yaml';
21 |
22 | type Language =
23 | | 'typescript'
24 | | 'python'
25 | | 'rust'
26 | | 'go'
27 | | 'java'
28 | | 'jsx'
29 | | 'bash'
30 | | 'json'
31 | | 'sql'
32 | | 'yaml';
33 |
34 | const CODE_SAMPLES: Record = {
35 | typescript: `// TypeScript Example
36 | interface User {
37 | id: number;
38 | name: string;
39 | email?: string;
40 | }
41 |
42 | class UserService {
43 | private users: User[] = [];
44 |
45 | async fetchUser(id: number): Promise {
46 | const response = await fetch(\`/api/users/\${id}\`);
47 | return response.ok ? response.json() : null;
48 | }
49 |
50 | createUser(name: string): User {
51 | const user = { id: this.users.length + 1, name };
52 | this.users.push(user);
53 | return user;
54 | }
55 | }
56 |
57 | export default UserService;`,
58 |
59 | python: `# Python Example
60 | from typing import Optional, List
61 | import asyncio
62 |
63 | class User:
64 | def __init__(self, id: int, name: str, email: Optional[str] = None):
65 | self.id = id
66 | self.name = name
67 | self.email = email
68 |
69 | def __repr__(self) -> str:
70 | return f"User(id={self.id}, name='{self.name}')"
71 |
72 | async def fetch_users(url: str) -> List[User]:
73 | """Fetch users from an API endpoint."""
74 | # Simulated async API call
75 | await asyncio.sleep(0.1)
76 | return [User(1, "Alice"), User(2, "Bob")]
77 |
78 | if __name__ == "__main__":
79 | users = asyncio.run(fetch_users("/api/users"))
80 | print(f"Fetched {len(users)} users")`,
81 |
82 | rust: `// Rust Example
83 | use std::collections::HashMap;
84 |
85 | #[derive(Debug, Clone)]
86 | struct User {
87 | id: u32,
88 | name: String,
89 | email: Option,
90 | }
91 |
92 | impl User {
93 | fn new(id: u32, name: &str) -> Self {
94 | Self {
95 | id,
96 | name: name.to_string(),
97 | email: None,
98 | }
99 | }
100 | }
101 |
102 | fn main() {
103 | let mut users: HashMap = HashMap::new();
104 |
105 | let user = User::new(1, "Alice");
106 | users.insert(user.id, user.clone());
107 |
108 | match users.get(&1) {
109 | Some(u) => println!("Found user: {:?}", u),
110 | None => println!("User not found"),
111 | }
112 | }`,
113 |
114 | go: `// Go Example
115 | package main
116 |
117 | import (
118 | "fmt"
119 | "time"
120 | )
121 |
122 | type User struct {
123 | ID int
124 | Name string
125 | Email *string
126 | }
127 |
128 | func NewUser(id int, name string) *User {
129 | return &User{
130 | ID: id,
131 | Name: name,
132 | }
133 | }
134 |
135 | func (u *User) String() string {
136 | return fmt.Sprintf("User{ID: %d, Name: %s}", u.ID, u.Name)
137 | }
138 |
139 | func main() {
140 | users := make([]*User, 0)
141 | users = append(users, NewUser(1, "Alice"))
142 |
143 | fmt.Printf("Created %d users at %v\\n", len(users), time.Now())
144 | }`,
145 |
146 | java: `// Java Example
147 | import java.util.*;
148 | import java.util.stream.*;
149 |
150 | public class UserService {
151 | private List users = new ArrayList<>();
152 |
153 | public static class User {
154 | private final int id;
155 | private final String name;
156 | private String email;
157 |
158 | public User(int id, String name) {
159 | this.id = id;
160 | this.name = name;
161 | }
162 |
163 | public int getId() { return id; }
164 | public String getName() { return name; }
165 |
166 | @Override
167 | public String toString() {
168 | return String.format("User{id=%d, name='%s'}", id, name);
169 | }
170 | }
171 |
172 | public Optional findById(int id) {
173 | return users.stream()
174 | .filter(u -> u.getId() == id)
175 | .findFirst();
176 | }
177 |
178 | public static void main(String[] args) {
179 | UserService service = new UserService();
180 | System.out.println("UserService initialized");
181 | }
182 | }`,
183 |
184 | jsx: `// React/JSX Example
185 | import React, { useState, useEffect } from 'react';
186 |
187 | interface UserProps {
188 | id: number;
189 | name: string;
190 | }
191 |
192 | const UserCard: React.FC = ({ id, name }) => {
193 | const [loading, setLoading] = useState(false);
194 | const [email, setEmail] = useState(null);
195 |
196 | useEffect(() => {
197 | async function fetchEmail() {
198 | setLoading(true);
199 | const response = await fetch(\`/api/users/\${id}/email\`);
200 | const data = await response.json();
201 | setEmail(data.email);
202 | setLoading(false);
203 | }
204 | fetchEmail();
205 | }, [id]);
206 |
207 | return (
208 |
209 |
{name}
210 | {loading ?
Loading...
:
{email}
}
211 |
212 | );
213 | };
214 |
215 | export default UserCard;`,
216 |
217 | bash: `#!/bin/bash
218 | # Bash Script Example
219 |
220 | set -euo pipefail
221 |
222 | USER_FILE="/tmp/users.txt"
223 | API_URL="https://api.example.com/users"
224 |
225 | # Function to fetch users
226 | fetch_users() {
227 | local url="$1"
228 | echo "Fetching users from $url..."
229 |
230 | if curl -s "$url" > "$USER_FILE"; then
231 | echo "Successfully fetched users"
232 | return 0
233 | else
234 | echo "Failed to fetch users" >&2
235 | return 1
236 | fi
237 | }
238 |
239 | # Main execution
240 | main() {
241 | if [[ ! -f "$USER_FILE" ]]; then
242 | fetch_users "$API_URL"
243 | fi
244 |
245 | # Process users
246 | while IFS= read -r line; do
247 | echo "Processing: $line"
248 | done < "$USER_FILE"
249 | }
250 |
251 | main "$@"`,
252 |
253 | json: `{
254 | "users": [
255 | {
256 | "id": 1,
257 | "name": "Alice Johnson",
258 | "email": "alice@example.com",
259 | "roles": ["admin", "user"],
260 | "active": true,
261 | "metadata": {
262 | "lastLogin": "2025-12-20T10:30:00Z",
263 | "preferences": {
264 | "theme": "dark",
265 | "notifications": true
266 | }
267 | }
268 | },
269 | {
270 | "id": 2,
271 | "name": "Bob Smith",
272 | "email": "bob@example.com",
273 | "roles": ["user"],
274 | "active": false,
275 | "metadata": null
276 | }
277 | ],
278 | "total": 2,
279 | "page": 1
280 | }`,
281 |
282 | sql: `-- SQL Example
283 | CREATE TABLE users (
284 | id SERIAL PRIMARY KEY,
285 | name VARCHAR(255) NOT NULL,
286 | email VARCHAR(255) UNIQUE,
287 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
288 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
289 | );
290 |
291 | CREATE INDEX idx_users_email ON users(email);
292 |
293 | -- Insert sample data
294 | INSERT INTO users (name, email) VALUES
295 | ('Alice Johnson', 'alice@example.com'),
296 | ('Bob Smith', 'bob@example.com');
297 |
298 | -- Query with JOIN
299 | SELECT
300 | u.id,
301 | u.name,
302 | COUNT(o.id) as order_count
303 | FROM users u
304 | LEFT JOIN orders o ON u.id = o.user_id
305 | WHERE u.created_at > NOW() - INTERVAL '30 days'
306 | GROUP BY u.id, u.name
307 | HAVING COUNT(o.id) > 0
308 | ORDER BY order_count DESC
309 | LIMIT 10;`,
310 |
311 | yaml: `# YAML Configuration Example
312 | apiVersion: v1
313 | kind: Service
314 | metadata:
315 | name: user-service
316 | namespace: production
317 | labels:
318 | app: users
319 | version: v1.2.3
320 | spec:
321 | type: LoadBalancer
322 | selector:
323 | app: users
324 | ports:
325 | - name: http
326 | port: 80
327 | targetPort: 8080
328 | protocol: TCP
329 | - name: https
330 | port: 443
331 | targetPort: 8443
332 | protocol: TCP
333 |
334 | # Advanced configuration
335 | sessionAffinity: ClientIP
336 | sessionAffinityConfig:
337 | clientIP:
338 | timeoutSeconds: 10800
339 |
340 | ---
341 | apiVersion: apps/v1
342 | kind: Deployment
343 | metadata:
344 | name: user-service
345 | spec:
346 | replicas: 3
347 | strategy:
348 | type: RollingUpdate
349 | rollingUpdate:
350 | maxSurge: 1
351 | maxUnavailable: 0`,
352 | };
353 |
354 | export default function SyntaxPreviewCard() {
355 | const [theme] = useAtom(themeAtom);
356 | const [preferences] = useAtom(preferencesAtom);
357 | const [language, setLanguage] = useState('typescript');
358 | const { isDarkMode } = useDarkMode();
359 | const [mode, setMode] = useState<'dark' | 'light'>(
360 | isDarkMode ? 'dark' : 'light'
361 | );
362 | const codeRef = useRef(null);
363 |
364 | useEffect(() => {
365 | if (codeRef.current) {
366 | Prism.highlightElement(codeRef.current);
367 | }
368 | }, [language, theme, mode]);
369 |
370 | const bgColor = theme.bg(mode).to('srgb').toString({ format: 'hex' });
371 | const fgColor = theme.fg(mode).to('srgb').toString({ format: 'hex' });
372 |
373 | // Map Solarized colors to Prism token types
374 | const prismTheme = {
375 | '--prism-background': bgColor,
376 | '--prism-foreground': fgColor,
377 | '--prism-comment': theme
378 | .get('base01')
379 | .to('srgb')
380 | .toString({ format: 'hex' }),
381 | '--prism-keyword': theme
382 | .get('green')
383 | .to('srgb')
384 | .toString({ format: 'hex' }),
385 | '--prism-string': theme.get('cyan').to('srgb').toString({ format: 'hex' }),
386 | '--prism-function': theme
387 | .get('blue')
388 | .to('srgb')
389 | .toString({ format: 'hex' }),
390 | '--prism-number': theme.get('cyan').to('srgb').toString({ format: 'hex' }),
391 | '--prism-operator': theme
392 | .get('green')
393 | .to('srgb')
394 | .toString({ format: 'hex' }),
395 | '--prism-punctuation': theme
396 | .fg(mode)
397 | .to('srgb')
398 | .toString({ format: 'hex' }),
399 | '--prism-class-name': theme
400 | .get('yellow')
401 | .to('srgb')
402 | .toString({ format: 'hex' }),
403 | '--prism-constant': theme
404 | .get('orange')
405 | .to('srgb')
406 | .toString({ format: 'hex' }),
407 | '--prism-builtin': theme
408 | .get('yellow')
409 | .to('srgb')
410 | .toString({ format: 'hex' }),
411 | '--prism-variable': theme
412 | .get('blue')
413 | .to('srgb')
414 | .toString({ format: 'hex' }),
415 | '--prism-tag': theme.get('red').to('srgb').toString({ format: 'hex' }),
416 | '--prism-attr-name': theme
417 | .get('cyan')
418 | .to('srgb')
419 | .toString({ format: 'hex' }),
420 | '--prism-attr-value': theme
421 | .get('cyan')
422 | .to('srgb')
423 | .toString({ format: 'hex' }),
424 | '--prism-decorator': theme
425 | .get('orange')
426 | .to('srgb')
427 | .toString({ format: 'hex' }),
428 | '--prism-regex': theme.get('red').to('srgb').toString({ format: 'hex' }),
429 | fontSize: preferences.fontSize ?? 14,
430 | };
431 |
432 | return (
433 |
434 |
435 | Syntax Highlighting Preview
436 |
437 |
438 | setMode(value as 'dark' | 'light')}
441 | data={[
442 | { label: 'Dark', value: 'dark' },
443 | { label: 'Light', value: 'light' },
444 | ]}
445 | fullWidth
446 | />
447 |
448 | setLanguage(value as Language)}
451 | data={[
452 | { label: 'TypeScript', value: 'typescript' },
453 | { label: 'Python', value: 'python' },
454 | { label: 'Rust', value: 'rust' },
455 | { label: 'Go', value: 'go' },
456 | { label: 'Java', value: 'java' },
457 | { label: 'JSX', value: 'jsx' },
458 | { label: 'Bash', value: 'bash' },
459 | { label: 'JSON', value: 'json' },
460 | { label: 'SQL', value: 'sql' },
461 | { label: 'YAML', value: 'yaml' },
462 | ]}
463 | fullWidth
464 | />
465 |
466 |
467 |
476 |
477 |
478 | {CODE_SAMPLES[language]}
479 |
480 |
481 |
482 |
483 |
484 | );
485 | }
486 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # SolarSystem - AI Onboarding Documentation
2 |
3 | ## Project Overview
4 |
5 | **SolarSystem** is a sophisticated web-based color palette builder that creates Solarized-inspired color schemes with a focus on perceptual uniformity and accessibility.
6 |
7 | ### What It Does
8 |
9 | - Generates and edits 16-color Solarized palettes (8 base + 8 accent colors)
10 | - Uses OKHSL/OKLCH color spaces for perceptually uniform editing
11 | - Calculates APCA contrast ratios for accessibility
12 | - Provides real-time preview in both dark and light modes
13 | - Supports multiple color format conversions (hex, rgb, display-p3, oklch, okhsl)
14 | - Exports themes to 12+ formats (VSCode, terminal emulators, JSON)
15 |
16 | ### Tech Stack
17 |
18 | - **React 19.1.1** + **TypeScript 5.9.3** + **Vite 7.1.7**
19 | - **Jotai** for atomic state management
20 | - **Mantine 8.3.6** for UI components
21 | - **colorjs.io** for color manipulation
22 | - **apcach** for APCA contrast calculation
23 |
24 | ## Project Structure
25 |
26 | ```
27 | /src/
28 | ├── App.tsx # Root component, layout orchestration
29 | ├── main.tsx # Entry point
30 | ├── state.ts # Jotai atoms (theme, colors, preferences)
31 | ├── solarized.ts # Core Solarized theme logic & Color class
32 | ├── preferences.ts # User preferences types
33 | ├── mantineTheme.ts # Mantine theme configuration
34 | ├── colorconversion.js # Color space conversion utilities
35 | ├── components/
36 | │ ├── ThemeDisplay.tsx # 16-color palette grid with controls
37 | │ ├── PreviewCard.tsx # Dark/light mode preview panel
38 | │ ├── SingleColorEditorCard.tsx # Individual color editor (3 sliders)
39 | │ ├── OutputCard.tsx # Export themes to various formats
40 | │ ├── TextFormat.tsx # Multi-format color input/output
41 | │ ├── CardTitle.tsx # Reusable card title component
42 | │ └── hueslider/ # Custom color slider components
43 | │ ├── ColorSlider.tsx # Base slider (adapted from Mantine)
44 | │ └── OKHueSlider.tsx # OKLCH-aware slider
45 | └── outputs/ # Export format implementations
46 | ├── types.ts # Exporter interfaces
47 | ├── index.ts # Exporter registry and categories
48 | ├── json.ts # JSON exporters (simple & detailed)
49 | ├── vscode.ts # VSCode theme exporters
50 | ├── ghostty.ts # Ghostty terminal exporters
51 | ├── iterm2.ts # iTerm2 color scheme exporters
52 | ├── alacritty.ts # Alacritty config exporters
53 | ├── kitty.ts # Kitty terminal exporters
54 | └── wezterm.ts # WezTerm config exporters
55 | ```
56 |
57 | ## Key Concepts
58 |
59 | ### Solarized Color System
60 |
61 | The palette consists of 16 colors divided into:
62 |
63 | **8 Base Colors** (background-to-foreground spectrum):
64 |
65 | - `base03` → `base02` → `base01` → `base00` → `base0` → `base1` → `base2` → `base3`
66 | - Dark mode: `base03` is background, `base0` is primary foreground
67 | - Light mode: `base3` is background, `base00` is primary foreground
68 |
69 | **8 Accent Colors**:
70 |
71 | - `yellow`, `orange`, `red`, `magenta`, `violet`, `blue`, `cyan`, `green`
72 |
73 | ### State Management (state.ts)
74 |
75 | **Primary Atoms:**
76 |
77 | 1. `themeAtom` - Stores complete `SolarizedTheme` instance, persists to localStorage
78 | 2. `singleColorAtomFamily` - Per-color derived atoms with preference-aware updates
79 | 3. `selectedColorAtom` - Currently selected color name for editing
80 | 4. `preferencesAtom` - User preferences (linked hues, font size)
81 |
82 | **Important Pattern:**
83 | When a color is updated via `singleColorAtomFamily`, it respects user preferences:
84 |
85 | - `linkDarkHues` - Synchronizes hue across dark base colors
86 | - `linkLightHues` - Synchronizes hue across light base colors
87 | - `linkColorLightness` - Synchronizes lightness across accent colors
88 |
89 | ### Color Space Strategy
90 |
91 | - **Storage/Defaults**: LAB (original Solarized values)
92 | - **Editing**: OKHSL (perceptually uniform, easy hue/sat/lightness control)
93 | - **Display CSS**: OKLCH (perceptually uniform, wide gamut)
94 | - **Legacy Output**: sRGB hex/rgb
95 | - **Wide Gamut**: Display P3
96 |
97 | ## Common Tasks
98 |
99 | ### Adding a New Color Format
100 |
101 | 1. Update `ColorFormat` enum in `preferences.ts`
102 | 2. Add conversion logic to `TextFormat.tsx` (both serialization and deserialization)
103 | 3. Add format detection to `detectColorFormat()` in `TextFormat.tsx`
104 |
105 | ### Modifying Color Defaults
106 |
107 | 1. Edit the default values in `solarized.ts` (search for `base03`, `yellow`, etc.)
108 | 2. Colors are defined in LAB space with L, a, b components
109 |
110 | ### Adding New UI Controls
111 |
112 | 1. Add new atom to `state.ts` if state is needed
113 | 2. Create component in `src/components/`
114 | 3. Import and use in `App.tsx` layout grid
115 | 4. Follow existing patterns: Mantine Paper/Card, glassmorphic styling
116 |
117 | ### Changing Color Editing Behavior
118 |
119 | - Modify `SolarizedTheme.withColorUpdate()` in `solarized.ts`
120 | - This method handles preference-aware color updates (linked hues, etc.)
121 |
122 | ### Adding a New Export Format
123 |
124 | 1. Create a new file in `src/outputs/` (e.g., `myformat.ts`)
125 | 2. Define your exporter function(s) that take a `SolarizedTheme` and return a string
126 | 3. Create `ExporterConfig` object(s) with metadata (id, name, description, fileExtension, fileName)
127 | 4. Export your exporter(s) from the file
128 | 5. Import and add to `EXPORTER_CATEGORIES` in `src/outputs/index.ts`
129 |
130 | Example structure:
131 |
132 | ```typescript
133 | import type { SolarizedTheme } from '../solarized';
134 | import type { ExporterConfig } from './types';
135 |
136 | function exportMyFormat(theme: SolarizedTheme): string {
137 | // Convert theme colors to your format
138 | return `formatted output`;
139 | }
140 |
141 | export const myFormatExporter: ExporterConfig = {
142 | id: 'myformat',
143 | name: 'My Format',
144 | description: 'Description of the format',
145 | fileExtension: 'ext',
146 | fileName: 'theme.ext',
147 | export: exportMyFormat,
148 | };
149 | ```
150 |
151 | ## Important Files Reference
152 |
153 | ### Core Logic
154 |
155 | - `src/solarized.ts:1-400` - `SolarizedTheme` class, color slots, APCA contrast
156 | - `src/state.ts:1-100` - All Jotai atoms and storage configuration
157 | - `src/colorconversion.js:1-50` - OKHSL/RGB conversion utilities
158 | - `src/outputs/` - Export format implementations (12+ formats)
159 |
160 | ### Main Components
161 |
162 | - `src/App.tsx:30-150` - Layout grid, component composition
163 | - `src/components/ThemeDisplay.tsx:1-300` - Color grid, reset button, preference toggles
164 | - `src/components/SingleColorEditorCard.tsx:1-200` - Color editor with 3 sliders
165 | - `src/components/PreviewCard.tsx:1-250` - Dark/light preview, font size control
166 | - `src/components/OutputCard.tsx:1-150` - Export UI with copy/download functionality
167 |
168 | ### Configuration
169 |
170 | - `package.json` - Dependencies, scripts, Node version
171 | - `vite.config.ts` - Vite configuration (allows all hosts)
172 | - `tsconfig.json` - TypeScript strict mode, path aliases
173 | - `eslint.config.js` - Linting rules, import sorting
174 |
175 | ## Code Conventions
176 |
177 | ### Naming
178 |
179 | - React components: PascalCase (`ThemeDisplay.tsx`)
180 | - Atoms: camelCase with "Atom" suffix (`themeAtom`, `selectedColorAtom`)
181 | - Color names: lowercase (`base03`, `yellow`)
182 | - CSS modules: ComponentName.module.css
183 |
184 | ### Immutability
185 |
186 | - `SolarizedTheme` class is **immutable** - all updates return new instances
187 | - Use `theme.withColorUpdate()` or `theme.withColor()` to create modified themes
188 | - Never mutate color objects directly
189 |
190 | ### Type Safety
191 |
192 | - Enable strict TypeScript checks
193 | - Use Zod schemas for runtime validation (see `TextFormat.tsx`)
194 | - Prefer explicit types over inference for public APIs
195 |
196 | ### Styling
197 |
198 | - Use Mantine components where possible
199 | - CSS modules for component-specific styles
200 | - CSS-in-JS for dynamic color-dependent styles
201 | - Follow existing glassmorphic design (backdrop-blur, translucent backgrounds)
202 |
203 | ## Development Workflow
204 |
205 | ```bash
206 | # Setup
207 | nvm use # Use Node 22 (from .nvmrc)
208 | npm install # Install dependencies
209 |
210 | # Development
211 | npm run dev # Start dev server (http://localhost:5173)
212 | npm run lint # Check code quality
213 | npm run lint:fix # Auto-fix linting issues
214 |
215 | # Build
216 | npm run build # Production build → dist/
217 | npm run preview # Preview production build
218 | ```
219 |
220 | ### Pre-commit Hooks
221 |
222 | Husky runs `npm run lint` before every commit. Fix issues with `npm run lint:fix`.
223 |
224 | ## Gotchas & Important Notes
225 |
226 | ### Color Object Serialization
227 |
228 | The `Color` class from colorjs.io doesn't serialize to JSON automatically. Custom serialization in `state.ts` handles this:
229 |
230 | ```typescript
231 | storage: createJSONStorage(() => localStorage, {
232 | reviver: (key, value) => {
233 | // Deserializes {space, coords} back to Color
234 | },
235 | replacer: (key, value) => {
236 | // Serializes Color to {space, coords}
237 | },
238 | });
239 | ```
240 |
241 | ### OKLCH in CSS
242 |
243 | When generating OKLCH CSS, chroma values may need clamping for sRGB gamut:
244 |
245 | - Use `color.to('srgb').to('oklch')` for gamut mapping
246 | - Or use `@supports (color: oklch(0 0 0))` and provide sRGB fallback
247 |
248 | ### Contrast Calculation
249 |
250 | APCA contrast is **directional** (text-on-background ≠ background-on-text):
251 |
252 | ```typescript
253 | // Correct usage in solarized.ts
254 | APCAcontrast(fgRgbArray, bgRgbArray); // Foreground first!
255 | ```
256 |
257 | ### State Updates with Preferences
258 |
259 | When updating colors via `singleColorAtomFamily`, the update automatically propagates to linked colors based on preferences. Don't manually update multiple colors - let the preference system handle it.
260 |
261 | ### Color Clamping
262 |
263 | OKHSL can produce out-of-gamut colors. The codebase uses:
264 |
265 | - `clampChroma()` - Reduce chroma until color fits in sRGB
266 | - `roundColor()` - Round coordinates to reduce precision
267 |
268 | ### React 19 Notes
269 |
270 | This project uses React 19, which has breaking changes from 18:
271 |
272 | - StrictMode is enabled (components mount twice in dev)
273 | - Some deprecated APIs removed (check React 19 changelog if upgrading deps)
274 |
275 | ## Accessibility Considerations
276 |
277 | - All accent colors show APCA contrast against both dark and light backgrounds
278 | - Target contrast: Lc 60+ for body text, Lc 90+ for small text
279 | - Foreground color on colored backgrounds uses `theme.foregroundOn()` for safe contrast
280 | - Font size adjustable in preview (accessibility testing)
281 |
282 | ## Extending the Project
283 |
284 | ### Adding New Theme Slots
285 |
286 | 1. Add new slot to `ColorSlot` enum in `solarized.ts`
287 | 2. Update `slotToColorName()` mapping
288 | 3. Add new slot usage in components (e.g., `ThemeDisplay.tsx`, `PreviewCard.tsx`)
289 |
290 | ### Supporting New Color Spaces
291 |
292 | 1. Add to colorjs.io's supported spaces (check documentation)
293 | 2. Update `ColorFormat` enum in `preferences.ts`
294 | 3. Add serialization/deserialization in `TextFormat.tsx`
295 | 4. Update slider overlays if needed (`OKHueSlider.tsx`)
296 |
297 | ### Persistence Beyond localStorage
298 |
299 | Replace `createJSONStorage(() => localStorage)` in `state.ts` with custom storage:
300 |
301 | - IndexedDB for larger data
302 | - URL parameters for sharing
303 | - Server sync for multi-device
304 |
305 | ### Adding Export Formats for New Platforms
306 |
307 | The export system in `src/outputs/` is designed for extensibility:
308 |
309 | 1. **Terminal Emulators**: Follow the pattern in `ghostty.ts`, `kitty.ts`, etc.
310 | - ANSI color mappings (0-15)
311 | - Background, foreground, cursor colors
312 | - Selection colors
313 |
314 | 2. **Code Editors**: Follow the pattern in `vscode.ts`
315 | - UI colors (sidebar, editor, statusbar)
316 | - Syntax highlighting token scopes
317 | - Terminal ANSI colors
318 |
319 | 3. **Data Formats**: Follow the pattern in `json.ts`
320 | - Multiple representations (hex, rgb, oklch)
321 | - ANSI color mappings for terminal use
322 |
323 | All exporters receive a `SolarizedTheme` instance and use helper functions like:
324 |
325 | - `theme.get(colorName)` - Get a Color object
326 | - `.to('srgb').toString({ format: 'hex' })` - Convert to hex
327 | - `theme.darkBg`, `theme.lightFg`, etc. - Access semantic color slots
328 |
329 | ## Performance Notes
330 |
331 | - Color calculations are memoized where possible
332 | - Jotai's atomic updates prevent unnecessary re-renders
333 | - Slider interactions are throttled (check `ColorSlider.tsx`)
334 | - CSS gradients generated once and cached in component state
335 |
336 | ## Testing Strategy (Not Yet Implemented)
337 |
338 | Consider adding:
339 |
340 | - Unit tests for `solarized.ts` color logic
341 | - Integration tests for preference-linked updates
342 | - Visual regression tests for color output
343 | - Accessibility tests (contrast ratios, keyboard navigation)
344 |
345 | ## Resources
346 |
347 | - [Solarized Color Scheme](https://ethanschoonover.com/solarized/)
348 | - [OKLCH Color Space](https://oklch.com/)
349 | - [APCA Contrast](https://git.apcacontrast.com/)
350 | - [colorjs.io Docs](https://colorjs.io/)
351 | - [Jotai Documentation](https://jotai.org/)
352 | - [Mantine UI](https://mantine.dev/)
353 |
354 | ---
355 |
356 | **Last Updated:** Generated for onboarding purposes on 2025-11-09
357 |
--------------------------------------------------------------------------------
/src/colorconversion.js:
--------------------------------------------------------------------------------
1 | /*
2 | This file was adapted from Björn Ottosson's OKHSV Color Picker implementation
3 |
4 | Copyright (c) 2021 Björn Ottosson
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy of
7 | this software and associated documentation files (the "Software"), to deal in
8 | the Software without restriction, including without limitation the rights to
9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 | of the Software, and to permit persons to whom the Software is furnished to do
11 | so, subject to the following conditions:
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 | */
22 |
23 | function srgb_transfer_function(a) {
24 | return 0.0031308 >= a
25 | ? 12.92 * a
26 | : 1.055 * Math.pow(a, 0.4166666666666667) - 0.055;
27 | }
28 |
29 | function srgb_transfer_function_inv(a) {
30 | return 0.04045 < a ? Math.pow((a + 0.055) / 1.055, 2.4) : a / 12.92;
31 | }
32 |
33 | function linear_srgb_to_oklab(r, g, b) {
34 | let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
35 | let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
36 | let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
37 |
38 | let l_ = Math.cbrt(l);
39 | let m_ = Math.cbrt(m);
40 | let s_ = Math.cbrt(s);
41 |
42 | return [
43 | 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
44 | 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
45 | 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
46 | ];
47 | }
48 |
49 | function oklab_to_linear_srgb(L, a, b) {
50 | let l_ = L + 0.3963377774 * a + 0.2158037573 * b;
51 | let m_ = L - 0.1055613458 * a - 0.0638541728 * b;
52 | let s_ = L - 0.0894841775 * a - 1.291485548 * b;
53 |
54 | let l = l_ * l_ * l_;
55 | let m = m_ * m_ * m_;
56 | let s = s_ * s_ * s_;
57 |
58 | return [
59 | +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
60 | -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
61 | -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
62 | ];
63 | }
64 |
65 | function toe(x) {
66 | const k_1 = 0.206;
67 | const k_2 = 0.03;
68 | const k_3 = (1 + k_1) / (1 + k_2);
69 |
70 | return (
71 | 0.5 *
72 | (k_3 * x -
73 | k_1 +
74 | Math.sqrt((k_3 * x - k_1) * (k_3 * x - k_1) + 4 * k_2 * k_3 * x))
75 | );
76 | }
77 |
78 | function toe_inv(x) {
79 | const k_1 = 0.206;
80 | const k_2 = 0.03;
81 | const k_3 = (1 + k_1) / (1 + k_2);
82 | return (x * x + k_1 * x) / (k_3 * (x + k_2));
83 | }
84 |
85 | // Finds the maximum saturation possible for a given hue that fits in sRGB
86 | // Saturation here is defined as S = C/L
87 | // a and b must be normalized so a^2 + b^2 == 1
88 | function compute_max_saturation(a, b) {
89 | // Max saturation will be when one of r, g or b goes below zero.
90 |
91 | // Select different coefficients depending on which component goes below zero first
92 | let k0, k1, k2, k3, k4, wl, wm, ws;
93 |
94 | if (-1.88170328 * a - 0.80936493 * b > 1) {
95 | // Red component
96 | k0 = +1.19086277;
97 | k1 = +1.76576728;
98 | k2 = +0.59662641;
99 | k3 = +0.75515197;
100 | k4 = +0.56771245;
101 | wl = +4.0767416621;
102 | wm = -3.3077115913;
103 | ws = +0.2309699292;
104 | } else if (1.81444104 * a - 1.19445276 * b > 1) {
105 | // Green component
106 | k0 = +0.73956515;
107 | k1 = -0.45954404;
108 | k2 = +0.08285427;
109 | k3 = +0.1254107;
110 | k4 = +0.14503204;
111 | wl = -1.2684380046;
112 | wm = +2.6097574011;
113 | ws = -0.3413193965;
114 | } else {
115 | // Blue component
116 | k0 = +1.35733652;
117 | k1 = -0.00915799;
118 | k2 = -1.1513021;
119 | k3 = -0.50559606;
120 | k4 = +0.00692167;
121 | wl = -0.0041960863;
122 | wm = -0.7034186147;
123 | ws = +1.707614701;
124 | }
125 |
126 | // Approximate max saturation using a polynomial:
127 | let S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
128 |
129 | // Do one step Halley's method to get closer
130 | // this gives an error less than 10e6, except for some blue hues where the dS/dh is close to infinite
131 | // this should be sufficient for most applications, otherwise do two/three steps
132 |
133 | let k_l = +0.3963377774 * a + 0.2158037573 * b;
134 | let k_m = -0.1055613458 * a - 0.0638541728 * b;
135 | let k_s = -0.0894841775 * a - 1.291485548 * b;
136 |
137 | {
138 | let l_ = 1 + S * k_l;
139 | let m_ = 1 + S * k_m;
140 | let s_ = 1 + S * k_s;
141 |
142 | let l = l_ * l_ * l_;
143 | let m = m_ * m_ * m_;
144 | let s = s_ * s_ * s_;
145 |
146 | let l_dS = 3 * k_l * l_ * l_;
147 | let m_dS = 3 * k_m * m_ * m_;
148 | let s_dS = 3 * k_s * s_ * s_;
149 |
150 | let l_dS2 = 6 * k_l * k_l * l_;
151 | let m_dS2 = 6 * k_m * k_m * m_;
152 | let s_dS2 = 6 * k_s * k_s * s_;
153 |
154 | let f = wl * l + wm * m + ws * s;
155 | let f1 = wl * l_dS + wm * m_dS + ws * s_dS;
156 | let f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2;
157 |
158 | S = S - (f * f1) / (f1 * f1 - 0.5 * f * f2);
159 | }
160 |
161 | return S;
162 | }
163 |
164 | function find_cusp(a, b) {
165 | // First, find the maximum saturation (saturation S = C/L)
166 | let S_cusp = compute_max_saturation(a, b);
167 |
168 | // Convert to linear sRGB to find the first point where at least one of r,g or b >= 1:
169 | let rgb_at_max = oklab_to_linear_srgb(1, S_cusp * a, S_cusp * b);
170 | let L_cusp = Math.cbrt(
171 | 1 / Math.max(Math.max(rgb_at_max[0], rgb_at_max[1]), rgb_at_max[2])
172 | );
173 | let C_cusp = L_cusp * S_cusp;
174 |
175 | return [L_cusp, C_cusp];
176 | }
177 |
178 | // Finds intersection of the line defined by
179 | // L = L0 * (1 - t) + t * L1;
180 | // C = t * C1;
181 | // a and b must be normalized so a^2 + b^2 == 1
182 | function find_gamut_intersection(a, b, L1, C1, L0, cusp = null) {
183 | if (!cusp) {
184 | // Find the cusp of the gamut triangle
185 | cusp = find_cusp(a, b);
186 | }
187 |
188 | // Find the intersection for upper and lower half seprately
189 | let t;
190 | if ((L1 - L0) * cusp[1] - (cusp[0] - L0) * C1 <= 0) {
191 | // Lower half
192 |
193 | t = (cusp[1] * L0) / (C1 * cusp[0] + cusp[1] * (L0 - L1));
194 | } else {
195 | // Upper half
196 |
197 | // First intersect with triangle
198 | t = (cusp[1] * (L0 - 1)) / (C1 * (cusp[0] - 1) + cusp[1] * (L0 - L1));
199 |
200 | // Then one step Halley's method
201 | {
202 | let dL = L1 - L0;
203 | let dC = C1;
204 |
205 | let k_l = +0.3963377774 * a + 0.2158037573 * b;
206 | let k_m = -0.1055613458 * a - 0.0638541728 * b;
207 | let k_s = -0.0894841775 * a - 1.291485548 * b;
208 |
209 | let l_dt = dL + dC * k_l;
210 | let m_dt = dL + dC * k_m;
211 | let s_dt = dL + dC * k_s;
212 |
213 | // If higher accuracy is required, 2 or 3 iterations of the following block can be used:
214 | {
215 | let L = L0 * (1 - t) + t * L1;
216 | let C = t * C1;
217 |
218 | let l_ = L + C * k_l;
219 | let m_ = L + C * k_m;
220 | let s_ = L + C * k_s;
221 |
222 | let l = l_ * l_ * l_;
223 | let m = m_ * m_ * m_;
224 | let s = s_ * s_ * s_;
225 |
226 | let ldt = 3 * l_dt * l_ * l_;
227 | let mdt = 3 * m_dt * m_ * m_;
228 | let sdt = 3 * s_dt * s_ * s_;
229 |
230 | let ldt2 = 6 * l_dt * l_dt * l_;
231 | let mdt2 = 6 * m_dt * m_dt * m_;
232 | let sdt2 = 6 * s_dt * s_dt * s_;
233 |
234 | let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1;
235 | let r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt;
236 | let r2 =
237 | 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2;
238 |
239 | let u_r = r1 / (r1 * r1 - 0.5 * r * r2);
240 | let t_r = -r * u_r;
241 |
242 | let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1;
243 | let g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt;
244 | let g2 =
245 | -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2;
246 |
247 | let u_g = g1 / (g1 * g1 - 0.5 * g * g2);
248 | let t_g = -g * u_g;
249 |
250 | let b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s - 1;
251 | let b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.707614701 * sdt;
252 | let b2 =
253 | -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.707614701 * sdt2;
254 |
255 | let u_b = b1 / (b1 * b1 - 0.5 * b * b2);
256 | let t_b = -b * u_b;
257 |
258 | t_r = u_r >= 0 ? t_r : 10e5;
259 | t_g = u_g >= 0 ? t_g : 10e5;
260 | t_b = u_b >= 0 ? t_b : 10e5;
261 |
262 | t += Math.min(t_r, Math.min(t_g, t_b));
263 | }
264 | }
265 | }
266 |
267 | return t;
268 | }
269 |
270 | function get_ST_max(a_, b_, cusp = null) {
271 | if (!cusp) {
272 | cusp = find_cusp(a_, b_);
273 | }
274 |
275 | let L = cusp[0];
276 | let C = cusp[1];
277 | return [C / L, C / (1 - L)];
278 | }
279 |
280 | function get_ST_mid(a_, b_) {
281 | S =
282 | 0.11516993 +
283 | 1 /
284 | (+7.4477897 +
285 | 4.1590124 * b_ +
286 | a_ *
287 | (-2.19557347 +
288 | 1.75198401 * b_ +
289 | a_ *
290 | (-2.13704948 -
291 | 10.02301043 * b_ +
292 | a_ * (-4.24894561 + 5.38770819 * b_ + 4.69891013 * a_))));
293 |
294 | T =
295 | 0.11239642 +
296 | 1 /
297 | (+1.6132032 -
298 | 0.68124379 * b_ +
299 | a_ *
300 | (+0.40370612 +
301 | 0.90148123 * b_ +
302 | a_ *
303 | (-0.27087943 +
304 | 0.6122399 * b_ +
305 | a_ * (+0.00299215 - 0.45399568 * b_ - 0.14661872 * a_))));
306 |
307 | return [S, T];
308 | }
309 |
310 | function get_Cs(L, a_, b_) {
311 | cusp = find_cusp(a_, b_);
312 |
313 | let C_max = find_gamut_intersection(a_, b_, L, 1, L, cusp);
314 | let ST_max = get_ST_max(a_, b_, cusp);
315 |
316 | let S_mid =
317 | 0.11516993 +
318 | 1 /
319 | (+7.4477897 +
320 | 4.1590124 * b_ +
321 | a_ *
322 | (-2.19557347 +
323 | 1.75198401 * b_ +
324 | a_ *
325 | (-2.13704948 -
326 | 10.02301043 * b_ +
327 | a_ * (-4.24894561 + 5.38770819 * b_ + 4.69891013 * a_))));
328 |
329 | let T_mid =
330 | 0.11239642 +
331 | 1 /
332 | (+1.6132032 -
333 | 0.68124379 * b_ +
334 | a_ *
335 | (+0.40370612 +
336 | 0.90148123 * b_ +
337 | a_ *
338 | (-0.27087943 +
339 | 0.6122399 * b_ +
340 | a_ * (+0.00299215 - 0.45399568 * b_ - 0.14661872 * a_))));
341 |
342 | let k = C_max / Math.min(L * ST_max[0], (1 - L) * ST_max[1]);
343 |
344 | let C_mid;
345 | {
346 | let C_a = L * S_mid;
347 | let C_b = (1 - L) * T_mid;
348 |
349 | C_mid =
350 | 0.9 *
351 | k *
352 | Math.sqrt(
353 | Math.sqrt(
354 | 1 / (1 / (C_a * C_a * C_a * C_a) + 1 / (C_b * C_b * C_b * C_b))
355 | )
356 | );
357 | }
358 |
359 | let C_0;
360 | {
361 | let C_a = L * 0.4;
362 | let C_b = (1 - L) * 0.8;
363 |
364 | C_0 = Math.sqrt(1 / (1 / (C_a * C_a) + 1 / (C_b * C_b)));
365 | }
366 |
367 | return [C_0, C_mid, C_max];
368 | }
369 |
370 | export function okhsl_to_srgb(h, s, l) {
371 | if (l == 1) {
372 | return [255, 255, 255];
373 | } else if (l == 0) {
374 | return [0, 0, 0];
375 | }
376 |
377 | let a_ = Math.cos(2 * Math.PI * h);
378 | let b_ = Math.sin(2 * Math.PI * h);
379 | let L = toe_inv(l);
380 |
381 | let Cs = get_Cs(L, a_, b_);
382 | let C_0 = Cs[0];
383 | let C_mid = Cs[1];
384 | let C_max = Cs[2];
385 |
386 | let C, t, k_0, k_1, k_2;
387 | if (s < 0.8) {
388 | t = 1.25 * s;
389 | k_0 = 0;
390 | k_1 = 0.8 * C_0;
391 | k_2 = 1 - k_1 / C_mid;
392 | } else {
393 | t = 5 * (s - 0.8);
394 | k_0 = C_mid;
395 | k_1 = (0.2 * C_mid * C_mid * 1.25 * 1.25) / C_0;
396 | k_2 = 1 - k_1 / (C_max - C_mid);
397 | }
398 |
399 | C = k_0 + (t * k_1) / (1 - k_2 * t);
400 |
401 | // If we would only use one of the Cs:
402 | //C = s*C_0;
403 | //C = s*1.25*C_mid;
404 | //C = s*C_max;
405 |
406 | let rgb = oklab_to_linear_srgb(L, C * a_, C * b_);
407 | return [
408 | 255 * srgb_transfer_function(rgb[0]),
409 | 255 * srgb_transfer_function(rgb[1]),
410 | 255 * srgb_transfer_function(rgb[2]),
411 | ];
412 | }
413 |
414 | export function srgb_to_okhsl(r, g, b) {
415 | let lab = linear_srgb_to_oklab(
416 | srgb_transfer_function_inv(r / 255),
417 | srgb_transfer_function_inv(g / 255),
418 | srgb_transfer_function_inv(b / 255)
419 | );
420 |
421 | let C = Math.sqrt(lab[1] * lab[1] + lab[2] * lab[2]);
422 | let a_ = lab[1] / C;
423 | let b_ = lab[2] / C;
424 |
425 | let L = lab[0];
426 | let h = 0.5 + (0.5 * Math.atan2(-lab[2], -lab[1])) / Math.PI;
427 |
428 | let Cs = get_Cs(L, a_, b_);
429 | let C_0 = Cs[0];
430 | let C_mid = Cs[1];
431 | let C_max = Cs[2];
432 |
433 | let s;
434 | if (C < C_mid) {
435 | let k_0 = 0;
436 | let k_1 = 0.8 * C_0;
437 | let k_2 = 1 - k_1 / C_mid;
438 |
439 | let t = (C - k_0) / (k_1 + k_2 * (C - k_0));
440 | s = t * 0.8;
441 | } else {
442 | let k_0 = C_mid;
443 | let k_1 = (0.2 * C_mid * C_mid * 1.25 * 1.25) / C_0;
444 | let k_2 = 1 - k_1 / (C_max - C_mid);
445 |
446 | let t = (C - k_0) / (k_1 + k_2 * (C - k_0));
447 | s = 0.8 + 0.2 * t;
448 | }
449 |
450 | let l = toe(L);
451 | return [h, s, l];
452 | }
453 |
454 | export function okhsv_to_srgb(h, s, v) {
455 | let a_ = Math.cos(2 * Math.PI * h);
456 | let b_ = Math.sin(2 * Math.PI * h);
457 |
458 | let ST_max = get_ST_max(a_, b_);
459 | let S_max = ST_max[0];
460 | let S_0 = 0.5;
461 | let T = ST_max[1];
462 | let k = 1 - S_0 / S_max;
463 |
464 | let L_v = 1 - (s * S_0) / (S_0 + T - T * k * s);
465 | let C_v = (s * T * S_0) / (S_0 + T - T * k * s);
466 |
467 | let L = v * L_v;
468 | let C = v * C_v;
469 |
470 | // to present steps along the way
471 | //L = v;
472 | //C = v*s*S_max;
473 | //L = v*(1 - s*S_max/(S_max+T));
474 | //C = v*s*S_max*T/(S_max+T);
475 |
476 | let L_vt = toe_inv(L_v);
477 | let C_vt = (C_v * L_vt) / L_v;
478 |
479 | let L_new = toe_inv(L); // * L_v/L_vt;
480 | C = (C * L_new) / L;
481 | L = L_new;
482 |
483 | let rgb_scale = oklab_to_linear_srgb(L_vt, a_ * C_vt, b_ * C_vt);
484 | let scale_L = Math.cbrt(
485 | 1 / Math.max(rgb_scale[0], rgb_scale[1], rgb_scale[2], 0)
486 | );
487 |
488 | // remove to see effect without rescaling
489 | L = L * scale_L;
490 | C = C * scale_L;
491 |
492 | let rgb = oklab_to_linear_srgb(L, C * a_, C * b_);
493 | return [
494 | 255 * srgb_transfer_function(rgb[0]),
495 | 255 * srgb_transfer_function(rgb[1]),
496 | 255 * srgb_transfer_function(rgb[2]),
497 | ];
498 | }
499 |
500 | export function srgb_to_okhsv(r, g, b) {
501 | let lab = linear_srgb_to_oklab(
502 | srgb_transfer_function_inv(r / 255),
503 | srgb_transfer_function_inv(g / 255),
504 | srgb_transfer_function_inv(b / 255)
505 | );
506 |
507 | let C = Math.sqrt(lab[1] * lab[1] + lab[2] * lab[2]);
508 | let a_ = lab[1] / C;
509 | let b_ = lab[2] / C;
510 |
511 | let L = lab[0];
512 | let h = 0.5 + (0.5 * Math.atan2(-lab[2], -lab[1])) / Math.PI;
513 |
514 | let ST_max = get_ST_max(a_, b_);
515 | let S_max = ST_max[0];
516 | let S_0 = 0.5;
517 | let T = ST_max[1];
518 | let k = 1 - S_0 / S_max;
519 |
520 | const t = T / (C + L * T);
521 | let L_v = t * L;
522 | let C_v = t * C;
523 |
524 | const L_vt = toe_inv(L_v);
525 | const C_vt = (C_v * L_vt) / L_v;
526 |
527 | const rgb_scale = oklab_to_linear_srgb(L_vt, a_ * C_vt, b_ * C_vt);
528 | const scale_L = Math.cbrt(
529 | 1 / Math.max(rgb_scale[0], rgb_scale[1], rgb_scale[2], 0)
530 | );
531 |
532 | L = L / scale_L;
533 | C = C / scale_L;
534 |
535 | C = (C * toe(L)) / L;
536 | L = toe(L);
537 |
538 | const v = L / L_v;
539 | const s = ((S_0 + T) * C_v) / (T * S_0 + T * k * C_v);
540 |
541 | return [h, s, v];
542 | }
543 |
--------------------------------------------------------------------------------