diff --git a/app/(dashboard)/journal/journal-client.tsx b/app/(dashboard)/journal/journal-client.tsx
index b2032c7..763fc47 100644
--- a/app/(dashboard)/journal/journal-client.tsx
+++ b/app/(dashboard)/journal/journal-client.tsx
@@ -35,8 +35,8 @@ import {
XAxis,
YAxis,
} from "recharts";
-import type { ReactNode } from "react";
import { useMemo, useState } from "react";
+import { StatCard } from "@/components/system/stat-card";
export type DailyLogItem = {
id: string;
@@ -95,25 +95,25 @@ export function JournalClient({ logs }: JournalClientProps) {
}
+ icon={Smile}
tone="primary"
/>
}
+ icon={Battery}
tone="green"
/>
}
+ icon={LineChartIcon}
tone="blue"
/>
}
+ icon={CalendarDays}
tone="amber"
/>
@@ -399,39 +399,6 @@ function ScoreBadge({ score, tone }: { score: number; tone: "primary" | "green"
return
diff --git a/app/(dashboard)/projects/projects-client.tsx b/app/(dashboard)/projects/projects-client.tsx
index fe7a25c..dacb498 100644
--- a/app/(dashboard)/projects/projects-client.tsx
+++ b/app/(dashboard)/projects/projects-client.tsx
@@ -40,6 +40,7 @@ import {
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ChangeEvent } from "react";
+import { StatCard } from "@/components/system/stat-card";
export type ProjectClientOption = {
id: string;
@@ -722,39 +723,6 @@ function ProgressBar({ progress, compact = false }: { progress: number; compact?
);
}
-function StatCard({
- label,
- value,
- icon: Icon,
- tone,
-}: {
- label: string;
- value: string;
- icon: typeof FolderKanban;
- tone: "green" | "blue" | "amber" | "red";
-}) {
- const toneClass = {
- green: "bg-emerald-50 text-emerald-700",
- blue: "bg-blue-50 text-blue-700",
- amber: "bg-amber-50 text-amber-700",
- red: "bg-primary/10 text-primary",
- }[tone];
-
- return (
-
-
-
-
-
-
-
-
- );
-}
-
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
return (
diff --git a/components/system/stat-card.tsx b/components/system/stat-card.tsx
new file mode 100644
index 0000000..7bb8602
--- /dev/null
+++ b/components/system/stat-card.tsx
@@ -0,0 +1,72 @@
+import type { LucideIcon } from "lucide-react";
+import { Card, CardContent } from "poyraz-ui/atoms";
+
+import { cn } from "@/lib/utils";
+
+export type StatCardTone =
+ | "primary"
+ | "green"
+ | "blue"
+ | "amber"
+ | "red"
+ | "rose";
+
+const iconToneClasses: Record
= {
+ primary: "bg-primary/10 text-primary",
+ green: "bg-success text-success-icon",
+ blue: "bg-info text-info-icon",
+ amber: "bg-warning text-warning-icon",
+ red: "bg-destructive-muted text-destructive-muted-foreground",
+ rose: "bg-destructive-muted text-destructive-muted-foreground",
+};
+
+type StatCardProps = {
+ label: string;
+ value: string;
+ icon: LucideIcon;
+ tone?: StatCardTone;
+ description?: string;
+ featured?: boolean;
+ className?: string;
+};
+
+export function StatCard({
+ label,
+ value,
+ icon: Icon,
+ tone = "primary",
+ description,
+ featured = false,
+ className,
+}: StatCardProps) {
+ return (
+
+
+
+
{label}
+
+ {value}
+
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/scripts/phase4-ui-boundary.mjs b/scripts/phase4-ui-boundary.mjs
index 43ca333..c026939 100644
--- a/scripts/phase4-ui-boundary.mjs
+++ b/scripts/phase4-ui-boundary.mjs
@@ -22,6 +22,10 @@ assert.ok(
globalsCss.includes('@import "poyraz-ui/preset.css";'),
"Poyraz UI preset must be imported by app/globals.css",
);
+assert.ok(
+ globalsCss.includes("@custom-variant dark (&:where(.dark, .dark *));"),
+ "Tailwind dark utilities must follow Neta's explicit root color mode",
+);
for (const component of [
"SidebarProvider",
@@ -62,7 +66,11 @@ assert.doesNotMatch(
assert.match(settingsPage, /\bRadioGroup\b/, "Settings must use the Poyraz RadioGroup for theme selection");
for (const workspaceControl of [
'name="workspaceName"',
- 'name="logo"',
+ 'name="metaTitle"',
+ 'name="shortName"',
+ 'name="lightLogo"',
+ 'name="darkLogo"',
+ 'name="favicon"',
'name="primaryColor"',
]) {
assert.ok(
@@ -70,6 +78,12 @@ for (const workspaceControl of [
`Settings must expose workspace branding control ${workspaceControl}`,
);
}
+assert.ok(settingsPage.includes('useState("Genel")'), "Workspace and appearance settings must share the General tab");
+assert.doesNotMatch(
+ settingsPage,
+ /\{\s*name:\s*"Görünüm"/,
+ "Appearance must not remain as a separate settings tab",
+);
for (const colorMode of ["light", "dark", "system"]) {
assert.ok(
settingsPage.includes(`value: "${colorMode}"`),
@@ -88,17 +102,77 @@ assert.ok(
fs.existsSync(path.join(repoRoot, "server/settings/preferences.ts")),
"Missing database-backed user preferences service",
);
+assert.match(
+ settingsPage,
+ /md:sticky md:top-8/,
+ "The desktop settings navigation must remain sticky while its content scrolls",
+);
+assert.equal(
+ fs.existsSync(path.join(repoRoot, "app/favicon.ico")),
+ false,
+ "Static App Router favicon must not override database-backed branding metadata",
+);
const requiredSystemCompositions = [
"components/system/page-header.tsx",
"components/system/feedback-state.tsx",
"components/system/status-badge.tsx",
"components/system/destructive-confirmation.tsx",
+ "components/system/stat-card.tsx",
];
for (const file of requiredSystemCompositions) {
assert.ok(fs.existsSync(path.join(repoRoot, file)), `Missing Neta system composition: ${file}`);
}
+const statCard = fs.readFileSync(
+ path.join(repoRoot, "components/system/stat-card.tsx"),
+ "utf8",
+);
+for (const semanticTone of [
+ "bg-success text-success-icon",
+ "bg-info text-info-icon",
+ "bg-warning text-warning-icon",
+ "bg-destructive-muted text-destructive-muted-foreground",
+]) {
+ assert.ok(
+ statCard.includes(semanticTone),
+ `Stat cards must use Poyraz's theme-aware ${semanticTone} tokens`,
+ );
+}
+
+for (const statPage of [
+ "app/(dashboard)/dashboard-client.tsx",
+ "app/(dashboard)/clients/clients-client.tsx",
+ "app/(dashboard)/projects/projects-client.tsx",
+ "app/(dashboard)/journal/journal-client.tsx",
+ "app/(dashboard)/finance/finance-client.tsx",
+]) {
+ const content = fs.readFileSync(path.join(repoRoot, statPage), "utf8");
+ assert.ok(
+ content.includes('from "@/components/system/stat-card"'),
+ `${statPage} must use the shared theme-aware stat card`,
+ );
+}
+
+const financePage = fs.readFileSync(
+ path.join(repoRoot, "app/(dashboard)/finance/finance-client.tsx"),
+ "utf8",
+);
+for (const sliderBehavior of [
+ "financeSummaryCardConfig",
+ "featured: true",
+ "snap-mandatory",
+ "overflow-x-auto",
+ "scrollBy",
+ 'event.key === "ArrowLeft"',
+ 'event.key === "ArrowRight"',
+]) {
+ assert.ok(
+ financePage.includes(sliderBehavior),
+ `Finance summary slider is missing ${sliderBehavior}`,
+ );
+}
+
const allowedLocalUiFiles = new Set([
"pending-link.tsx",
"pending-submit-button.tsx",