├── .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 | 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 | 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 | --------------------------------------------------------------------------------