Compare commits

..
8 Commits
Author SHA1 Message Date
MohFarawati 0f48381894 refactor(home): align contact CTA typography to the type scale
CI / quality (push) Has been cancelled
Use the eyebrow/.text-h2/body-lg tokens and caption/small labels so the
contact CTA matches the unified section styling. No structural change.
2026-07-14 22:38:08 +02:00
MohFarawati 59094eba85 refactor(home): unify section headings, differentiate capabilities and process
Reduce the "interchangeable cards" monotony flagged in Phase 0 and apply the
warm-premium type scale.

- SectionHeading: use the type scale (eyebrow via .eyebrow with brand tint,
  text-h2 title, body-lg description). Propagates to every home section.
- CapabilitiesSection: cleaner cards with a brand-tinted icon tile, type-scale
  title/body, soft elevation (drops the AppCard wrapper).
- ProcessSection: reworked into an editorial numbered timeline (large
  translucent brand numerals, top rule, no boxes) so it no longer looks
  identical to Capabilities.

Verified: eslint clean, no new type errors, tailwind generates the utilities.
2026-07-14 22:37:09 +02:00
MohFarawati dd72f013d4 feat(home): hero availability status and CTAs
The homepage hero had no call to action (Phase 0 finding). Add a live
availability pill and primary/secondary CTAs while keeping the signature title.

- HomeHero: optional availability status pill (pulsing dot) plus primary
  (view work) and secondary (contact) CTAs, RTL-aware arrow, using the type
  scale. Backward compatible (all new props optional).
- Wire copy + hrefs from the homepage; add heroVisual.primaryCta /
  secondaryCta to en/de/ar.

Verified: JSON valid, eslint clean, no new type errors.
2026-07-14 22:23:54 +02:00
MohFarawati 86243b79c2 feat(portfolio): real project-detail hero, no wasted space
The detail page rendered an empty <PageHero /> (blank gradient band) with the
real title buried in a card lower down (Phase 0 finding).

- Populate the detail PageHero with the project category (badge), title and
  summary, so the top of the page is a proper header.
- Slim the in-content ProjectHeader to back-link + meta chips + preview button,
  removing the now-duplicated title and summary. Applies across all three
  detail templates (grid/story/case-study).

Verified: eslint clean, no new type errors.
2026-07-14 22:20:47 +02:00
MohFarawati 4892f27006 feat(home): image-led featured projects section
Bring imagery to the homepage projects block (Phase 3), matching the portfolio
gallery.

- Thread coverImagePath through ProjectCardCopy and the home mapping.
- Rewrite ProjectsSection as image-first cards (reusing PortfolioCover with the
  branded placeholder fallback): whole-card link, featured project spans two
  columns with a larger cover, type-scale title, meta + stack badges, hover
  lift. Replaces the previous text-only cards.

Verified: eslint clean; no new type errors (pre-existing Prisma-gen anys only).
2026-07-14 22:19:05 +02:00
MohFarawati f7355b5356 feat(portfolio): image-led project gallery with branded placeholders
Redesign the portfolio list/category grid into a premium, image-first gallery
(Phase 3), applying the warm-premium tokens.

- Add components/site/portfolio-cover.tsx: renders the stored cover image
  (next/image, object-cover, hover zoom) or a restrained branded placeholder
  (monogram + category) when none is set. Placeholders are temporary — the
  owner will swap in real artwork.
- Rewrite portfolio-project-grid: responsive 1/2/3-col gallery, featured
  projects span two columns with a larger cover, cards use the type scale
  (text-h2/h3/caption), eyebrow category labels, line-clamped summaries, soft
  elevation and hover lift. Directly fixes the "portfolio shows no images"
  gap on the list and category pages.

Verified: eslint clean, no new type errors, tailwind generates the utilities.
2026-07-14 22:13:26 +02:00
MohFarawati dcffa87be7 feat(design-system): add type scale, spacing rhythm and font tokens
Apply the Phase 2 warm-premium base additively (no existing page changes):
- tailwind.config.ts: add fontSize roles (display, h1, h2, h3, title, body-lg,
  body, small, caption, overline) with line-height/tracking/weight; add
  fontFamily (sans/latin = Museo, arabic = Dubai); add section-sm/section-lg
  spacing rhythm.
- globals.css: add Arabic-safe .eyebrow utility (uppercase + tracking on Latin,
  neutralised for html[lang="ar"]).

Colors and fonts unchanged. Verified: tsc clean, eslint clean, tailwind build
generates the new utilities with correct values.
2026-07-14 22:08:34 +02:00
MohFarawati d1a1a67b84 docs(design-system): document warm-premium direction and token system
Add docs/DESIGN_SYSTEM.md capturing Phase 2 visual identity:
- Chosen direction (warm premium, work-forward) and its principles.
- Documents existing color/surface/brand/status/border/hero tokens with
  light + dark values and an accent-budget usage rule.
