Start of themes

This commit is contained in:
Gregory Schier
2023-03-07 11:24:38 -08:00
parent c0d7962142
commit db2d786d50
24 changed files with 1490 additions and 156 deletions

1064
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,9 @@
"preview": "vite preview",
"tauri-dev": "concurrently -n app,rsw \"tauri dev\" \"rsw watch\"",
"tauri-build": "npm run build:icon && tauri build",
"build:icon": "tauri icon src-tauri/icons/icon.png"
"build:icon": "tauri icon src-tauri/icons/icon.png",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@codemirror/commands": "^6.2.1",
@@ -34,6 +36,7 @@
"classnames": "^2.3.2",
"codemirror": "^6.0.1",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",
"parse-json": "^6.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -43,6 +46,7 @@
"devDependencies": {
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
@@ -63,6 +67,7 @@
"typescript": "^4.6.4",
"vite": "^4.0.0",
"vite-plugin-rsw": "^2.0.11",
"vite-plugin-top-level-await": "^1.2.4"
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
}
}

View File

@@ -33,21 +33,21 @@ function App() {
<div className="grid grid-rows-[auto_1fr] h-full overflow-hidden">
<HStack
as={WindowDragRegion}
className="px-3 bg-gray-50/50 text-sm text-gray-900 border-b border-b-gray-50 pt-[1px]"
className="px-3 bg-background text-sm text-gray-900 border-b border-b-gray-100 pt-[1px]"
items="center"
>
{request.name}
</HStack>
<div
className={classnames(
'bg-gray-25 grid overflow-auto',
'grid overflow-auto',
isH ? 'grid-cols-[1fr_1fr]' : 'grid-rows-[minmax(0,auto)_minmax(0,100%)]',
)}
>
<RequestPane
fullHeight={isH}
request={request}
className={classnames(isH ? 'pr-0' : 'pb-3 mb-1')}
className={classnames(!isH && 'pr-2 pb-3 mb-1')}
/>
<ResponsePane requestId={request.id} />
</div>

View File

@@ -10,7 +10,8 @@ import { Icon } from './Icon';
const colorStyles = {
default: 'hover:bg-gray-500/10 text-gray-600',
gray: 'text-gray-800 bg-gray-50 hover:bg-gray-500/20',
gray: 'text-gray-800 bg-gray-100 hover:bg-gray-500/20',
tint: 'text-white/90 hover:text-white hover:bg-white/20',
primary: 'bg-blue-400',
secondary: 'bg-violet-400',
warning: 'bg-orange-400',
@@ -45,7 +46,9 @@ export const Button = forwardRef(function Button<T extends ElementType>(
type="button"
className={classnames(
className,
'transition-all rounded-md flex items-center bg-opacity-80 hover:bg-opacity-100 hover:text-white',
'outline-none', // TODO: Add focus styles
'border border-transparent focus-visible:border-blue-300',
'transition-all rounded-md flex items-center hover:text-white',
// 'active:translate-y-[0.5px] active:scale-[0.99]',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',
@@ -57,7 +60,7 @@ export const Button = forwardRef(function Button<T extends ElementType>(
{...props}
>
{children}
{forDropdown && <Icon icon="triangle-down" className="ml-1 -mr-1" />}
{forDropdown && <Icon icon="triangleDown" className="ml-1 -mr-1" />}
</Component>
);
});

View File

@@ -264,11 +264,7 @@ function DropdownMenuSeparator({ className, ...props }: D.DropdownMenuSeparatorP
function DropdownMenuTrigger({ children, className, ...props }: D.DropdownMenuTriggerProps) {
return (
<D.Trigger
asChild
className={classnames(className, 'focus:outline-none focus:border-0 focus:shadow-none')}
{...props}
>
<D.Trigger asChild className={classnames(className)} {...props}>
{children}
</D.Trigger>
);
@@ -290,7 +286,7 @@ const ItemInner = forwardRef<HTMLDivElement, ItemInnerProps>(function ItemInner(
ref={ref}
className={classnames(
className,
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700',
'outline-none px-2 py-1.5 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
!noHover && 'focus:bg-gray-50 focus:text-gray-900 rounded',
)}
{...props}

View File

@@ -12,7 +12,7 @@
}
.cm-editor {
@apply bg-background w-full block text-[0.85rem];
@apply w-full block text-[0.85rem];
&.cm-focused {
outline: none !important;
@@ -26,6 +26,18 @@
@apply text-placeholder;
}
.cm-gutters {
@apply border-0 text-gray-500 text-opacity-30;
.cm-gutterElement {
@apply cursor-default;
}
}
&.cm-focused .cm-gutters,
.cm-gutters:hover {
@apply text-opacity-60;
}
.placeholder-widget {
@apply text-xs text-white/90 bg-blue-400/80 py-[0.5px] px-1 mx-[1px] rounded cursor-default hover:bg-blue-400 hover:text-white;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.9);
@@ -69,10 +81,6 @@
align-items: center !important;
}
.cm-editor .cm-gutters {
@apply bg-background border-0 text-gray-200;
}
.cm-editor .cm-gutterElement {
transition: color var(--transition-duration);
}
@@ -98,10 +106,6 @@
@apply text-gray-400 bg-gray-100/20;
}
.cm-editor.cm-focused .cm-gutters {
@apply text-gray-300;
}
.cm-editor .cm-foldPlaceholder {
@apply px-2 border border-gray-200 bg-gray-100;
}

View File

@@ -1,19 +1,17 @@
import { defaultKeymap } from '@codemirror/commands';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import { EditorView } from 'codemirror';
import type { CSSProperties, HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import { singleLineExt } from './singleLine';
export interface EditorProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
height?: 'auto' | 'full';
heightMode?: 'auto' | 'full';
contentType?: string;
backgroundColor?: string;
autoFocus?: boolean;
valueKey?: string | number;
defaultValue?: string;
@@ -25,9 +23,8 @@ export interface EditorProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onCha
}
export default function Editor({
height,
heightMode,
contentType,
backgroundColor,
autoFocus,
placeholder,
valueKey,
@@ -53,6 +50,18 @@ export default function Editor({
[contentType, ref.current],
);
const syncGutterBg = () => {
if (ref.current === null) return;
if (singleLine) return;
const gutterEl = ref.current.querySelector<HTMLDivElement>('.cm-gutters');
const bgClass = className
?.split(/\s+/)
.find((c) => c.startsWith('!bg-') || c.startsWith('bg-'));
if (bgClass && gutterEl) {
gutterEl?.classList.add(`${bgClass}`);
}
};
// Create codemirror instance when ref initializes
useEffect(() => {
if (ref.current === null) return;
@@ -69,6 +78,7 @@ export default function Editor({
parent: ref.current,
});
setCm({ view, langHolder });
syncGutterBg();
if (autoFocus && view) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
@@ -76,6 +86,10 @@ export default function Editor({
return () => view?.destroy();
}, [ref.current, valueKey]);
useEffect(() => {
syncGutterBg();
}, [ref.current, className]);
// Update value when valueKey changes
// TODO: This would be more efficient but the onChange handler gets fired on update
// useEffect(() => {
@@ -98,11 +112,10 @@ export default function Editor({
ref={ref}
className={classnames(
className,
'cm-wrapper text-base',
height === 'auto' ? 'cm-auto-height' : 'cm-full-height',
'cm-wrapper text-base bg-background',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
)}
data-color-background="var(--color-gray-50)"
{...props}
/>
);
@@ -158,24 +171,3 @@ function getExtensions({
}),
];
}
const newState = ({
langHolder,
contentType,
useTemplating,
defaultValue,
extensions,
}: {
langHolder: Compartment;
contentType?: string;
useTemplating?: boolean;
defaultValue?: string;
extensions: Extension[];
}) => {
console.log('NEW STATE', defaultValue);
const langExt = getLanguageExtension({ contentType, useTemplating });
return EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [...extensions, langHolder.of(langExt)],
});
};

View File

@@ -51,7 +51,7 @@ export const myHighlightStyle = HighlightStyle.define([
{ tag: [t.attributeName], color: 'hsl(var(--color-violet-600))' },
{ tag: [t.attributeValue], color: 'hsl(var(--color-orange-600))' },
{ tag: [t.string], color: 'hsl(var(--color-yellow-600))' },
{ tag: [t.keyword, t.meta, t.operator], color: '#45e8a4' },
{ tag: [t.keyword, t.meta, t.operator], color: 'hsl(var(--color-red-600))' },
]);
// export const defaultHighlightStyle = HighlightStyle.define([

View File

@@ -2,6 +2,7 @@ import {
ArchiveIcon,
CameraIcon,
CheckIcon,
ClockIcon,
CodeIcon,
Cross2Icon,
EyeOpenIcon,
@@ -15,6 +16,8 @@ import {
SunIcon,
TrashIcon,
TriangleDownIcon,
TriangleLeftIcon,
TriangleRightIcon,
UpdateIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
@@ -26,13 +29,16 @@ type IconName =
| 'camera'
| 'gear'
| 'eye'
| 'triangle-down'
| 'paper-plane'
| 'triangleDown'
| 'triangleLeft'
| 'triangleRight'
| 'paperPlane'
| 'update'
| 'question'
| 'check'
| 'plus'
| 'plus-circled'
| 'plusCircle'
| 'clock'
| 'sun'
| 'code'
| 'x'
@@ -40,13 +46,16 @@ type IconName =
| 'moon';
const icons: Record<IconName, NamedExoticComponent<{ className: string }>> = {
'paper-plane': PaperPlaneIcon,
'triangle-down': TriangleDownIcon,
paperPlane: PaperPlaneIcon,
triangleDown: TriangleDownIcon,
plus: PlusIcon,
'plus-circled': PlusCircledIcon,
plusCircle: PlusCircledIcon,
clock: ClockIcon,
archive: ArchiveIcon,
camera: CameraIcon,
check: CheckIcon,
triangleLeft: TriangleLeftIcon,
triangleRight: TriangleRightIcon,
gear: GearIcon,
home: HomeIcon,
update: UpdateIcon,

View File

@@ -5,18 +5,22 @@ import type { ButtonProps } from './Button';
import { Button } from './Button';
import classnames from 'classnames';
type Props = Omit<IconProps, 'size'> & ButtonProps<typeof Button>;
type Props = Omit<IconProps, 'size'> &
ButtonProps<typeof Button> & {
iconClassName?: string;
};
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
{ icon, spin, ...props }: Props,
{ icon, spin, className, iconClassName, ...props }: Props,
ref,
) {
return (
<Button ref={ref} className="group" {...props}>
<Button ref={ref} className={classnames(className, 'group')} {...props}>
<Icon
icon={icon}
spin={spin}
className={classnames(
iconClassName,
'text-gray-700 group-hover:text-gray-900',
props.disabled && 'opacity-70',
)}

View File

@@ -56,7 +56,7 @@ export function Input({
className={classnames(
containerClassName,
'relative w-full rounded-md text-gray-900 bg-gray-200/10',
'border border-gray-500/10 focus-within:border-blue-400/40',
'border border-gray-50 focus-within:border-blue-400/40',
size === 'md' && 'h-10',
size === 'sm' && 'h-8',
)}

View File

@@ -22,7 +22,7 @@ export function RequestPane({ fullHeight, request, className }: Props) {
>
<div className="pl-2">
<UrlBar
className="border-0 mb-1"
className="bg-transparent"
key={request.id}
method={request.method}
url={request.url}
@@ -36,28 +36,27 @@ export function RequestPane({ fullHeight, request, className }: Props) {
{/*<Divider className="mb-2" />*/}
<ScrollArea className="max-w-full pb-2 mx-2">
<HStack className="mt-2 hide-scrollbar" space={1}>
{['JSON', 'Params', 'Headers', 'Auth', 'Docs'].map((label, i) => (
{['JSON', 'Params', 'Headers', 'Auth'].map((label, i) => (
<Button
key={label}
size="xs"
color={i === 0 && 'gray'}
className={i !== 0 && 'opacity-50 hover:opacity-60'}
className={i !== 0 && 'opacity-80 hover:opacity-100'}
>
{label}
</Button>
))}
</HStack>
</ScrollArea>
<div className="px-0">
<Editor
height={fullHeight ? 'full' : 'auto'}
valueKey={request.id}
useTemplating
defaultValue={request.body ?? ''}
contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</div>
<Editor
className="mt-1 !bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
valueKey={request.id}
useTemplating
defaultValue={request.body ?? ''}
contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })}
/>
</div>
);
}

View File

@@ -46,7 +46,7 @@ export function ResponsePane({ requestId, className }: Props) {
<div
className={classnames(
className,
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 bg-gray-50/50 rounded-md',
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 bg-gray-100 rounded-md overflow-hidden border border-gray-50',
)}
>
{/*<HStack as={WindowDragRegion} items="center" className="pl-1.5 pr-1">*/}
@@ -58,7 +58,7 @@ export function ResponsePane({ requestId, className }: Props) {
<>
<HStack
items="center"
className="italic text-gray-500 text-sm w-full mb-1 flex-shrink-0 pl-2 py-1"
className="italic text-gray-500 text-sm w-full mb-1 flex-shrink-0 pl-2"
>
<div className="whitespace-nowrap">
{response.status}
@@ -68,7 +68,7 @@ export function ResponsePane({ requestId, className }: Props) {
{Math.round(response.body.length / 1000)} KB
</div>
<HStack items="center" className="ml-auto">
<HStack items="center" className="ml-auto h-8">
{contentType.includes('html') && (
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
@@ -97,7 +97,12 @@ export function ResponsePane({ requestId, className }: Props) {
})),
]}
>
<IconButton icon="gear" className="ml-auto" size="sm" />
<IconButton
icon="clock"
className="ml-auto"
iconClassName="text-gray-300"
size="sm"
/>
</Dropdown>
</HStack>
</HStack>
@@ -113,7 +118,7 @@ export function ResponsePane({ requestId, className }: Props) {
</div>
) : response?.body ? (
<Editor
backgroundColor="red"
className="mr-1 !bg-gray-100"
valueKey={`${contentType}:${response.body}`}
defaultValue={response?.body}
contentType={contentType}

View File

@@ -20,11 +20,14 @@ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
export function Sidebar({ className, activeRequestId, workspaceId, requests, ...props }: Props) {
const createRequest = useRequestCreate({ workspaceId, navigateAfter: true });
const { toggleTheme } = useTheme();
const { appearance, toggleAppearance } = useTheme();
const [open, setOpen] = useState<boolean>(false);
return (
<div
className={classnames(className, 'w-52 bg-gray-50 h-full border-gray-100/50 relative z-10')}
className={classnames(
className,
'w-52 bg-violet-600 dark:bg-violet-50 h-full border-gray-100/50 relative z-10',
)}
{...props}
>
<HStack as={WindowDragRegion} items="center" justify="end">
@@ -34,17 +37,14 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests, ...
Save
</Button>
</Dialog>
{/*<IconButton*/}
{/* size="sm"*/}
{/* icon="camera"*/}
{/* onClick={() => {*/}
{/* setOpen((v) => !v);*/}
{/* }}*/}
{/*/>*/}
<IconButton size="sm" icon="sun" onClick={toggleTheme} />
<IconButton
size="sm"
icon="plus-circled"
icon={appearance === 'dark' ? 'moon' : 'sun'}
onClick={toggleAppearance}
/>
<IconButton
size="sm"
icon="plusCircle"
onClick={async () => {
await createRequest.mutate({ name: 'Test Request' });
}}
@@ -54,6 +54,19 @@ export function Sidebar({ className, activeRequestId, workspaceId, requests, ...
{requests.map((r) => (
<SidebarItem key={r.id} request={r} active={r.id === activeRequestId} />
))}
<div>
<div className="w-10 h-5 bg-blue-50" />
<div className="w-10 h-5 bg-blue-100" />
<div className="w-10 h-5 bg-blue-200" />
<div className="w-10 h-5 bg-blue-300" />
<div className="w-10 h-5 bg-blue-400" />
<div className="w-10 h-5 bg-blue-500" />
<div className="w-10 h-5 bg-blue-600" />
<div className="w-10 h-5 bg-blue-700" />
<div className="w-10 h-5 bg-blue-800" />
<div className="w-10 h-5 bg-blue-900" />
<div className="w-10 h-5 bg-blue-950" />
</div>
</VStack>
</div>
);
@@ -64,12 +77,13 @@ function SidebarItem({ request, active }: { request: HttpRequest; active: boolea
<li key={request.id}>
<Button
as={Link}
color="tint"
to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
className={classnames('w-full', active && 'bg-gray-500/[0.1] text-gray-900')}
size="xs"
justify="start"
>
{request.name}
{request.name || request.url}
</Button>
</li>
);

View File

@@ -58,8 +58,8 @@ export function UrlBar({
<Button
type="button"
disabled={loading}
size="sm"
className="ml-1 mr-2 !px-2 !text-gray-800"
size="xs"
className="mx-0.5 !text-gray-800"
justify="start"
>
{method.toUpperCase()}
@@ -69,11 +69,11 @@ export function UrlBar({
rightSlot={
<IconButton
type="submit"
size="sm"
icon={loading ? 'update' : 'paper-plane'}
className="mr-0.5"
size="xs"
icon={loading ? 'update' : 'paperPlane'}
spin={loading}
disabled={loading}
className="mx-1 !px-4"
title="Send Request"
/>
}

View File

@@ -1,14 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { setTheme, subscribeToPreferredThemeChange, toggleTheme } from '../lib/theme';
import type { Appearance } from '../lib/theme/window';
import {
getAppearance,
setAppearance,
subscribeToPreferredAppearanceChange,
toggleAppearance,
} from '../lib/theme/window';
const appearanceQueryKey = ['theme', 'appearance'];
export default function useTheme() {
const queryClient = useQueryClient();
const appearance = useQuery({
queryKey: appearanceQueryKey,
queryFn: getAppearance,
initialData: getAppearance(),
});
const themeChange = (appearance: Appearance) => {
setAppearance(appearance);
};
const handleToggleAppearance = async () => {
const newAppearance = toggleAppearance();
await queryClient.setQueryData(appearanceQueryKey, newAppearance);
};
export default function useTheme(subscribeToChanges = true): { toggleTheme: () => void } {
useEffect(() => {
if (!subscribeToChanges) return;
const unsub = subscribeToPreferredThemeChange(setTheme);
return unsub;
}, [subscribeToChanges]);
return subscribeToPreferredAppearanceChange(themeChange);
}, []);
return {
toggleTheme: toggleTheme,
appearance: appearance.data,
toggleAppearance: handleToggleAppearance,
};
}

View File

@@ -29,7 +29,7 @@ export interface HttpResponse extends BaseModel {
requestId: string;
body: string;
error: string;
status: string;
status: number;
elapsed: number;
statusReason: string;
url: string;

View File

@@ -1,22 +0,0 @@
export type Theme = 'dark' | 'light';
export function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') ?? getPreferredTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
export function setTheme(theme?: Theme) {
document.documentElement.setAttribute('data-theme', theme ?? getPreferredTheme());
}
export function getPreferredTheme(): Theme {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function subscribeToPreferredThemeChange(cb: (theme: Theme) => void): () => void {
const listener = (e: MediaQueryListEvent) => cb(e.matches ? 'dark' : 'light');
const m = window.matchMedia('(prefers-color-scheme: dark)');
m.addEventListener('change', listener);
return () => m.removeEventListener('change', listener);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { generateColorVariant, toTailwindVariable } from './theme';
describe('suite name', () => {
it('Generates dark variants', () => {
expect(generateColorVariant('blue', 50, 'dark')).toEqual('hsl(240,100%,5.0%)');
expect(generateColorVariant('blue', 100, 'dark')).toEqual('hsl(240,100%,10.0%)');
expect(generateColorVariant('blue', 200, 'dark')).toEqual('hsl(240,100%,20.0%)');
expect(generateColorVariant('blue', 300, 'dark')).toEqual('hsl(240,100%,30.0%)');
expect(generateColorVariant('blue', 400, 'dark')).toEqual('hsl(240,100%,40.0%)');
expect(generateColorVariant('blue', 500, 'dark')).toEqual('hsl(240,100%,50.0%)');
expect(generateColorVariant('blue', 600, 'dark')).toEqual('hsl(240,100%,60.0%)');
expect(generateColorVariant('blue', 700, 'dark')).toEqual('hsl(240,100%,70.0%)');
expect(generateColorVariant('blue', 800, 'dark')).toEqual('hsl(240,100%,80.0%)');
expect(generateColorVariant('blue', 900, 'dark')).toEqual('hsl(240,100%,90.0%)');
expect(generateColorVariant('blue', 950, 'dark')).toEqual('hsl(240,100%,95.0%)');
});
it('Generates light variants', () => {
expect(generateColorVariant('blue', 50, 'light')).toEqual('hsl(240,100%,95.0%)');
expect(generateColorVariant('blue', 100, 'light')).toEqual('hsl(240,100%,90.0%)');
expect(generateColorVariant('blue', 200, 'light')).toEqual('hsl(240,100%,80.0%)');
expect(generateColorVariant('blue', 300, 'light')).toEqual('hsl(240,100%,70.0%)');
expect(generateColorVariant('blue', 400, 'light')).toEqual('hsl(240,100%,60.0%)');
expect(generateColorVariant('blue', 500, 'light')).toEqual('hsl(240,100%,50.0%)');
expect(generateColorVariant('blue', 600, 'light')).toEqual('hsl(240,100%,40.0%)');
expect(generateColorVariant('blue', 700, 'light')).toEqual('hsl(240,100%,30.0%)');
expect(generateColorVariant('blue', 800, 'light')).toEqual('hsl(240,100%,20.0%)');
expect(generateColorVariant('blue', 900, 'light')).toEqual('hsl(240,100%,10.0%)');
expect(generateColorVariant('blue', 950, 'light')).toEqual('hsl(240,100%,5.0%)');
});
});
describe('Generates Tailwind color', () => {
it('Does it', () => {
expect(
toTailwindVariable({ name: 'blue', cssColor: 'hsl(10, 20%, 30%)', variant: 100 }),
).toEqual('--color-blue-100: 10 20% 30%;');
});
});

115
src-web/lib/theme/theme.ts Normal file
View File

@@ -0,0 +1,115 @@
import parseColor from 'parse-color';
import type { Appearance } from './window';
export type AppThemeColor =
| 'gray'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'blue'
| 'pink'
| 'violet';
const colorNames: AppThemeColor[] = [
'gray',
'red',
'orange',
'yellow',
'green',
'blue',
'pink',
'violet',
];
export type AppThemeColorVariant = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
export const appThemeVariants: AppThemeColorVariant[] = [
50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
];
export type AppThemeLayer = 'root' | 'sidebar' | 'titlebar' | 'content' | 'above';
export interface AppThemeLayerStyle {
colors: Record<AppThemeColor, string>;
}
interface ThemeColorObj {
name: AppThemeColor;
variant: AppThemeColorVariant;
cssColor: string;
}
export interface AppTheme {
name: string;
appearance: Appearance;
layers: Partial<Record<AppThemeLayer, AppThemeLayerStyle>>;
}
export function generateCSS(t: AppTheme): ThemeColorObj[] {
const rootColors = t.layers.root?.colors;
if (rootColors === undefined) return [];
const colors: ThemeColorObj[] = [];
for (const color of colorNames) {
const rawValue = rootColors[color];
if (!rawValue) continue;
colors.push(...generateColors(color, rawValue, t.appearance));
}
return colors;
}
export function generateColors(
name: AppThemeColor,
color: string,
appearance: Appearance,
): ThemeColorObj[] {
const colors = [];
for (const variant of appThemeVariants) {
colors.push({ name, variant, cssColor: generateColorVariant(color, variant, appearance) });
}
return colors;
}
const lightnessMap: Record<Appearance, Record<AppThemeColorVariant, number>> = {
light: {
50: 1,
100: 0.8,
200: 0.7,
300: 0.5,
400: 0.3,
500: 0.1,
600: -0.2,
700: -0.3,
800: -0.5,
900: -0.7,
950: -0.8,
},
dark: {
50: -0.95,
100: -0.8,
200: -0.6,
300: -0.4,
400: -0.2,
500: 0,
600: 0.2,
700: 0.4,
800: 0.5,
900: 0.7,
950: 0.9,
},
};
export function generateColorVariant(
color: string,
variant: AppThemeColorVariant,
appearance: Appearance,
): string {
const { hsl } = parseColor(color || '');
const lightnessMod = lightnessMap[appearance][variant];
const newL = hsl[2] + (100 - hsl[2]) * lightnessMod;
return `hsl(${hsl[0]},${hsl[1]}%,${newL.toFixed(1)}%)`;
}
export function toTailwindVariable({ name, variant, cssColor }: ThemeColorObj): string {
const { hsl } = parseColor(cssColor || '');
return `--color-${name}-${variant}: ${hsl[0]} ${hsl[1]}% ${hsl[2]}%;`;
}

View File

@@ -0,0 +1,92 @@
import type { AppTheme } from './theme';
import { generateCSS, toTailwindVariable } from './theme';
export type Appearance = 'dark' | 'light';
const darkTheme: AppTheme = {
name: 'Default Dark',
appearance: 'dark',
layers: {
root: {
colors: {
gray: '#69789b',
red: '#ff1c1c',
orange: '#ff9411',
yellow: '#ffff1f',
green: '#35ff35',
blue: '#1365ff',
pink: '#ff74ff',
violet: '#873fff',
},
},
},
};
const lightTheme: AppTheme = {
name: 'Default Light',
appearance: 'light',
layers: {
root: {
colors: {
gray: '#69789b',
red: '#e13939',
orange: '#da881f',
yellow: '#e3b22d',
green: '#37c237',
blue: '#1365ff',
pink: '#e861e8',
violet: '#8d47ff',
},
},
},
};
export function getAppearance(): Appearance {
const docAppearance = document.documentElement.getAttribute('data-appearance');
if (docAppearance === 'dark' || docAppearance === 'light') {
return docAppearance;
}
return getPreferredAppearance();
}
export function toggleAppearance(): Appearance {
const currentTheme =
document.documentElement.getAttribute('data-appearance') ?? getPreferredAppearance();
const newAppearance = currentTheme === 'dark' ? 'light' : 'dark';
setAppearance(newAppearance);
return newAppearance;
}
export function setAppearance(a?: Appearance) {
const appearance = a ?? getPreferredAppearance();
const theme = appearance === 'dark' ? darkTheme : lightTheme;
document.documentElement.setAttribute('data-appearance', appearance);
document.documentElement.setAttribute('data-theme', theme.name);
let existingStyleEl = document.head.querySelector(`style[data-theme-definition="${theme.name}"]`);
if (!existingStyleEl) {
const styleEl = document.createElement('style');
document.head.appendChild(styleEl);
existingStyleEl = styleEl;
}
existingStyleEl.textContent = [
`[data-theme="${theme.name}"] {`,
...generateCSS(theme).map(toTailwindVariable),
'}',
].join('\n');
existingStyleEl.setAttribute('data-theme-definition', theme.name);
}
export function getPreferredAppearance(): Appearance {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function subscribeToPreferredAppearanceChange(
cb: (appearance: Appearance) => void,
): () => void {
const listener = (e: MediaQueryListEvent) => cb(e.matches ? 'dark' : 'light');
const m = window.matchMedia('(prefers-color-scheme: dark)');
m.addEventListener('change', listener);
return () => m.removeEventListener('change', listener);
}

View File

@@ -41,17 +41,15 @@ html, body, #root {
/* }*/
/*}*/
[data-color-background] {
--color-background: attr(data-bg-color);
}
@layer base {
:root, [data-theme="light"] {
/* Colors */
:root {
--color-white: 255 100% 100%;
--color-black: 255 0% 0%;
--color-background: var(--color-white);
--color-background: var(--color-gray-50);
}
:root, [data-theme="light"] {
/* Colors */
--color-green-50: 160 84% 95%;
--color-green-100: 160 84% 88%;
--color-green-200: 160 84% 76%;
@@ -61,7 +59,7 @@ html, body, #root {
--color-green-600: 160 84% 38%;
--color-green-700: 160 84% 30%;
--color-green-800: 160 84% 20%;
--color-green-900: 160 84% 10%;
--color-green-900: 160 84% 10%;
--color-blue-50: 217 91% 95%;
--color-blue-100: 217 91% 88%;
@@ -74,7 +72,7 @@ html, body, #root {
--color-blue-800: 217 91% 20%;
--color-blue-900: 217 91% 10%;
--color-pink-50: 292 84% 95%;
--color-pink-50: 292 84% 95%;
--color-pink-100: 292 84% 88%;
--color-pink-200: 292 84% 76%;
--color-pink-300: 292 84% 70%;
@@ -118,7 +116,7 @@ html, body, #root {
--color-orange-800: 25 95% 20%;
--color-orange-900: 25 95% 10%;
--color-yellow-50: 45 93% 95%;
--color-yellow-50: 45 93% 95%;
--color-yellow-100: 45 93% 88%;
--color-yellow-200: 45 93% 76%;
--color-yellow-300: 45 93% 70%;
@@ -150,8 +148,6 @@ html, body, #root {
}
[data-theme="dark"] {
--color-background: 217 21% 7%;
--color-green-900: 160 84% 95%;
--color-green-800: 160 84% 88%;
--color-green-700: 160 84% 76%;
@@ -161,7 +157,7 @@ html, body, #root {
--color-green-300: 160 84% 38%;
--color-green-200: 160 84% 30%;
--color-green-100: 160 84% 20%;
--color-green-50: 160 84% 10%;
--color-green-50: 160 84% 10%;
--color-blue-900: 217 91% 95%;
--color-blue-800: 217 91% 88%;
@@ -174,7 +170,7 @@ html, body, #root {
--color-blue-100: 217 91% 20%;
--color-blue-50: 217 91% 10%;
--color-pink-900: 292 84% 95%;
--color-pink-900: 292 84% 95%;
--color-pink-800: 292 84% 88%;
--color-pink-700: 292 84% 76%;
--color-pink-600: 292 84% 70%;
@@ -218,7 +214,7 @@ html, body, #root {
--color-orange-100: 25 95% 20%;
--color-orange-50: 25 95% 10%;
--color-yellow-900: 45 93% 95%;
--color-yellow-900: 45 93% 95%;
--color-yellow-800: 45 93% 88%;
--color-yellow-700: 45 93% 76%;
--color-yellow-600: 45 93% 70%;

View File

@@ -13,11 +13,11 @@ import { requestsQueryKey } from './hooks/useRequest';
import { responsesQueryKey } from './hooks/useResponses';
import type { HttpRequest, HttpResponse } from './lib/models';
import { convertDates } from './lib/models';
import { setTheme } from './lib/theme';
import { setAppearance } from './lib/theme/window';
import './main.css';
import { Workspaces } from './pages/Workspaces';
setTheme();
setAppearance();
// WASM stuff
await init();

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class', '[data-theme="dark"]'],
darkMode: ['class', '[data-appearance="dark"]'],
content: [
"./index.html",
"./src-web/**/*.{js,ts,jsx,tsx}",
@@ -17,8 +17,8 @@ module.exports = {
},
colors: {
transparent: 'transparent',
white: 'hsl(var(--color-white) / <alpha-value>)',
black: 'hsl(var(--color-black) / <alpha-value>)',
white: 'hsl(0 100% 100% / <alpha-value>)',
black: 'hsl(0 100% 0% / <alpha-value>)',
background: 'hsl(var(--color-background) / <alpha-value>)',
placeholder: 'hsl(var(--color-gray-200) / <alpha-value>)',
gray: color('gray'),
@@ -34,7 +34,7 @@ module.exports = {
}
function color(name) {
const map = {
return {
50: `hsl(var(--color-${name}-50) / <alpha-value>)`,
100: `hsl(var(--color-${name}-100) / <alpha-value>)`,
200: `hsl(var(--color-${name}-200) / <alpha-value>)`,
@@ -45,9 +45,6 @@ function color(name) {
700: `hsl(var(--color-${name}-700) / <alpha-value>)`,
800: `hsl(var(--color-${name}-800) / <alpha-value>)`,
900: `hsl(var(--color-${name}-900) / <alpha-value>)`,
}
if (name === 'gray') {
map[25] = `hsl(var(--color-${name}-25) / <alpha-value>)`;
}
return map;
950: `hsl(var(--color-${name}-950) / <alpha-value>)`,
};
}