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

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 87ddbf712a
commit c0fd85740c
63 changed files with 2008 additions and 443 deletions
+22
View File
@@ -10,6 +10,7 @@ func (m *AppConfigModel) DecodeMsgpack(dec *msgpack.Decoder) error {
*config
LegacyNotificationEnabled AppConfigValue `msgpack:"EmailLoginNotificationEnabled"`
LegacyEmailVerificationEnabled AppConfigValue `msgpack:"EmailVerificationEnabled"`
LegacyAccentColor AppConfigValue `msgpack:"AccentColor"`
}{config: (*config)(m)}
if err := dec.Decode(&value); err != nil {
return err
@@ -20,6 +21,9 @@ func (m *AppConfigModel) DecodeMsgpack(dec *msgpack.Decoder) error {
if m.EmailVerification == "" && value.LegacyEmailVerificationEnabled != "" {
m.EmailVerification = legacyEmailVerification(value.LegacyEmailVerificationEnabled)
}
if m.ColorTheme == "" && value.LegacyAccentColor != "" {
m.ColorTheme = legacyColorTheme(value.LegacyAccentColor)
}
return nil
}
@@ -36,3 +40,21 @@ func legacyEmailVerification(enabled AppConfigValue) AppConfigValue {
}
return EmailVerificationDisabled
}
// legacyColorTheme maps an accent color from before color themes to the theme built around it, or to a custom theme with that color
func legacyColorTheme(accent AppConfigValue) AppConfigValue {
switch accent {
case "", "default":
return "mono"
case "oklch(0.6 0.2 240)":
return "mist"
case "oklch(0.65 0.2 150)":
return "sage"
case "oklch(0.6 0.15 180)":
return "midnight"
case "oklch(0.6 0.24 300)":
return "lavender"
default:
return "custom:" + accent
}
}
+3
View File
@@ -65,6 +65,9 @@ func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
if legacyCfg["emailVerification"] == "" {
dest.EmailVerification = legacyEmailVerification(AppConfigValue(legacyCfg["emailVerificationEnabled"]))
}
if legacyCfg["colorTheme"] == "" {
dest.ColorTheme = legacyColorTheme(AppConfigValue(legacyCfg["accentColor"]))
}
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
+2 -2
View File
@@ -32,7 +32,7 @@ type AppConfigModel struct {
SessionDuration AppConfigValue `json:"sessionDuration" env:"SESSION_DURATION" type:"int"` // In minutes
HomePageURL AppConfigValue `json:"homePageUrl" env:"HOME_PAGE_URL" public:"true"`
EmailsVerified AppConfigValue `json:"emailsVerified" env:"EMAILS_VERIFIED" type:"bool"`
AccentColor AppConfigValue `json:"accentColor" env:"ACCENT_COLOR" public:"true"`
ColorTheme AppConfigValue `json:"colorTheme" env:"COLOR_THEME" public:"true"` // A theme id, or custom:<css color> with an optional :dark suffix for a dark sidebar
DisableAnimations AppConfigValue `json:"disableAnimations" env:"DISABLE_ANIMATIONS" type:"bool" public:"true"`
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" env:"ALLOW_OWN_ACCOUNT_EDIT" type:"bool" public:"true"`
AllowUserSignups AppConfigValue `json:"allowUserSignups" env:"ALLOW_USER_SIGNUPS" public:"true"`
@@ -144,7 +144,7 @@ func getDefaultConfig() *AppConfigModel {
AllowUserSignups: "disabled",
SignupDefaultUserGroupIDs: "[]",
SignupDefaultCustomClaims: "[]",
AccentColor: "default",
ColorTheme: "mono",
// Email
RequireUserEmail: "true",
SmtpHost: "",
+29
View File
@@ -345,4 +345,33 @@ func TestAppConfigModel_DecodeMsgpack(t *testing.T) {
assert.Equal(t, AppConfigValue(LoginNotificationAlways), m.EmailLoginNotificationMode)
assert.Equal(t, AppConfigValue(EmailVerificationRequired), m.EmailVerification)
})
t.Run("maps the legacy accent color to a color theme", func(t *testing.T) {
tests := map[string]AppConfigValue{
"default": "mono",
"oklch(0.6 0.2 240)": "mist",
"oklch(0.63 0.2 15)": "custom:oklch(0.63 0.2 15)",
}
for accent, expected := range tests {
data, err := msgpack.Marshal(map[string]string{"AccentColor": accent})
require.NoError(t, err)
var m AppConfigModel
err = msgpack.Unmarshal(data, &m)
require.NoError(t, err)
assert.Equal(t, expected, m.ColorTheme)
}
})
t.Run("an explicit color theme wins over the legacy accent color", func(t *testing.T) {
data, err := msgpack.Marshal(map[string]string{"ColorTheme": "sage", "AccentColor": "default"})
require.NoError(t, err)
var m AppConfigModel
err = msgpack.Unmarshal(data, &m)
require.NoError(t, err)
assert.Equal(t, AppConfigValue("sage"), m.ColorTheme)
})
}
+8
View File
@@ -258,6 +258,14 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
}
}
// TODO: Remove in next major version (v4)
// Preserve the old accent color setting unless an explicit color theme replaces it
if _, ok := os.LookupEnv("COLOR_THEME"); !ok {
if accent, exists := os.LookupEnv("ACCENT_COLOR"); exists {
dest.ColorTheme = legacyColorTheme(AppConfigValue(accent))
}
}
// Validate the resolved configuration before exposing values to the rest of the application
err := validateEnvConfig(dest)
if err != nil {
+1 -1
View File
@@ -23,7 +23,7 @@ type AppConfigUpdateDto struct {
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json_string_array"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json_custom_claims"`
AccentColor string `json:"accentColor"`
ColorTheme string `json:"colorTheme"`
RequireUserEmail string `json:"requireUserEmail" binding:"required,boolean_string"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
+6 -8
View File
@@ -62,7 +62,7 @@ type FileCleanupJobs struct {
fileStorage storage.FileStorage
}
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials or were rendered by an earlier version
func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) error {
var users []model.User
err := j.db.
@@ -73,10 +73,10 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
return fmt.Errorf("failed to fetch users: %w", err)
}
// Create a map to track which initials are in use
initialsInUse := make(map[string]struct{})
// Create a map to track which pictures are in use
filesInUse := make(map[string]struct{})
for _, user := range users {
initialsInUse[profilepicture.Initials(user.FirstName, user.LastName, user.Username)] = struct{}{}
filesInUse[profilepicture.DefaultPictureFile(profilepicture.Initials(user.FirstName, user.LastName, user.Username))] = struct{}{}
}
defaultPicturesDir := path.Join("profile-pictures", "defaults")
@@ -91,10 +91,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
if filename == "" {
continue
}
initials := strings.TrimSuffix(filename, ".png")
// If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok {
// If no user needs this picture, delete the file
if _, ok := filesInUse[filename]; !ok {
filePath := path.Join(defaultPicturesDir, filename)
err = j.fileStorage.Delete(ctx, filePath)
if err != nil {
+1 -1
View File
@@ -140,7 +140,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
// Try cached default for initials
initials := profilepicture.Initials(user.FirstName, user.LastName, user.Username)
defaultPicturePath := path.Join("profile-pictures", "defaults", initials+".png")
defaultPicturePath := path.Join("profile-pictures", "defaults", profilepicture.DefaultPictureFile(initials))
file, size, err = s.fileStorage.Open(ctx, defaultPicturePath)
if err == nil {
return file, size, nil
@@ -88,7 +88,7 @@ func centerSquare(img image.Image) image.Image {
}
var loadInitialsFont = sync.OnceValues(func() (*opentype.Font, error) {
fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf")
fontBytes, err := resources.FS.ReadFile("fonts/FunnelDisplay-SemiBold.ttf")
if err != nil {
return nil, fmt.Errorf("failed to read font file: %w", err)
}
@@ -130,6 +130,12 @@ func canRenderInitials(initials string) bool {
return true
}
// DefaultPictureFile returns the cache file name of the default picture for the initials
// The suffix changes whenever the rendering changes, so the cleanup job deletes pictures cached by an earlier version
func DefaultPictureFile(initials string) string {
return initials + "-v3.png"
}
// CreateDefaultProfilePicture creates a profile picture with the initials
func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
// Create a blank image with a white background
@@ -142,7 +148,8 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
}
// Create a font.Face with a specific size
fontSize := 160.0
// Clients often crop the picture to a circle, so the initials keep clear of its edge
fontSize := 120.0
face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.