Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/dashboard/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<!-- Resolve high contrast before first paint. Keep in sync with src/lib/state/contrast.svelte.ts. -->
<script>
'use strict';
(() => {
const prefKey = 'contrast-preference';
try {
let pref = localStorage.getItem(prefKey);
if (pref !== 'auto' && pref !== 'on' && pref !== 'off') {
// Migrate users who enabled the original toggle (stored as a mode-watcher theme).
const legacy = localStorage.getItem('mode-watcher-theme') === 'high-contrast';
pref = legacy ? 'on' : 'auto';
localStorage.setItem(prefKey, pref);
}
if (localStorage.getItem('mode-watcher-theme') === 'high-contrast') {
localStorage.removeItem('mode-watcher-theme');
}
const on =
pref === 'on' ||
(pref === 'auto' && window.matchMedia('(prefers-contrast: more)').matches);
if (on) {
document.documentElement.setAttribute('data-contrast', 'more');
}
} catch {
// Storage unavailable; fall back to the default (no high contrast).
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@
listSshKeys
} from '#lib/remote/ssh-keys.remote.js';
import { disableTwoFactorWithVerification } from '#lib/remote/two-factor.remote.js';
import { type ContrastPreference, contrast } from '#lib/state/contrast.svelte.js';
import type { UserSettingsTab } from '#lib/state/user-settings.svelte.js';
import { getErrorMessage } from '#lib/utils.js';
import { goto, invalidate } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Check from '~icons/lucide/check';
import Circle from '~icons/lucide/circle';
import Contrast from '~icons/lucide/contrast';
import Loader2 from '~icons/lucide/loader-2';
import Minus from '~icons/lucide/minus';
import Plus from '~icons/lucide/plus';
Expand Down Expand Up @@ -59,6 +62,12 @@
{ value: 'system', label: 'Auto', icon: Monitor }
];

const contrastOptions: { value: ContrastPreference; label: string; icon: typeof Sun }[] = [
{ value: 'off', label: 'Off', icon: Circle },
{ value: 'on', label: 'On', icon: Contrast },
{ value: 'auto', label: 'Auto', icon: Monitor }
];

interface Props {
activeTab?: UserSettingsTab;
open?: boolean;
Expand Down Expand Up @@ -1063,6 +1072,34 @@
Auto follows your system's light/dark setting.
</p>
</div>
<fieldset class="mt-4 border-t border-border/50 pt-4">
<legend class="float-left mb-1.5 w-full text-sm leading-none font-medium">
High contrast
</legend>
<div class="clear-both grid grid-cols-3 gap-2">
{#each contrastOptions as option (option.value)}
{@const Icon = option.icon}
<button
type="button"
aria-pressed={contrast.preference === option.value}
onclick={() => contrast.setPreference(option.value)}
class="flex flex-col items-center gap-1.5 border px-3 py-2.5 text-xs transition-colors {contrast.preference ===
option.value
? 'border-primary bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:border-ring hover:text-foreground'}"
>
<Icon class="h-4 w-4" />
{option.label}
</button>
{/each}
</div>
<p class="mt-1.5 text-xs text-muted-foreground">
Stronger colors, borders and focus outlines. Auto follows your system's contrast
setting{contrast.preference === 'auto'
? ` (currently ${contrast.systemPrefersMore ? 'on' : 'off'})`
: ''}.
</p>
</fieldset>
</div>
</Tabs.Content>
</div>
Expand Down
13 changes: 7 additions & 6 deletions apps/dashboard/src/lib/components/ui/button/button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
base: "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:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
default:
'bg-primary text-primary-foreground [a]:hover:bg-primary/80 high-contrast:border-primary high-contrast:hover:border-foreground high-contrast:hover:bg-foreground high-contrast:hover:text-background high-contrast:hover:[&_svg]:text-current',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground high-contrast:border-border high-contrast:bg-background high-contrast:hover:border-foreground high-contrast:hover:bg-foreground high-contrast:hover:text-background high-contrast:hover:[&_svg]:text-current',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground high-contrast:border-border high-contrast:hover:border-foreground high-contrast:hover:bg-foreground high-contrast:hover:text-background high-contrast:hover:[&_svg]:text-current',
ghost:
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground high-contrast:hover:border-foreground high-contrast:hover:bg-foreground high-contrast:hover:text-background high-contrast:hover:[&_svg]:text-current',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline'
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30 high-contrast:border-destructive high-contrast:hover:border-destructive high-contrast:hover:bg-destructive high-contrast:hover:text-destructive-foreground high-contrast:hover:[&_svg]:text-current',
link: 'text-primary underline-offset-4 hover:underline high-contrast:underline'
},
size: {
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
data-slot="input-group"
role="group"
class={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 high-contrast:has-disabled:opacity-100 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 high-contrast:bg-background high-contrast:has-disabled:bg-background high-contrast:has-disabled:border-dashed dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
className
)}
{...props}
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/lib/components/ui/input/input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 high-contrast:bg-background high-contrast:disabled:bg-background high-contrast:disabled:border-dashed high-contrast:disabled:text-muted-foreground high-contrast:disabled:opacity-100',
className
)}
type="file"
Expand All @@ -38,7 +38,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 high-contrast:bg-background high-contrast:disabled:bg-background high-contrast:disabled:border-dashed high-contrast:disabled:text-muted-foreground high-contrast:disabled:opacity-100',
className
)}
{type}
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/lib/components/ui/switch/switch.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@
data-slot="switch"
data-size={size}
class={cn(
'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50',
'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50 high-contrast:border-input high-contrast:data-unchecked:bg-background',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 rtl:data-[state=checked]:translate-x-[calc(-100%)] group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
class="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 rtl:data-[state=checked]:translate-x-[calc(-100%)] group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground high-contrast:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 high-contrast:bg-background high-contrast:disabled:bg-background high-contrast:disabled:border-dashed high-contrast:disabled:text-muted-foreground high-contrast:disabled:opacity-100',
className
)}
bind:value
Expand Down
76 changes: 76 additions & 0 deletions apps/dashboard/src/lib/state/contrast.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { MediaQuery } from 'svelte/reactivity';

