From dc081ccc5bb486b627a60bff5366c021486fb08e Mon Sep 17 00:00:00 2001 From: MangoPig Date: Mon, 6 Jul 2026 17:02:40 +0100 Subject: [PATCH] Feat: add persisted account theme settings --- Backend/internal/bootstrap/bootstrap_types.go | 21 +- .../bootstrap/project_disk_bootstrap.go | 3 + Backend/internal/bootstrap/service.go | 8 + Backend/internal/bootstrap/service_test.go | 40 +++ Backend/internal/bootstrap/settings_theme.go | 111 +++++++++ Backend/internal/httpx/api_routes.go | 1 + Backend/internal/httpx/api_settings_routes.go | 43 ++++ .../app-shell/data/app-shell.context.tsx | 19 +- .../app-shell/data/app-shell.types.ts | 2 + .../ThemeSettingsPage.module.scss | 234 ++++++++++++++++++ .../ThemeSettingsPage/ThemeSettingsPage.tsx | 129 ++++++++++ Frontend/src/helper/themeRuntime.ts | 59 +++-- Frontend/src/routes/settings/theme.tsx | 10 +- Frontend/src/theme/presets.ts | 19 +- 14 files changed, 653 insertions(+), 46 deletions(-) create mode 100644 Backend/internal/bootstrap/settings_theme.go create mode 100644 Backend/internal/httpx/api_settings_routes.go create mode 100644 Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.module.scss create mode 100644 Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.tsx diff --git a/Backend/internal/bootstrap/bootstrap_types.go b/Backend/internal/bootstrap/bootstrap_types.go index fea221c..9bec72b 100644 --- a/Backend/internal/bootstrap/bootstrap_types.go +++ b/Backend/internal/bootstrap/bootstrap_types.go @@ -91,6 +91,11 @@ type AdminRecord struct { DisplayName string `json:"displayName"` IsInstanceAdmin bool `json:"isInstanceAdmin"` HomeTitle string `json:"homeTitle"` + ThemePresetID string `json:"themePresetId,omitempty"` +} + +type SaveThemePresetInput struct { + PresetID string } type OrganizationRecord struct { @@ -243,8 +248,8 @@ type MoveProjectItemInput struct { } type CreateProjectFolderResult struct { - ProjectID string `json:"projectId"` - CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` + ProjectID string `json:"projectId"` + CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"` Folders []ProjectHierarchyFolderRecord `json:"folders"` } @@ -272,8 +277,8 @@ type MoveProjectFolderResult struct { } type CreateProjectItemResult struct { - ProjectID string `json:"projectId"` - CreatedItem ProjectTreeNodeRecord `json:"createdItem"` + ProjectID string `json:"projectId"` + CreatedItem ProjectTreeNodeRecord `json:"createdItem"` Nodes []ProjectTreeNodeRecord `json:"nodes"` } @@ -285,10 +290,10 @@ type DeleteProjectItemResult struct { } type MoveProjectItemResult struct { - ProjectID string `json:"projectId"` - PreviousItemStableID string `json:"previousItemId"` - PreviousItemPath string `json:"previousItemPath"` - MovedItem ProjectTreeNodeRecord `json:"movedItem"` + ProjectID string `json:"projectId"` + PreviousItemStableID string `json:"previousItemId"` + PreviousItemPath string `json:"previousItemPath"` + MovedItem ProjectTreeNodeRecord `json:"movedItem"` Nodes []ProjectTreeNodeRecord `json:"nodes"` } diff --git a/Backend/internal/bootstrap/project_disk_bootstrap.go b/Backend/internal/bootstrap/project_disk_bootstrap.go index 871cae7..0a6b78a 100644 --- a/Backend/internal/bootstrap/project_disk_bootstrap.go +++ b/Backend/internal/bootstrap/project_disk_bootstrap.go @@ -232,6 +232,9 @@ func (service *Service) ensureBootstrapPOSIXSkeleton( "type": "personal", "name": personalName, "slug": personalSlug, + "theme": map[string]any{ + "presetId": "moku-midnight", + }, }); err != nil { return fmt.Errorf("write personal %s: %w", posixSettingsFileName, err) } diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index 96e3db4..db6c799 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -318,6 +318,14 @@ func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) { return nil, err } + themePresetID, err := service.loadAdminThemePresetID(ctx, record.DisplayName) + if err != nil { + return nil, err + } + if themePresetID != "" { + record.ThemePresetID = themePresetID + } + return &record, nil } diff --git a/Backend/internal/bootstrap/service_test.go b/Backend/internal/bootstrap/service_test.go index 5939153..5d92cf6 100644 --- a/Backend/internal/bootstrap/service_test.go +++ b/Backend/internal/bootstrap/service_test.go @@ -206,6 +206,13 @@ func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) { if personalSettings["slug"] != "ronald" { t.Fatalf("expected personal slug ronald, got %#v", personalSettings["slug"]) } + personalTheme, ok := personalSettings["theme"].(map[string]any) + if !ok { + t.Fatalf("expected personal settings to contain theme object, got %#v", personalSettings) + } + if personalTheme["presetId"] != defaultThemePresetID { + t.Fatalf("expected personal theme preset %s, got %#v", defaultThemePresetID, personalTheme["presetId"]) + } personalHome := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", posixHomeFileName)) if personalHome["type"] != "personal-home" { @@ -277,6 +284,39 @@ func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing } } +func TestPersonalSettingsPathForDisplayName(t *testing.T) { + path := personalSettingsPathForDisplayName(" Ronald ") + if path != "users/personals/personal-ronald/settings.cbor" { + t.Fatalf("unexpected personal settings path: %s", path) + } +} + +func TestApplyThemePresetToSettingsPreservesExistingFields(t *testing.T) { + settings := map[string]any{ + "type": "personal", + "slug": "ronald", + "theme": map[string]any{ + "density": "comfortable", + }, + } + + updated := applyThemePresetToSettings(settings, "moku-default") + + if updated["type"] != "personal" { + t.Fatalf("expected type personal, got %#v", updated["type"]) + } + theme, ok := updated["theme"].(map[string]any) + if !ok { + t.Fatalf("expected theme object, got %#v", updated["theme"]) + } + if theme["presetId"] != "moku-default" { + t.Fatalf("expected presetId moku-default, got %#v", theme["presetId"]) + } + if theme["density"] != "comfortable" { + t.Fatalf("expected density to be preserved, got %#v", theme["density"]) + } +} + func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) { rootPath := filepath.Join(t.TempDir(), "POSIX") service := NewService(nil, rootPath) diff --git a/Backend/internal/bootstrap/settings_theme.go b/Backend/internal/bootstrap/settings_theme.go new file mode 100644 index 0000000..cdde381 --- /dev/null +++ b/Backend/internal/bootstrap/settings_theme.go @@ -0,0 +1,111 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5" +) + +const defaultThemePresetID = "moku-midnight" + +func personalSettingsPathForDisplayName(displayName string) string { + personalName := strings.TrimSpace(displayName) + if personalName == "" { + personalName = defaultPersonalDisplayName + } + + personalSlug := normalizePOSIXSlug(personalName) + return filepath.ToSlash(filepath.Join("users", "personals", slugDir("personal", personalSlug), posixSettingsFileName)) +} + +func extractThemePresetID(settings map[string]any) string { + theme, ok := settings["theme"].(map[string]any) + if !ok { + return "" + } + + presetID, _ := theme["presetId"].(string) + return strings.TrimSpace(presetID) +} + +func applyThemePresetToSettings(settings map[string]any, presetID string) map[string]any { + normalized := settings + if normalized == nil { + normalized = map[string]any{} + } + + theme, ok := normalized["theme"].(map[string]any) + if !ok || theme == nil { + theme = map[string]any{} + } + + theme["presetId"] = presetID + normalized["theme"] = theme + return normalized +} + +func (service *Service) SaveThemePreset(ctx context.Context, input SaveThemePresetInput) (*AdminRecord, error) { + admin, err := service.GetAdmin(ctx) + if err != nil { + return nil, err + } + if admin == nil { + return nil, ErrAdminNotConfigured + } + + presetID := strings.TrimSpace(input.PresetID) + if presetID == "" { + return nil, fmt.Errorf("theme preset id is required") + } + + rootPath := strings.TrimSpace(service.posixRoot) + if rootPath == "" { + return nil, fmt.Errorf("posix root is not configured") + } + + relativeSettingsPath := personalSettingsPathForDisplayName(admin.DisplayName) + absoluteSettingsPath := filepath.Join(rootPath, filepath.FromSlash(relativeSettingsPath)) + settings := readStructuredFileMap(absoluteSettingsPath) + if len(settings) == 0 { + return nil, fmt.Errorf("personal settings file is missing") + } + + updatedSettings := applyThemePresetToSettings(settings, presetID) + if err := writeCBORFile(absoluteSettingsPath, updatedSettings); err != nil { + return nil, fmt.Errorf("write personal %s: %w", posixSettingsFileName, err) + } + + if err := service.rebuildProjection(ctx); err != nil { + return nil, err + } + + return service.GetAdmin(ctx) +} + +func (service *Service) loadAdminThemePresetID(ctx context.Context, displayName string) (string, error) { + path := personalSettingsPathForDisplayName(displayName) + + var presetID *string + err := service.db.Pool.QueryRow(ctx, ` + SELECT content_json->'theme'->>'presetId' + FROM posix_nodes + WHERE path = $1 + LIMIT 1; + `, path).Scan(&presetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return "", err + } + + if presetID == nil { + return "", nil + } + + return strings.TrimSpace(*presetID), nil +} diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go index 144251a..3163dac 100644 --- a/Backend/internal/httpx/api_routes.go +++ b/Backend/internal/httpx/api_routes.go @@ -31,6 +31,7 @@ func (routes apiRoutes) Register(router chi.Router) { bootstrapRouter.Post("/structure", routes.handleBootstrapStructureStep) }) apiRouter.Get("/app-shell", routes.handleAppShellState) + apiRouter.Put("/settings/theme", routes.handleSaveThemePreset) apiRouter.Get("/organizations", routes.handleOrganizations) apiRouter.Get("/workspaces", routes.handleWorkspaces) apiRouter.Route("/projects/{projectId}", func(projectRouter chi.Router) { diff --git a/Backend/internal/httpx/api_settings_routes.go b/Backend/internal/httpx/api_settings_routes.go new file mode 100644 index 0000000..d9a5a7c --- /dev/null +++ b/Backend/internal/httpx/api_settings_routes.go @@ -0,0 +1,43 @@ +package httpx + +import ( + "net/http" + "strings" + + bootstrapservice "moku-backend/internal/bootstrap" +) + +type saveThemePresetRequest struct { + PresetID string `json:"presetId"` +} + +func (routes apiRoutes) handleSaveThemePreset(w http.ResponseWriter, r *http.Request) { + payload, ok := decodeBootstrapRequest[saveThemePresetRequest](w, r) + if !ok { + return + } + + payload.PresetID = strings.TrimSpace(payload.PresetID) + if payload.PresetID == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Theme preset ID is required.") + return + } + + admin, err := routes.bootstrapService().SaveThemePreset(r.Context(), bootstrapservice.SaveThemePresetInput{ + PresetID: payload.PresetID, + }) + if err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "presetId": payload.PresetID, + "admin": admin, + }, + "meta": map[string]any{ + "resource": "settings-theme", + }, + }) +} diff --git a/Frontend/src/components/app-shell/data/app-shell.context.tsx b/Frontend/src/components/app-shell/data/app-shell.context.tsx index 75c1a6c..ac12884 100644 --- a/Frontend/src/components/app-shell/data/app-shell.context.tsx +++ b/Frontend/src/components/app-shell/data/app-shell.context.tsx @@ -1,14 +1,8 @@ // Path: Frontend/src/components/app-shell/data/app-shell.context.tsx -import { - createContext, - createMemo, - createSignal, - onMount, - useContext, - type JSX, -} from "solid-js"; +import { createContext, createMemo, createSignal, onMount, useContext, type JSX } from "solid-js"; import { resolveAPIBase } from "../../../lib/api"; +import { applyThemePresetById, resolvePreferredThemePresetId } from "../../../helper/themeRuntime"; import { buildActiveDepartment, buildActiveProject, @@ -56,7 +50,13 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem throw new Error(errorMessage || "Failed to load app shell state."); } - setPayload(normalizeAppShellPayload(body.data)); + const normalizedPayload = normalizeAppShellPayload(body.data); + const persistedThemePresetId = normalizedPayload.admin?.themePresetId?.trim(); + if (persistedThemePresetId && persistedThemePresetId !== resolvePreferredThemePresetId()) { + await applyThemePresetById(persistedThemePresetId); + } + + setPayload(normalizedPayload); setStatus("success"); } catch (loadError) { setStatus("error"); @@ -72,6 +72,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem status, error, installation: createMemo(() => payload()?.installation), + admin: createMemo(() => payload()?.admin), railItems: createMemo(() => buildRailItems(payload())), activeServer: createMemo(() => buildActiveServer(payload())), activeProject: createMemo(() => buildActiveProject(payload())), diff --git a/Frontend/src/components/app-shell/data/app-shell.types.ts b/Frontend/src/components/app-shell/data/app-shell.types.ts index b4103e6..bf19b79 100644 --- a/Frontend/src/components/app-shell/data/app-shell.types.ts +++ b/Frontend/src/components/app-shell/data/app-shell.types.ts @@ -30,6 +30,7 @@ export type AppShellAdmin = { displayName: string; isInstanceAdmin: boolean; homeTitle: string; + themePresetId?: string; }; export type AppShellOrganization = { @@ -87,6 +88,7 @@ export type AppShellContextValue = { status: Accessor<"idle" | "loading" | "success" | "error">; error: Accessor; installation: Accessor; + admin: Accessor; railItems: Accessor; activeServer: Accessor; activeProject: Accessor; diff --git a/Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.module.scss b/Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.module.scss new file mode 100644 index 0000000..e614973 --- /dev/null +++ b/Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.module.scss @@ -0,0 +1,234 @@ +// Path: Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.module.scss + +.viewport { + width: 100%; + max-width: min(80rem, 100%); + min-height: 100%; + padding: var(--space-5) var(--space-6) calc(var(--space-7) + env(safe-area-inset-bottom)); + box-sizing: border-box; + display: grid; + gap: var(--space-5); + align-content: start; +} + +.hero, +.card, +.errorBanner { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-xl); + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); +} + +.hero { + padding: var(--space-6); + display: flex; + align-items: center; + gap: var(--space-4); + border-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle)); + box-shadow: + 0 10px 24px color-mix(in srgb, var(--color-accent) 8%, transparent), + var(--shadow-sm); +} + +.heroIcon { + width: 3rem; + height: 3rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-lg); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-accent) 14%, transparent), + color-mix(in srgb, var(--color-accent-soft) 22%, transparent) + ); + border: 1px solid color-mix(in srgb, var(--color-accent) 16%, var(--color-border-subtle)); + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 8%, transparent), + 0 6px 14px color-mix(in srgb, var(--color-accent) 7%, transparent); + color: var(--color-text-primary); + flex: 0 0 auto; +} + +.heroCopy { + display: grid; + gap: var(--space-2); +} + +.title, +.cardTitle { + margin: 0; + color: var(--color-text-primary); +} + +.title { + @include text-display; + line-height: 1.02; + letter-spacing: -0.02em; +} + +.description, +.cardDescription, +.errorBanner { + margin: 0; + color: var(--color-text-secondary); + @include text-body; +} + +.errorBanner { + padding: var(--space-4) var(--space-5); + border-color: color-mix(in srgb, #ef4444 24%, var(--color-border-subtle)); + color: color-mix(in srgb, #ef4444 64%, var(--color-text-primary)); +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} + +.card { + padding: var(--space-4); + display: grid; + gap: var(--space-4); + text-align: left; + color: inherit; + cursor: pointer; + transition: + transform 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; +} + +.card:hover:not(:disabled) { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border-strong)); + box-shadow: + 0 10px 22px color-mix(in srgb, var(--color-accent) 8%, transparent), + var(--shadow-md); +} + +.card:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-accent) 54%, transparent); + outline-offset: 2px; +} + +.card:disabled { + cursor: default; +} + +.cardSelected { + border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border-strong)); + box-shadow: + 0 10px 22px color-mix(in srgb, var(--color-accent) 10%, transparent), + var(--shadow-md); +} + +.preview { + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--color-border-subtle); + background: var(--color-surface); + min-height: 9.5rem; +} + +.previewCanvas { + height: 100%; + min-height: 9.5rem; + padding: 0.8rem; + box-sizing: border-box; + display: grid; + grid-template-rows: auto 1fr; + gap: 0.75rem; +} + +.previewTopbar { + height: 0.65rem; + border-radius: 999px; + opacity: 0.9; +} + +.previewSurface { + border-radius: calc(var(--radius-lg) - 0.25rem); + padding: 0.9rem; + display: grid; + align-content: start; + gap: 0.55rem; + box-shadow: inset 0 1px 0 color-mix(in srgb, white 6%, transparent); +} + +.previewChip { + width: 2.8rem; + height: 0.55rem; + border-radius: 999px; + opacity: 0.95; +} + +.previewLine, +.previewLineShort { + height: 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--color-text-primary) 10%, transparent); +} + +.previewLineShort { + width: 58%; +} + +.cardBody { + display: grid; + gap: var(--space-2); +} + +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.cardTitle { + @include text-title; + line-height: 1.1; +} + +.badge { + padding: 0.25rem 0.55rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--color-accent) 18%, var(--color-border-subtle)); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + color: color-mix(in srgb, var(--color-accent) 44%, var(--color-text-primary)); + @include text-label; + white-space: nowrap; +} + +@media (max-width: 64rem) { + .grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 48rem) { + .viewport { + padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom)); + } + + .hero, + .card { + padding: var(--space-4); + } + + .hero { + align-items: flex-start; + } + + .title { + max-width: none; + } + + .cardHeader { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.tsx b/Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.tsx new file mode 100644 index 0000000..063275e --- /dev/null +++ b/Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.tsx @@ -0,0 +1,129 @@ +// Path: Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.tsx + +import { For, Show, createMemo, createSignal, type JSX } from "solid-js"; +import { resolveAPIBase } from "../../../lib/api"; +import { Moon } from "../../../lib/icons"; +import { useAppShellData } from "../../app-shell/data/app-shell.context"; +import { applyThemePresetById, resolvePreferredThemePresetId } from "../../../helper/themeRuntime"; +import { themePresetMetas } from "../../../theme/presets"; +import styles from "./ThemeSettingsPage.module.scss"; + +type SaveThemeResponse = { + data?: { + presetId?: string; + }; + error?: { message?: string } | string; + message?: string; +}; + +export const ThemeSettingsPage = (): JSX.Element => { + const appShell = useAppShellData(); + const [savingPresetId, setSavingPresetId] = createSignal(null); + const [error, setError] = createSignal(""); + + const activePresetId = createMemo(() => { + return appShell.admin()?.themePresetId?.trim() || resolvePreferredThemePresetId(); + }); + + const savePreset = async (presetId: string): Promise => { + if (savingPresetId() || presetId === activePresetId()) { + return; + } + + setSavingPresetId(presetId); + setError(""); + + try { + const response = await fetch(`${resolveAPIBase()}/settings/theme`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ presetId }), + }); + + const body = (await response.json().catch(() => ({}))) as SaveThemeResponse; + const errorMessage = + typeof body.message === "string" + ? body.message + : typeof body.error === "string" + ? body.error + : body.error?.message; + + if (!response.ok) { + throw new Error(errorMessage || "Failed to save theme preset."); + } + + await applyThemePresetById(presetId); + await appShell.reload(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Failed to save theme preset."); + } finally { + setSavingPresetId(null); + } + }; + + return ( +
+
+
+ +
+
+

Theme Settings

+

Pick the theme preset for your account.

+
+
+ + + + + +
+ + {(preset) => { + const isSelected = () => activePresetId() === preset.id; + const isSaving = () => savingPresetId() === preset.id; + + return ( + + ); + }} + +
+
+ ); +}; diff --git a/Frontend/src/helper/themeRuntime.ts b/Frontend/src/helper/themeRuntime.ts index 4b8f796..106fcdd 100644 --- a/Frontend/src/helper/themeRuntime.ts +++ b/Frontend/src/helper/themeRuntime.ts @@ -36,6 +36,29 @@ const persistThemePreset = (themeDefinition: ThemeDefinition): void => { localStorage.setItem(THEME_PRESET_STORAGE_KEY, themeDefinition.id); }; +const fetchThemeDefinitionByPresetId = async (presetId: string): Promise => { + const themePath = resolveThemePresetPath(presetId) ?? defaultThemePresetPath; + + const response = await fetch(themePath, { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Theme preset request failed with status ${response.status}.`); + } + + const candidate = (await response.json()) as unknown; + const result = validateThemeDefinition(candidate); + + if (!result.success) { + throw new Error(result.errors.join(" ")); + } + + return result.data; +}; + const setDocumentThemeMode = (theme: Theme): void => { const rootElement = getRootElement(); @@ -111,6 +134,21 @@ export const resolvePreferredThemePresetPath = (): string => { return resolveThemePresetPath(presetId) ?? defaultThemePresetPath; }; +export const applyThemePresetById = async (presetId: string, theme: Theme = getDocumentTheme()): Promise => { + try { + const definition = await fetchThemeDefinitionByPresetId(presetId); + if (!definition) { + return null; + } + + applyThemeDefinition(definition, theme); + return definition; + } catch (error) { + console.error("Failed to apply theme preset.", error); + return null; + } +}; + export const initializeThemeRuntime = async (): Promise => { if (typeof window === "undefined") { return null; @@ -124,26 +162,7 @@ export const initializeThemeRuntime = async (): Promise if (!themeInitializationPromise) { themeInitializationPromise = (async (): Promise => { try { - const response = await fetch(resolvePreferredThemePresetPath(), { - headers: { - Accept: "application/json", - }, - }); - - if (!response.ok) { - throw new Error(`Theme preset request failed with status ${response.status}.`); - } - - const candidate = (await response.json()) as unknown; - const result = validateThemeDefinition(candidate); - - if (!result.success) { - throw new Error(result.errors.join(" ")); - } - - applyThemeDefinition(result.data, getDocumentTheme()); - - return result.data; + return await applyThemePresetById(resolvePreferredThemePresetId(), getDocumentTheme()); } catch (error) { console.error("Failed to initialize theme runtime.", error); return null; diff --git a/Frontend/src/routes/settings/theme.tsx b/Frontend/src/routes/settings/theme.tsx index 7500612..0b7e1f9 100644 --- a/Frontend/src/routes/settings/theme.tsx +++ b/Frontend/src/routes/settings/theme.tsx @@ -1,16 +1,10 @@ // Path: Frontend/src/routes/settings/theme.tsx import { type JSX } from "solid-js"; -import { SettingsDetailPage } from "../../components/settings/SettingsDetailPage/SettingsDetailPage"; +import { ThemeSettingsPage } from "../../components/settings/ThemeSettingsPage/ThemeSettingsPage"; const SettingsThemeRoute = (): JSX.Element => { - return ( - - ); + return ; }; export default SettingsThemeRoute; diff --git a/Frontend/src/theme/presets.ts b/Frontend/src/theme/presets.ts index 7fcfbb6..a30e3ca 100644 --- a/Frontend/src/theme/presets.ts +++ b/Frontend/src/theme/presets.ts @@ -8,14 +8,31 @@ export const themePresetMetas = [ name: "Moku Default", description: "The baseline Moku theme preset, matching the original shell styling tokens.", path: "/themes/moku-default.json", + preview: { + canvas: "hsl(210 20% 99%)", + surface: "hsl(0 0% 100%)", + accent: "hsl(221 83% 53%)", + }, }, { id: "moku-midnight", name: "Moku Midnight", description: "The active warm, low-light Moku theme preset inspired by the Midnight Discord palette direction.", path: "/themes/moku-midnight.json", + preview: { + canvas: "#282828", + surface: "hsl(20 8% 16%)", + accent: "#d3869b", + }, }, -] as const satisfies readonly (Pick & { path: string })[]; +] as const satisfies readonly (Pick & { + path: string; + preview: { + canvas: string; + surface: string; + accent: string; + }; +})[]; export const defaultThemePresetPath = "/themes/moku-midnight.json";