Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions backend/internal/application/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,47 +738,64 @@ func (s *Service) UpdateProfile(ctx context.Context, userID uint, input UpdatePr
}
if input.AppearancePreferences != nil {
appearancePreferences := strings.TrimSpace(*input.AppearancePreferences)
if err := validateAppearancePreferences(appearancePreferences); err != nil {
normalizedAppearancePreferences, err := normalizeAppearancePreferences(appearancePreferences)
if err != nil {
return nil, err
}
updateInput.AppearancePreferences = &appearancePreferences
updateInput.AppearancePreferences = &normalizedAppearancePreferences
}

return s.repo.UpdateProfile(ctx, userID, updateInput)
}

func validateAppearancePreferences(raw string) error {
func normalizeAppearancePreferences(raw string) (string, error) {
if raw == "" {
return nil
return "", nil
}

var payload map[string]string
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return ErrInvalidAppearancePreferences
return "", ErrInvalidAppearancePreferences
}

normalized := make(map[string]string, len(payload))
for key, value := range payload {
switch key {
case "theme":
if value != "light" && value != "dark" && value != "system" {
return ErrInvalidAppearancePreferences
return "", ErrInvalidAppearancePreferences
}
normalized[key] = value
case "preset":
if value != "default" && value != "azure" && value != "cobalt" && value != "graphite" && value != "lagoon" && value != "ink" && value != "ochre" && value != "sepia" {
return ErrInvalidAppearancePreferences
return "", ErrInvalidAppearancePreferences
}
normalized[key] = value
case "chatFont":
if value != "default" && value != "songti" && value != "heiti" && value != "mono" {
return ErrInvalidAppearancePreferences
return "", ErrInvalidAppearancePreferences
}
normalized[key] = value
case "chatFontWeight":
if value != "regular" && value != "medium" && value != "semibold" && value != "bold" {
return ErrInvalidAppearancePreferences
return "", ErrInvalidAppearancePreferences
}
normalized[key] = value
case "fontSize":
if value != "small" && value != "standard" && value != "medium" && value != "large" {
value = "standard"
}
normalized[key] = value
default:
return ErrInvalidAppearancePreferences
return "", ErrInvalidAppearancePreferences
}
}
return nil

next, err := json.Marshal(normalized)
if err != nil {
return "", ErrInvalidAppearancePreferences
}
return string(next), nil
}

func normalizeLocale(raw string) (string, error) {
Expand Down
33 changes: 33 additions & 0 deletions backend/internal/application/auth/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package auth

import "testing"

func TestNormalizeAppearancePreferencesAllowsFontSize(t *testing.T) {
for _, fontSize := range []string{"small", "standard", "medium", "large"} {
payload := `{"theme":"system","preset":"default","chatFont":"default","chatFontWeight":"regular","fontSize":"` + fontSize + `"}`

if _, err := normalizeAppearancePreferences(payload); err != nil {
t.Fatalf("expected fontSize %q appearance preference to be valid, got %v", fontSize, err)
}
}
}

func TestNormalizeAppearancePreferencesDefaultsInvalidFontSize(t *testing.T) {
payload := `{"fontSize":"huge"}`

normalized, err := normalizeAppearancePreferences(payload)
if err != nil {
t.Fatalf("expected invalid fontSize appearance preference to fall back, got %v", err)
}
if normalized != `{"fontSize":"standard"}` {
t.Fatalf("expected invalid fontSize to fall back to standard, got %s", normalized)
}
}

func TestNormalizeAppearancePreferencesRejectsUnknownKey(t *testing.T) {
payload := `{"fontSize":"standard","unknown":"value"}`

if _, err := normalizeAppearancePreferences(payload); err == nil {
t.Fatal("expected unknown appearance preference key to be rejected")
}
}
90 changes: 90 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
--font-heiti: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", sans-serif;
--font-chat: var(--font-sans);
--font-chat-weight: 400;
--ui-font-scale: 1;
--chat-font-scale: 1;
--radius: 0.5rem;
--shadow-x: 0;
--shadow-y: 1px;
Expand Down Expand Up @@ -931,6 +933,23 @@
--font-serif: var(--font-serif);
--font-economist: var(--font-economist);

--text-xs: calc(0.75rem * var(--ui-font-scale));
--text-sm: calc(0.875rem * var(--ui-font-scale));
--text-base: calc(1rem * var(--ui-font-scale));
--text-lg: calc(1.125rem * var(--ui-font-scale));
--text-xl: calc(1.25rem * var(--ui-font-scale));
--text-2xl: calc(1.5rem * var(--ui-font-scale));
--text-3xl: calc(1.875rem * var(--ui-font-scale));
--text-4xl: calc(2.25rem * var(--ui-font-scale));
--text-xs--line-height: calc(1rem * var(--ui-font-scale));
--text-sm--line-height: calc(1.25rem * var(--ui-font-scale));
--text-base--line-height: calc(1.5rem * var(--ui-font-scale));
--text-lg--line-height: calc(1.75rem * var(--ui-font-scale));
--text-xl--line-height: calc(1.75rem * var(--ui-font-scale));
--text-2xl--line-height: calc(2rem * var(--ui-font-scale));
--text-3xl--line-height: calc(2.25rem * var(--ui-font-scale));
--text-4xl--line-height: calc(2.5rem * var(--ui-font-scale));

--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
Expand Down Expand Up @@ -1055,7 +1074,78 @@
--font-chat-weight: 700;
}