/**
* High contrast preference.
*
* - `auto` follows the OS/browser `prefers-contrast: more` setting (default)
* - `on` / `off` force it
*
* The effective value is reflected as `data-contrast="more"` on `<html>`, which
* the CSS (and the `high-contrast:` variant) keys off. An inline script in
* `src/app.html` resolves it before first paint; keep it in sync with this file.
*/
export type ContrastPreference = 'auto' | 'on' | 'off';

export const contrastPreferenceStorageKey = 'contrast-preference';
export const contrastAttribute = 'data-contrast';

function isContrastPreference(value: unknown): value is ContrastPreference {
return value === 'auto' || value === 'on' || value === 'off';
}

function readPreference(): ContrastPreference {
if (typeof localStorage === 'undefined') {
return 'auto';
}
try {
const stored = localStorage.getItem(contrastPreferenceStorageKey);
return isContrastPreference(stored) ? stored : 'auto';
} catch {
return 'auto';
}
}

class ContrastState {
readonly #system = new MediaQuery('prefers-contrast: more', false);

preference = $state<ContrastPreference>(readPreference());

/** Whether the system currently asks for more contrast. */
get systemPrefersMore() {
return this.#system.current;
}

/** Whether high contrast is actually in effect. */
get enabled() {
return this.preference === 'on' || (this.preference === 'auto' && this.#system.current);
}

setPreference(preference: ContrastPreference) {
this.preference = preference;
try {
localStorage.setItem(contrastPreferenceStorageKey, preference);
} catch {
// Storage unavailable (private mode etc.); preference lasts for this session.
}
}

/** Reflect the effective value onto the document. Call from an effect. */
apply() {
const root = document.documentElement;
if (this.enabled) {
root.setAttribute(contrastAttribute, 'more');
} else {
root.removeAttribute(contrastAttribute);
}
}

/** Pick up preference changes made in other tabs. */
syncFromStorage(event: StorageEvent) {
if (event.key === contrastPreferenceStorageKey && isContrastPreference(event.newValue)) {
this.preference = event.newValue;
}
}
}

export const contrast = new ContrastState();
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@
{#if colo.selectedUnit}
<div class="flex min-h-0 flex-1 flex-col overflow-auto">
<div class="shrink-0">
<div class="grid shrink-0 grid-cols-2 gap-px border-b border-border bg-muted lg:grid-cols-4">
<div
class="grid shrink-0 grid-cols-2 gap-px border-b border-border bg-muted lg:grid-cols-4 high-contrast:bg-border"
>
{#each charts as chart (chart.label)}
<div class="relative flex flex-col bg-background">
<div class="flex items-baseline justify-between px-4 pt-3 pb-1">
Expand Down
Loading
Loading