diff --git a/apps/dashboard/src/app.html b/apps/dashboard/src/app.html index 6a2bb582..02ecd330 100644 --- a/apps/dashboard/src/app.html +++ b/apps/dashboard/src/app.html @@ -4,6 +4,33 @@ + + %sveltekit.head% diff --git a/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte b/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte index 918a9e1c..6ab1f0b7 100644 --- a/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte +++ b/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte @@ -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'; @@ -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; @@ -1063,6 +1072,34 @@ Auto follows your system's light/dark setting.

+
+ + High contrast + +
+ {#each contrastOptions as option (option.value)} + {@const Icon = option.icon} + + {/each} +
+

+ Stronger colors, borders and focus outlines. Auto follows your system's contrast + setting{contrast.preference === 'auto' + ? ` (currently ${contrast.systemPrefersMore ? 'on' : 'off'})` + : ''}. +

+
diff --git a/apps/dashboard/src/lib/components/ui/button/button.svelte b/apps/dashboard/src/lib/components/ui/button/button.svelte index c2ae9171..08d62b3e 100644 --- a/apps/dashboard/src/lib/components/ui/button/button.svelte +++ b/apps/dashboard/src/lib/components/ui/button/button.svelte @@ -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: diff --git a/apps/dashboard/src/lib/components/ui/input-group/input-group.svelte b/apps/dashboard/src/lib/components/ui/input-group/input-group.svelte index aeda9184..d818c0b4 100644 --- a/apps/dashboard/src/lib/components/ui/input-group/input-group.svelte +++ b/apps/dashboard/src/lib/components/ui/input-group/input-group.svelte @@ -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} diff --git a/apps/dashboard/src/lib/components/ui/input/input.svelte b/apps/dashboard/src/lib/components/ui/input/input.svelte index 0b63e568..5f23cf93 100644 --- a/apps/dashboard/src/lib/components/ui/input/input.svelte +++ b/apps/dashboard/src/lib/components/ui/input/input.svelte @@ -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" @@ -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} diff --git a/apps/dashboard/src/lib/components/ui/switch/switch.svelte b/apps/dashboard/src/lib/components/ui/switch/switch.svelte index 52243042..9a678da9 100644 --- a/apps/dashboard/src/lib/components/ui/switch/switch.svelte +++ b/apps/dashboard/src/lib/components/ui/switch/switch.svelte @@ -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} > diff --git a/apps/dashboard/src/lib/components/ui/textarea/textarea.svelte b/apps/dashboard/src/lib/components/ui/textarea/textarea.svelte index 63cc7baf..fc1dca49 100644 --- a/apps/dashboard/src/lib/components/ui/textarea/textarea.svelte +++ b/apps/dashboard/src/lib/components/ui/textarea/textarea.svelte @@ -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 diff --git a/apps/dashboard/src/lib/state/contrast.svelte.ts b/apps/dashboard/src/lib/state/contrast.svelte.ts new file mode 100644 index 00000000..bd3c97f1 --- /dev/null +++ b/apps/dashboard/src/lib/state/contrast.svelte.ts @@ -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 ``, 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(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(); diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/colocation/[id]/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/colocation/[id]/+page.svelte index 2f58f2e9..46ea1cb1 100644 --- a/apps/dashboard/src/routes/(app)/projects/[projectid]/colocation/[id]/+page.svelte +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/colocation/[id]/+page.svelte @@ -105,7 +105,9 @@ {#if colo.selectedUnit}
-
+
{#each charts as chart (chart.label)}
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/+page.svelte index a34676b6..8588c7c3 100644 --- a/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/+page.svelte +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/+page.svelte @@ -221,7 +221,9 @@

Server Overview

-
+
{#each charts as chart (chart.label)}
@@ -275,7 +277,9 @@ {/each}
-
+
Server Details diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 854fd2b4..da0c0605 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import { initPlausible } from '#lib/analytics/plausible.js'; import { initPostHog } from '#lib/analytics/posthog.js'; import NavigationProgress from '#lib/components/navigation-progress.svelte'; + import { contrast } from '#lib/state/contrast.svelte.js'; let { children } = $props(); @@ -12,6 +13,10 @@ initPostHog(); initPlausible(); }); + + $effect(() => { + contrast.apply(); + }); @@ -21,6 +26,8 @@ + contrast.syncFromStorage(event)} /> + diff --git a/apps/dashboard/src/routes/layout.css b/apps/dashboard/src/routes/layout.css index 1436a6fc..6b3a3baf 100644 --- a/apps/dashboard/src/routes/layout.css +++ b/apps/dashboard/src/routes/layout.css @@ -5,6 +5,7 @@ @import "@fontsource-variable/inter"; @custom-variant dark (&:is(.dark *)); +@custom-variant high-contrast (&:is([data-contrast="more"] *)); :root { --gray-50: #fafafa; @@ -69,6 +70,73 @@ --ring: var(--gray-500); } +/* + * High contrast mode. Applied via `data-contrast="more"` on (see + * #lib/state/contrast.svelte.ts), so it stacks on top of light/dark. The red scale is remapped too, because many + * components use raw `red-*` utilities rather than semantic tokens. + */ +:root[data-contrast="more"] { + --red-200: #7a0d0d; + --red-300: #7a0d0d; + --red-400: #8f1010; + --red-500: #9b1111; + --red-600: #8a0f0f; + --red-700: #6e0b0b; + --red-950: #fff0f0; + --color-red-800: #6e0b0b; + --color-red-900: #5c0909; + --background: #ffffff; + --foreground: #000000; + --card: #ffffff; + --card-foreground: #000000; + --popover: #ffffff; + --popover-foreground: #000000; + --primary: #9b1111; + --primary-foreground: #ffffff; + --secondary: #f0f0f0; + --secondary-foreground: #000000; + --muted: #f0f0f0; + --muted-foreground: #262626; + --accent: #9b1111; + --accent-foreground: #ffffff; + --destructive: #9b1111; + --destructive-foreground: #ffffff; + --border: #000000; + --input: #000000; + --ring: #000000; +} + +.dark[data-contrast="more"] { + --red-200: #ffe5e5; + --red-300: #ffcccc; + --red-400: #ffa3a3; + --red-500: #ff7a7a; + --red-600: #ff7a7a; + --red-700: #ff8f8f; + --red-950: #1a0000; + --color-red-800: #ff8f8f; + --color-red-900: #ff8f8f; + --background: #000000; + --foreground: #ffffff; + --card: #000000; + --card-foreground: #ffffff; + --popover: #000000; + --popover-foreground: #ffffff; + --primary: #ff7a7a; + --primary-foreground: #000000; + --secondary: #1a1a1a; + --secondary-foreground: #ffffff; + --muted: #1a1a1a; + --muted-foreground: #e5e5e5; + --accent: #ff7a7a; + --accent-foreground: #000000; + --destructive: #ff7a7a; + --destructive-foreground: #000000; + --border: #ffffff; + --input: #ffffff; + --ring: #ffffff; +} + @theme inline { --color-gray-50: var(--gray-50); --color-gray-100: var(--gray-100); @@ -133,6 +201,74 @@ background-color: var(--background); @apply text-foreground antialiased; } + [data-contrast="more"] :focus-visible { + outline: 2px solid var(--ring) !important; + outline-offset: 2px; + } +} + +/* + * High contrast: many borders, dividers, rings and text use an opacity modifier + * (e.g. `border-border/50`, `text-foreground/60`), which washes them out. + * Force them opaque. Exact class tokens (`~=`) are used so variant classes such + * as `aria-invalid:border-destructive/50` are NOT matched. Specificity equals + * the base utility (it comes later, so it wins), while stateful variants like + * `hover:border-ring` still take precedence. + */ +@layer utilities { + :where([data-contrast="more"]) [class~="border-border/20"], + :where([data-contrast="more"]) [class~="border-border/30"], + :where([data-contrast="more"]) [class~="border-border/40"], + :where([data-contrast="more"]) [class~="border-border/50"], + :where([data-contrast="more"]) [class~="border-border/60"], + :where([data-contrast="more"]) [class~="border-border/80"], + :where([data-contrast="more"]) [class~="border-input/30"] { + border-color: var(--border); + } + + :where([data-contrast="more"]) [class~="divide-border/20"] > :where(:not(:last-child)), + :where([data-contrast="more"]) [class~="divide-border/30"] > :where(:not(:last-child)), + :where([data-contrast="more"]) [class~="divide-border/40"] > :where(:not(:last-child)), + :where([data-contrast="more"]) [class~="divide-border/50"] > :where(:not(:last-child)) { + border-color: var(--border); + } + + :where([data-contrast="more"]) [class~="ring-foreground/10"] { + --tw-ring-color: var(--border); + } + + :where([data-contrast="more"]) [class~="border-red-400/60"], + :where([data-contrast="more"]) [class~="border-red-500/10"], + :where([data-contrast="more"]) [class~="border-red-500/20"], + :where([data-contrast="more"]) [class~="border-red-500/50"], + :where([data-contrast="more"]) [class~="border-red-900/50"], + :where(.dark[data-contrast="more"]) + [class~="dark:border-red-800/50"][class~="dark:border-red-800/50"], + :where(.dark[data-contrast="more"]) + [class~="dark:border-red-900/20"][class~="dark:border-red-900/20"], + :where(.dark[data-contrast="more"]) + [class~="dark:border-red-900/30"][class~="dark:border-red-900/30"], + :where(.dark[data-contrast="more"]) + [class~="dark:border-red-900/40"][class~="dark:border-red-900/40"], + :where(.dark[data-contrast="more"]) + [class~="dark:border-red-900/50"][class~="dark:border-red-900/50"] { + border-color: var(--red-500); + } + + :where([data-contrast="more"]) [class~="text-foreground/60"] { + color: var(--foreground); + } + + :where([data-contrast="more"]) [class~="text-muted-foreground/60"] { + color: var(--muted-foreground); + } + + :where([data-contrast="more"]) [class~="text-red-200/80"], + :where([data-contrast="more"]) [class~="text-red-300/70"], + :where([data-contrast="more"]) [class~="text-red-400/60"], + :where([data-contrast="more"]) [class~="text-red-400/80"] { + color: var(--red-400); + } } @utility icon-flat { diff --git a/apps/dashboard/tests/accessibility.test.ts b/apps/dashboard/tests/accessibility.test.ts index fec77aef..cd095753 100644 --- a/apps/dashboard/tests/accessibility.test.ts +++ b/apps/dashboard/tests/accessibility.test.ts @@ -17,21 +17,30 @@ function formatImpact(impact: string | null | undefined) { return impactKey[impact] ?? impact; } -const modes = ['dark', 'light'] as const; +const modes = [ + { label: 'dark', mode: 'dark', contrast: 'off' }, + { label: 'light', mode: 'light', contrast: 'off' }, + { label: 'dark high contrast', mode: 'dark', contrast: 'on' }, + { label: 'light high contrast', mode: 'light', contrast: 'on' } +] as const; -for (const mode of modes) { +for (const { label: modeLabel, mode, contrast } of modes) { for (const { label, path } of pages) { - test(`Testing for accessibility violations on ${label} in ${mode} mode.`, async ({ + test(`Testing for accessibility violations on ${label} in ${modeLabel} mode.`, async ({ page, makeAxeBuilder }) => { - await page.addInitScript((selectedMode) => { - window.localStorage.setItem('mode-watcher-mode', selectedMode); - }, mode); + await page.addInitScript( + ([selectedMode, selectedContrast]) => { + window.localStorage.setItem('mode-watcher-mode', selectedMode); + window.localStorage.setItem('contrast-preference', selectedContrast); + }, + [mode, contrast] as const + ); await page.goto(`${localURL}${path}`, { waitUntil: 'networkidle' }); const { violations } = await makeAxeBuilder().analyze(); - const reportMessage = `Found ${violations.length} accessibility violations on ${label} in ${mode} mode.`; + const reportMessage = `Found ${violations.length} accessibility violations on ${label} in ${modeLabel} mode.`; if (violations.length === 0) { expect(violations, reportMessage).toHaveLength(0);