:root[data-font-size="small"] {
--ui-font-scale: 0.88;
--chat-font-scale: 0.88;
}

:root[data-font-size="medium"] {
--ui-font-scale: 1.12;
--chat-font-scale: 1.12;
}

:root[data-font-size="large"] {
--ui-font-scale: 1.24;
--chat-font-scale: 1.24;
}

@layer utilities {
.text-\[8px\] {
font-size: calc(8px * var(--ui-font-scale));
}

.text-\[9px\] {
font-size: calc(9px * var(--ui-font-scale));
}

.text-\[10px\] {
font-size: calc(10px * var(--ui-font-scale));
}

.text-\[11px\] {
font-size: calc(11px * var(--ui-font-scale));
}

.text-\[12px\] {
font-size: calc(12px * var(--ui-font-scale));
}

.text-\[12\.5px\] {
font-size: calc(12.5px * var(--ui-font-scale));
}

.text-\[13px\] {
font-size: calc(13px * var(--ui-font-scale));
}

.text-\[15px\] {
font-size: calc(15px * var(--ui-font-scale));
}

.text-\[22px\] {
font-size: calc(22px * var(--ui-font-scale));
}

.text-\[32px\] {
font-size: calc(32px * var(--ui-font-scale));
}

.text-\[0\.8rem\] {
font-size: calc(0.8rem * var(--ui-font-scale));
}

.chat-font-content.text-\[15px\],
.chat-font-content .text-\[15px\],
textarea.text-\[15px\] {
font-size: calc(15px * var(--chat-font-scale));
}

@media (min-width: 48rem) {
.md\:text-\[32px\] {
font-size: calc(32px * var(--ui-font-scale));
}
}

.chat-font-content {
font-family: var(--font-chat);
font-weight: var(--font-chat-weight);
Expand Down
15 changes: 9 additions & 6 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";

import { ChatFontProvider } from "@/features/layouts/components/providers/chat-font-provider";
import { FontSizeProvider } from "@/features/layouts/components/providers/font-size-provider";
import { WorkspaceShell } from "@/features/layouts/components/sections/workspace-shell";
import { AppI18nProvider } from "@/i18n/app-i18n-provider";
import { DevtoolsBrandBanner } from "@/shared/components/devtools-brand-banner";
Expand Down Expand Up @@ -49,12 +50,14 @@ export default function RootLayout({
>
<AppI18nProvider>
<ThemeProvider>
<ChatFontProvider>
<WorkspaceShell>{children}</WorkspaceShell>
<Toaster />
{webVitalsEnabled ? <WebVitals /> : null}
<DevtoolsBrandBanner />
</ChatFontProvider>
<FontSizeProvider>
<ChatFontProvider>
<WorkspaceShell>{children}</WorkspaceShell>
<Toaster />
{webVitalsEnabled ? <WebVitals /> : null}
<DevtoolsBrandBanner />
</ChatFontProvider>
</FontSizeProvider>
</ThemeProvider>
</AppI18nProvider>
</body>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client";

import * as React from "react";

import {
applyFontSizePreference,
useFontSizePreference,
} from "@/features/settings/utils/font-size";

export function FontSizeProvider({
children,
}: {
children: React.ReactNode;
}) {
const fontSize = useFontSizePreference();

React.useEffect(() => {
applyFontSizePreference(fontSize);
}, [fontSize]);

return children;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
writeChatFontPreference,
writeChatFontWeightPreference,
} from "@/features/settings/utils/chat-font";
import {
readFontSizePreference,
writeFontSizePreference,
} from "@/features/settings/utils/font-size";
import { useAuthSession } from "@/shared/auth/auth-session-context";
import { useTheme } from "@/shared/components/theme-provider";

Expand Down Expand Up @@ -39,6 +43,9 @@ export function AppearancePreferencesSync() {
if (next.chatFontWeight !== readChatFontWeightPreference()) {
writeChatFontWeightPreference(next.chatFontWeight);
}
if (next.fontSize !== readFontSizePreference()) {
writeFontSizePreference(next.fontSize);
}
}, [setPreset, setTheme, user, userStatus]);

return null;
Expand Down
Loading
Loading