feat!: add v3 design tokens, fonts and color themes

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 87ddbf712a
commit c0fd85740c
63 changed files with 2008 additions and 443 deletions
+22
View File
@@ -10,6 +10,7 @@ func (m *AppConfigModel) DecodeMsgpack(dec *msgpack.Decoder) error {
*config
LegacyNotificationEnabled AppConfigValue `msgpack:"EmailLoginNotificationEnabled"`
LegacyEmailVerificationEnabled AppConfigValue `msgpack:"EmailVerificationEnabled"`
LegacyAccentColor AppConfigValue `msgpack:"AccentColor"`
}{config: (*config)(m)}
if err := dec.Decode(&value); err != nil {
return err
@@ -20,6 +21,9 @@ func (m *AppConfigModel) DecodeMsgpack(dec *msgpack.Decoder) error {
if m.EmailVerification == "" && value.LegacyEmailVerificationEnabled != "" {
m.EmailVerification = legacyEmailVerification(value.LegacyEmailVerificationEnabled)
}
if m.ColorTheme == "" && value.LegacyAccentColor != "" {
m.ColorTheme = legacyColorTheme(value.LegacyAccentColor)
}
return nil
}
@@ -36,3 +40,21 @@ func legacyEmailVerification(enabled AppConfigValue) AppConfigValue {
}
return EmailVerificationDisabled
}
// legacyColorTheme maps an accent color from before color themes to the theme built around it, or to a custom theme with that color
func legacyColorTheme(accent AppConfigValue) AppConfigValue {
switch accent {
case "", "default":
return "mono"
case "oklch(0.6 0.2 240)":
return "mist"
case "oklch(0.65 0.2 150)":
return "sage"
case "oklch(0.6 0.15 180)":
return "midnight"
case "oklch(0.6 0.24 300)":
return "lavender"
default:
return "custom:" + accent
}
}
+3
View File
@@ -65,6 +65,9 @@ func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
if legacyCfg["emailVerification"] == "" {
dest.EmailVerification = legacyEmailVerification(AppConfigValue(legacyCfg["emailVerificationEnabled"]))
}
if legacyCfg["colorTheme"] == "" {
dest.ColorTheme = legacyColorTheme(AppConfigValue(legacyCfg["accentColor"]))
}
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
+2 -2
View File
@@ -32,7 +32,7 @@ type AppConfigModel struct {
SessionDuration AppConfigValue `json:"sessionDuration" env:"SESSION_DURATION" type:"int"` // In minutes
HomePageURL AppConfigValue `json:"homePageUrl" env:"HOME_PAGE_URL" public:"true"`
EmailsVerified AppConfigValue `json:"emailsVerified" env:"EMAILS_VERIFIED" type:"bool"`
AccentColor AppConfigValue `json:"accentColor" env:"ACCENT_COLOR" public:"true"`
ColorTheme AppConfigValue `json:"colorTheme" env:"COLOR_THEME" public:"true"` // A theme id, or custom:<css color> with an optional :dark suffix for a dark sidebar
DisableAnimations AppConfigValue `json:"disableAnimations" env:"DISABLE_ANIMATIONS" type:"bool" public:"true"`
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" env:"ALLOW_OWN_ACCOUNT_EDIT" type:"bool" public:"true"`
AllowUserSignups AppConfigValue `json:"allowUserSignups" env:"ALLOW_USER_SIGNUPS" public:"true"`
@@ -144,7 +144,7 @@ func getDefaultConfig() *AppConfigModel {
AllowUserSignups: "disabled",
SignupDefaultUserGroupIDs: "[]",
SignupDefaultCustomClaims: "[]",
AccentColor: "default",
ColorTheme: "mono",
// Email
RequireUserEmail: "true",
SmtpHost: "",
+29
View File
@@ -345,4 +345,33 @@ func TestAppConfigModel_DecodeMsgpack(t *testing.T) {
assert.Equal(t, AppConfigValue(LoginNotificationAlways), m.EmailLoginNotificationMode)
assert.Equal(t, AppConfigValue(EmailVerificationRequired), m.EmailVerification)
})
t.Run("maps the legacy accent color to a color theme", func(t *testing.T) {
tests := map[string]AppConfigValue{
"default": "mono",
"oklch(0.6 0.2 240)": "mist",
"oklch(0.63 0.2 15)": "custom:oklch(0.63 0.2 15)",
}
for accent, expected := range tests {
data, err := msgpack.Marshal(map[string]string{"AccentColor": accent})
require.NoError(t, err)
var m AppConfigModel
err = msgpack.Unmarshal(data, &m)
require.NoError(t, err)
assert.Equal(t, expected, m.ColorTheme)
}
})
t.Run("an explicit color theme wins over the legacy accent color", func(t *testing.T) {
data, err := msgpack.Marshal(map[string]string{"ColorTheme": "sage", "AccentColor": "default"})
require.NoError(t, err)
var m AppConfigModel
err = msgpack.Unmarshal(data, &m)
require.NoError(t, err)
assert.Equal(t, AppConfigValue("sage"), m.ColorTheme)
})
}
+8
View File
@@ -258,6 +258,14 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
}
}
// TODO: Remove in next major version (v4)
// Preserve the old accent color setting unless an explicit color theme replaces it
if _, ok := os.LookupEnv("COLOR_THEME"); !ok {
if accent, exists := os.LookupEnv("ACCENT_COLOR"); exists {
dest.ColorTheme = legacyColorTheme(AppConfigValue(accent))
}
}
// Validate the resolved configuration before exposing values to the rest of the application
err := validateEnvConfig(dest)
if err != nil {
+1 -1
View File
@@ -23,7 +23,7 @@ type AppConfigUpdateDto struct {
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json_string_array"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json_custom_claims"`
AccentColor string `json:"accentColor"`
ColorTheme string `json:"colorTheme"`
RequireUserEmail string `json:"requireUserEmail" binding:"required,boolean_string"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
+6 -8
View File
@@ -62,7 +62,7 @@ type FileCleanupJobs struct {
fileStorage storage.FileStorage
}
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials or were rendered by an earlier version
func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) error {
var users []model.User
err := j.db.
@@ -73,10 +73,10 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
return fmt.Errorf("failed to fetch users: %w", err)
}
// Create a map to track which initials are in use
initialsInUse := make(map[string]struct{})
// Create a map to track which pictures are in use
filesInUse := make(map[string]struct{})
for _, user := range users {
initialsInUse[profilepicture.Initials(user.FirstName, user.LastName, user.Username)] = struct{}{}
filesInUse[profilepicture.DefaultPictureFile(profilepicture.Initials(user.FirstName, user.LastName, user.Username))] = struct{}{}
}
defaultPicturesDir := path.Join("profile-pictures", "defaults")
@@ -91,10 +91,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
if filename == "" {
continue
}
initials := strings.TrimSuffix(filename, ".png")
// If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok {
// If no user needs this picture, delete the file
if _, ok := filesInUse[filename]; !ok {
filePath := path.Join(defaultPicturesDir, filename)
err = j.fileStorage.Delete(ctx, filePath)
if err != nil {
+1 -1
View File
@@ -140,7 +140,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
// Try cached default for initials
initials := profilepicture.Initials(user.FirstName, user.LastName, user.Username)
defaultPicturePath := path.Join("profile-pictures", "defaults", initials+".png")
defaultPicturePath := path.Join("profile-pictures", "defaults", profilepicture.DefaultPictureFile(initials))
file, size, err = s.fileStorage.Open(ctx, defaultPicturePath)
if err == nil {
return file, size, nil
@@ -88,7 +88,7 @@ func centerSquare(img image.Image) image.Image {
}
var loadInitialsFont = sync.OnceValues(func() (*opentype.Font, error) {
fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf")
fontBytes, err := resources.FS.ReadFile("fonts/FunnelDisplay-SemiBold.ttf")
if err != nil {
return nil, fmt.Errorf("failed to read font file: %w", err)
}
@@ -130,6 +130,12 @@ func canRenderInitials(initials string) bool {
return true
}
// DefaultPictureFile returns the cache file name of the default picture for the initials
// The suffix changes whenever the rendering changes, so the cleanup job deletes pictures cached by an earlier version
func DefaultPictureFile(initials string) string {
return initials + "-v3.png"
}
// CreateDefaultProfilePicture creates a profile picture with the initials
func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
// Create a blank image with a white background
@@ -142,7 +148,8 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
}
// Create a font.Face with a specific size
fontSize := 160.0
// Clients often crop the picture to a circle, so the initials keep clear of its edge
fontSize := 120.0
face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+4 -3
View File
@@ -49,9 +49,9 @@ export const BaseTemplate = ({
);
// The heading font is self-hosted by the app, so it loads from the same origin as the logo and works without third-party requests
// Clients without web font support fall back to the serif stack declared on the headings
// Clients without web font support fall back to the system stack declared on the headings
const fontFaceCss = (appURL: string) =>
`@font-face{font-family:'Gloock';font-style:normal;font-weight:400;mso-font-alt:'Georgia';src:url(${appURL}/fonts/Gloock-Regular.woff) format('woff');}`;
`@font-face{font-family:'Funnel Display';font-style:normal;font-weight:300 800;mso-font-alt:'Arial';src:url(${appURL}/fonts/FunnelDisplay-latin.woff2) format('woff2');}`;
const bodyStyle = {
margin: 0,
@@ -84,9 +84,10 @@ const logoStyle = {
};
const appNameStyle = {
fontFamily: fonts.serif,
fontFamily: fonts.display,
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
color: colors.foreground,
textDecoration: 'none'
};
+2 -2
View File
@@ -15,10 +15,10 @@ export default function CardHeader({ title }: CardHeaderProps) {
const titleStyle = {
margin: '0 0 16px 0',
fontFamily: fonts.serif,
fontFamily: fonts.display,
fontSize: '26px',
lineHeight: '32px',
fontWeight: 400,
fontWeight: 600,
letterSpacing: '-0.01em',
color: colors.foreground
};
+7 -6
View File
@@ -1,20 +1,21 @@
// Design tokens mirrored from the frontend's app.css (light theme) so emails match the app
// Email clients have no CSS variable or oklch support, so the values are the resolved hex equivalents
export const colors = {
background: '#f5f5f5',
background: '#fafafa',
card: '#ffffff',
border: '#e5e5e5',
muted: '#fafafa',
border: '#e8e8e8',
muted: '#f5f5f5',
foreground: '#0a0a0a',
text: '#404040',
mutedForeground: '#737373',
primary: '#171717',
mutedForeground: '#5e5e5e',
primary: '#0a0a0a',
primaryForeground: '#ffffff'
};
export const fonts = {
sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Gloock, Georgia, 'Times New Roman', serif",
display:
"'Funnel Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
mono: "'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace"
};
+21 -7
View File
@@ -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
View File
@@ -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 {
+129
View File
@@ -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;
}
+452
View File
@@ -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'))
);
}
+590
View File
@@ -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
}
}
}
}
];
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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()}
+1 -1
View File
@@ -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
@@ -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}
@@ -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>
@@ -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}
@@ -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>
@@ -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>
+1 -1
View File
@@ -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.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

+1 -1
View File
@@ -146,7 +146,7 @@ function compareExports(dir1: string, dir2: string): void {
expect(files2).toEqual(files1);
for (const file of files1) {
const profilePictures = ['CF.png', 'CR.png'];
const profilePictures = ['CF-v3.png', 'CR-v3.png'];
if (profilePictures.includes(path.basename(file))) {
continue; // Skip profile pictures because they get generated on demand and can differ between exports
}