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,