- Fonts (Museo Latin, Dubai Arabic) and a defined type scale + Arabic overrides.
- Radius, elevation, container and spacing-rhythm guidance, motion, component
  conventions, and do/don't. Colors and fonts are owner-fixed and unchanged.
2026-07-14 22:03:15 +02:00
18 changed files with 585 additions and 150 deletions
+4
View File
@@ -180,6 +180,7 @@ export default async function HomePage({ params }: HomePageProps) {
String(project.projectYear),
],
featured: index === 0,
coverImagePath: project.coverImagePath,
}));
const fallbackProjectCards: ProjectCardCopy[] = projectsCopy.fallbackItems.map((item) => ({
@@ -203,6 +204,9 @@ export default async function HomePage({ params }: HomePageProps) {
kicker={t("heroVisual.kicker")}
title={t("heroVisual.title")}
description={t("heroVisual.description")}
availability={contactCtaCopy.availabilityValue}
primaryCta={{ label: t("heroVisual.primaryCta"), href: portfolioHref }}
secondaryCta={{ label: t("heroVisual.secondaryCta"), href: contactHref }}
/>
<Container
@@ -58,10 +58,18 @@ export default async function PortfolioItemPage({
}
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
const title = getLocalizedValue(item.title, localeKey);
const category = getLocalizedValue(item.category.name, localeKey);
const summary = getLocalizedValue(item.summary, localeKey);
return (
<>
<PageHero />
<PageHero
locale={localeKey}
badge={category}
lines={[{ text: title, tone: "soft", align: "center" }]}
description={summary}
/>
<Container size="wide" className="pb-12 lg:pb-16">
<PortfolioProjectDetail
+12
View File
@@ -168,6 +168,18 @@ html[lang="ar"] {
font-family: var(--font-dubai), sans-serif;
}
/* Eyebrow / overline label. Latin gets uppercase + tracking; Arabic keeps
normal case and spacing so joined letterforms are not broken. */
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.14em;
}
html[lang="ar"] .eyebrow {
text-transform: none;
letter-spacing: normal;
}
@layer base {
* {
@apply border-border;
+7 -12
View File
@@ -1,6 +1,5 @@
import { Bot, Database, Globe, ServerCog } from "lucide-react";
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading";
import type { CapabilitiesSectionCopy } from "./types";
@@ -19,29 +18,25 @@ export function CapabilitiesSection({ copy }: CapabilitiesSectionProps) {
description={copy.description}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 xl:gap-6">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
{copy.items.map((item, index) => {
const Icon = capabilityIcons[index] ?? Globe;
return (
<AppCard
<div
key={item.title}
layer="single"
interactive
className="group border-border/80 bg-surface-2 transition-all duration-300 hover:-translate-y-1 hover:border-border-strong hover:shadow-panel"
className="group flex h-full flex-col gap-5 rounded-surface border border-border bg-surface-1 p-6 shadow-card transition duration-300 ease-out hover:-translate-y-1 hover:shadow-panel"
>
<div className="flex h-full flex-col gap-5 p-6">
<div className="flex h-11 w-11 items-center justify-center rounded-nested border border-border/70 bg-background text-foreground transition-transform duration-300 group-hover:-translate-y-0.5">
<div className="flex h-12 w-12 items-center justify-center rounded-nested bg-brand-primary/10 text-brand-primary transition-transform duration-300 group-hover:-translate-y-0.5">
<Icon className="h-5 w-5" />
</div>
<div className="space-y-3">
<h3 className="text-xl font-semibold tracking-[-0.04em] text-foreground">
<div className="space-y-2">
<h3 className="text-title font-semibold text-foreground">
{item.title}
</h3>
<p className="text-sm leading-7 text-muted-foreground">{item.description}</p>
<p className="text-small leading-relaxed text-muted-foreground">{item.description}</p>
</div>
</div>
</AppCard>
);
})}
</div>
+8 -8
View File
@@ -19,26 +19,26 @@ export function ContactCtaSection({
<AppCard className="overflow-hidden">
<div className="grid gap-6 rounded-nested border border-border/70 bg-background p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:p-10">
<div className="space-y-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<p className="eyebrow text-caption font-medium text-brand-primary">
{copy.eyebrow}
</p>
<h2 className="max-w-3xl text-balance text-3xl font-semibold tracking-[-0.05em] text-foreground sm:text-4xl lg:text-5xl">
<h2 className="max-w-3xl text-balance text-h2 text-foreground">
{copy.title}
</h2>
<p className="max-w-2xl text-sm leading-7 text-muted-foreground sm:text-base">
<p className="max-w-2xl text-body-lg text-muted-foreground">
{copy.description}
</p>
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex flex-wrap gap-8 pt-2">
<div className="space-y-1">
<p className="font-semibold text-foreground">{copy.emailLabel}</p>
<a href={`mailto:${copy.emailValue}`} className="text-muted-foreground hover:text-foreground">
<p className="text-caption font-medium text-muted-foreground">{copy.emailLabel}</p>
<a href={`mailto:${copy.emailValue}`} className="text-small text-foreground transition-colors hover:text-brand-primary">
{copy.emailValue}
</a>
</div>
<div className="space-y-1">
<p className="font-semibold text-foreground">{copy.availabilityLabel}</p>
<p className="text-muted-foreground">{copy.availabilityValue}</p>
<p className="text-caption font-medium text-muted-foreground">{copy.availabilityLabel}</p>
<p className="text-small text-foreground">{copy.availabilityValue}</p>
</div>
</div>
</div>
+11 -19
View File
@@ -1,4 +1,3 @@
import { AppCard } from "@/components/ui/app-card";
import { SectionHeading } from "./section-heading";
import type { ProcessSectionCopy } from "./types";
@@ -8,35 +7,28 @@ type ProcessSectionProps = {
export function ProcessSection({ copy }: ProcessSectionProps) {
return (
<section className="space-y-8">
<section className="space-y-10">
<SectionHeading
eyebrow={copy.eyebrow}
title={copy.title}
description={copy.description}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 xl:gap-6">
<ol className="grid grid-cols-1 gap-x-8 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{copy.steps.map((step, index) => (
<AppCard
key={step.title}
layer="single"
interactive
className="group border-border/80 bg-surface-2 transition-all duration-300 hover:-translate-y-1 hover:border-border-strong hover:shadow-panel"
>
<div className="flex h-full flex-col gap-5 p-6">
<p className="text-sm font-semibold tracking-[0.18em] text-muted-foreground">
<li key={step.title} className="border-t border-border pt-5">
<span className="block text-[2.75rem] font-semibold leading-none tracking-tight text-brand-primary/25">
{String(index + 1).padStart(2, "0")}
</p>
<div className="space-y-3">
<h3 className="text-2xl font-semibold tracking-[-0.05em] text-foreground">
</span>
<h3 className="mt-5 text-title font-semibold text-foreground">
{step.title}
</h3>
<p className="text-sm leading-7 text-muted-foreground">{step.description}</p>
</div>
</div>
</AppCard>
<p className="mt-2 text-small leading-relaxed text-muted-foreground">
{step.description}
</p>
</li>
))}
</div>
</ol>
</section>
);
}
+52 -32
View File
@@ -1,8 +1,9 @@
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { Badge } from "@/components/ui/badge";
import { AppCard } from "@/components/ui/app-card";
import { cn } from "@/lib/utils";
import { SectionHeading } from "./section-heading";
import type { ProjectCardCopy, ProjectsSectionCopy } from "./types";
@@ -20,66 +21,85 @@ export function ProjectsSection({ copy, items }: ProjectsSectionProps) {
description={copy.description}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:gap-6">
{items.map((item, index) => (
<AppCard
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
{items.map((item, index) => {
const featured = Boolean(item.featured);
return (
<Link
key={`${item.title}-${index}`}
layer="single"
interactive
className={[
"group h-full border-border/80 bg-surface-2 transition-all duration-300 hover:-translate-y-1 hover:border-border-strong hover:bg-background/80 hover:shadow-lg",
index === 0 ? "lg:col-span-2" : "",
].join(" ")}
href={item.href}
className={cn(
"group flex h-full flex-col overflow-hidden rounded-surface border border-border bg-surface-1 shadow-card transition duration-300 ease-out hover:-translate-y-1 hover:shadow-panel focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
featured && "lg:col-span-2",
)}
>
<div className="flex h-full flex-col gap-6 p-6">
<div
className={cn(
"relative overflow-hidden",
featured ? "aspect-[16/10]" : "aspect-[4/3]",
)}
>
<PortfolioCover
src={item.coverImagePath ?? null}
title={item.title}
category={item.meta[0]}
sizes={
featured
? "(min-width: 1024px) 66vw, 100vw"
: "(min-width: 1024px) 33vw, 100vw"
}
/>
</div>
<div className="flex flex-1 flex-col p-6">
<div className="flex flex-wrap items-center gap-2">
{item.meta.map((metaItem) => (
<Badge
key={metaItem}
variant="outline"
className="border-border/80 bg-background text-muted-foreground transition-colors duration-300 group-hover:border-border-strong group-hover:text-foreground/80"
className="border-border text-caption text-muted-foreground"
>
{metaItem}
</Badge>
))}
</div>
<div className="space-y-3">
<h3 className="text-2xl font-semibold tracking-[-0.05em] text-foreground transition-colors duration-300 group-hover:text-foreground/95">
<h3
className={cn(
"mt-4 font-semibold tracking-tight text-foreground",
featured ? "text-h3 sm:text-h2" : "text-h3",
)}
>
{item.title}
</h3>
<p className="text-sm leading-7 text-muted-foreground">{item.summary}</p>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{copy.stackLabel}
<p className="mt-2 line-clamp-2 text-small leading-relaxed text-muted-foreground">
{item.summary}
</p>
<div className="flex flex-wrap gap-2">
{item.stack.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.stack.map((stackItem) => (
<Badge
key={stackItem}
variant="secondary"
className="bg-secondary/70 text-foreground transition-colors duration-300 group-hover:bg-secondary"
className="bg-secondary/70 text-caption text-foreground"
>
{stackItem}
</Badge>
))}
</div>
</div>
) : null}
<div className="mt-auto">
<Link
href={item.href}
className="inline-flex items-center gap-2 text-sm font-medium text-foreground transition-all duration-200 group-hover:translate-x-0.5"
>
<span className="mt-auto inline-flex items-center gap-2 pt-6 text-caption font-medium text-foreground/70 transition-colors group-hover:text-brand-primary">
{item.cta}
<ArrowUpRight className="h-4 w-4 transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
<ArrowUpRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</span>
</div>
</Link>
</div>
</div>
</AppCard>
))}
);
})}
</div>
</section>
);
+3 -3
View File
@@ -23,13 +23,13 @@ export function SectionHeading({
className,
)}
>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<p className="eyebrow text-caption font-medium text-brand-primary">
{eyebrow}
</p>
<h2 className="max-w-4xl text-balance text-3xl font-semibold tracking-[-0.05em] text-foreground sm:text-4xl lg:text-5xl">
<h2 className="max-w-4xl text-balance text-h2 text-foreground">
{title}
</h2>
<p className="max-w-3xl text-sm leading-7 text-muted-foreground sm:text-[1.02rem]">
<p className={cn("text-body-lg text-muted-foreground", align === "center" ? "mx-auto" : "max-w-2xl")}>
{description}
</p>
</div>
+1
View File
@@ -71,6 +71,7 @@ export type ProjectCardCopy = {
cta: string;
meta: string[];
featured?: boolean;
coverImagePath?: string | null;
};
export type ProjectsSectionCopy = {
+55 -5
View File
@@ -1,5 +1,8 @@
"use client";
import { ArrowLeft, ArrowRight, Mail } from "lucide-react";
import Link from "next/link";
import { HeroBadge } from "@/components/layout/hero-badge";
import {
HeroContentMotion,
@@ -7,12 +10,21 @@ import {
HeroShell,
HeroTitle,
} from "@/components/layout/site-hero";
import { Button } from "@/components/ui/button";
type HeroCta = {
label: string;
href: string;
};
type HomeHeroProps = {
locale: string;
kicker: string;
title: string;
description: string;
availability?: string;
primaryCta?: HeroCta;
secondaryCta?: HeroCta;
};
export function HomeHero({
@@ -20,9 +32,13 @@ export function HomeHero({
kicker,
title,
description,
availability,
primaryCta,
secondaryCta,
}: HomeHeroProps) {
const titleLines = title.split("\n").filter(Boolean);
const isArabic = locale === "ar";
const DirectionIcon = isArabic ? ArrowLeft : ArrowRight;
const lines = isArabic
? [
{
@@ -67,13 +83,22 @@ export function HomeHero({
return (
<HeroShell showBridges>
<HeroContentMotion variant="home">
{availability ? (
<HeroMotionItem variant="home">
<span className="inline-flex items-center gap-2.5 rounded-pill border border-foreground/10 bg-background/55 px-3.5 py-1.5 text-caption font-medium text-foreground/70 shadow-[var(--shadow-sm)] backdrop-blur-[18px]">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-success opacity-70" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-status-success" />
</span>
{availability}
</span>
</HeroMotionItem>
) : null}
<HeroMotionItem variant="home">
<HeroBadge
trailing={
<span
aria-hidden="true"
className="text-[0.95rem] leading-none"
>
<span aria-hidden="true" className="text-[0.95rem] leading-none">
👋
</span>
}
@@ -81,12 +106,37 @@ export function HomeHero({
{kicker}
</HeroBadge>
</HeroMotionItem>
<HeroTitle locale={locale} lines={lines} />
<HeroMotionItem variant="home">
<p className="mx-auto mt-7 max-w-[33rem] whitespace-pre-line text-sm leading-7 text-foreground/68 sm:text-base sm:leading-8">
<p className="mx-auto mt-7 max-w-[33rem] whitespace-pre-line text-body text-foreground/68">
{description}
</p>
</HeroMotionItem>
{primaryCta || secondaryCta ? (
<HeroMotionItem variant="home">
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
{primaryCta ? (
<Button asChild size="lg" className="w-full sm:w-auto">
<Link href={primaryCta.href}>
{primaryCta.label}
<DirectionIcon className="h-4 w-4" />
</Link>
</Button>
) : null}
{secondaryCta ? (
<Button asChild variant="outline" size="lg" className="w-full sm:w-auto">
<Link href={secondaryCta.href}>
<Mail className="h-4 w-4" />
{secondaryCta.label}
</Link>
</Button>
) : null}
</div>
</HeroMotionItem>
) : null}
</HeroContentMotion>
</HeroShell>
);
+67
View File
@@ -0,0 +1,67 @@
import Image from "next/image";
import { ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type PortfolioCoverProps = {
src: string | null;
title: string;
category?: string;
sizes?: string;
priority?: boolean;
className?: string;
};
const DEFAULT_SIZES =
"(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw";
/**
* Project cover. Renders the stored image when present, otherwise a restrained
* branded placeholder (owner will replace placeholders with real artwork).
* Fills a positioned, aspect-sized parent (`relative` + `aspect-*`).
*/
export function PortfolioCover({
src,
title,
category,
sizes = DEFAULT_SIZES,
priority = false,
className,
}: PortfolioCoverProps) {
if (src) {
return (
<Image
src={src}
alt={title}
fill
sizes={sizes}
priority={priority}
className={cn(
"object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]",
className,
)}
/>
);
}
const monogram = title.trim().charAt(0).toUpperCase() || "•";
return (
<div
aria-hidden
className={cn(
"relative flex h-full w-full flex-col items-center justify-center gap-3 bg-surface-2",
className,
)}
>
<span className="pointer-events-none absolute -top-1/3 left-1/2 h-2/3 w-2/3 -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,hsl(var(--brand-primary)/0.12),transparent)]" />
<span className="text-[clamp(3rem,7vw,4.5rem)] font-semibold leading-none text-foreground/12">
{monogram}
</span>
<span className="inline-flex items-center gap-1.5 text-caption text-muted-foreground/70">
<ImageIcon className="h-3.5 w-3.5" />
{category}
</span>
</div>
);
}
+1 -8
View File
@@ -235,14 +235,7 @@ function ProjectHeader({
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
</Button>
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl lg:text-5xl">
{getLocalizedValue(item.title, locale)}
</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-muted-foreground sm:text-lg">
{getLocalizedValue(item.summary, locale)}
</p>
<div className="mt-8">
<div className="mt-6">
<ProjectMeta item={item} locale={locale} />
</div>
+69 -32
View File
@@ -2,10 +2,10 @@ import { ArrowUpRight, CalendarDays } from "lucide-react";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import { getLocalizedValue, type PortfolioProjectView } from "@/lib/portfolio";
import { cn } from "@/lib/utils";
type PortfolioProjectGridProps = {
locale: AppLocale;
@@ -22,48 +22,85 @@ export function PortfolioProjectGrid({
emptyLabel,
openLabel,
}: PortfolioProjectGridProps) {
if (projects.length === 0) {
return (
<section className="grid gap-4 md:grid-cols-2">
{projects.map((item, index) => (
<MotionFade key={item.slug} delay={index * 0.05}>
<AppCard interactive>
<CardContent className="p-5">
<section>
<div className="rounded-surface border border-border bg-surface-1 px-6 py-16 text-center text-small text-muted-foreground">
{emptyLabel}
</div>
</section>
);
}
return (
<section className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((item, index) => {
const featured = item.isFeatured;
const title = getLocalizedValue(item.title, locale);
const category = getLocalizedValue(item.category.name, locale);
return (
<MotionFade
key={item.slug}
delay={Math.min(index * 0.05, 0.3)}
className={cn(featured && "sm:col-span-2 lg:col-span-2")}
>
<Link
href={getLocalizedPath(locale, `/portfolio/${item.slug}`, defaultLocale)}
className="group block"
className="group flex h-full flex-col overflow-hidden rounded-surface border border-border bg-surface-1 shadow-card transition duration-300 ease-out hover:-translate-y-1 hover:shadow-panel focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<div
className={cn(
"relative overflow-hidden",
featured ? "aspect-[16/10]" : "aspect-[4/3]",
)}
>
<PortfolioCover
src={item.coverImagePath}
title={title}
category={category}
priority={index === 0}
sizes={
featured
? "(min-width: 1024px) 66vw, 100vw"
: "(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
}
/>
</div>
<div className="flex flex-1 flex-col p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground/80">
{getLocalizedValue(item.category.name, locale)}
</p>
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground/80">
<span className="eyebrow text-caption text-muted-foreground">
{category}
</span>
<span className="inline-flex items-center gap-1.5 text-caption text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5" />
{item.projectYear}
</p>
</span>
</div>
<h2 className="mt-3 text-xl font-semibold text-foreground">
{getLocalizedValue(item.title, locale)}
<h2
className={cn(
"mt-3 font-semibold tracking-tight text-foreground",
featured ? "text-h3 sm:text-h2" : "text-h3",
)}
>
{title}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 line-clamp-2 text-small leading-relaxed text-muted-foreground">
{getLocalizedValue(item.summary, locale)}
</p>
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
{openLabel}
<ArrowUpRight className="h-4 w-4" />
</p>
</Link>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard className="md:col-span-2">
<CardContent className="p-6 text-sm text-muted-foreground">
{emptyLabel}
</CardContent>
</AppCard>
) : null}
<span className="mt-5 inline-flex items-center gap-2 text-caption font-medium text-foreground/70 transition-colors group-hover:text-brand-primary">
{openLabel}
<ArrowUpRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</span>
</div>
</Link>
</MotionFade>
);
})}
</section>
);
}
+231
View File
@@ -0,0 +1,231 @@
# Design System — mohfarawati.de
The visual language for the public site and admin. This documents the token
system that already lives in `app/globals.css` and `tailwind.config.ts`, and
defines the layers that are still missing (type scale, spacing rhythm) so the
site has one consistent, premium base before any page is redesigned.
Source of truth for values: `app/globals.css` (`:root` + `.dark`). Tailwind
mappings: `tailwind.config.ts`. This file is the *usage guide* for those tokens.
---
## Direction: Warm Premium (work-forward)
Chosen visual direction. Every design decision serves these principles:
1. **The work is the hero.** Layouts lead with project imagery and a strong
title hierarchy; chrome stays quiet.
2. **One decisive accent.** The warm red-orange brand color appears sparingly —
primary CTAs, one highlighted word, active/selected states, focus. Everything
else is neutral ink on light/dark surfaces. Accent is a punctuation mark, not
a background.
3. **Generous whitespace + restraint.** Space and type hierarchy carry the
premium feel — not borders, gradients, or decoration. "Too busy" is the
default failure mode; cut before adding.
4. **Depth, softly.** Elevation via one soft shadow layer and the large 24px
radius already in the tokens. Avoid stacking shadows or heavy borders.
5. **Light + dark parity.** Every token has a dark value; never hardcode a hex.
6. **Arabic is first-class.** RTL layout, no uppercase or letter-spacing on
Arabic text, slightly looser line-height. Latin uses Museo, Arabic uses Dubai.
Colors and fonts are fixed by the owner and are **not** changed by this system —
it only organizes and documents them and adds the missing scale layers.
---
## 1. Color tokens
All colors are HSL channel triplets consumed as `hsl(var(--token))`. Light is
`:root`, dark is `.dark`.
### Base
| Token | Light | Dark | Use |
|---|---|---|---|
| `--background` | `0 0% 100%` | `222 22% 8%` | Page canvas |
| `--foreground` | `222 24% 10%` | `210 20% 96%` | Primary ink |
| `--muted` | `220 14% 96%` | `222 16% 14%` | Quiet fills |
| `--muted-foreground` | `220 10% 42%` | `217 10% 64%` | Secondary text |
### Surfaces (elevation ramp)
| Token | Light | Dark | Use |
|---|---|---|---|
| `--surface-1` | `0 0% 100%` | `222 22% 9%` | Base card |
| `--surface-2` | `220 16% 97%` | `222 18% 12%` | Raised card / section tint |
| `--surface-3` | `220 14% 94%` | `222 16% 15%` | Highest / hover |
| `--surface-inverse` | `222 24% 12%` | `210 20% 96%` | Inverted blocks |
Step surfaces 1 → 2 → 3 for hierarchy. Do not invent intermediate greys.
### Brand + semantic (use sparingly)
| Token | Light | Dark | Use |
|---|---|---|---|
| `--brand-primary` / `--primary` / `--ring` | `9 73% 50%` | `9 78% 56%` | The one accent: CTAs, highlight word, focus |
| `--brand-secondary` | `12 64% 58%` | `12 74% 64%` | Accent gradient partner, live dots |
| `--primary-foreground` | `210 40% 98%` | `210 40% 98%` | Text on accent |
| `--destructive` | `0 84% 60%` | `0 63% 31%` | Errors only |
### Status
| Token | Light | Dark |
|---|---|---|
| `--status-success` / `--status-success-soft` | `145 63% 42%` / `145 58% 92%` | `145 68% 66%` / `145 36% 18%` |
| `--status-warning` / `--status-warning-soft` | `36 92% 44%` / `41 96% 90%` | `42 96% 66%` / `35 40% 18%` |
Reserved for real state (success/warning feedback), never decoration.
### Lines + hero
| Token | Light | Dark | Use |
|---|---|---|---|
| `--border` | `220 13% 90%` | `222 14% 18%` | Default hairline |
| `--border-strong` | `220 11% 82%` | `222 12% 26%` | Emphasis divider |
| `--hero-base` / `--hero-ink` | `260 22% 95%` / `226 43% 11%` | `222 24% 10%` / `210 20% 96%` | Hero backdrop base + ink |
Tailwind exposes these as `bg-background`, `text-foreground`, `bg-surface-2`,
`text-brand-primary`, `border-border`, `bg-status-success-soft`, etc.
**Accent budget:** at most one accent-filled element per view (usually the
primary CTA). Additional emphasis uses ink weight/size, not more color.
---
## 2. Typography
### Fonts (fixed)
| Script | Family | Variable | Utility | Weights |
|---|---|---|---|---|
| Latin (default) | Museo Sans Rounded | `--font-museo` | `.font-latin` | 300, 500, 700, 900 |
| Arabic | Dubai | `--font-dubai` | `.font-arabic` | 300, 400, 700 |
Applied on `body` (Latin) and `html[lang="ar"]` (Arabic) in `globals.css`.
### Type scale (to add as tokens in 2.3)
A single documented scale replaces ad-hoc sizes. Latin values; Arabic overrides
noted below.
| Role | Size (clamp) | Weight | Line-height | Tracking |
|---|---|---|---|---|
| `display` | `clamp(2.75rem, 8vw, 5.5rem)` | 900 | 0.95 | -0.03em |
| `h1` | `clamp(2.25rem, 5vw, 3.5rem)` | 700 | 1.05 | -0.02em |
| `h2` | `clamp(1.75rem, 3.5vw, 2.5rem)` | 700 | 1.1 | -0.015em |
| `h3` | `1.375rem` (22px) | 700 | 1.2 | -0.01em |
| `title` | `1.125rem` (18px) | 500 | 1.3 | 0 |
| `body-lg` | `1.125rem` (18px) | 400 | 1.7 | 0 |
| `body` | `1rem` (16px) | 400 | 1.7 | 0 |
| `small` | `0.875rem` (14px) | 400 / 500 | 1.6 | 0 |
| `caption` | `0.8125rem` (13px) | 500 | 1.5 | 0 |
| `overline` | `0.75rem` (12px) | 500 | 1.4 | 0.14em, uppercase |
**Arabic overrides:** never `uppercase`, never letter-spacing (breaks joins);
add ~0.1 to line-height on display/h1/h2; cap weight at 700 (Dubai has no 900).
The `overline` role for Arabic uses normal tracking and normal case.
Only two Latin weights per view in body copy (regular 400 + one bold). Reserve
900 for `display` only.
---
## 3. Radius
| Token | Value | Use |
|---|---|---|
| `--radius-surface` | `24px` | Cards, panels, large containers (`rounded-surface` / `lg` / `xl`) |
| `--radius-nested` | `16px` | Nested elements, inputs, buttons (`rounded-nested` / `sm` / `md`) |
| `--radius-pill` | `9999px` | Pills, badges, toggles (`rounded-pill`) |
The generous 24px surface radius is part of the premium feel — keep it
consistent; do not mix arbitrary radii.
---
## 4. Elevation (shadows)
| Token | Use |
|---|---|
| `--shadow-xs` / `shadow-xs` | Subtle lift (hairline cards) |
| `--shadow-sm` / `shadow-sm` | Small controls, pills |
| `--shadow-card` / `shadow-card` | Default raised card |
| `--shadow-panel` / `shadow-panel` | Floating panel / glass surface |
| `--shadow-md`, `--shadow-lg` | Reserved for overlays; use rarely |
Discipline: most surfaces are flat with a hairline border. Raise with **one**
shadow layer at a time. Never stack `shadow-lg` on nested elements.
---
## 5. Spacing + layout
### Containers
| Token | Value | Use |
|---|---|---|
| `--container-narrow` | `48rem` | Reading width, forms |
| `--container-default` | `72rem` | Standard page content |
| `--container-wide` | `80rem` | Galleries, wide grids |
| `--container-admin` | `88rem` | Admin workspace |
Header: `--header-height: 4.5rem`.
### Vertical rhythm (to formalize in 2.3)
Existing: `--section-space: 3.5rem`, `--content-space: 1.5rem`
(`spacing.section`, `spacing.content`).
Premium layouts need more air. Recommended section rhythm to add:
| Token | Value | Use |
|---|---|---|
| `section-sm` | `3rem` | Tight sections |
| `section` | `5rem` (mobile) → `7rem` (lg) | Default between major sections |
| `section-lg` | `8rem`+ | Hero-adjacent breathing room |
Internal gaps use rem steps: `1rem`, `1.5rem`, `2rem`.
---
## 6. Motion
framer-motion, restrained. Entrance pattern (see `components/layout/site-hero.tsx`):
- item: `opacity 0→1`, `y 16→0`, `duration 0.54`, ease `[0.22, 1, 0.36, 1]`
- container stagger: `0.13`, delay `0.08`
Rules: entrance + hover only; respect `prefers-reduced-motion`; no looping or
attention-grabbing motion on content.
---
## 7. Component conventions
- **Cards** route through `components/ui/app-card.tsx` (`AppCard` with
`level`/`layer`/`padding`). Use `level` to express hierarchy — do not restyle
cards ad hoc.
- **Buttons**: one `primary` (accent) per view; others `outline`/`ghost`.
- **Badges**: `success`/`warning` variants map to status tokens; `outline` for
neutral tags.
- **Inputs**: `--radius-nested`, `--border`, focus uses `--focus-ring`.
- **Inline feedback** (admin): `components/admin/admin-flash.tsx`.
---
## 8. Do / Don't
**Do**
- Lead with imagery and whitespace; let type hierarchy do the work.
- Use the accent once per view, deliberately.
- Keep one radius language (24 / 16 / pill) and one shadow layer.
- Test every screen in light and dark, and Arabic RTL.
**Don't**
- Fill backgrounds with brand color or gradients.
- Add a second accent hue.
- Apply uppercase/letter-spacing to Arabic text.
- Stack shadows or mix arbitrary radii/greys.
- Put decoration where whitespace would do.
+2
View File
@@ -44,6 +44,8 @@
},
"heroVisual": {
"kicker": "مرحبا، أنا محمد",
"primaryCta": "شاهد الأعمال",
"secondaryCta": "تواصل معي",
"title": "مطوّر واجهات ويب\nومصمّم جرافيك",
"description": "أبني حلول رقمية لمشاكل حقيقية\nتبدو قوية وتعمل بكفاءة."
},
+2
View File
@@ -44,6 +44,8 @@
},
"heroVisual": {
"kicker": "Hi, ich bin Moh",
"primaryCta": "Arbeiten ansehen",
"secondaryCta": "Kontakt aufnehmen",
"title": "frontend\ndeveloper\n& designer",
"description": "Ich entwickle digitale Loesungen fuer echte Probleme,\ndie gut aussehen und sauber funktionieren."
},
+2
View File
@@ -44,6 +44,8 @@
},
"heroVisual": {
"kicker": "Hi, I am Moh",
"primaryCta": "View work",
"secondaryCta": "Get in touch",
"title": "frontend\ndeveloper\n& designer",
"description": "I craft digital solutions for human problems\nthat look good and work well."
},
+19
View File
@@ -74,6 +74,23 @@ const config: Config = {
ring: "hsl(var(--sidebar-ring))",
},
},
fontFamily: {
sans: ["var(--font-museo)", "Arial", "Helvetica", "sans-serif"],
latin: ["var(--font-museo)", "Arial", "Helvetica", "sans-serif"],
arabic: ["var(--font-dubai)", "Tahoma", "Arial", "sans-serif"],
},
fontSize: {
display: ["clamp(2.75rem, 8vw, 5.5rem)", { lineHeight: "0.95", letterSpacing: "-0.03em", fontWeight: "900" }],
h1: ["clamp(2.25rem, 5vw, 3.5rem)", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "700" }],
h2: ["clamp(1.75rem, 3.5vw, 2.5rem)", { lineHeight: "1.1", letterSpacing: "-0.015em", fontWeight: "700" }],
h3: ["1.375rem", { lineHeight: "1.2", letterSpacing: "-0.01em", fontWeight: "700" }],
title: ["1.125rem", { lineHeight: "1.3", fontWeight: "500" }],
"body-lg": ["1.125rem", { lineHeight: "1.7", fontWeight: "400" }],
body: ["1rem", { lineHeight: "1.7", fontWeight: "400" }],
small: ["0.875rem", { lineHeight: "1.6" }],
caption: ["0.8125rem", { lineHeight: "1.5", fontWeight: "500" }],
overline: ["0.75rem", { lineHeight: "1.4", letterSpacing: "0.14em", fontWeight: "500" }],
},
borderRadius: {
sm: "var(--radius-nested)",
md: "var(--radius-nested)",
@@ -106,6 +123,8 @@ const config: Config = {
},
spacing: {
section: "var(--section-space)",
"section-sm": "3rem",
"section-lg": "8rem",
content: "var(--content-space)",
},
backdropBlur: {