- {eyebrow ? (
-
- {eyebrow}
-
- ) : null}
{title}
diff --git a/components/theme/color-mode-sync.tsx b/components/theme/color-mode-sync.tsx
new file mode 100644
index 0000000..cf6c00d
--- /dev/null
+++ b/components/theme/color-mode-sync.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { useEffect } from "react";
+import {
+ COLOR_MODE_COOKIE,
+ COLOR_MODE_COOKIE_MAX_AGE,
+ type ColorMode,
+} from "@/lib/color-mode";
+
+export function ColorModeSync({ colorMode }: { colorMode: ColorMode }) {
+ useEffect(() => {
+ applyColorMode(colorMode);
+ }, [colorMode]);
+
+ return null;
+}
+
+export function applyColorMode(colorMode: ColorMode): void {
+ const root = document.documentElement;
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+
+ root.dataset.colorMode = colorMode;
+ root.classList.toggle("dark", colorMode === "dark" || (colorMode === "system" && prefersDark));
+
+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
+ document.cookie = `${COLOR_MODE_COOKIE}=${colorMode}; Path=/; Max-Age=${COLOR_MODE_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
+}
diff --git a/lib/color-mode.ts b/lib/color-mode.ts
new file mode 100644
index 0000000..dbcad51
--- /dev/null
+++ b/lib/color-mode.ts
@@ -0,0 +1,10 @@
+export const colorModes = ["light", "dark", "system"] as const;
+
+export type ColorMode = (typeof colorModes)[number];
+
+export const COLOR_MODE_COOKIE = "neta-color-mode";
+export const COLOR_MODE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+
+export function isColorMode(value: unknown): value is ColorMode {
+ return typeof value === "string" && colorModes.includes(value as ColorMode);
+}
diff --git a/scripts/phase3-storage-smoke.ts b/scripts/phase3-storage-smoke.ts
index 048d9af..ff156e4 100644
--- a/scripts/phase3-storage-smoke.ts
+++ b/scripts/phase3-storage-smoke.ts
@@ -5,7 +5,7 @@ import Database from "better-sqlite3";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../server/db/schema";
-import { BrandingService, contrastRatio } from "../server/branding/service";
+import { BrandingService, contrastRatio, deriveAccentColor } from "../server/branding/service";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { resolveStoragePath } from "../server/files/paths";
@@ -76,7 +76,6 @@ try {
applicationName: "Studio Portal",
shortName: "Studio",
primaryColor: "#336699",
- accentColor: "#f0cc22",
lightLogoFileId: logo.id,
iconFileId: icon.id,
defaultColorMode: "dark",
@@ -84,6 +83,7 @@ try {
});
assert.equal(branding.applicationName, "Studio Portal");
assert.equal(branding.primaryColor, "#336699");
+ assert.equal(branding.accentColor, deriveAccentColor("#336699"), "Accent palette must derive from the single primary color");
assert.equal(branding.darkLogoUrl, branding.lightLogoUrl, "Missing dark logo must fall back to light logo");
assert.equal(fileService.readPublicBranding(logo.id).metadata.id, logo.id);
assert.ok(contrastRatio(branding.primaryColor, branding.cssVariables["--poyraz-primary-foreground"]) >= 4.5);
diff --git a/scripts/phase4-ui-boundary.mjs b/scripts/phase4-ui-boundary.mjs
index 8541e8c..43ca333 100644
--- a/scripts/phase4-ui-boundary.mjs
+++ b/scripts/phase4-ui-boundary.mjs
@@ -6,6 +6,11 @@ const repoRoot = process.cwd();
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const globalsCss = fs.readFileSync(path.join(repoRoot, "app/globals.css"), "utf8");
const appShell = fs.readFileSync(path.join(repoRoot, "components/layout/app-shell.tsx"), "utf8");
+const rootLayout = fs.readFileSync(path.join(repoRoot, "app/layout.tsx"), "utf8");
+const settingsPage = fs.readFileSync(
+ path.join(repoRoot, "app/(dashboard)/settings/page.tsx"),
+ "utf8",
+);
assert.match(packageJson.dependencies["poyraz-ui"] ?? "", /^\^?3\./, "poyraz-ui must use major v3");
assert.match(
@@ -22,7 +27,6 @@ for (const component of [
"SidebarProvider",
"SidebarPanel",
"SidebarHeader",
- "SidebarBranding",
"SidebarContent",
"SidebarMenu",
"SidebarMenuItem",
@@ -33,6 +37,58 @@ for (const component of [
assert.ok(appShell.includes(component), `App shell must compose the Poyraz ${component} organism`);
}
+assert.ok(appShell.includes("WorkspaceLogo"), "Sidebar header must render the workspace logo");
+assert.ok(
+ appShell.includes(''),
+ "Desktop sidebar must use the fixed-width Poyraz default variant",
+);
+assert.ok(
+ !appShell.includes(''),
+ "Desktop sidebar must not be collapsible",
+);
+assert.ok(!appShell.includes("SidebarRail"), "Desktop sidebar must not expose a collapse rail");
+
+assert.doesNotMatch(
+ appShell,
+ /]*\basChild\b)(?=[^>]*\bmedia=)[^>]*>/s,
+ "Poyraz DropdownMenuItem must not combine asChild with rich media because Radix cannot inject accessibility props into the generated Fragment",
+);
+assert.doesNotMatch(
+ appShell,
+ /]*\binteractiveMotion=/s,
+ "Poyraz UI 3.0.2 leaks DropdownMenuItem interactiveMotion to the DOM",
+);
+
+assert.match(settingsPage, /\bRadioGroup\b/, "Settings must use the Poyraz RadioGroup for theme selection");
+for (const workspaceControl of [
+ 'name="workspaceName"',
+ 'name="logo"',
+ 'name="primaryColor"',
+]) {
+ assert.ok(
+ settingsPage.includes(workspaceControl),
+ `Settings must expose workspace branding control ${workspaceControl}`,
+ );
+}
+for (const colorMode of ["light", "dark", "system"]) {
+ assert.ok(
+ settingsPage.includes(`value: "${colorMode}"`),
+ `Settings must expose the ${colorMode} color mode`,
+ );
+}
+assert.ok(
+ rootLayout.includes("COLOR_MODE_COOKIE"),
+ "Root layout must resolve the persisted color mode before rendering",
+);
+assert.ok(
+ appShell.includes("ColorModeSync"),
+ "Authenticated shells must synchronize the database-backed color mode",
+);
+assert.ok(
+ fs.existsSync(path.join(repoRoot, "server/settings/preferences.ts")),
+ "Missing database-backed user preferences service",
+);
+
const requiredSystemCompositions = [
"components/system/page-header.tsx",
"components/system/feedback-state.tsx",
diff --git a/server/branding/service.ts b/server/branding/service.ts
index 24202f6..923b9b6 100644
--- a/server/branding/service.ts
+++ b/server/branding/service.ts
@@ -128,15 +128,20 @@ export class BrandingService {
throw new DomainError("FORBIDDEN", "Instance marka ayarları başka bir owner'a ait.");
}
+ const update = { ...parsed.data };
+ if (parsed.data.primaryColor && parsed.data.accentColor === undefined) {
+ update.accentColor = deriveAccentColor(parsed.data.primaryColor);
+ }
+
if (existing) {
- this.repository.update({ ...parsed.data, updatedByUserId: scope.ownerUserId });
+ this.repository.update({ ...update, updatedByUserId: scope.ownerUserId });
} else {
this.repository.create({
id: "default",
ownerUserId: scope.ownerUserId,
updatedByUserId: scope.ownerUserId,
...DEFAULT_BRANDING,
- ...parsed.data,
+ ...update,
});
}
return this.getPublic();
@@ -202,6 +207,10 @@ export function contrastRatio(first: string, second: string): number {
return (Math.max(firstLuminance, secondLuminance) + 0.05) / (Math.min(firstLuminance, secondLuminance) + 0.05);
}
+export function deriveAccentColor(primary: string): string {
+ return mixHex(primary, "#FFFFFF", 0.88);
+}
+
function readableForeground(background: string): "#000000" | "#FFFFFF" {
return contrastRatio(background, "#000000") >= contrastRatio(background, "#FFFFFF")
? "#000000"
diff --git a/server/settings/preferences.ts b/server/settings/preferences.ts
new file mode 100644
index 0000000..43e50b8
--- /dev/null
+++ b/server/settings/preferences.ts
@@ -0,0 +1,60 @@
+import "server-only";
+
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+import type { ColorMode } from "@/lib/color-mode";
+import { getSqliteConnection } from "@/server/db/client";
+import { userPreferences } from "@/server/db/schema";
+import { assertEnabledActor, type DomainActor } from "@/server/domain/actor";
+import { DomainError } from "@/server/domain/errors";
+
+const colorModeInputSchema = z.object({
+ colorMode: z.enum(["light", "dark", "system"]),
+});
+
+export type PublicUserPreferences = {
+ colorMode: ColorMode;
+};
+
+export function getUserPreferences(actor: DomainActor): PublicUserPreferences {
+ assertEnabledActor(actor);
+
+ const row = getSqliteConnection().db
+ .select({ colorMode: userPreferences.colorMode })
+ .from(userPreferences)
+ .where(eq(userPreferences.ownerUserId, actor.authUserId))
+ .get();
+
+ return {
+ colorMode: (row?.colorMode as ColorMode | undefined) ?? "system",
+ };
+}
+
+export function updateColorModePreference(
+ actor: DomainActor,
+ input: unknown,
+): PublicUserPreferences {
+ assertEnabledActor(actor);
+
+ const parsed = colorModeInputSchema.safeParse(input);
+ if (!parsed.success) {
+ throw new DomainError("VALIDATION_ERROR", "Renk modu tercihi geçersiz.");
+ }
+
+ const { db } = getSqliteConnection();
+ db.insert(userPreferences)
+ .values({
+ ownerUserId: actor.authUserId,
+ colorMode: parsed.data.colorMode,
+ })
+ .onConflictDoUpdate({
+ target: userPreferences.ownerUserId,
+ set: {
+ colorMode: parsed.data.colorMode,
+ updatedAt: new Date().toISOString(),
+ },
+ })
+ .run();
+
+ return { colorMode: parsed.data.colorMode };
+}