diff --git a/backend/internal/appconfig/legacy.go b/backend/internal/appconfig/legacy.go index 65d34be6..751f1c3b 100644 --- a/backend/internal/appconfig/legacy.go +++ b/backend/internal/appconfig/legacy.go @@ -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 + } +} diff --git a/backend/internal/appconfig/migration.go b/backend/internal/appconfig/migration.go index 38119c21..a9d39a76 100644 --- a/backend/internal/appconfig/migration.go +++ b/backend/internal/appconfig/migration.go @@ -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() diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go index d765ae0b..e6b7267b 100644 --- a/backend/internal/appconfig/model.go +++ b/backend/internal/appconfig/model.go @@ -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: 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: "", diff --git a/backend/internal/appconfig/model_test.go b/backend/internal/appconfig/model_test.go index 729c6aa2..90e3d170 100644 --- a/backend/internal/appconfig/model_test.go +++ b/backend/internal/appconfig/model_test.go @@ -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) + }) } diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go index f9411421..f20b3ced 100644 --- a/backend/internal/appconfig/service.go +++ b/backend/internal/appconfig/service.go @@ -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 { diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index cb2f0499..54e24c81 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -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"` diff --git a/backend/internal/job/file_cleanup_job.go b/backend/internal/job/file_cleanup_job.go index 8bbe8bd9..7fbab41f 100644 --- a/backend/internal/job/file_cleanup_job.go +++ b/backend/internal/job/file_cleanup_job.go @@ -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 { diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index df80fa9c..b31846a9 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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 diff --git a/backend/internal/utils/image/profile_picture.go b/backend/internal/utils/image/profile_picture.go index 7814c04d..3d49de2f 100644 --- a/backend/internal/utils/image/profile_picture.go +++ b/backend/internal/utils/image/profile_picture.go @@ -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, diff --git a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl index 657e01da..4706b6cb 100644 --- a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl +++ b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl @@ -1 +1 @@ -{{define "root"}}Your API key {{.Data.ApiKeyName}} expires on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}
Your API key {{.Data.ApiKeyName}} expires on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

API key expiring soon

Hello {{.Data.Name}},

Your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. Anything that uses this key will stop working once it expires.

To keep access, create a new API key in your {{.AppName}} account settings before then.

{{end}} \ No newline at end of file +{{define "root"}}Your API key {{.Data.ApiKeyName}} expires on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}
Your API key {{.Data.ApiKeyName}} expires on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

API key expiring soon

Hello {{.Data.Name}},

Your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. Anything that uses this key will stop working once it expires.

To keep access, create a new API key in your {{.AppName}} account settings before then.

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/email-verification_html.tmpl b/backend/resources/email-templates/email-verification_html.tmpl index 262c1963..533607b4 100644 --- a/backend/resources/email-templates/email-verification_html.tmpl +++ b/backend/resources/email-templates/email-verification_html.tmpl @@ -1 +1 @@ -{{define "root"}}Confirm the email address for your {{.AppName}} account
Confirm the email address for your {{.AppName}} account
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

Verify your email address

Hello {{.Data.UserFullName}},

Click the button below to confirm the email address for your {{.AppName}} account. This link expires in 24 hours.

Verify email address

Or if you don't like clicking buttons, open this link:
{{.Data.VerificationLink}}

{{end}} \ No newline at end of file +{{define "root"}}Confirm the email address for your {{.AppName}} account
Confirm the email address for your {{.AppName}} account
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

Verify your email address

Hello {{.Data.UserFullName}},

Click the button below to confirm the email address for your {{.AppName}} account. This link expires in 24 hours.

Verify email address

Or if you don't like clicking buttons, open this link:
{{.Data.VerificationLink}}

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/login-with-new-device_html.tmpl b/backend/resources/email-templates/login-with-new-device_html.tmpl index 21c8cc86..eb74c542 100644 --- a/backend/resources/email-templates/login-with-new-device_html.tmpl +++ b/backend/resources/email-templates/login-with-new-device_html.tmpl @@ -1 +1 @@ -{{define "root"}}A new sign-in to your {{.AppName}} account was detected
A new sign-in to your {{.AppName}} account was detected
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

New sign-in detected

Your {{.AppName}} account was recently accessed from a new IP address or browser. If this was you, no further action is needed.

Approximate location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-in method
{{.Data.Method}}
Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}

If you don't recognize this activity, review the passkeys in your {{.AppName}} account settings and remove any you don't recognize.

{{end}} \ No newline at end of file +{{define "root"}}A new sign-in to your {{.AppName}} account was detected
A new sign-in to your {{.AppName}} account was detected
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

