1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-14 19:22:29 +00:00

feat: ui accent colors (#643)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-06-13 07:06:54 -05:00
committed by GitHub
parent 215531d65c
commit 883877adec
24 changed files with 525 additions and 236 deletions

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch/index.js';
let {
id,
checked = $bindable(),
label,
description,
disabled = false,
onCheckedChange
}: {
id: string;
checked: boolean;
label: string;
description?: string;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
} = $props();
</script>
<div class="items-top flex space-x-2">
<Switch
{id}
{disabled}
onCheckedChange={(v) => onCheckedChange && onCheckedChange(v == true)}
bind:checked
/>
<div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm leading-none font-medium">
{label}
</Label>
{#if description}
<p class="text-muted-foreground text-[0.8rem]">
{description}
</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,10 @@
import Root from './radio-group.svelte';
import Item from './radio-group-item.svelte';
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
value = $bindable(''),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn('grid gap-3', className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
import Root from './switch.svelte';
export {
Root,
//
Root as Switch
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>

View File

@@ -1,5 +1,6 @@
import AppConfigService from '$lib/services/app-config-service';
import type { AppConfig } from '$lib/types/application-configuration';
import { applyAccentColor } from '$lib/utils/accent-color-util';
import { writable } from 'svelte/store';
const appConfigStore = writable<AppConfig>();
@@ -8,10 +9,11 @@ const appConfigService = new AppConfigService();
const reload = async () => {
const appConfig = await appConfigService.list();
appConfigStore.set(appConfig);
set(appConfig);
};
const set = (appConfig: AppConfig) => {
applyAccentColor(appConfig.accentColor);
appConfigStore.set(appConfig);
};

View File

@@ -6,6 +6,7 @@ export type AppConfig = {
ldapEnabled: boolean;
disableAnimations: boolean;
uiConfigDisabled: boolean;
accentColor: string;
};
export type AllAppConfig = AppConfig & {

View File

@@ -0,0 +1,58 @@
export function applyAccentColor(accentValue: string) {
if (accentValue === 'default') {
document.documentElement.style.removeProperty('--primary');
document.documentElement.style.removeProperty('--primary-foreground');
document.documentElement.style.removeProperty('--ring');
document.documentElement.style.removeProperty('--sidebar-ring');
return;
}
document.documentElement.style.setProperty('--primary', accentValue);
// Smart foreground color selection based on brightness
const foregroundColor = getContrastingForeground(accentValue);
document.documentElement.style.setProperty('--primary-foreground', foregroundColor);
// Create proper ring colors based on input format
const ringColor = `color-mix(in srgb, ${accentValue} 50%, transparent)`;
document.documentElement.style.setProperty('--ring', ringColor);
document.documentElement.style.setProperty('--sidebar-ring', ringColor);
}
function getContrastingForeground(color: string): string {
const brightness = getColorBrightness(color);
// Use white text for dark colors, black text for light colors
return brightness < 0.55 ? 'oklch(0.98 0 0)' : 'oklch(0.09 0 0)';
}
function getColorBrightness(color: string): number {
// Create a temporary element to get computed color
const tempElement = document.createElement('div');
tempElement.style.color = color;
document.body.appendChild(tempElement);
const computedColor = window.getComputedStyle(tempElement).color;
document.body.removeChild(tempElement);
// Parse RGB values from computed color
const rgbMatch = computedColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!rgbMatch) {
// Fallback: assume medium brightness
return 0.5;
}
const [, r, g, b] = rgbMatch.map(Number);
// Calculate relative luminance using the standard formula
// https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
const sR = r / 255;
const sG = g / 255;
const sB = b / 255;
const rLinear = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4);
const gLinear = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4);
const bLinear = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4);
return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
}