mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 20:19:04 +01:00
feat!: add v3 design tokens, fonts and color themes
This commit is contained in:
@@ -440,12 +440,6 @@
|
||||
"select_an_option": "Select an option",
|
||||
"select_user": "Select User",
|
||||
"error": "Error",
|
||||
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
|
||||
"accent_color": "Accent Color",
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
@@ -700,5 +694,25 @@
|
||||
"set_custom_client_id": "Set custom client ID",
|
||||
"confidential_client": "Confidential Client",
|
||||
"confidential_client_description": "Authenticates with a client secret. For apps with a backend that can keep the secret safe.",
|
||||
"public_client_type_description": "Has no client secret and uses PKCE instead. For single-page, mobile and desktop apps."
|
||||
"public_client_type_description": "Has no client secret and uses PKCE instead. For single-page, mobile and desktop apps.",
|
||||
"color_theme": "Color theme",
|
||||
"color_theme_description": "Colors for everyone, on sign-in pages and in the app, in light and dark mode.",
|
||||
"color_theme_mono": "Mono",
|
||||
"color_theme_mist": "Mist",
|
||||
"color_theme_sage": "Sage",
|
||||
"color_theme_midnight": "Midnight",
|
||||
"color_theme_lavender": "Lavender",
|
||||
"custom_theme_color": "Custom theme color",
|
||||
"custom_theme_color_description": "Any CSS color, like #2f6f4e.",
|
||||
"color": "Color",
|
||||
"preview": "Preview",
|
||||
"sidebar": "Sidebar",
|
||||
"use_color": "Use color",
|
||||
"enter_a_css_color": "Enter a CSS color, like #2f6f4e.",
|
||||
"use_a_color_without_transparency": "Use a color without transparency, like #2f6f4e.",
|
||||
"custom_color_note_darkened": "Darkened a little so white text on buttons stays readable.",
|
||||
"custom_color_note_lightened": "Lightened in dark mode so buttons stand out from the background.",
|
||||
"custom_color_note_edge": "Buttons get a thin border because the color is close to the background.",
|
||||
"custom_color_note_near_status": "This color is close to the error, warning or success color, so buttons may read as a status.",
|
||||
"custom": "Custom"
|
||||
}
|
||||
|
||||
+414
-127
@@ -1,143 +1,371 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn-svelte/tailwind.css';
|
||||
@import './fonts.css';
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* The values below are the Mono color theme; applyColorTheme in color-theme-util.ts overrides the neutrals and the primary family at runtime */
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--warning: oklch(0.96 0.06 85);
|
||||
--warning-foreground: oklch(0.32 0.07 70);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--h-xs: 26px;
|
||||
--h-sm: 34px;
|
||||
--h-md: 42px;
|
||||
--h-lg: 48px;
|
||||
--h-xl: 48px;
|
||||
--row-dense: 36px;
|
||||
--row: 44px;
|
||||
--row-2line: 52px;
|
||||
--col-auth: 464px;
|
||||
--col-content: 1120px;
|
||||
/* The identity column gives way on tablets so the other columns fit without scrolling sideways */
|
||||
--table-id-max: clamp(240px, 36vw, 440px);
|
||||
--sidebar-w: 256px;
|
||||
--rail-w: 60px;
|
||||
--dialog-sm: 420px;
|
||||
--dialog-md: 480px;
|
||||
--dialog-lg: 560px;
|
||||
--sheet-w: 560px;
|
||||
--palette-w: 640px;
|
||||
--toast-w: 340px;
|
||||
--clear-end-phone: 148px;
|
||||
--z-sticky: 10;
|
||||
--z-nav: 20;
|
||||
--z-bar: 30;
|
||||
--z-overlay: 40;
|
||||
--z-popover: 50;
|
||||
--z-toast: 60;
|
||||
--z-tooltip: 70;
|
||||
--z-progress: 80;
|
||||
--halo-width: 3px;
|
||||
--selection-border: 1.5px;
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 200ms;
|
||||
--dur-slow: 280ms;
|
||||
--rise: 4px;
|
||||
--nudge: 2px;
|
||||
--slide: 24px;
|
||||
--press-scale: 0.98;
|
||||
--pop-scale: 0.96;
|
||||
--t-success-hold: 800ms;
|
||||
--t-redirect-fallback: 3000ms;
|
||||
--t-dim-delay: 200ms;
|
||||
--t-skeleton-delay: 150ms;
|
||||
--t-pulse: 1600ms;
|
||||
--t-spin: 800ms;
|
||||
--t-caret: 1000ms;
|
||||
--t-progress: 3000ms;
|
||||
|
||||
--canvas: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-sunken: #f5f5f5;
|
||||
--fill-muted: #f2f2f2;
|
||||
--fill-hover: #eeeeee;
|
||||
--fill-row-hover: #fafafa;
|
||||
--fill-selected: #ebebeb;
|
||||
--fill-skeleton: #eeeeee;
|
||||
--border: #e8e8e8;
|
||||
--border-strong: #8c8c8c;
|
||||
--border-control: #8c8c8c;
|
||||
--fg: #0a0a0a;
|
||||
--fg-muted: #5e5e5e;
|
||||
--fg-subtle: #a3a3a3;
|
||||
--inverse: #111111;
|
||||
--inverse-fg: #fafafa;
|
||||
--inverse-muted: #b5b5b5;
|
||||
--inverse-hover: #262626;
|
||||
--inverse-border: transparent;
|
||||
--success-solid: #319751;
|
||||
--success-fg: #206b38;
|
||||
--success-soft: #ebfaed;
|
||||
--success-border: #bcdfc2;
|
||||
--warning-solid: #c5770f;
|
||||
--warning-fg: #864e18;
|
||||
--warning-soft: #fff6e4;
|
||||
--warning-border: #eecf9c;
|
||||
--danger-solid: #d73337;
|
||||
--danger-fg: #b32228;
|
||||
--danger-soft: #fff1f0;
|
||||
--danger-border: #f9c1bf;
|
||||
--danger-fill: #c52b30;
|
||||
--danger-fill-hover: #b32228;
|
||||
--danger-fill-fg: #ffffff;
|
||||
--info-solid: #5e5e5e;
|
||||
--info-fg: #0a0a0a;
|
||||
--info-soft: #f5f5f5;
|
||||
--info-border: #e8e8e8;
|
||||
--well-light: #f6f4f0;
|
||||
--well-dark: #242220;
|
||||
--well-light-fg: #57534d;
|
||||
--well-dark-fg: #bab4aa;
|
||||
--qr-bg: #ffffff;
|
||||
--qr-fg: #121212;
|
||||
--scrim: rgb(18 17 16 / 0.4);
|
||||
--ring: #0a0a0a;
|
||||
--ring-halo: rgb(10 10 10 / 0.24);
|
||||
--elevation-popover: 0 1px 2px rgb(31 29 27 / 0.06), 0 8px 24px -6px rgb(31 29 27 / 0.14);
|
||||
--elevation-overlay: 0 2px 4px rgb(31 29 27 / 0.06), 0 16px 40px -8px rgb(31 29 27 / 0.2);
|
||||
--elevation-thumb: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
--elevation-selected: 0 1px 2px rgb(31 29 27 / 0.06), 0 4px 14px rgb(31 29 27 / 0.07);
|
||||
--border-selected: color-mix(in oklab, var(--border-strong) 40%, var(--border));
|
||||
--sidebar: #fafafa;
|
||||
--primary: #0a0a0a;
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-hover: #262626;
|
||||
--primary-control: #0a0a0a;
|
||||
--primary-control-foreground: #ffffff;
|
||||
--primary-edge: transparent;
|
||||
--fw-display-l: 600;
|
||||
--fw-display-m: 600;
|
||||
|
||||
/* shadcn-svelte variables, mapped onto the tokens above so the ui/ components keep their class names */
|
||||
--background: var(--canvas);
|
||||
--foreground: var(--fg);
|
||||
--card: var(--surface);
|
||||
--card-foreground: var(--fg);
|
||||
--popover: var(--surface-raised);
|
||||
--popover-foreground: var(--fg);
|
||||
--secondary: var(--fill-muted);
|
||||
--secondary-foreground: var(--fg);
|
||||
--muted: var(--fill-muted);
|
||||
--muted-foreground: var(--fg-muted);
|
||||
--accent: var(--fill-hover);
|
||||
--accent-foreground: var(--fg);
|
||||
--destructive: var(--danger-fg);
|
||||
--destructive-foreground: var(--danger-fill-fg);
|
||||
--warning: var(--warning-soft);
|
||||
--warning-foreground: var(--warning-fg);
|
||||
--input: var(--fill-muted);
|
||||
--sidebar-foreground: var(--fg);
|
||||
--sidebar-primary: var(--primary);
|
||||
--sidebar-primary-foreground: var(--primary-foreground);
|
||||
--sidebar-accent: var(--fill-selected);
|
||||
--sidebar-accent-foreground: var(--fg);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--warning: oklch(0.42 0.09 70);
|
||||
--warning-foreground: oklch(0.93 0.03 85);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--canvas: #000000;
|
||||
--surface: #000000;
|
||||
--surface-raised: #121212;
|
||||
--surface-sunken: #0e0e0e;
|
||||
--fill-muted: #171717;
|
||||
--fill-hover: #1a1a1a;
|
||||
--fill-row-hover: #0c0c0c;
|
||||
--fill-selected: #1f1f1f;
|
||||
--fill-skeleton: #1a1a1a;
|
||||
--border: #1f1f1f;
|
||||
--border-strong: #6b6b6b;
|
||||
--border-control: #6b6b6b;
|
||||
--fg: #ededed;
|
||||
--fg-muted: #a1a1a1;
|
||||
--fg-subtle: #5c5c5c;
|
||||
--inverse: #1f1f1f;
|
||||
--inverse-fg: #ededed;
|
||||
--inverse-muted: #b5b5b5;
|
||||
--inverse-hover: #2e2e2e;
|
||||
--inverse-border: #333333;
|
||||
--success-solid: #54b66e;
|
||||
--success-fg: #83d494;
|
||||
--success-soft: #1a2b1d;
|
||||
--success-border: #2c5235;
|
||||
--warning-solid: #eba941;
|
||||
--warning-fg: #f5ca7a;
|
||||
--warning-soft: #342611;
|
||||
--warning-border: #68491a;
|
||||
--danger-solid: #f45a56;
|
||||
--danger-fg: #ff8f8d;
|
||||
--danger-soft: #3b1e1e;
|
||||
--danger-border: #813939;
|
||||
--danger-fill: #c52b30;
|
||||
--danger-fill-hover: #b32228;
|
||||
--danger-fill-fg: #ffffff;
|
||||
--info-solid: #a1a1a1;
|
||||
--info-fg: #ededed;
|
||||
--info-soft: #0e0e0e;
|
||||
--info-border: #1f1f1f;
|
||||
--well-light: #f6f4f0;
|
||||
--well-dark: #242220;
|
||||
--well-light-fg: #57534d;
|
||||
--well-dark-fg: #bab4aa;
|
||||
--qr-bg: #ffffff;
|
||||
--qr-fg: #121212;
|
||||
--scrim: rgb(0 0 0 / 0.6);
|
||||
--ring: #ededed;
|
||||
--ring-halo: rgb(237 237 237 / 0.24);
|
||||
--elevation-popover: 0 1px 2px rgb(0 0 0 / 0.4), 0 8px 24px -6px rgb(0 0 0 / 0.6);
|
||||
--elevation-overlay: 0 2px 4px rgb(0 0 0 / 0.4), 0 16px 40px -8px rgb(0 0 0 / 0.7);
|
||||
--elevation-thumb: 0 1px 2px rgb(0 0 0 / 0.5);
|
||||
--elevation-selected: 0 1px 2px rgb(0 0 0 / 0.4), 0 4px 14px rgb(0 0 0 / 0.5);
|
||||
--sidebar: #0a0a0a;
|
||||
--primary: #ededed;
|
||||
--primary-foreground: #000000;
|
||||
--primary-hover: #d6d6d6;
|
||||
--primary-control: #ededed;
|
||||
--primary-control-foreground: #000000;
|
||||
--primary-edge: transparent;
|
||||
--fw-display-l: 500;
|
||||
--fw-display-m: 500;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Reduced motion: the OS setting and the Disable animations setting share one token collapse */
|
||||
html.no-motion {
|
||||
--dur-fast: 0ms;
|
||||
--dur-base: 0ms;
|
||||
--dur-slow: 0ms;
|
||||
--rise: 0px;
|
||||
--nudge: 0px;
|
||||
--slide: 0px;
|
||||
--press-scale: 1;
|
||||
--pop-scale: 1;
|
||||
--t-pulse: 0ms;
|
||||
--t-caret: 0ms;
|
||||
--t-progress: 0ms;
|
||||
--t-spin: 1200ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--dur-fast: 0ms;
|
||||
--dur-base: 0ms;
|
||||
--dur-slow: 0ms;
|
||||
--rise: 0px;
|
||||
--nudge: 0px;
|
||||
--slide: 0px;
|
||||
--press-scale: 1;
|
||||
--pop-scale: 1;
|
||||
--t-pulse: 0ms;
|
||||
--t-caret: 0ms;
|
||||
--t-progress: 0ms;
|
||||
--t-spin: 1200ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safety net for motion that does not read the tokens (bits-ui keyframes, svelte-sonner): only spinners and scroll-linked fades keep moving */
|
||||
html.no-motion *:not(.animate-spin, .scroll-fade-x, .scroll-fade-y),
|
||||
html.no-motion *::before,
|
||||
html.no-motion *::after {
|
||||
animation-duration: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*:not(.animate-spin, .scroll-fade-x, .scroll-fade-y),
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Set for two frames by setModeInstantly, so light and dark swap at once instead of fading control by control */
|
||||
html.theme-switching *,
|
||||
html.theme-switching *::before,
|
||||
html.theme-switching *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Funnel Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-display: 'Funnel Display', 'Funnel Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 24px;
|
||||
--radius-3xl: 28px;
|
||||
--radius-4xl: 32px;
|
||||
|
||||
--ease-out: cubic-bezier(0.2, 0, 0, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Radius (for rounded-*) */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--text-display-l: 34px;
|
||||
--text-display-l--line-height: 42px;
|
||||
--text-display-l--font-weight: var(--fw-display-l);
|
||||
--text-display-l--letter-spacing: -0.02em;
|
||||
--text-display-m: 28px;
|
||||
--text-display-m--line-height: 36px;
|
||||
--text-display-m--font-weight: var(--fw-display-m);
|
||||
--text-display-m--letter-spacing: -0.015em;
|
||||
--text-display-s: 22px;
|
||||
--text-display-s--line-height: 30px;
|
||||
--text-display-s--font-weight: 500;
|
||||
--text-display-s--letter-spacing: -0.01em;
|
||||
--text-title: 18px;
|
||||
--text-title--line-height: 26px;
|
||||
--text-title--font-weight: 600;
|
||||
--text-title--letter-spacing: 0em;
|
||||
--text-heading: 15px;
|
||||
--text-heading--line-height: 22px;
|
||||
--text-heading--font-weight: 600;
|
||||
--text-heading--letter-spacing: 0em;
|
||||
--text-body-l: 17px;
|
||||
--text-body-l--line-height: 26px;
|
||||
--text-body-l--font-weight: 400;
|
||||
--text-body-l--letter-spacing: 0em;
|
||||
--text-body: 15px;
|
||||
--text-body--line-height: 22px;
|
||||
--text-body--font-weight: 400;
|
||||
--text-body--letter-spacing: 0em;
|
||||
--text-control: 14px;
|
||||
--text-control--line-height: 20px;
|
||||
--text-control--font-weight: 400;
|
||||
--text-control--letter-spacing: 0em;
|
||||
--text-caption: 13px;
|
||||
--text-caption--line-height: 18px;
|
||||
--text-caption--font-weight: 400;
|
||||
--text-caption--letter-spacing: 0em;
|
||||
--text-micro: 12px;
|
||||
--text-micro--line-height: 16px;
|
||||
--text-micro--font-weight: 500;
|
||||
--text-micro--letter-spacing: 0.01em;
|
||||
--text-mono: 14px;
|
||||
--text-mono--line-height: 20px;
|
||||
--text-mono--font-weight: 400;
|
||||
--text-mono--letter-spacing: 0em;
|
||||
--text-mono-s: 13px;
|
||||
--text-mono-s--line-height: 18px;
|
||||
--text-mono-s--font-weight: 400;
|
||||
--text-mono-s--letter-spacing: 0em;
|
||||
--text-code-cell: 30px;
|
||||
--text-code-cell--line-height: 38px;
|
||||
--text-code-cell--font-weight: 500;
|
||||
--text-code-cell--letter-spacing: 0em;
|
||||
|
||||
--shadow-popover: var(--elevation-popover);
|
||||
--shadow-overlay: var(--elevation-overlay);
|
||||
--shadow-thumb: var(--elevation-thumb);
|
||||
--shadow-selected: var(--elevation-selected);
|
||||
|
||||
/* Colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-radius: var(--radius);
|
||||
--color-sidebar-background: var(--sidebar-background);
|
||||
--color-ring-halo: var(--ring-halo);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
@@ -145,37 +373,96 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Font */
|
||||
--font-gloock: 'gloock', serif;
|
||||
--color-scrim: var(--scrim);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-raised: var(--surface-raised);
|
||||
--color-surface-sunken: var(--surface-sunken);
|
||||
--color-fill-muted: var(--fill-muted);
|
||||
--color-fill-hover: var(--fill-hover);
|
||||
--color-fill-row-hover: var(--fill-row-hover);
|
||||
--color-fill-selected: var(--fill-selected);
|
||||
--color-fill-skeleton: var(--fill-skeleton);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: var(--border-strong);
|
||||
--color-border-selected: var(--border-selected);
|
||||
--color-fg: var(--fg);
|
||||
--color-fg-muted: var(--fg-muted);
|
||||
--color-fg-subtle: var(--fg-subtle);
|
||||
--color-inverse: var(--inverse);
|
||||
--color-inverse-fg: var(--inverse-fg);
|
||||
--color-inverse-muted: var(--inverse-muted);
|
||||
--color-inverse-hover: var(--inverse-hover);
|
||||
--color-inverse-border: var(--inverse-border);
|
||||
--color-success-solid: var(--success-solid);
|
||||
--color-success-soft: var(--success-soft);
|
||||
--color-warning-solid: var(--warning-solid);
|
||||
--color-warning-fg: var(--warning-fg);
|
||||
--color-danger-solid: var(--danger-solid);
|
||||
--color-danger-fg: var(--danger-fg);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
--color-danger-fill: var(--danger-fill);
|
||||
--color-danger-fill-hover: var(--danger-fill-hover);
|
||||
--color-danger-fill-fg: var(--danger-fill-fg);
|
||||
--color-well-light: var(--well-light);
|
||||
--color-well-dark: var(--well-dark);
|
||||
--color-well-light-fg: var(--well-light-fg);
|
||||
--color-well-dark-fg: var(--well-dark-fg);
|
||||
--color-qr-bg: var(--qr-bg);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-primary-control: var(--primary-control);
|
||||
--color-primary-control-foreground: var(--primary-control-foreground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@apply border-border outline-ring;
|
||||
}
|
||||
|
||||
/* Links and anything else without its own focus style get the standard ring, which component utilities override */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Names are interpolated into sentences, so a long unbroken one wraps instead of pushing the page sideways */
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-canvas text-body text-fg wrap-break-word;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Gloock';
|
||||
src: url('/fonts/Gloock-Regular.woff') format('woff');
|
||||
/* Text selection stays neutral: the accent marks state, never decoration */
|
||||
::selection {
|
||||
background: var(--fill-selected);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Lucide icons keep a constant stroke regardless of their rendered size */
|
||||
svg.lucide {
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
svg.lucide * {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compositor-only motion; Tailwind 4 writes scale and translate as their own properties, so they are listed next to transform */
|
||||
@utility transition-motion {
|
||||
transition-property: opacity, transform, translate, scale;
|
||||
transition-duration: var(--dur-fast);
|
||||
transition-timing-function: var(--ease-out);
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
@font-face {
|
||||
font-family: 'Funnel Display';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FunnelDisplay-latin-ext.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
|
||||
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Funnel Display';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FunnelDisplay-latin.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Funnel Sans';
|
||||
font-style: italic;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FunnelSans-Italic-latin-ext.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
|
||||
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Funnel Sans';
|
||||
font-style: italic;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FunnelSans-Italic-latin.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Funnel Sans';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FunnelSans-latin-ext.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
|
||||
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Funnel Sans';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FunnelSans-latin.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-cyrillic-ext.woff2') format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-greek.woff2') format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-vietnamese.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301,
|
||||
U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-latin-ext.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
|
||||
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-latin.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
<a href="/" class="flex items-center transition-opacity hover:opacity-80">
|
||||
<Logo class="size-8" />
|
||||
<Separator orientation="vertical" class="mr-3 ml-2 h-5! bg-neutral-600" />
|
||||
<h1 class="font-gloock text-2xl" data-testid="application-name">
|
||||
<h1 class="font-display text-2xl" data-testid="application-name">
|
||||
{m.settings()}
|
||||
</h1>
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import AppConfigService from '#lib/services/app-config-service.ts';
|
||||
import type { AppConfig } from '#lib/types/application-configuration.type.ts';
|
||||
import { applyAccentColor } from '#lib/utils/accent-color-util.ts';
|
||||
import { applyColorTheme } from '#lib/utils/color-theme-util.ts';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const appConfigStore = writable<AppConfig>();
|
||||
@@ -13,7 +13,8 @@ const reload = async () => {
|
||||
};
|
||||
|
||||
const set = (appConfig: AppConfig) => {
|
||||
applyAccentColor(appConfig.accentColor);
|
||||
applyColorTheme(appConfig.colorTheme);
|
||||
document.documentElement.classList.toggle('no-motion', appConfig.disableAnimations);
|
||||
appConfigStore.set(appConfig);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export type AppConfig = {
|
||||
ldapEnabled: boolean;
|
||||
disableAnimations: boolean;
|
||||
uiConfigDisabled: boolean;
|
||||
accentColor: string;
|
||||
colorTheme: string;
|
||||
requireUserEmail: boolean;
|
||||
tracingEnabled: boolean;
|
||||
iconLibrary: 'default' | 'custom' | 'disabled';
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { colorThemes } from './color-themes.ts';
|
||||
|
||||
// A stored value is a theme id, or custom:<css color> for a custom theme built from one color, with a :dark suffix when its sidebar is dark
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
type Oklch = { l: number; c: number; h: number };
|
||||
type Mode = 'light' | 'dark';
|
||||
|
||||
export type AccentInputs = {
|
||||
name: Mode;
|
||||
surface: Rgb;
|
||||
canvas: Rgb;
|
||||
fg: Rgb;
|
||||
inkFill: Rgb;
|
||||
inkFillFg: Rgb;
|
||||
inkHover: Rgb;
|
||||
borderControl: Rgb;
|
||||
controlBgs: Rgb[];
|
||||
semanticHues: Record<string, number>;
|
||||
};
|
||||
|
||||
export type ColorTheme = {
|
||||
id: string;
|
||||
accent: string;
|
||||
tokens: Record<Mode, Record<string, string>>;
|
||||
accentInputs: Record<Mode, AccentInputs>;
|
||||
};
|
||||
|
||||
export const DEFAULT_COLOR_THEME = 'mono';
|
||||
|
||||
// sRGB transfer functions
|
||||
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
||||
const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
|
||||
|
||||
// OKLCH to linear sRGB (Björn Ottosson's matrices)
|
||||
function oklchToLinear({ l, c, h }: Oklch): Rgb {
|
||||
const hr = (h * Math.PI) / 180;
|
||||
const a = c * Math.cos(hr);
|
||||
const b = c * Math.sin(hr);
|
||||
const L = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
||||
const M = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
||||
const S = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
||||
return [
|
||||
4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,
|
||||
-1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,
|
||||
-0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S
|
||||
];
|
||||
}
|
||||
|
||||
function linearToOklch([r, g, b]: Rgb): Oklch {
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
||||
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
||||
let h = (Math.atan2(B, A) * 180) / Math.PI;
|
||||
if (h < 0) h += 360;
|
||||
return {
|
||||
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
c: Math.sqrt(A * A + B * B),
|
||||
h
|
||||
};
|
||||
}
|
||||
|
||||
const inGamut = (lin: Rgb) => lin.every((v) => v >= -0.0005 && v <= 1.0005);
|
||||
|
||||
// Reduce chroma until the color fits sRGB, keeping lightness and hue (CSS Color 4 style)
|
||||
function clampChroma(o: Oklch): Oklch {
|
||||
if (inGamut(oklchToLinear(o))) return o;
|
||||
let lo = 0;
|
||||
let hi = o.c;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (inGamut(oklchToLinear({ ...o, c: mid }))) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return { ...o, c: lo };
|
||||
}
|
||||
|
||||
function oklchToRgb(o: Oklch): Rgb {
|
||||
return oklchToLinear(clampChroma(o)).map((v) =>
|
||||
Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)
|
||||
) as Rgb;
|
||||
}
|
||||
|
||||
const rgbToOklch = (rgb: Rgb) => linearToOklch(rgb.map((v) => toLinear(v / 255)) as Rgb);
|
||||
const hex = (rgb: Rgb) => '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): Rgb {
|
||||
h = (((h % 360) + 360) % 360) / 360;
|
||||
const f = (n: number) => {
|
||||
const k = (n + h * 12) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||||
};
|
||||
return [f(0), f(8), f(4)].map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255)) as Rgb;
|
||||
}
|
||||
|
||||
// Parses #rgb, #rrggbb(aa), rgb(), hsl() and oklch(), the formats the custom color dialog and the stored presets use
|
||||
export function parseColor(input: string): { rgb: Rgb; alpha: number } | null {
|
||||
const s = String(input).trim().toLowerCase();
|
||||
const alphaOf = (v: string | undefined, pct: string | undefined) =>
|
||||
v === undefined || v === '' ? 1 : pct ? +v / 100 : +v;
|
||||
let m;
|
||||
if ((m = s.match(/^#([0-9a-f]{3})([0-9a-f])?$/)))
|
||||
return {
|
||||
rgb: m[1].split('').map((x) => parseInt(x + x, 16)) as Rgb,
|
||||
alpha: m[2] ? parseInt(m[2] + m[2], 16) / 255 : 1
|
||||
};
|
||||
if ((m = s.match(/^#([0-9a-f]{6})([0-9a-f]{2})?$/))) {
|
||||
const digits = m[1];
|
||||
return {
|
||||
rgb: [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)) as Rgb,
|
||||
alpha: m[2] ? parseInt(m[2], 16) / 255 : 1
|
||||
};
|
||||
}
|
||||
if (
|
||||
(m = s.match(
|
||||
/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?))?\s*\)$/
|
||||
))
|
||||
)
|
||||
return {
|
||||
rgb: [m[1], m[2], m[3]].map((x) => Math.round(+x)) as Rgb,
|
||||
alpha: alphaOf(m[4], m[5])
|
||||
};
|
||||
if (
|
||||
(m = s.match(
|
||||
/^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*(?:[,/]\s*([\d.]+)(%?))?\s*\)$/
|
||||
))
|
||||
)
|
||||
return { rgb: hslToRgb(+m[1], +m[2] / 100, +m[3] / 100), alpha: alphaOf(m[4], m[5]) };
|
||||
if (
|
||||
(m = s.match(/^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+)(%?))?\s*\)$/))
|
||||
) {
|
||||
const l = m[2] ? +m[1] / 100 : +m[1];
|
||||
return { rgb: oklchToRgb({ l, c: +m[3], h: +m[4] }), alpha: alphaOf(m[5], m[6]) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function luminance(rgb: Rgb) {
|
||||
const [r, g, b] = rgb.map((v) => toLinear(v / 255));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrast(a: Rgb, b: Rgb) {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
const WHITE: Rgb = [255, 255, 255];
|
||||
const INK_ON_ACCENT = oklchToRgb({ l: 0.17, c: 0.004, h: 75 });
|
||||
|
||||
// Lightness search bounds for the accent fit
|
||||
const FIT_MAX_DEEPEN = 0.12;
|
||||
const DARK_MIN_SURFACE_CONTRAST = 2.2;
|
||||
const DARK_MAX_LIFT = 0.3;
|
||||
const HOVER_STEP = 0.045;
|
||||
// The near-white ink fill of dark mode needs a larger step than operator colors before its hover is visible
|
||||
const DARK_INK_HOVER_STEP = 0.07;
|
||||
// Controls need 3:1, and 3.2 leaves margin for rounding and antialiasing
|
||||
const CONTROL_TARGET = 3.2;
|
||||
const CONTROL_MAX_LIFT = 0.12;
|
||||
// A fill below this contrast against the surface or canvas gets a 1px border so the button keeps its shape
|
||||
const EDGE_MIN = 1.5;
|
||||
// An accent within this hue window of a semantic color, at visible chroma, may read as a status
|
||||
const SEMANTIC_HUE_WINDOW = 20;
|
||||
const SEMANTIC_MIN_CHROMA = 0.08;
|
||||
|
||||
export type DerivedAccent = {
|
||||
fill: Rgb;
|
||||
fillFg: Rgb;
|
||||
hover: Rgb;
|
||||
control: Rgb;
|
||||
controlFg: Rgb;
|
||||
edge: Rgb | null;
|
||||
// The fill was darkened so white text passes, or lightened so it stays visible on the dark surface
|
||||
deepened: boolean;
|
||||
lifted: boolean;
|
||||
// The semantic color the accent sits next to, so the picker can warn that a primary may read as a status
|
||||
nearSemantic: string | null;
|
||||
};
|
||||
|
||||
// Derives every primary token for one theme and mode from an operator color, keeping WCAG AA for text and 3:1 for controls
|
||||
export function deriveAccent(input: string, theme: AccentInputs): DerivedAccent {
|
||||
const isDefault = !input || input.trim().toLowerCase() === 'default';
|
||||
const parsed = isDefault ? null : parseColor(input);
|
||||
let rgb = parsed && parsed.alpha >= 1 ? parsed.rgb : null;
|
||||
|
||||
// Default, and anything unusable, uses the ink pair: near-black in light, near-white in dark
|
||||
let fill = theme.inkFill;
|
||||
let fillFg = theme.inkFillFg;
|
||||
let hover = theme.inkHover;
|
||||
let fromOperator = false;
|
||||
let deepened = false;
|
||||
let lifted = false;
|
||||
|
||||
if (rgb) {
|
||||
let o = rgbToOklch(rgb);
|
||||
|
||||
// Keep the operator hue, but deepen by at most FIT_MAX_DEEPEN when that lets white text pass AA
|
||||
if (contrast(WHITE, rgb) < 4.5) {
|
||||
for (let d = 0.005; d <= FIT_MAX_DEEPEN + 1e-9; d += 0.005) {
|
||||
const cand = oklchToRgb({ ...o, l: o.l - d });
|
||||
if (contrast(WHITE, cand) >= 4.5) {
|
||||
rgb = cand;
|
||||
o = rgbToOklch(cand);
|
||||
deepened = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In dark mode, lift fills that would disappear into the surface
|
||||
if (theme.name === 'dark' && contrast(rgb, theme.surface) < DARK_MIN_SURFACE_CONTRAST) {
|
||||
for (let d = 0.01; d <= DARK_MAX_LIFT + 1e-9; d += 0.01) {
|
||||
const cand = oklchToRgb({ ...o, l: o.l + d });
|
||||
if (contrast(cand, theme.surface) >= DARK_MIN_SURFACE_CONTRAST) {
|
||||
rgb = cand;
|
||||
o = rgbToOklch(cand);
|
||||
lifted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the text color, preferring white, and fall back to ink when neither passes
|
||||
const textColor =
|
||||
contrast(WHITE, rgb) >= 4.5
|
||||
? WHITE
|
||||
: contrast(INK_ON_ACCENT, rgb) >= 4.5
|
||||
? INK_ON_ACCENT
|
||||
: null;
|
||||
if (textColor) {
|
||||
fill = rgb;
|
||||
fillFg = textColor;
|
||||
fromOperator = true;
|
||||
|
||||
// Hover moves lightness away from the text color so contrast only grows
|
||||
const dir = textColor === WHITE ? -1 : 1;
|
||||
let hl = o.l + dir * HOVER_STEP;
|
||||
if (hl < 0.1 || hl > 0.96) hl = o.l - dir * HOVER_STEP;
|
||||
const hov = oklchToRgb({ ...o, l: hl });
|
||||
hover = contrast(textColor, hov) >= 4.5 ? hov : rgb;
|
||||
}
|
||||
}
|
||||
|
||||
// Switch tracks, checkboxes, radios and the focus ring need 3:1 against every background a control can sit on
|
||||
const minVs = (c: Rgb) => Math.min(...theme.controlBgs.map((b) => contrast(c, b)));
|
||||
// The glyph on a control needs 3:1 on the control; prefer the fill's own text color
|
||||
const glyphFor = (c: Rgb) =>
|
||||
[fillFg, WHITE, INK_ON_ACCENT].find((g) => contrast(g, c) >= 3) || null;
|
||||
let control: Rgb | null = null;
|
||||
if (minVs(fill) >= CONTROL_TARGET && glyphFor(fill)) control = fill;
|
||||
else if (theme.name === 'dark' && fromOperator) {
|
||||
// In dark mode, lift the control on its own before giving up, so a mid-tone brand color stays the brand color on switches and rings
|
||||
const oc = rgbToOklch(fill);
|
||||
for (let d = 0.005; d <= CONTROL_MAX_LIFT + 1e-9; d += 0.005) {
|
||||
const cand = oklchToRgb({ ...oc, l: oc.l + d });
|
||||
if (minVs(cand) >= CONTROL_TARGET && glyphFor(cand)) {
|
||||
control = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsEdge =
|
||||
contrast(fill, theme.surface) < EDGE_MIN || contrast(fill, theme.canvas) < EDGE_MIN;
|
||||
|
||||
let nearSemantic: string | null = null;
|
||||
if (fromOperator) {
|
||||
const fo = rgbToOklch(fill);
|
||||
if (fo.c >= SEMANTIC_MIN_CHROMA) {
|
||||
let best = SEMANTIC_HUE_WINDOW + 1e-9;
|
||||
for (const [tone, h] of Object.entries(theme.semanticHues)) {
|
||||
const dh = Math.min(Math.abs(fo.h - h), 360 - Math.abs(fo.h - h));
|
||||
if (dh <= best) {
|
||||
best = dh;
|
||||
nearSemantic = tone;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fill,
|
||||
fillFg,
|
||||
hover,
|
||||
control: control || theme.fg,
|
||||
controlFg: control ? glyphFor(control)! : theme.inkFillFg,
|
||||
edge: needsEdge ? theme.borderControl : null,
|
||||
deepened,
|
||||
lifted,
|
||||
nearSemantic
|
||||
};
|
||||
}
|
||||
|
||||
export function primaryVars(d: DerivedAccent): Record<string, string> {
|
||||
const rgba = (rgb: Rgb, a: number) => `rgb(${rgb.join(' ')} / ${a})`;
|
||||
return {
|
||||
primary: hex(d.fill),
|
||||
'primary-foreground': hex(d.fillFg),
|
||||
'primary-hover': hex(d.hover),
|
||||
'primary-control': hex(d.control),
|
||||
'primary-control-foreground': hex(d.controlFg),
|
||||
ring: hex(d.control),
|
||||
'ring-halo': rgba(d.control, 0.24),
|
||||
'primary-edge': d.edge ? hex(d.edge) : 'transparent'
|
||||
};
|
||||
}
|
||||
|
||||
export function parseColorTheme(value: string): {
|
||||
id: string;
|
||||
color?: string;
|
||||
darkSidebar?: boolean;
|
||||
} {
|
||||
if (!value.startsWith('custom:')) return { id: value };
|
||||
const color = value.slice('custom:'.length);
|
||||
if (color.endsWith(':dark'))
|
||||
return { id: 'custom', color: color.slice(0, -':dark'.length), darkSidebar: true };
|
||||
return { id: 'custom', color, darkSidebar: false };
|
||||
}
|
||||
|
||||
export function customColorTheme(color: string, darkSidebar: boolean) {
|
||||
return `custom:${color}${darkSidebar ? ':dark' : ''}`;
|
||||
}
|
||||
|
||||
const themeById = (id: string) => colorThemes.find((t) => t.id === id);
|
||||
|
||||
// A custom theme tints Sage's neutrals with the operator's hue at the same lightness, so contrast stays where Sage has it
|
||||
// A muted color tints less and a gray one gives plain grays; the accent is the color itself
|
||||
// A dark sidebar tints Midnight's sidebar tokens the same way, so the rail keeps Midnight's contrast
|
||||
function customTheme(color: string, darkSidebar: boolean): ColorTheme {
|
||||
const sage = themeById('sage')!;
|
||||
const parsed = parseColor(color);
|
||||
if (!parsed || parsed.alpha < 1)
|
||||
return { ...themeById(DEFAULT_COLOR_THEME)!, id: 'custom', accent: color };
|
||||
const o = rgbToOklch(parsed.rgb);
|
||||
const k = Math.min(1, o.c / 0.12);
|
||||
const tint = (value: string) => {
|
||||
const t = rgbToOklch(parseColor(value)!.rgb);
|
||||
return hex(oklchToRgb({ l: t.l, c: t.c * k, h: o.h }));
|
||||
};
|
||||
const tokens = {} as ColorTheme['tokens'];
|
||||
const accentInputs = {} as ColorTheme['accentInputs'];
|
||||
for (const mode of ['light', 'dark'] as const) {
|
||||
tokens[mode] = Object.fromEntries(
|
||||
Object.entries(sage.tokens[mode]).map(([key, value]) => [key, tint(value)])
|
||||
);
|
||||
if (darkSidebar) {
|
||||
for (const [key, value] of Object.entries(themeById('midnight')!.tokens[mode])) {
|
||||
if (key === 'sidebar' || key.startsWith('sidebar-')) tokens[mode][key] = tint(value);
|
||||
}
|
||||
}
|
||||
const t = tokens[mode];
|
||||
const rgb = (key: string) => parseColor(t[key])!.rgb;
|
||||
accentInputs[mode] = {
|
||||
...sage.accentInputs[mode],
|
||||
surface: rgb('surface'),
|
||||
canvas: rgb('canvas'),
|
||||
fg: rgb('fg'),
|
||||
inkFill: rgb('fg'),
|
||||
inkFillFg: mode === 'light' ? WHITE : rgb('canvas'),
|
||||
inkHover:
|
||||
mode === 'light'
|
||||
? rgb('inverse-hover')
|
||||
: oklchToRgb({
|
||||
...rgbToOklch(rgb('fg')),
|
||||
l: rgbToOklch(rgb('fg')).l - DARK_INK_HOVER_STEP
|
||||
}),
|
||||
borderControl: rgb('border-strong'),
|
||||
controlBgs: [
|
||||
'canvas',
|
||||
'surface',
|
||||
'surface-raised',
|
||||
'surface-sunken',
|
||||
'fill-muted',
|
||||
'fill-hover',
|
||||
'fill-row-hover',
|
||||
'fill-selected'
|
||||
].map(rgb)
|
||||
};
|
||||
}
|
||||
return {
|
||||
...sage,
|
||||
id: 'custom',
|
||||
accent: color,
|
||||
tokens,
|
||||
accentInputs,
|
||||
sidebarVars: darkSidebar ? themeById('midnight')!.sidebarVars : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function colorThemeFor(value: string): ColorTheme {
|
||||
const { id, color, darkSidebar } = parseColorTheme(value || DEFAULT_COLOR_THEME);
|
||||
if (id === 'custom' && color) return customTheme(color, !!darkSidebar);
|
||||
return themeById(id) || themeById(DEFAULT_COLOR_THEME)!;
|
||||
}
|
||||
|
||||
// Writes the theme's neutrals and derived primary family for light and dark as one stylesheet that overrides app.css
|
||||
export function applyColorTheme(value: string) {
|
||||
const theme = colorThemeFor(value);
|
||||
const block = (vars: Record<string, string>) =>
|
||||
Object.entries(vars)
|
||||
.map(([k, v]) => `--${k}: ${v};`)
|
||||
.join(' ');
|
||||
const css = (['light', 'dark'] as const)
|
||||
.map((mode) => {
|
||||
const vars = {
|
||||
...theme.tokens[mode],
|
||||
...primaryVars(deriveAccent(theme.accent, theme.accentInputs[mode]))
|
||||
};
|
||||
return `${mode === 'light' ? 'html:root' : 'html.dark'} { ${block(vars)} }`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
let el = document.getElementById('color-theme');
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = 'color-theme';
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
}
|
||||
|
||||
// The light and dark halves of a theme tile: sidebar, page, selected line, text line and the fitted primary
|
||||
export function colorThemePreview(value: string) {
|
||||
const theme = colorThemeFor(value);
|
||||
return (['light', 'dark'] as const).map((mode) => {
|
||||
const t = theme.tokens[mode];
|
||||
return {
|
||||
mode,
|
||||
sidebar: t.sidebar,
|
||||
page: t.canvas,
|
||||
line: t['fill-selected'],
|
||||
text: t['fg-muted'],
|
||||
primary: hex(deriveAccent(theme.accent, theme.accentInputs[mode]).fill)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// mode-watcher's own disableTransitions stays off because it also blocks transitions briefly at load, which cut the authorize animation short
|
||||
export function setModeInstantly(mode: 'light' | 'dark' | 'system') {
|
||||
const root = document.documentElement;
|
||||
root.classList.add('theme-switching');
|
||||
setMode(mode);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => root.classList.remove('theme-switching'))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
// Each theme sets the neutrals for light and dark; color-theme-util.ts derives the primary family from its accent at runtime
|
||||
import type { ColorTheme } from './color-theme-util.ts';
|
||||
|
||||
export const colorThemes: ColorTheme[] = [
|
||||
{
|
||||
id: 'mono',
|
||||
accent: 'default',
|
||||
tokens: {
|
||||
light: {
|
||||
sidebar: '#fafafa',
|
||||
canvas: '#ffffff',
|
||||
'surface-sunken': '#f5f5f5',
|
||||
'fill-muted': '#f2f2f2',
|
||||
'fill-hover': '#eeeeee',
|
||||
'fill-row-hover': '#fafafa',
|
||||
'fill-selected': '#ebebeb',
|
||||
border: '#e8e8e8',
|
||||
'border-strong': '#8c8c8c',
|
||||
fg: '#0a0a0a',
|
||||
'fg-muted': '#5e5e5e',
|
||||
'fg-subtle': '#a3a3a3',
|
||||
inverse: '#111111',
|
||||
'inverse-fg': '#fafafa',
|
||||
'inverse-muted': '#b5b5b5',
|
||||
'inverse-hover': '#262626',
|
||||
surface: '#ffffff',
|
||||
'surface-raised': '#ffffff',
|
||||
'fill-skeleton': '#eeeeee',
|
||||
'border-control': '#8c8c8c',
|
||||
'border-field': '#8c8c8c',
|
||||
'on-solid': '#ffffff',
|
||||
'info-solid': '#5e5e5e',
|
||||
'info-fg': '#0a0a0a',
|
||||
'info-soft': '#f5f5f5',
|
||||
'info-border': '#e8e8e8',
|
||||
'qr-fg': '#121212'
|
||||
},
|
||||
dark: {
|
||||
sidebar: '#0a0a0a',
|
||||
canvas: '#000000',
|
||||
'surface-sunken': '#0e0e0e',
|
||||
'fill-muted': '#171717',
|
||||
'fill-hover': '#1a1a1a',
|
||||
'fill-row-hover': '#0c0c0c',
|
||||
'fill-selected': '#1f1f1f',
|
||||
border: '#1f1f1f',
|
||||
'border-strong': '#6b6b6b',
|
||||
fg: '#ededed',
|
||||
'fg-muted': '#a1a1a1',
|
||||
'fg-subtle': '#5c5c5c',
|
||||
inverse: '#1f1f1f',
|
||||
'inverse-fg': '#ededed',
|
||||
'inverse-muted': '#b5b5b5',
|
||||
'inverse-hover': '#2e2e2e',
|
||||
'inverse-border': '#333333',
|
||||
surface: '#000000',
|
||||
'surface-raised': '#121212',
|
||||
'fill-skeleton': '#1a1a1a',
|
||||
'border-control': '#6b6b6b',
|
||||
'border-field': '#6b6b6b',
|
||||
'on-solid': '#0a0a0a',
|
||||
'info-solid': '#a1a1a1',
|
||||
'info-fg': '#ededed',
|
||||
'info-soft': '#0e0e0e',
|
||||
'info-border': '#1f1f1f',
|
||||
'qr-fg': '#121212'
|
||||
}
|
||||
},
|
||||
accentInputs: {
|
||||
light: {
|
||||
name: 'light',
|
||||
surface: [255, 255, 255],
|
||||
canvas: [255, 255, 255],
|
||||
fg: [10, 10, 10],
|
||||
inkFill: [10, 10, 10],
|
||||
inkFillFg: [255, 255, 255],
|
||||
inkHover: [38, 38, 38],
|
||||
borderControl: [140, 140, 140],
|
||||
controlBgs: [
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[245, 245, 245],
|
||||
[242, 242, 242],
|
||||
[238, 238, 238],
|
||||
[250, 250, 250],
|
||||
[235, 235, 235]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 65,
|
||||
success: 150
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
name: 'dark',
|
||||
surface: [0, 0, 0],
|
||||
canvas: [0, 0, 0],
|
||||
fg: [237, 237, 237],
|
||||
inkFill: [237, 237, 237],
|
||||
inkFillFg: [0, 0, 0],
|
||||
inkHover: [214, 214, 214],
|
||||
borderControl: [107, 107, 107],
|
||||
controlBgs: [
|
||||
[0, 0, 0],
|
||||
[0, 0, 0],
|
||||
[18, 18, 18],
|
||||
[14, 14, 14],
|
||||
[23, 23, 23],
|
||||
[26, 26, 26],
|
||||
[12, 12, 12],
|
||||
[31, 31, 31]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 75,
|
||||
success: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mist',
|
||||
accent: 'oklch(0.6 0.2 240)',
|
||||
tokens: {
|
||||
light: {
|
||||
sidebar: '#f4f5f7',
|
||||
canvas: '#ffffff',
|
||||
'surface-sunken': '#f7f8fa',
|
||||
'fill-muted': '#f0f2f5',
|
||||
'fill-hover': '#eceef2',
|
||||
'fill-row-hover': '#f9fafb',
|
||||
'fill-selected': '#e6e9ee',
|
||||
border: '#e3e6eb',
|
||||
'border-strong': '#868d99',
|
||||
fg: '#0f1218',
|
||||
'fg-muted': '#545b67',
|
||||
'fg-subtle': '#9aa1ad',
|
||||
inverse: '#161a21',
|
||||
'inverse-fg': '#f4f5f7',
|
||||
'inverse-muted': '#b3b9c4',
|
||||
'inverse-hover': '#262b34',
|
||||
surface: '#ffffff',
|
||||
'surface-raised': '#ffffff',
|
||||
'fill-skeleton': '#eceef2',
|
||||
'border-control': '#868d99',
|
||||
'border-field': '#868d99',
|
||||
'on-solid': '#ffffff',
|
||||
'info-solid': '#545b67',
|
||||
'info-fg': '#0f1218',
|
||||
'info-soft': '#f7f8fa',
|
||||
'info-border': '#e3e6eb',
|
||||
'qr-fg': '#121212'
|
||||
},
|
||||
dark: {
|
||||
sidebar: '#15171b',
|
||||
canvas: '#1b1e23',
|
||||
'surface-sunken': '#202328',
|
||||
'fill-muted': '#262a30',
|
||||
'fill-hover': '#272b31',
|
||||
'fill-row-hover': '#1f2227',
|
||||
'fill-selected': '#2c3138',
|
||||
border: '#2e333a',
|
||||
'border-strong': '#737b87',
|
||||
fg: '#eef0f3',
|
||||
'fg-muted': '#a3aab5',
|
||||
'fg-subtle': '#646b76',
|
||||
inverse: '#2c3138',
|
||||
'inverse-fg': '#eef0f3',
|
||||
'inverse-muted': '#c0c6cf',
|
||||
'inverse-hover': '#3a4048',
|
||||
'inverse-border': '#48505a',
|
||||
surface: '#1b1e23',
|
||||
'surface-raised': '#23272d',
|
||||
'fill-skeleton': '#272b31',
|
||||
'border-control': '#737b87',
|
||||
'border-field': '#737b87',
|
||||
'on-solid': '#15171b',
|
||||
'info-solid': '#a3aab5',
|
||||
'info-fg': '#eef0f3',
|
||||
'info-soft': '#202328',
|
||||
'info-border': '#2e333a',
|
||||
'qr-fg': '#121212'
|
||||
}
|
||||
},
|
||||
accentInputs: {
|
||||
light: {
|
||||
name: 'light',
|
||||
surface: [255, 255, 255],
|
||||
canvas: [255, 255, 255],
|
||||
fg: [15, 18, 24],
|
||||
inkFill: [15, 18, 24],
|
||||
inkFillFg: [255, 255, 255],
|
||||
inkHover: [38, 43, 52],
|
||||
borderControl: [134, 141, 153],
|
||||
controlBgs: [
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[247, 248, 250],
|
||||
[240, 242, 245],
|
||||
[236, 238, 242],
|
||||
[249, 250, 251],
|
||||
[230, 233, 238]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 65,
|
||||
success: 150
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
name: 'dark',
|
||||
surface: [27, 30, 35],
|
||||
canvas: [27, 30, 35],
|
||||
fg: [238, 240, 243],
|
||||
inkFill: [238, 240, 243],
|
||||
inkFillFg: [27, 30, 35],
|
||||
inkHover: [215, 217, 220],
|
||||
borderControl: [115, 123, 135],
|
||||
controlBgs: [
|
||||
[27, 30, 35],
|
||||
[27, 30, 35],
|
||||
[35, 39, 45],
|
||||
[32, 35, 40],
|
||||
[38, 42, 48],
|
||||
[39, 43, 49],
|
||||
[31, 34, 39],
|
||||
[44, 49, 56]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 75,
|
||||
success: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sage',
|
||||
accent: 'oklch(0.65 0.2 150)',
|
||||
tokens: {
|
||||
light: {
|
||||
sidebar: '#f1f4ee',
|
||||
canvas: '#ffffff',
|
||||
'surface-sunken': '#f6f8f4',
|
||||
'fill-muted': '#edf1ea',
|
||||
'fill-hover': '#e8ede4',
|
||||
'fill-row-hover': '#f8faf6',
|
||||
'fill-selected': '#dfe7da',
|
||||
border: '#e0e6db',
|
||||
'border-strong': '#838c7e',
|
||||
fg: '#121611',
|
||||
'fg-muted': '#515a4e',
|
||||
'fg-subtle': '#98a093',
|
||||
inverse: '#1a1f19',
|
||||
'inverse-fg': '#f1f4ee',
|
||||
'inverse-muted': '#b7bfb2',
|
||||
'inverse-hover': '#2b3229',
|
||||
surface: '#ffffff',
|
||||
'surface-raised': '#ffffff',
|
||||
'fill-skeleton': '#e8ede4',
|
||||
'border-control': '#838c7e',
|
||||
'border-field': '#838c7e',
|
||||
'on-solid': '#ffffff',
|
||||
'info-solid': '#515a4e',
|
||||
'info-fg': '#121611',
|
||||
'info-soft': '#f6f8f4',
|
||||
'info-border': '#e0e6db',
|
||||
'qr-fg': '#121212'
|
||||
},
|
||||
dark: {
|
||||
sidebar: '#151814',
|
||||
canvas: '#1b1f1a',
|
||||
'surface-sunken': '#20241e',
|
||||
'fill-muted': '#262b24',
|
||||
'fill-hover': '#272c25',
|
||||
'fill-row-hover': '#1f231e',
|
||||
'fill-selected': '#2d342a',
|
||||
border: '#2f362c',
|
||||
'border-strong': '#747d6f',
|
||||
fg: '#eef2eb',
|
||||
'fg-muted': '#a5ad9e',
|
||||
'fg-subtle': '#656d5f',
|
||||
inverse: '#2d342a',
|
||||
'inverse-fg': '#eef2eb',
|
||||
'inverse-muted': '#c3cabd',
|
||||
'inverse-hover': '#3b4238',
|
||||
'inverse-border': '#4a5246',
|
||||
surface: '#1b1f1a',
|
||||
'surface-raised': '#242922',
|
||||
'fill-skeleton': '#272c25',
|
||||
'border-control': '#747d6f',
|
||||
'border-field': '#747d6f',
|
||||
'on-solid': '#151814',
|
||||
'info-solid': '#a5ad9e',
|
||||
'info-fg': '#eef2eb',
|
||||
'info-soft': '#20241e',
|
||||
'info-border': '#2f362c',
|
||||
'qr-fg': '#121212'
|
||||
}
|
||||
},
|
||||
accentInputs: {
|
||||
light: {
|
||||
name: 'light',
|
||||
surface: [255, 255, 255],
|
||||
canvas: [255, 255, 255],
|
||||
fg: [18, 22, 17],
|
||||
inkFill: [18, 22, 17],
|
||||
inkFillFg: [255, 255, 255],
|
||||
inkHover: [43, 50, 41],
|
||||
borderControl: [131, 140, 126],
|
||||
controlBgs: [
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[246, 248, 244],
|
||||
[237, 241, 234],
|
||||
[232, 237, 228],
|
||||
[248, 250, 246],
|
||||
[223, 231, 218]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 65,
|
||||
success: 150
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
name: 'dark',
|
||||
surface: [27, 31, 26],
|
||||
canvas: [27, 31, 26],
|
||||
fg: [238, 242, 235],
|
||||
inkFill: [238, 242, 235],
|
||||
inkFillFg: [27, 31, 26],
|
||||
inkHover: [215, 219, 212],
|
||||
borderControl: [116, 125, 111],
|
||||
controlBgs: [
|
||||
[27, 31, 26],
|
||||
[27, 31, 26],
|
||||
[36, 41, 34],
|
||||
[32, 36, 30],
|
||||
[38, 43, 36],
|
||||
[39, 44, 37],
|
||||
[31, 35, 30],
|
||||
[45, 52, 42]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 75,
|
||||
success: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
accent: 'oklch(0.6 0.15 180)',
|
||||
tokens: {
|
||||
light: {
|
||||
sidebar: '#151b2e',
|
||||
canvas: '#ffffff',
|
||||
'surface-sunken': '#f6f7fa',
|
||||
'fill-muted': '#eff1f5',
|
||||
'fill-hover': '#ebedf2',
|
||||
'fill-row-hover': '#f9fafc',
|
||||
'fill-selected': '#e4e7ee',
|
||||
border: '#e2e5ec',
|
||||
'border-strong': '#858c9b',
|
||||
fg: '#0e1220',
|
||||
'fg-muted': '#535a6b',
|
||||
'fg-subtle': '#999fad',
|
||||
inverse: '#151b2e',
|
||||
'inverse-fg': '#f3f5f9',
|
||||
'inverse-muted': '#b4bccd',
|
||||
'inverse-hover': '#242c44',
|
||||
surface: '#ffffff',
|
||||
'surface-raised': '#ffffff',
|
||||
'fill-skeleton': '#ebedf2',
|
||||
'border-control': '#858c9b',
|
||||
'border-field': '#858c9b',
|
||||
'on-solid': '#ffffff',
|
||||
'info-solid': '#535a6b',
|
||||
'info-fg': '#0e1220',
|
||||
'info-soft': '#f6f7fa',
|
||||
'info-border': '#e2e5ec',
|
||||
'qr-fg': '#121212'
|
||||
},
|
||||
dark: {
|
||||
sidebar: '#0d111d',
|
||||
canvas: '#161a25',
|
||||
'surface-sunken': '#1b1f2a',
|
||||
'fill-muted': '#222735',
|
||||
'fill-hover': '#232836',
|
||||
'fill-row-hover': '#1a1e29',
|
||||
'fill-selected': '#292f3e',
|
||||
border: '#2a3040',
|
||||
'border-strong': '#727a8c',
|
||||
fg: '#edf0f6',
|
||||
'fg-muted': '#a2aabb',
|
||||
'fg-subtle': '#626a7b',
|
||||
inverse: '#292f3e',
|
||||
'inverse-fg': '#edf0f6',
|
||||
'inverse-muted': '#c0c7d4',
|
||||
'inverse-hover': '#363d4e',
|
||||
'inverse-border': '#454d60',
|
||||
surface: '#161a25',
|
||||
'surface-raised': '#1f2430',
|
||||
'fill-skeleton': '#232836',
|
||||
'border-control': '#727a8c',
|
||||
'border-field': '#727a8c',
|
||||
'on-solid': '#0d111d',
|
||||
'info-solid': '#a2aabb',
|
||||
'info-fg': '#edf0f6',
|
||||
'info-soft': '#1b1f2a',
|
||||
'info-border': '#2a3040',
|
||||
'qr-fg': '#121212'
|
||||
}
|
||||
},
|
||||
accentInputs: {
|
||||
light: {
|
||||
name: 'light',
|
||||
surface: [255, 255, 255],
|
||||
canvas: [255, 255, 255],
|
||||
fg: [14, 18, 32],
|
||||
inkFill: [14, 18, 32],
|
||||
inkFillFg: [255, 255, 255],
|
||||
inkHover: [36, 44, 68],
|
||||
borderControl: [133, 140, 155],
|
||||
controlBgs: [
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[246, 247, 250],
|
||||
[239, 241, 245],
|
||||
[235, 237, 242],
|
||||
[249, 250, 252],
|
||||
[228, 231, 238]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 65,
|
||||
success: 150
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
name: 'dark',
|
||||
surface: [22, 26, 37],
|
||||
canvas: [22, 26, 37],
|
||||
fg: [237, 240, 246],
|
||||
inkFill: [237, 240, 246],
|
||||
inkFillFg: [22, 26, 37],
|
||||
inkHover: [214, 217, 223],
|
||||
borderControl: [114, 122, 140],
|
||||
controlBgs: [
|
||||
[22, 26, 37],
|
||||
[22, 26, 37],
|
||||
[31, 36, 48],
|
||||
[27, 31, 42],
|
||||
[34, 39, 53],
|
||||
[35, 40, 54],
|
||||
[26, 30, 41],
|
||||
[41, 47, 62]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 75,
|
||||
success: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'lavender',
|
||||
accent: 'oklch(0.6 0.24 300)',
|
||||
tokens: {
|
||||
light: {
|
||||
sidebar: '#f5f4f9',
|
||||
canvas: '#ffffff',
|
||||
'surface-sunken': '#f8f7fb',
|
||||
'fill-muted': '#f0eef6',
|
||||
'fill-hover': '#ebe9f3',
|
||||
'fill-row-hover': '#faf9fc',
|
||||
'fill-selected': '#e6e2f1',
|
||||
border: '#e5e2ee',
|
||||
'border-strong': '#888498',
|
||||
fg: '#14121b',
|
||||
'fg-muted': '#56526a',
|
||||
'fg-subtle': '#a19db0',
|
||||
inverse: '#1b1824',
|
||||
'inverse-fg': '#f5f4f9',
|
||||
'inverse-muted': '#b9b5c8',
|
||||
'inverse-hover': '#2c2838',
|
||||
surface: '#ffffff',
|
||||
'surface-raised': '#ffffff',
|
||||
'fill-skeleton': '#ebe9f3',
|
||||
'border-control': '#888498',
|
||||
'border-field': '#888498',
|
||||
'on-solid': '#ffffff',
|
||||
'info-solid': '#56526a',
|
||||
'info-fg': '#14121b',
|
||||
'info-soft': '#f8f7fb',
|
||||
'info-border': '#e5e2ee',
|
||||
'qr-fg': '#121212'
|
||||
},
|
||||
dark: {
|
||||
sidebar: '#16141c',
|
||||
canvas: '#1c1a23',
|
||||
'surface-sunken': '#211e29',
|
||||
'fill-muted': '#27242f',
|
||||
'fill-hover': '#292632',
|
||||
'fill-row-hover': '#201d27',
|
||||
'fill-selected': '#302c3c',
|
||||
border: '#312d3c',
|
||||
'border-strong': '#787387',
|
||||
fg: '#f1eff6',
|
||||
'fg-muted': '#aaa5ba',
|
||||
'fg-subtle': '#6a6579',
|
||||
inverse: '#302c3c',
|
||||
'inverse-fg': '#f1eff6',
|
||||
'inverse-muted': '#c7c2d4',
|
||||
'inverse-hover': '#3e394c',
|
||||
'inverse-border': '#4c465c',
|
||||
surface: '#1c1a23',
|
||||
'surface-raised': '#25222e',
|
||||
'fill-skeleton': '#292632',
|
||||
'border-control': '#787387',
|
||||
'border-field': '#787387',
|
||||
'on-solid': '#16141c',
|
||||
'info-solid': '#aaa5ba',
|
||||
'info-fg': '#f1eff6',
|
||||
'info-soft': '#211e29',
|
||||
'info-border': '#312d3c',
|
||||
'qr-fg': '#121212'
|
||||
}
|
||||
},
|
||||
accentInputs: {
|
||||
light: {
|
||||
name: 'light',
|
||||
surface: [255, 255, 255],
|
||||
canvas: [255, 255, 255],
|
||||
fg: [20, 18, 27],
|
||||
inkFill: [20, 18, 27],
|
||||
inkFillFg: [255, 255, 255],
|
||||
inkHover: [44, 40, 56],
|
||||
borderControl: [136, 132, 152],
|
||||
controlBgs: [
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
[248, 247, 251],
|
||||
[240, 238, 246],
|
||||
[235, 233, 243],
|
||||
[250, 249, 252],
|
||||
[230, 226, 241]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 65,
|
||||
success: 150
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
name: 'dark',
|
||||
surface: [28, 26, 35],
|
||||
canvas: [28, 26, 35],
|
||||
fg: [241, 239, 246],
|
||||
inkFill: [241, 239, 246],
|
||||
inkFillFg: [28, 26, 35],
|
||||
inkHover: [218, 216, 223],
|
||||
borderControl: [120, 115, 135],
|
||||
controlBgs: [
|
||||
[28, 26, 35],
|
||||
[28, 26, 35],
|
||||
[37, 34, 46],
|
||||
[33, 30, 41],
|
||||
[39, 36, 47],
|
||||
[41, 38, 50],
|
||||
[32, 29, 39],
|
||||
[48, 44, 60]
|
||||
],
|
||||
semanticHues: {
|
||||
danger: 25,
|
||||
warning: 75,
|
||||
success: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -176,7 +176,7 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-4xl font-bold">
|
||||
<h1 class="font-display mt-5 text-4xl font-bold">
|
||||
{m.authorize_device()}
|
||||
</h1>
|
||||
{#if errorMessage}
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
|
||||
<ClientProviderImages client={interactionSession.client} {success} error={!!errorMessage} />
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.sign_in_to({ name: interactionSession.client.name })}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2 mb-10">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<SignInWrapper>
|
||||
<ClientProviderImages error success={false} />
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.error()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2 mb-10">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.sign_in_to_appname({ appName: $appConfigStore.appName })}
|
||||
</h1>
|
||||
{#if error}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="bg-muted mx-auto rounded-2xl p-3">
|
||||
<Logo class="size-10" />
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
|
||||
<p class="text-muted-foreground mt-3">
|
||||
{m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}
|
||||
</p>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.login_code()}</h1>
|
||||
<h1 class="font-display mt-5 text-4xl font-bold">{m.login_code()}</h1>
|
||||
{#if error}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{error}. {m.please_try_again()}
|
||||
|
||||
@@ -46,9 +46,12 @@
|
||||
isStarting = true;
|
||||
|
||||
try {
|
||||
request = await deviceLoginService.createRequest(controller.signal);
|
||||
const next = await deviceLoginService.createRequest(controller.signal);
|
||||
// Ignore a response from an attempt replaced while the request was in flight
|
||||
if (requestController !== controller) return;
|
||||
// The old panel stays until the new code arrives, so the card does not collapse in between
|
||||
request = next;
|
||||
ended = undefined;
|
||||
schedulePoll(controller);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -137,7 +140,7 @@
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!errorMessage} />
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-2xl font-bold sm:text-4xl">
|
||||
<h1 class="font-display mt-5 text-2xl font-bold sm:text-4xl">
|
||||
{m.sign_in_with_another_device()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
|
||||
{#if error}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{error}. {m.please_try_again()}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<Logo class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-4xl">{m.sign_out()}</h1>
|
||||
<h1 class="font-display mt-5 text-4xl">{m.sign_out()}</h1>
|
||||
|
||||
<p class="text-muted-foreground mt-2">
|
||||
<FormattedMessage
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '#lib/components/ui/label/index.js';
|
||||
import * as RadioGroup from '#lib/components/ui/radio-group/index.js';
|
||||
import { applyAccentColor } from '#lib/utils/accent-color-util.ts';
|
||||
import { Check, Plus } from '@lucide/svelte';
|
||||
import CustomColorDialog from './custom-accent-color-dialog.svelte';
|
||||
|
||||
let {
|
||||
selectedColor = $bindable(),
|
||||
previousColor,
|
||||
disabled = false
|
||||
}: { selectedColor: string; previousColor: string; disabled?: boolean } = $props();
|
||||
let showCustomColorDialog = $state(false);
|
||||
|
||||
const accentColors = [
|
||||
{ label: 'Default', color: 'default' },
|
||||
{ label: 'Rose', color: 'oklch(0.63 0.2 15)' },
|
||||
{ label: 'Orange', color: 'oklch(0.68 0.2 50)' },
|
||||
{ label: 'Amber', color: 'oklch(0.75 0.18 80)' },
|
||||
{ label: 'Green', color: 'oklch(0.65 0.2 150)' },
|
||||
{ label: 'Teal', color: 'oklch(0.6 0.15 180)' },
|
||||
{ label: 'Blue', color: 'oklch(0.6 0.2 240)' },
|
||||
{ label: 'Purple', color: 'oklch(0.6 0.24 300)' }
|
||||
];
|
||||
|
||||
// Check if current accent color is a custom color (not in predefined list)
|
||||
let isCustomColor = $derived(!accentColors.some((c) => c.color === selectedColor));
|
||||
let isPreviousColorCustom = $derived(!accentColors.some((c) => c.color === previousColor));
|
||||
|
||||
// Applies the selection as a live preview. Because it re-runs whenever the bound value
|
||||
// changes, it also restores the saved colour when the selection is discarded.
|
||||
$effect(() => {
|
||||
applyAccentColor(selectedColor);
|
||||
});
|
||||
|
||||
function handleAccentColorChange(accentValue: string) {
|
||||
selectedColor = accentValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<RadioGroup.Root
|
||||
class="flex flex-wrap gap-3"
|
||||
value={isCustomColor ? 'custom' : selectedColor}
|
||||
onValueChange={(value) => {
|
||||
if (value != 'custom') {
|
||||
handleAccentColorChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#each accentColors as accent (accent.color)}
|
||||
{@render colorOption(accent.label, accent.color, selectedColor === accent.color)}
|
||||
{/each}
|
||||
{#if isCustomColor || isPreviousColorCustom}
|
||||
{@render colorOption('Custom', isCustomColor ? selectedColor : previousColor, isCustomColor)}
|
||||
{/if}
|
||||
{@render colorOption('Custom', 'custom', false, true)}
|
||||
</RadioGroup.Root>
|
||||
|
||||
<CustomColorDialog bind:open={showCustomColorDialog} onApply={handleAccentColorChange} />
|
||||
|
||||
{#snippet colorOption(
|
||||
label: string,
|
||||
color: string,
|
||||
isSelected: boolean,
|
||||
isCustomColorSelection = false
|
||||
)}
|
||||
<div class="group/item relative">
|
||||
<RadioGroup.Item id={color} value={color} class="sr-only" />
|
||||
<Label
|
||||
for={color}
|
||||
class={{
|
||||
'cursor-pointer': !disabled,
|
||||
'cursor-not-allowed': disabled,
|
||||
group: isCustomColorSelection
|
||||
}}
|
||||
onclick={() => {
|
||||
if (isCustomColorSelection && !disabled) {
|
||||
showCustomColorDialog = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={{
|
||||
'relative z-10 size-8 rounded-full border-2 transition-all duration-200 ease-out group-hover/item:z-20 group-hover/item:scale-110': true,
|
||||
'bg-black dark:bg-white': color === 'default'
|
||||
}}
|
||||
style={color !== 'default' ? `background-color: ${color}` : ''}
|
||||
title={label}
|
||||
>
|
||||
{#if isCustomColorSelection}
|
||||
<div
|
||||
class="bg-muted absolute inset-0 flex items-center justify-center rounded-full border-2 border-dashed border-gray-300"
|
||||
>
|
||||
<Plus class="text-muted-foreground size-4" />
|
||||
</div>
|
||||
{:else if isSelected}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<Check class="size-4 text-white drop-shadow-sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground group-hover/item:text-foreground bg-background absolute top-12 left-1/2 z-20 max-w-0 -translate-x-1/2 transform overflow-hidden rounded-md border px-2 py-1 text-xs whitespace-nowrap opacity-0 shadow-sm transition-all duration-300 ease-out group-hover/item:max-w-[100px] group-hover/item:opacity-100"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{/snippet}
|
||||
+7
-12
@@ -9,7 +9,7 @@
|
||||
import { createForm } from '#lib/utils/form-util.ts';
|
||||
import { trackFormChanges } from '#lib/utils/unsaved-changes-util.svelte.ts';
|
||||
import { z } from 'zod/v4';
|
||||
import AccentColorPicker from './accent-color-picker.svelte';
|
||||
import ColorThemePicker from './color-theme-picker.svelte';
|
||||
|
||||
let {
|
||||
callback,
|
||||
@@ -30,7 +30,7 @@
|
||||
sessionDuration: appConfig.sessionDuration,
|
||||
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
|
||||
disableAnimations: appConfig.disableAnimations,
|
||||
accentColor: appConfig.accentColor
|
||||
colorTheme: appConfig.colorTheme
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -39,7 +39,7 @@
|
||||
sessionDuration: z.number().min(1).max(43200),
|
||||
allowOwnAccountEdit: z.boolean(),
|
||||
disableAnimations: z.boolean(),
|
||||
accentColor: z.string()
|
||||
colorTheme: z.string()
|
||||
});
|
||||
|
||||
let formStore = $derived(createForm(formSchema, updatedAppConfig));
|
||||
@@ -95,16 +95,11 @@
|
||||
|
||||
<Field.Field class="space-y-5">
|
||||
<div>
|
||||
<Field.Label>
|
||||
{m.accent_color()}
|
||||
</Field.Label>
|
||||
<Field.Description>
|
||||
{m.select_an_accent_color_to_customize_the_appearance_of_pocket_id()}
|
||||
</Field.Description>
|
||||
<Field.Label>{m.color_theme()}</Field.Label>
|
||||
<Field.Description>{m.color_theme_description()}</Field.Description>
|
||||
</div>
|
||||
<AccentColorPicker
|
||||
previousColor={appConfig.accentColor}
|
||||
bind:selectedColor={$inputs.accentColor.value}
|
||||
<ColorThemePicker
|
||||
bind:value={$inputs.colorTheme.value}
|
||||
disabled={$appConfigStore.uiConfigDisabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import {
|
||||
applyColorTheme,
|
||||
colorThemePreview,
|
||||
parseColorTheme
|
||||
} from '#lib/utils/color-theme-util.ts';
|
||||
import { colorThemes } from '#lib/utils/color-themes.ts';
|
||||
import { cn } from '#lib/utils/style.ts';
|
||||
import { Check, Palette, Plus } from '@lucide/svelte';
|
||||
import { RadioGroup } from 'bits-ui';
|
||||
import CustomThemeColorDialog from './custom-theme-color-dialog.svelte';
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
disabled = false
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
const themeNames: Record<string, () => string> = {
|
||||
mono: m.color_theme_mono,
|
||||
mist: m.color_theme_mist,
|
||||
sage: m.color_theme_sage,
|
||||
midnight: m.color_theme_midnight,
|
||||
lavender: m.color_theme_lavender
|
||||
};
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let selectedId = $derived(parseColorTheme(value).id);
|
||||
let customColor = $state<string>();
|
||||
|
||||
// The last custom color stays on its tile while other themes are previewed
|
||||
$effect.pre(() => {
|
||||
const color = parseColorTheme(value).color;
|
||||
if (color) customColor = color;
|
||||
});
|
||||
|
||||
// Picking a tile previews across the whole app at once
|
||||
// Discard in the unsaved bar resets value, which re-applies the saved theme
|
||||
$effect(() => {
|
||||
applyColorTheme(value);
|
||||
});
|
||||
|
||||
function onValueChange(id: string) {
|
||||
if (id !== 'custom') value = id;
|
||||
else if (customColor) value = `custom:${customColor}`;
|
||||
else dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex max-w-[760px] flex-col gap-3">
|
||||
<RadioGroup.Root
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3"
|
||||
aria-label={m.color_theme()}
|
||||
bind:value={() => selectedId, onValueChange}
|
||||
{disabled}
|
||||
>
|
||||
{#each colorThemes as theme (theme.id)}
|
||||
{@render tile(theme.id, themeNames[theme.id](), theme.id)}
|
||||
{/each}
|
||||
{@render tile('custom', m.custom(), customColor ? `custom:${customColor}` : null)}
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if selectedId === 'custom'}
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="text-fg inline-flex items-center gap-1.5 text-sm font-medium underline-offset-4 hover:underline"
|
||||
onclick={() => (dialogOpen = true)}
|
||||
{disabled}
|
||||
>
|
||||
<Palette class="size-3.5" />
|
||||
{m.edit_custom_color()}
|
||||
</button>
|
||||
<p class="text-fg-muted text-caption">{m.custom_color_theme_note()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CustomThemeColorDialog
|
||||
bind:open={dialogOpen}
|
||||
initialColor={customColor}
|
||||
onUse={(color) => (value = `custom:${color}`)}
|
||||
/>
|
||||
|
||||
{#snippet tile(id: string, name: string, previewValue: string | null)}
|
||||
<RadioGroup.Item
|
||||
value={id}
|
||||
class={cn(
|
||||
'group bg-surface flex flex-col gap-2 rounded-lg p-2 text-left shadow-[inset_0_0_0_1px_var(--border)] transition-shadow duration-(--dur-fast)',
|
||||
'hover:shadow-[inset_0_0_0_1px_var(--border-strong)] data-[state=checked]:shadow-[inset_0_0_0_2px_var(--fg)]',
|
||||
'focus-visible:outline-ring focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-55 data-[state=checked]:disabled:opacity-100'
|
||||
)}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
{#if previewValue}
|
||||
<div class="grid h-[72px] grid-cols-2 overflow-hidden rounded-md" aria-hidden="true">
|
||||
{#each colorThemePreview(previewValue) as half (half.mode)}
|
||||
<div class="flex" style="background: {half.page}">
|
||||
<div class="flex w-[34%] flex-col gap-1 p-1.5" style="background: {half.sidebar}">
|
||||
<span class="h-1 w-full rounded-full" style="background: {half.line}"></span>
|
||||
<span class="h-1 w-3/4 rounded-full opacity-50" style="background: {half.text}"
|
||||
></span>
|
||||
<span class="h-1 w-2/3 rounded-full opacity-50" style="background: {half.text}"
|
||||
></span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1 p-1.5">
|
||||
<span class="h-1 w-4/5 rounded-full opacity-60" style="background: {half.text}"
|
||||
></span>
|
||||
<span class="h-1 w-1/2 rounded-full opacity-40" style="background: {half.text}"
|
||||
></span>
|
||||
<span
|
||||
class="mt-auto h-2.5 w-full max-w-8 rounded-[3px]"
|
||||
style="background: {half.primary}"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="border-border-strong text-fg-muted text-caption flex h-[72px] flex-col items-center justify-center gap-1 rounded-md border border-dashed"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
{m.pick_a_color()}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="flex items-center justify-between gap-2 px-0.5 text-sm font-medium">
|
||||
{name}
|
||||
{#if checked}<Check class="size-4" />{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</RadioGroup.Item>
|
||||
{/snippet}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
import * as Dialog from '#lib/components/ui/dialog/index.ts';
|
||||
import * as Field from '#lib/components/ui/field/index.ts';
|
||||
import { Input } from '#lib/components/ui/input/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import { preventDefault } from '#lib/utils/event-util.ts';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onApply
|
||||
}: {
|
||||
open: boolean;
|
||||
onApply: (color: string) => void;
|
||||
} = $props();
|
||||
|
||||
let customColorInput = $state('');
|
||||
|
||||
function applyCustomColor() {
|
||||
if (!isValidColor(customColorInput)) return;
|
||||
onApply(customColorInput);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function isValidColor(color: string): boolean {
|
||||
// Create a temporary element to test if the color is valid
|
||||
const testElement = document.createElement('div');
|
||||
testElement.style.color = color;
|
||||
return testElement.style.color !== '';
|
||||
}
|
||||
|
||||
function onOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
customColorInput = '';
|
||||
}
|
||||
open = newOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">{m.custom_accent_color()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.custom_accent_color_description()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={preventDefault(applyCustomColor)}>
|
||||
<div class="space-y-4">
|
||||
<Field.Field>
|
||||
<Field.Label for="custom-color-input">{m.color_value()}</Field.Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-full transition">
|
||||
<Input
|
||||
id="custom-color-input"
|
||||
bind:value={customColorInput}
|
||||
placeholder="#3b82f6"
|
||||
class="mt-1 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class={{
|
||||
'border-border mt-1 rounded-lg border-1 transition-all duration-200 ease-in-out': true,
|
||||
'h-9 w-9': isValidColor(customColorInput),
|
||||
'h-0 w-0': !isValidColor(customColorInput)
|
||||
}}
|
||||
style="background-color: {customColorInput}"
|
||||
></div>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="mt-6">
|
||||
<Button variant="secondary" onclick={() => onOpenChange(false)}>{m.cancel()}</Button>
|
||||
<Button type="submit" disabled={!customColorInput || !isValidColor(customColorInput)}
|
||||
>{m.apply()}</Button
|
||||
>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
import * as Dialog from '#lib/components/ui/dialog/index.ts';
|
||||
import * as Field from '#lib/components/ui/field/index.ts';
|
||||
import { Input } from '#lib/components/ui/input/index.ts';
|
||||
import { Switch } from '#lib/components/ui/switch/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import {
|
||||
colorThemeFor,
|
||||
deriveAccent,
|
||||
parseColor,
|
||||
primaryVars
|
||||
} from '#lib/utils/color-theme-util.ts';
|
||||
import { preventDefault } from '#lib/utils/event-util.ts';
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
initialColor,
|
||||
onUse
|
||||
}: {
|
||||
open: boolean;
|
||||
initialColor?: string;
|
||||
onUse: (color: string) => void;
|
||||
} = $props();
|
||||
|
||||
let color = $state('');
|
||||
let showError = $state(false);
|
||||
let errorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const parsed = $derived(parseColor(color));
|
||||
const valid = $derived(!!parsed && parsed.alpha >= 1);
|
||||
const error = $derived(
|
||||
!color.trim() || valid
|
||||
? null
|
||||
: parsed
|
||||
? m.use_a_color_without_transparency()
|
||||
: m.enter_a_css_color()
|
||||
);
|
||||
|
||||
// The preview fits the accent against the tinted surfaces of the custom theme, so it is exactly what people will see
|
||||
const fitted = $derived.by(() => {
|
||||
if (!valid) return null;
|
||||
const theme = colorThemeFor(`custom:${color.trim()}`);
|
||||
const modeName: 'light' | 'dark' = mode.current === 'dark' ? 'dark' : 'light';
|
||||
return {
|
||||
light: deriveAccent(theme.accent, theme.accentInputs.light),
|
||||
dark: deriveAccent(theme.accent, theme.accentInputs.dark),
|
||||
current: modeName
|
||||
};
|
||||
});
|
||||
const previewVars = $derived(fitted ? primaryVars(fitted[fitted.current]) : null);
|
||||
const previewStyle = $derived(
|
||||
previewVars
|
||||
? Object.entries(previewVars)
|
||||
.map(([k, v]) => `--${k}: ${v}`)
|
||||
.join('; ')
|
||||
: ''
|
||||
);
|
||||
const notes = $derived.by(() => {
|
||||
if (!fitted) return [];
|
||||
const out: string[] = [];
|
||||
if (fitted.light.deepened) out.push(m.custom_color_note_darkened());
|
||||
if (fitted.dark.lifted) out.push(m.custom_color_note_lightened());
|
||||
if (fitted.light.edge || fitted.dark.edge) out.push(m.custom_color_note_edge());
|
||||
if (fitted.light.nearSemantic) out.push(m.custom_color_note_near_status());
|
||||
return out;
|
||||
});
|
||||
|
||||
// Errors wait for a pause in typing, never interrupting it
|
||||
$effect(() => {
|
||||
void color;
|
||||
showError = false;
|
||||
clearTimeout(errorTimer);
|
||||
errorTimer = setTimeout(() => (showError = true), 700);
|
||||
return () => clearTimeout(errorTimer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) color = initialColor ?? '';
|
||||
});
|
||||
|
||||
function use() {
|
||||
showError = true;
|
||||
if (!valid) return;
|
||||
onUse(color.trim());
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="top-[15%] max-w-[420px] translate-y-0 sm:max-h-[80dvh]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.custom_theme_color()}</Dialog.Title>
|
||||
<Dialog.Description>{m.custom_theme_color_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form class="flex flex-col gap-5" onsubmit={preventDefault(use)}>
|
||||
<Field.Field data-invalid={showError && error ? true : undefined}>
|
||||
<Field.Label for="custom-theme-color">{m.color()}</Field.Label>
|
||||
<Input
|
||||
id="custom-theme-color"
|
||||
class="font-mono"
|
||||
placeholder="#2f6f4e"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
bind:value={color}
|
||||
aria-invalid={showError && error ? true : undefined}
|
||||
/>
|
||||
<div class="max-sm:min-h-[18px] sm:empty:hidden">
|
||||
{#if showError && error}
|
||||
<Field.Error>{error}</Field.Error>
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
{#if fitted}
|
||||
<div class="flex flex-col gap-2" style={previewStyle}>
|
||||
<span class="text-fg-muted text-caption font-medium">{m.preview()}</span>
|
||||
<div class="bg-surface-sunken flex items-center gap-4 rounded-md p-3">
|
||||
<Button type="button" tabindex={-1}>{m.authenticate()}</Button>
|
||||
<Switch checked tabindex={-1} aria-hidden="true" />
|
||||
<span class="text-fg-muted ml-auto font-mono text-xs">{previewVars?.primary}</span>
|
||||
</div>
|
||||
<p class="text-fg-muted text-caption" aria-live="polite">{notes.join(' ')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="ghost" onclick={() => (open = false)}>{m.cancel()}</Button>
|
||||
<Button type="submit" disabled={!valid}>{m.use_color()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -56,7 +56,7 @@
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.signup_to_appname({ appName: $appConfigStore.appName })}
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
confirm: {
|
||||
label: m.skip_for_now(),
|
||||
destructive: true,
|
||||
icon: LucideSkipForward,
|
||||
action: () => {
|
||||
dismiss();
|
||||
goto('/settings/account');
|
||||
@@ -76,7 +77,7 @@
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.setup_your_passkey()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
|
||||
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
|
||||
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
|
||||
{m.signup_to_appname({ appName: $appConfigStore.appName })}
|
||||
</h1>
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user