New sign-in detected

Your {{.AppName}} account was recently accessed from a new IP address or browser. If this was you, no further action is needed.

Approximate location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-in method
{{.Data.Method}}
Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}

If you don't recognize this activity, review the passkeys in your {{.AppName}} account settings and remove any you don't recognize.

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/one-time-access_html.tmpl b/backend/resources/email-templates/one-time-access_html.tmpl index 166284d6..d0f51c7f 100644 --- a/backend/resources/email-templates/one-time-access_html.tmpl +++ b/backend/resources/email-templates/one-time-access_html.tmpl @@ -1 +1 @@ -{{define "root"}}Your {{.AppName}} login code is {{.Data.Code}}
Your {{.AppName}} login code is {{.Data.Code}}
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

Your login code

Use the code below to sign in to {{.AppName}}. It expires in {{.Data.ExpirationString}}.

{{.Data.Code}}

Sign in

Or open {{.Data.LoginLink}} and enter the code manually.

{{end}} \ No newline at end of file +{{define "root"}}Your {{.AppName}} login code is {{.Data.Code}}
Your {{.AppName}} login code is {{.Data.Code}}
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

Your login code

Use the code below to sign in to {{.AppName}}. It expires in {{.Data.ExpirationString}}.

{{.Data.Code}}

Sign in

Or open {{.Data.LoginLink}} and enter the code manually.

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/test_html.tmpl b/backend/resources/email-templates/test_html.tmpl index d5a5a7f3..df00c0a9 100644 --- a/backend/resources/email-templates/test_html.tmpl +++ b/backend/resources/email-templates/test_html.tmpl @@ -1 +1 @@ -{{define "root"}}Your email setup is working correctly
Your email setup is working correctly
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

Test email

Your email setup is working correctly!

{{end}} \ No newline at end of file +{{define "root"}}Your email setup is working correctly
Your email setup is working correctly
 ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍ ‌​‍
{{.AppName}}{{.AppName}}

Test email

Your email setup is working correctly!

{{end}} \ No newline at end of file diff --git a/backend/resources/fonts/FunnelDisplay-SemiBold.ttf b/backend/resources/fonts/FunnelDisplay-SemiBold.ttf new file mode 100644 index 00000000..0956c48a Binary files /dev/null and b/backend/resources/fonts/FunnelDisplay-SemiBold.ttf differ diff --git a/backend/resources/fonts/Gloock.ttf b/backend/resources/fonts/Gloock.ttf deleted file mode 100644 index 3e58c4e4..00000000 Binary files a/backend/resources/fonts/Gloock.ttf and /dev/null differ diff --git a/email-templates/components/base-template.tsx b/email-templates/components/base-template.tsx index 52038770..7945a46a 100644 --- a/email-templates/components/base-template.tsx +++ b/email-templates/components/base-template.tsx @@ -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' }; diff --git a/email-templates/components/card-header.tsx b/email-templates/components/card-header.tsx index 7e27f7c9..e016e907 100644 --- a/email-templates/components/card-header.tsx +++ b/email-templates/components/card-header.tsx @@ -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 }; diff --git a/email-templates/components/theme.ts b/email-templates/components/theme.ts index 794ac8dd..39504695 100644 --- a/email-templates/components/theme.ts +++ b/email-templates/components/theme.ts @@ -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" }; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e3c907f2..7b273c92 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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" } diff --git a/frontend/src/app.css b/frontend/src/app.css index ea332227..f015cb6f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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 { diff --git a/frontend/src/fonts.css b/frontend/src/fonts.css new file mode 100644 index 00000000..0645aa57 --- /dev/null +++ b/frontend/src/fonts.css @@ -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; +} diff --git a/frontend/src/lib/components/header/header.svelte b/frontend/src/lib/components/header/header.svelte index d68f3993..3d24ba74 100644 --- a/frontend/src/lib/components/header/header.svelte +++ b/frontend/src/lib/components/header/header.svelte @@ -36,7 +36,7 @@ -

+

{m.settings()}

diff --git a/frontend/src/lib/stores/application-configuration-store.ts b/frontend/src/lib/stores/application-configuration-store.ts index a9eaba6f..b054d8f0 100644 --- a/frontend/src/lib/stores/application-configuration-store.ts +++ b/frontend/src/lib/stores/application-configuration-store.ts @@ -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(); @@ -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); }; diff --git a/frontend/src/lib/types/application-configuration.type.ts b/frontend/src/lib/types/application-configuration.type.ts index d8b67ed1..26ee3bf6 100644 --- a/frontend/src/lib/types/application-configuration.type.ts +++ b/frontend/src/lib/types/application-configuration.type.ts @@ -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'; diff --git a/frontend/src/lib/utils/accent-color-util.ts b/frontend/src/lib/utils/accent-color-util.ts deleted file mode 100644 index 775618f8..00000000 --- a/frontend/src/lib/utils/accent-color-util.ts +++ /dev/null @@ -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; -} diff --git a/frontend/src/lib/utils/color-theme-util.ts b/frontend/src/lib/utils/color-theme-util.ts new file mode 100644 index 00000000..21e99663 --- /dev/null +++ b/frontend/src/lib/utils/color-theme-util.ts @@ -0,0 +1,452 @@ +import { setMode } from 'mode-watcher'; +import { colorThemes } from './color-themes.ts'; + +// A stored value is a theme id, or custom: 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; +}; + +export type ColorTheme = { + id: string; + accent: string; + tokens: Record>; + accentInputs: Record; +}; + +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 { + 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) => + 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')) + ); +} diff --git a/frontend/src/lib/utils/color-themes.ts b/frontend/src/lib/utils/color-themes.ts new file mode 100644 index 00000000..7c7fd904 --- /dev/null +++ b/frontend/src/lib/utils/color-themes.ts @@ -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 + } + } + } + } +]; diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte index 63942789..9af43b64 100644 --- a/frontend/src/routes/device/+page.svelte +++ b/frontend/src/routes/device/+page.svelte @@ -176,7 +176,7 @@ /> {/if} -

+

{m.authorize_device()}

{#if errorMessage} diff --git a/frontend/src/routes/interaction/+page.svelte b/frontend/src/routes/interaction/+page.svelte index 3b5d7f84..a80bd01b 100644 --- a/frontend/src/routes/interaction/+page.svelte +++ b/frontend/src/routes/interaction/+page.svelte @@ -122,7 +122,7 @@ -

+

{m.sign_in_to({ name: interactionSession.client.name })}

diff --git a/frontend/src/routes/interaction/error/+page.svelte b/frontend/src/routes/interaction/error/+page.svelte index 196bec7d..0b8c24d2 100644 --- a/frontend/src/routes/interaction/error/+page.svelte +++ b/frontend/src/routes/interaction/error/+page.svelte @@ -18,7 +18,7 @@ -

+

{m.error()}

diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index b1c2da44..7662e487 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -43,7 +43,7 @@

-

+

{m.sign_in_to_appname({ appName: $appConfigStore.appName })}

{#if error} diff --git a/frontend/src/routes/login/alternative/+page.svelte b/frontend/src/routes/login/alternative/+page.svelte index 4ead460e..a25319b3 100644 --- a/frontend/src/routes/login/alternative/+page.svelte +++ b/frontend/src/routes/login/alternative/+page.svelte @@ -46,7 +46,7 @@
-

{m.alternative_sign_in()}

+

{m.alternative_sign_in()}

{m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}

diff --git a/frontend/src/routes/login/alternative/code/+page.svelte b/frontend/src/routes/login/alternative/code/+page.svelte index 106dcc01..0fc0f3c2 100644 --- a/frontend/src/routes/login/alternative/code/+page.svelte +++ b/frontend/src/routes/login/alternative/code/+page.svelte @@ -72,7 +72,7 @@
-

{m.login_code()}

+

{m.login_code()}

{#if error}

{error}. {m.please_try_again()} diff --git a/frontend/src/routes/login/alternative/device/+page.svelte b/frontend/src/routes/login/alternative/device/+page.svelte index 268d574e..74d0ab41 100644 --- a/frontend/src/routes/login/alternative/device/+page.svelte +++ b/frontend/src/routes/login/alternative/device/+page.svelte @@ -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 @@

-

+

{m.sign_in_with_another_device()}

diff --git a/frontend/src/routes/login/alternative/email/+page.svelte b/frontend/src/routes/login/alternative/email/+page.svelte index 7a9e070f..80aa65d9 100644 --- a/frontend/src/routes/login/alternative/email/+page.svelte +++ b/frontend/src/routes/login/alternative/email/+page.svelte @@ -38,7 +38,7 @@

-

{m.email_login()}

+

{m.email_login()}

{#if error}

{error}. {m.please_try_again()} diff --git a/frontend/src/routes/logout/+page.svelte b/frontend/src/routes/logout/+page.svelte index a4593a5a..3caf36f7 100644 --- a/frontend/src/routes/logout/+page.svelte +++ b/frontend/src/routes/logout/+page.svelte @@ -34,7 +34,7 @@ -

{m.sign_out()}

+

{m.sign_out()}

- 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; - } - - - { - 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)} - - - - -{#snippet colorOption( - label: string, - color: string, - isSelected: boolean, - isCustomColorSelection = false -)} -

- - -
-{/snippet} diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte index c14b74ca..9391e509 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte @@ -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 @@
- - {m.accent_color()} - - - {m.select_an_accent_color_to_customize_the_appearance_of_pocket_id()} - + {m.color_theme()} + {m.color_theme_description()}
-
diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/color-theme-picker.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/color-theme-picker.svelte new file mode 100644 index 00000000..3b89f580 --- /dev/null +++ b/frontend/src/routes/settings/admin/application-configuration/forms/color-theme-picker.svelte @@ -0,0 +1,138 @@ + + +
+ 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)} + + + {#if selectedId === 'custom'} +
+ +

{m.custom_color_theme_note()}

+
+ {/if} +
+ + (value = `custom:${color}`)} +/> + +{#snippet tile(id: string, name: string, previewValue: string | null)} + + {#snippet children({ checked })} + {#if previewValue} + + {:else} + + {/if} + + {name} + {#if checked}{/if} + + {/snippet} + +{/snippet} diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/custom-accent-color-dialog.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/custom-accent-color-dialog.svelte deleted file mode 100644 index a9c37a26..00000000 --- a/frontend/src/routes/settings/admin/application-configuration/forms/custom-accent-color-dialog.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - {m.custom_accent_color()} - - {m.custom_accent_color_description()} - - - -
-
- - {m.color_value()} -
-
- -
-
-
-
-
- - - - - -
-
-
diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/custom-theme-color-dialog.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/custom-theme-color-dialog.svelte new file mode 100644 index 00000000..6815b588 --- /dev/null +++ b/frontend/src/routes/settings/admin/application-configuration/forms/custom-theme-color-dialog.svelte @@ -0,0 +1,135 @@ + + + + + + {m.custom_theme_color()} + {m.custom_theme_color_description()} + + +
+ + {m.color()} + +
+ {#if showError && error} + {error} + {/if} +
+
+ + {#if fitted} +
+ {m.preview()} +
+ +
+

{notes.join(' ')}

+
+ {/if} + + + + + +
+
+
diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte index 1e7f32b3..81f4886e 100644 --- a/frontend/src/routes/signup/+page.svelte +++ b/frontend/src/routes/signup/+page.svelte @@ -56,7 +56,7 @@ -

+

{m.signup_to_appname({ appName: $appConfigStore.appName })}

diff --git a/frontend/src/routes/signup/add-passkey/+page.svelte b/frontend/src/routes/signup/add-passkey/+page.svelte index af0d0672..78f4e2e9 100644 --- a/frontend/src/routes/signup/add-passkey/+page.svelte +++ b/frontend/src/routes/signup/add-passkey/+page.svelte @@ -53,6 +53,7 @@ confirm: { label: m.skip_for_now(), destructive: true, + icon: LucideSkipForward, action: () => { dismiss(); goto('/settings/account'); @@ -76,7 +77,7 @@
-

+

{m.setup_your_passkey()}

diff --git a/frontend/src/routes/signup/setup/+page.svelte b/frontend/src/routes/signup/setup/+page.svelte index b72c04c4..29acaac5 100644 --- a/frontend/src/routes/signup/setup/+page.svelte +++ b/frontend/src/routes/signup/setup/+page.svelte @@ -41,7 +41,7 @@ -

+

{m.signup_to_appname({ appName: $appConfigStore.appName })}

diff --git a/frontend/static/fonts/FunnelDisplay-latin-ext.woff2 b/frontend/static/fonts/FunnelDisplay-latin-ext.woff2 new file mode 100644 index 00000000..f22bf80f Binary files /dev/null and b/frontend/static/fonts/FunnelDisplay-latin-ext.woff2 differ diff --git a/frontend/static/fonts/FunnelDisplay-latin.woff2 b/frontend/static/fonts/FunnelDisplay-latin.woff2 new file mode 100644 index 00000000..9d9829b9 Binary files /dev/null and b/frontend/static/fonts/FunnelDisplay-latin.woff2 differ diff --git a/frontend/static/fonts/FunnelSans-Italic-latin-ext.woff2 b/frontend/static/fonts/FunnelSans-Italic-latin-ext.woff2 new file mode 100644 index 00000000..30cf7ac7 Binary files /dev/null and b/frontend/static/fonts/FunnelSans-Italic-latin-ext.woff2 differ diff --git a/frontend/static/fonts/FunnelSans-Italic-latin.woff2 b/frontend/static/fonts/FunnelSans-Italic-latin.woff2 new file mode 100644 index 00000000..f9364953 Binary files /dev/null and b/frontend/static/fonts/FunnelSans-Italic-latin.woff2 differ diff --git a/frontend/static/fonts/FunnelSans-latin-ext.woff2 b/frontend/static/fonts/FunnelSans-latin-ext.woff2 new file mode 100644 index 00000000..050b1b52 Binary files /dev/null and b/frontend/static/fonts/FunnelSans-latin-ext.woff2 differ diff --git a/frontend/static/fonts/FunnelSans-latin.woff2 b/frontend/static/fonts/FunnelSans-latin.woff2 new file mode 100644 index 00000000..8b5753bf Binary files /dev/null and b/frontend/static/fonts/FunnelSans-latin.woff2 differ diff --git a/frontend/static/fonts/Gloock-Regular.woff b/frontend/static/fonts/Gloock-Regular.woff deleted file mode 100644 index 8a1f6b48..00000000 Binary files a/frontend/static/fonts/Gloock-Regular.woff and /dev/null differ diff --git a/frontend/static/fonts/JetBrainsMono-cyrillic-ext.woff2 b/frontend/static/fonts/JetBrainsMono-cyrillic-ext.woff2 new file mode 100644 index 00000000..024a1af5 Binary files /dev/null and b/frontend/static/fonts/JetBrainsMono-cyrillic-ext.woff2 differ diff --git a/frontend/static/fonts/JetBrainsMono-cyrillic.woff2 b/frontend/static/fonts/JetBrainsMono-cyrillic.woff2 new file mode 100644 index 00000000..1b61cf96 Binary files /dev/null and b/frontend/static/fonts/JetBrainsMono-cyrillic.woff2 differ diff --git a/frontend/static/fonts/JetBrainsMono-greek.woff2 b/frontend/static/fonts/JetBrainsMono-greek.woff2 new file mode 100644 index 00000000..06f09360 Binary files /dev/null and b/frontend/static/fonts/JetBrainsMono-greek.woff2 differ diff --git a/frontend/static/fonts/JetBrainsMono-latin-ext.woff2 b/frontend/static/fonts/JetBrainsMono-latin-ext.woff2 new file mode 100644 index 00000000..310faddf Binary files /dev/null and b/frontend/static/fonts/JetBrainsMono-latin-ext.woff2 differ diff --git a/frontend/static/fonts/JetBrainsMono-latin.woff2 b/frontend/static/fonts/JetBrainsMono-latin.woff2 new file mode 100644 index 00000000..2ca6ac60 Binary files /dev/null and b/frontend/static/fonts/JetBrainsMono-latin.woff2 differ diff --git a/frontend/static/fonts/JetBrainsMono-vietnamese.woff2 b/frontend/static/fonts/JetBrainsMono-vietnamese.woff2 new file mode 100644 index 00000000..d37ea359 Binary files /dev/null and b/frontend/static/fonts/JetBrainsMono-vietnamese.woff2 differ diff --git a/tests/resources/export/uploads/profile-pictures/defaults/CF-v3.png b/tests/resources/export/uploads/profile-pictures/defaults/CF-v3.png new file mode 100644 index 00000000..3d64de7c Binary files /dev/null and b/tests/resources/export/uploads/profile-pictures/defaults/CF-v3.png differ diff --git a/tests/resources/export/uploads/profile-pictures/defaults/CF.png b/tests/resources/export/uploads/profile-pictures/defaults/CF.png deleted file mode 100644 index 1b533929..00000000 Binary files a/tests/resources/export/uploads/profile-pictures/defaults/CF.png and /dev/null differ diff --git a/tests/resources/export/uploads/profile-pictures/defaults/TC-v3.png b/tests/resources/export/uploads/profile-pictures/defaults/TC-v3.png new file mode 100644 index 00000000..43396f2a Binary files /dev/null and b/tests/resources/export/uploads/profile-pictures/defaults/TC-v3.png differ diff --git a/tests/resources/export/uploads/profile-pictures/defaults/TC.png b/tests/resources/export/uploads/profile-pictures/defaults/TC.png deleted file mode 100644 index 9ff79b45..00000000 Binary files a/tests/resources/export/uploads/profile-pictures/defaults/TC.png and /dev/null differ diff --git a/tests/specs/cli.spec.ts b/tests/specs/cli.spec.ts index 4f07aa49..b0ad98e4 100644 --- a/tests/specs/cli.spec.ts +++ b/tests/specs/cli.spec.ts @@ -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 }