Merge pull request 'feature/portfolio-redesign' (#1) from feature/portfolio-redesign into main
All checks were successful
Deploy dzanan.net / deploy (push) Successful in 34s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
v7
2026-09-06 19:57:59 +02:00
29 changed files with 1198 additions and 841 deletions

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://dzanan.net/sitemap.xml

9
public/sitemap.xml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://dzanan.net/</loc></url>
<url><loc>https://dzanan.net/cv</loc></url>
<url><loc>https://dzanan.net/work/secure-ai-integration-gateway</loc></url>
<url><loc>https://dzanan.net/work/travel-operations-platform</loc></url>
<url><loc>https://dzanan.net/work/service-operations-platform</loc></url>
<url><loc>https://dzanan.net/work/biodiversity-data-platform</loc></url>
</urlset>

View File

@@ -1,8 +1,16 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'cv', renderMode: RenderMode.Prerender },
{ path: 'work/secure-ai-integration-gateway', renderMode: RenderMode.Prerender },
{ path: 'work/travel-operations-platform', renderMode: RenderMode.Prerender },
{ path: 'work/service-operations-platform', renderMode: RenderMode.Prerender },
{ path: 'work/biodiversity-data-platform', renderMode: RenderMode.Prerender },
{ path: 'portfolio', renderMode: RenderMode.Server },
{
path: '**',
renderMode: RenderMode.Prerender
renderMode: RenderMode.Server,
status: 404
}
];

View File

@@ -1,7 +1,43 @@
import { routes } from './app.routes';
describe('app routes', () => {
it('keeps the public pages registered', () => {
expect(routes.map(route => route.path)).toEqual(['', 'cv', 'portfolio']);
it('registers every public portfolio route', () => {
expect(routes.map(route => route.path)).toEqual([
'',
'cv',
'work/secure-ai-integration-gateway',
'work/travel-operations-platform',
'work/service-operations-platform',
'work/biodiversity-data-platform',
'portfolio',
'**'
]);
});
it('redirects the legacy portfolio route to the homepage', () => {
const legacyRoute = routes.find(route => route.path === 'portfolio');
expect(legacyRoute?.redirectTo).toBe('');
expect(legacyRoute?.pathMatch).toBe('full');
});
it('maps each work route to a stable project identifier', () => {
const projectIds = routes
.filter(route => route.path?.startsWith('work/'))
.map(route => route.data?.['projectId']);
expect(projectIds).toEqual([
'ai-integration',
'travel-operations',
'service-operations',
'biodiversity-data'
]);
});
it('renders a real not-found page for unknown URLs', () => {
const wildcard = routes.find(route => route.path === '**');
expect(wildcard?.redirectTo).toBeUndefined();
expect(wildcard?.loadComponent).toBeDefined();
});
});

View File

@@ -11,7 +11,32 @@ export const routes: Routes = [
loadComponent: () => import('./features/cv/cv-page.component').then(m => m.CvPageComponent)
},
{
path: 'portfolio',
path: 'work/secure-ai-integration-gateway',
data: { projectId: 'ai-integration' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'work/travel-operations-platform',
data: { projectId: 'travel-operations' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'work/service-operations-platform',
data: { projectId: 'service-operations' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'work/biodiversity-data-platform',
data: { projectId: 'biodiversity-data' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'portfolio',
redirectTo: '',
pathMatch: 'full'
},
{
path: '**',
loadComponent: () => import('./features/not-found/not-found-page.component').then(m => m.NotFoundPageComponent)
}
];

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { SectionNavItem } from '../../shared/models/section-nav.model';
import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.component';
@@ -7,88 +7,49 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
@Component({
selector: 'app-page-shell',
standalone: true,
imports: [CommonModule, RouterLink, ThemeToggleComponent, NgOptimizedImage],
imports: [CommonModule, RouterLink, ThemeToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="min-h-screen bg-[#f4f7f5] text-zinc-800 font-saira transition-colors duration-300 dark:bg-slate-950 dark:text-zinc-100"
>
<header
class="sticky top-0 z-30 border-b border-zinc-200/70 bg-[#f8faf7]/85 backdrop-blur transition-colors duration-300 dark:border-slate-900/60 dark:bg-slate-950/70"
>
<div
class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 md:px-8"
>
<div class="flex items-center gap-3">
<img
ngSrc="/images/profile.jpg"
width="40"
height="40"
priority
alt="Dzanan"
class="h-10 w-10 rounded-full object-cover ring-1 ring-emerald-600/20 transition-colors dark:ring-emerald-500/30"
/>
<div class="flex flex-col leading-tight">
<span class="text-sm text-zinc-500 dark:text-zinc-400"
>Senior Software Engineer</span
>
<span class="text-base font-semibold">Amar Džanan</span>
</div>
</div>
<div class="site-frame">
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="site-header">
<div class="shell header-inner">
<a routerLink="/" class="wordmark" aria-label="Amar Džanan, home">
<span class="wordmark-mark">AD/</span>
<span class="wordmark-copy">Amar Džanan<small>Software systems & AI</small></span>
</a>
<!-- Desktop Nav & Toggle -->
<div class="hidden items-center gap-4 md:flex">
<nav class="flex items-center gap-2">
<div class="desktop-nav">
<nav aria-label="Primary navigation">
@for (section of sections; track section.label) {
<a
[routerLink]="section.path || []"
[fragment]="section.fragment"
class="rounded-full px-3 py-2 text-sm font-medium text-zinc-600 transition hover:bg-emerald-50/80 hover:text-zinc-900 dark:text-zinc-200 dark:hover:bg-slate-900 dark:hover:text-white"
>
{{ section.label }}
</a>
<a [routerLink]="section.path || []" [fragment]="section.fragment">{{ section.label }}</a>
}
</nav>
<div
class="h-6 w-px bg-zinc-200 dark:bg-slate-800"
aria-hidden="true"
></div>
<app-theme-toggle />
</div>
<!-- Mobile Toggle & Menu Placeholder -->
<div class="flex items-center gap-4 md:hidden">
<button class="menu-button" type="button" (click)="toggleMenu()" [attr.aria-expanded]="menuOpen()" aria-controls="mobile-navigation">
<span>Menu</span><span aria-hidden="true">{{ menuOpen() ? '×' : '+' }}</span>
</button>
</div>
@if (menuOpen()) {
<div id="mobile-navigation" class="mobile-nav shell">
<nav aria-label="Mobile navigation">
@for (section of sections; track section.label) {
<a [routerLink]="section.path || []" [fragment]="section.fragment" (click)="closeMenu()">{{ section.label }}</a>
}
</nav>
<app-theme-toggle />
<span class="text-sm text-zinc-500 dark:text-zinc-400"
>Navigate</span
>
</div>
</div>
}
</header>
<main class="relative overflow-hidden opacity-0 animate-[fade-in-up_1s_ease-out_0.3s_forwards]">
<div
class="pointer-events-none absolute inset-0 opacity-60 transition-opacity duration-500 dark:opacity-60"
aria-hidden="true"
>
<div
class="absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_50%_20%,rgba(16,185,129,0.1),transparent_42%)] dark:bg-[radial-gradient(circle_at_50%_20%,rgba(16,185,129,0.2),transparent_40%)]"
></div>
<div
class="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_80%_50%,rgba(14,165,233,0.07),transparent_38%)] dark:bg-[radial-gradient(circle_at_80%_50%,rgba(59,130,246,0.12),transparent_35%)]"
></div>
</div>
<div class="relative">
<ng-content />
</div>
<main id="main-content">
<ng-content />
</main>
<footer
class="border-t border-zinc-200/70 bg-[#f8faf7]/85 transition-colors duration-300 dark:border-slate-900/60 dark:bg-slate-950/80"
>
<div
class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-6 text-sm text-zinc-500 dark:text-zinc-400 md:px-8"
>
<span>Built for clarity and craft.</span>
<span class="text-zinc-600 dark:text-zinc-500">dzanan.net</span>
<footer class="site-footer">
<div class="shell footer-inner">
<span>Amar Džanan · Sarajevo, Bosnia and Herzegovina</span>
<div><a href="mailto:amar@dzanan.net">amar&#64;dzanan.net</a><span aria-hidden="true"> / </span><a href="https://www.linkedin.com/in/amardzanan/">LinkedIn</a></div>
</div>
</footer>
</div>
@@ -96,4 +57,13 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
})
export class PageShellComponent {
@Input({ required: true }) sections: SectionNavItem[] = [];
readonly menuOpen = signal(false);
toggleMenu(): void {
this.menuOpen.update(open => !open);
}
closeMenu(): void {
this.menuOpen.set(false);
}
}

View File

@@ -0,0 +1,45 @@
import { DOCUMENT } from '@angular/common';
import { inject, Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
export type SeoPage = {
title: string;
description: string;
path: string;
type?: 'website' | 'article';
structuredData?: Record<string, unknown>;
};
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly document = inject(DOCUMENT);
private readonly meta = inject(Meta);
private readonly title = inject(Title);
update(page: SeoPage): void {
const canonicalUrl = `https://dzanan.net${page.path}`;
this.title.setTitle(page.title);
this.meta.updateTag({ name: 'description', content: page.description });
this.meta.updateTag({ property: 'og:title', content: page.title });
this.meta.updateTag({ property: 'og:description', content: page.description });
this.meta.updateTag({ property: 'og:type', content: page.type ?? 'website' });
this.meta.updateTag({ property: 'og:url', content: canonicalUrl });
let canonical = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!canonical) {
canonical = this.document.createElement('link');
canonical.rel = 'canonical';
this.document.head.appendChild(canonical);
}
canonical.href = canonicalUrl;
this.document.head.querySelector('script[data-page-structured-data]')?.remove();
if (page.structuredData) {
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-page-structured-data', '');
script.textContent = JSON.stringify(page.structuredData);
this.document.head.appendChild(script);
}
}
}

View File

@@ -1,85 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SectionComponent } from '../../shared/ui/section/section.component';
@Component({
selector: 'app-about-it-section',
standalone: true,
imports: [CommonModule, SectionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-section
[id]="sectionId"
eyebrow="About IT"
title="Thoughtful engineering over novelty"
lead="I gravitate toward choices that keep the codebase predictable, observable, and easy to evolve."
>
<div class="grid gap-6 lg:grid-cols-3">
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
Architecture
</p>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
Lean compositions, clear data boundaries, and contracts that are
easy to test. Small, composable modules beat monolith components.
</p>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">Front end</p>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
Signals for straightforward state, typed APIs, accessibility baked
into the UI, and styling that stays aligned with design systems.
</p>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
Delivery
</p>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
Automated checks, preview builds, and incremental releases over
big-bang launches. Measure impact; iterate with feedback.
</p>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
Backend Integration
</p>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
Seamless integration with backend services, optimizing data flow, and ensuring efficient full-stack performance.
</p>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
API Contracts
</p>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
Defining clear, strongly-typed API contracts to ensure reliability, improve developer experience, and reduce integration friction.
</p>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
Client Handling
</p>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-300">
Proactive communication, understanding client needs, translating requirements into technical specifications, and ensuring timely delivery with clear expectations.
</p>
</div>
</div>
</app-section>
`
})
export class AboutItSectionComponent {
readonly sectionId = 'about-it';
}

View File

@@ -1,60 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SectionComponent } from '../../shared/ui/section/section.component';
@Component({
selector: 'app-about-me-section',
standalone: true,
imports: [CommonModule, SectionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-section
[id]="sectionId"
eyebrow="About me"
title="Engineer, collaborator, perpetual learner"
lead="I like to keep teams moving: establish a clear plan, align stakeholders, and turn good ideas into shipped, measurable outcomes."
>
<div class="grid gap-8 lg:grid-cols-2">
<div class="space-y-4 text-zinc-600 dark:text-zinc-200">
<p>
I care about pairing good taste with solid engineering practices.
Whether building a new product or refining an existing one, I work
to keep the codebase tidy, the UI cohesive, and the delivery
predictable.
</p>
<p>
I thrive in cross-functional teams, translating between design,
product, and engineering to keep efforts aligned and transparent.
</p>
</div>
<div class="grid gap-3">
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
Working principles
</p>
<ul class="mt-2 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
<li>• Design for clarity, then refine for speed.</li>
<li>• Prefer simple interfaces over clever abstractions.</li>
<li>• Optimize for maintainability and team velocity.</li>
</ul>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">Ways I help</p>
<ul class="mt-2 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
<li>• Structuring design systems and component libraries.</li>
<li>• Improving developer experience and tooling.</li>
<li>• Coaching teams on pragmatic testing and reviews.</li>
</ul>
</div>
</div>
</div>
</app-section>
`
})
export class AboutMeSectionComponent {
readonly sectionId = 'about-me';
}

View File

@@ -0,0 +1,25 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { CvPageComponent } from './cv-page.component';
describe('CvPageComponent', () => {
it('renders a complete, contactable CV with curated learning', async () => {
await TestBed.configureTestingModule({
imports: [CvPageComponent],
providers: [provideRouter([])]
}).compileComponents();
const fixture = TestBed.createComponent(CvPageComponent);
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('h1')?.textContent).toContain('Senior full-stack engineer');
expect(page.textContent).toContain('Senior Software Engineer');
expect(page.textContent).toContain('Product Owner');
expect(page.textContent).toContain('Software Developer');
expect(page.textContent).toContain('Junior Software Developer');
expect(page.querySelectorAll('[data-testid="course"]').length).toBe(8);
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
expect(page.querySelector('a[href="https://www.linkedin.com/in/amardzanan/"]')).toBeTruthy();
});
});

93
src/app/features/cv/cv-page.component.ts Executable file → Normal file
View File

@@ -1,19 +1,104 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PageShellComponent } from '../../core/layout/page-shell.component';
import { CvSectionComponent } from './cv-section.component';
import { SeoService } from '../../core/services/seo.service';
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
@Component({
selector: 'app-cv-page',
standalone: true,
imports: [PageShellComponent, CvSectionComponent],
imports: [PageShellComponent, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-shell [sections]="sections">
<app-cv-section />
<header class="cv-hero shell">
<nav class="breadcrumb mono" aria-label="Breadcrumb"><a routerLink="/">Home</a><span>/</span><span>CV</span></nav>
<div class="cv-heading">
<div><p class="eyebrow mono">Curriculum vitae / Amar Džanan</p><h1>Senior full-stack engineer with product judgment.</h1></div>
<div class="cv-contact">
<p>Sarajevo, Bosnia and Herzegovina<br>Available for senior engineering work across Europe.</p>
<a href="mailto:amar@dzanan.net">amar&#64;dzanan.net ↗</a>
<a href="https://www.linkedin.com/in/amardzanan/">LinkedIn ↗</a>
</div>
</div>
</header>
<section class="cv-section shell" aria-labelledby="summary-title">
<p class="case-index mono">01 / Summary</p>
<div><h2 id="summary-title">Profile</h2><p class="large-copy">I help teams understand and improve mature software. My work spans product decisions, Angular interfaces, .NET services, data, integrations, delivery, and production diagnostics—with recent focus on safe AI access to business systems.</p></div>
</section>
<section class="cv-section shell" aria-labelledby="cv-experience-title">
<p class="case-index mono">02 / Career</p>
<div><h2 id="cv-experience-title">Experience</h2>
<div class="cv-roles">
@for (role of roles; track role.period + role.title) {
<article><div class="cv-role-head"><time class="mono">{{ role.period }}</time><div><h3>{{ role.title }}</h3><p>{{ role.company }}</p></div></div><p>{{ role.description }}</p></article>
}
</div>
</div>
</section>
<section class="cv-section shell" aria-labelledby="skills-title">
<p class="case-index mono">03 / Practice</p>
<div><h2 id="skills-title">Capabilities & tools</h2>
<div class="cv-skill-grid">
@for (group of skillGroups; track group.title) { <article><h3>{{ group.title }}</h3><p>{{ group.items }}</p></article> }
</div>
</div>
</section>
<section class="cv-section shell" aria-labelledby="learning-title">
<p class="case-index mono">04 / Learning</p>
<div><h2 id="learning-title">Selected courses</h2>
<div class="course-list">
@for (course of courses; track course.title) {
<article data-testid="course"><time class="mono">{{ course.period }}</time><h3>{{ course.title }}</h3><p>{{ course.provider }}</p></article>
}
</div>
</div>
</section>
<section class="contact-block shell" aria-labelledby="cv-contact-title">
<p class="eyebrow mono">Contact / Direct</p>
<h2 id="cv-contact-title">Let’s talk about the system you need to move forward.</h2>
<div class="contact-actions"><a class="button button-light" href="mailto:amar@dzanan.net">amar&#64;dzanan.net ↗</a><a class="text-link light" routerLink="/">Selected work →</a></div>
</section>
</app-page-shell>
`
})
export class CvPageComponent {
readonly sections = MAIN_NAVIGATION;
readonly roles = [
{ period: '2025—Present', company: 'Unija Smart Accounting BiH', title: 'Senior Software Engineer', description: 'Modernize enterprise platforms and deliver full-stack workflows, operational automation, secure integration paths, and AI-assisted capabilities.' },
{ period: 'Aug 2023—Present', company: 'COMP-2000', title: 'Product Owner', description: 'Shape product direction, manage priorities and backlog, align stakeholders, run reviews and retrospectives, and turn user feedback into delivery decisions.' },
{ period: 'Nov 2020—Present', company: 'COMP-2000', title: 'Software Developer', description: 'Design, implement, test, and maintain applications across .NET, C#, Angular, Blazor, Ionic, Web API, SQL Server, and .NET MAUI.' },
{ period: 'Nov 2018—2020', company: 'COMP-2000', title: 'Junior Software Developer', description: 'Built core engineering experience in C#, object-oriented design, SQL, Angular, Xamarin Forms, WPF, Windows Forms, and application lifecycle practices.' }
];
readonly skillGroups = [
{ title: 'Backend & data', items: '.NET, C#, ASP.NET Core, Web API, Entity Framework Core, SQL Server, PostgreSQL, PostGIS' },
{ title: 'Frontend & mobile', items: 'Angular, TypeScript, RxJS, Blazor, Ionic, .NET MAUI, Xamarin Forms, OpenLayers' },
{ title: 'Architecture & integration', items: 'CQRS, multitenancy, SignalR, identity, SOAP and HTTP integrations, Model Context Protocol' },
{ title: 'Delivery & operations', items: 'Docker, CI/CD, IIS, TLS, diagnostics, automated testing, incremental modernization' },
{ title: 'Product practice', items: 'Product ownership, backlog shaping, stakeholder alignment, sprint reviews, retrospectives, user feedback' },
{ title: 'Working style', items: 'End-to-end ownership, clear boundaries, pragmatic design, production awareness, cross-functional communication' }
];
readonly courses = [
{ title: 'Learn Go Course', provider: 'Codecademy', period: 'Apr 2024' },
{ title: 'Reactive Angular RxJS (Angular 17)', provider: 'Udemy', period: 'Mar 2024' },
{ title: 'C# Advanced Topics: Prepare for Technical Interviews', provider: 'Udemy', period: 'Sep 2021' },
{ title: 'Learning Docker', provider: 'LinkedIn Learning', period: 'Sep 2021' },
{ title: 'Learning Entity Framework Core', provider: 'LinkedIn Learning', period: 'May 2021' },
{ title: 'Building Web APIs with ASP.NET Core', provider: 'LinkedIn Learning', period: 'Aug 2020' },
{ title: 'Software Architecture Foundations', provider: 'LinkedIn Learning', period: 'Jul 2020' },
{ title: 'Linux Command Line', provider: 'Udemy', period: 'Jul 2018' }
];
constructor() {
inject(SeoService).update({
title: 'CV — Amar Džanan, Senior Software Engineer',
description: 'Experience, capabilities, and selected learning from senior full-stack engineer and product owner Amar Džanan.',
path: '/cv'
});
}
}

View File

@@ -1,225 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SectionComponent } from '../../shared/ui/section/section.component';
type CourseItem = {
title: string;
provider: string;
period: string;
};
@Component({
selector: 'app-cv-section',
standalone: true,
imports: [CommonModule, SectionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-section
[id]="sectionId"
eyebrow="CV"
title="Snapshot of my experience"
lead="Highlights of how I work and where I add value. Full CV is ready to share on request."
>
<div class="grid gap-6 lg:grid-cols-3">
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
Experience focus
</p>
<ul class="mt-2 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
<li>• Software architecture, product ownership, and team support.</li>
<li>• Full-stack development across .NET, Angular, and mobile apps.</li>
<li>• Designing, testing, and maintaining reliable C# services.</li>
<li>• Turning user feedback into prioritized product delivery.</li>
</ul>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">Skills & tools</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
Angular
</span>
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
TypeScript
</span>
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
.NET
</span>
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
Blazor
</span>
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
Web API
</span>
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
MSSQL
</span>
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
.NET MAUI
</span>
<span class="rounded-full bg-emerald-50/80 px-3 py-1 text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0">
Ionic
</span>
</div>
</div>
<div
class="rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
How I structure work
</p>
<ul class="mt-2 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
<li>• Shape product roadmaps through research and prioritization.</li>
<li>• Manage backlogs, reviews, and retrospectives.</li>
<li>• Collaborate with UX/UI, sales, marketing, and engineering.</li>
<li>
• Monitor KPIs and user feedback for continuous improvement.
</li>
</ul>
</div>
</div>
<div
class="mt-6 rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-4 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-sm font-semibold text-emerald-600 dark:text-emerald-300">
Courses & certifications
</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-300">
Publicly listed learning history from LinkedIn-derived profile data.
</p>
</div>
<span class="text-xs text-zinc-500 dark:text-zinc-400">
2018 - 2024
</span>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
@for (course of courses; track course.title) {
<article
class="rounded-lg border border-zinc-200/70 bg-white/55 p-3 dark:border-slate-800/70 dark:bg-slate-950/30"
>
<div class="flex items-start justify-between gap-3">
<h3 class="text-sm font-semibold text-zinc-800 dark:text-zinc-50">
{{ course.title }}
</h3>
<span class="shrink-0 text-xs text-zinc-500 dark:text-zinc-400">
{{ course.period }}
</span>
</div>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{{ course.provider }}
</p>
</article>
}
</div>
</div>
</app-section>
`
})
export class CvSectionComponent {
readonly sectionId = 'cv';
readonly courses: CourseItem[] = [
{
title: 'Learn Go Course',
provider: 'Codecademy',
period: 'Apr 2024'
},
{
title: 'Reactive Angular RxJS (Angular 17)',
provider: 'Udemy',
period: 'Mar 2024'
},
{
title: 'C# Advanced Topics: Prepare for Technical Interviews',
provider: 'Udemy',
period: 'Sep 2021'
},
{
title: 'Learning Docker',
provider: 'LinkedIn',
period: 'Sep 2021'
},
{
title: 'Applying MVVM in Xamarin.Forms Applications',
provider: 'Pluralsight',
period: 'May 2021'
},
{
title: 'Architecting Xamarin.Forms Applications for code reuse',
provider: 'Pluralsight',
period: 'May 2021'
},
{
title: 'Learning Entity Framework Core',
provider: 'LinkedIn',
period: 'May 2021'
},
{
title: 'C# Intermediate: Classes, Objects and OOP',
provider: 'Udemy',
period: 'Oct 2020'
},
{
title: 'Xamarin Forms: Build Native Cross-platform Apps with C#',
provider: 'Udemy',
period: 'Sep 2020'
},
{
title: 'Building Web APIs with ASP.NET Core',
provider: 'LinkedIn',
period: 'Aug 2020'
},
{
title: 'Database Foundations: Storage',
provider: 'LinkedIn',
period: 'Jul 2020'
},
{
title: 'Software Architecture Foundations',
provider: 'LinkedIn',
period: 'Jul 2020'
},
{
title: 'C# Essential Training: 2 Flow Control, Arrays, and Exception Handling',
provider: 'LinkedIn',
period: 'Jan 2020'
},
{
title: 'C#: Interfaces and Generics',
provider: 'LinkedIn',
period: 'Jan 2020'
},
{
title: 'C# Essential Training: 1 Syntax and Object Oriented Programming',
provider: 'LinkedIn',
period: 'Dec 2019'
},
{
title: 'SQL Server - Easy Install & Configure a Developer Instance',
provider: 'Udemy',
period: 'Jan 2019'
},
{
title: 'C# Fundamentals by Coding',
provider: 'Udemy',
period: 'Dec 2018'
},
{
title: 'Javascript Essentials',
provider: 'Udemy',
period: 'Jul 2018'
},
{
title: 'Linux Command Line',
provider: 'Udemy',
period: 'Jul 2018'
}
];
}

View File

@@ -1,14 +0,0 @@
import { HomeSectionComponent } from './home-section.component';
describe('HomeSectionComponent', () => {
it('orders the career timeline from newest to oldest', () => {
const component = new HomeSectionComponent();
expect(component.timeline.map(entry => entry.step)).toEqual([
'2025 - Present',
'Aug 2023 - Present',
'Nov 2020 - Present',
'Nov 2018 - 2020'
]);
});
});

View File

@@ -1,146 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SectionComponent } from '../../shared/ui/section/section.component';
type TimelineSide = 'left' | 'right';
type TimelineEntry = {
step: string;
phase: string;
title: string;
subtitle: string;
side: TimelineSide;
pulsating?: boolean; // Make pulsating an optional boolean property
};
@Component({
selector: 'app-home-section',
standalone: true,
imports: [CommonModule, SectionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-section
[id]="sectionId"
eyebrow="Career Journey"
title="Building dependable software that feels effortless"
lead="Here’s a timeline of my professional growth and key milestones, highlighting significant roles and contributions."
>
<div
class="overflow-hidden rounded-3xl border border-zinc-200/70 bg-[#fbfcfa]/80 shadow-xl shadow-emerald-900/5 backdrop-blur-sm transition-colors duration-300 dark:border-slate-800/60 dark:bg-slate-950/40 dark:shadow-emerald-900/20"
>
<div class="relative px-4 py-16 md:px-12">
<!-- Central Line -->
<div
class="absolute left-8 top-0 h-full w-px -translate-x-1/2 bg-gradient-to-b from-transparent via-zinc-300 to-transparent transition-colors duration-300 dark:via-slate-700 md:left-1/2"
aria-hidden="true"
></div>
<div class="flex flex-col gap-12 md:gap-20">
@for (entry of timeline; track entry.title) {
<div
class="group relative grid grid-cols-[2rem_1fr] gap-8 md:grid-cols-[1fr_auto_1fr] md:items-center md:gap-0"
>
<!-- Left Side Content (Desktop) -->
<div class="hidden md:block md:col-start-1 md:pr-12 md:text-right">
@if (entry.side === 'left') {
<div class="relative">
<span class="mb-2 inline-block text-xs font-bold uppercase tracking-widest text-emerald-600/90 dark:text-emerald-500/80">
{{ entry.step }} &bull; {{ entry.phase }}
</span>
<h3 class="mb-2 text-xl font-semibold text-zinc-800 transition-colors group-hover:text-emerald-600 dark:text-slate-100 dark:group-hover:text-emerald-400">
{{ entry.title }}
</h3>
<p class="text-base text-zinc-600 leading-relaxed dark:text-slate-400">
{{ entry.subtitle }}
</p>
</div>
}
</div>
<!-- Center Marker -->
<div class="relative col-start-1 flex items-center justify-center md:col-start-2">
<div class="relative flex h-3 w-3 items-center justify-center">
@if (entry.pulsating) {
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"
></span>
}
<div
class="relative h-3 w-3 rounded-full bg-emerald-500 ring-4 ring-[#f4f7f5] shadow-[0_0_12px_rgba(16,185,129,0.45)] transition-all duration-300 group-hover:scale-125 dark:ring-slate-950"
></div>
</div>
</div>
<!-- Right Side Content (Desktop) & Mobile Content (All) -->
<div class="col-start-2 md:col-start-3 md:pl-12">
@if (entry.side === 'right') {
<div class="relative">
<span class="mb-2 inline-block text-xs font-bold uppercase tracking-widest text-emerald-600/90 dark:text-emerald-500/80">
{{ entry.step }} &bull; {{ entry.phase }}
</span>
<h3 class="mb-2 text-xl font-semibold text-zinc-800 transition-colors group-hover:text-emerald-600 dark:text-slate-100 dark:group-hover:text-emerald-400">
{{ entry.title }}
</h3>
<p class="text-base text-zinc-600 leading-relaxed dark:text-slate-400">
{{ entry.subtitle }}
</p>
</div>
}
@else {
<!-- Mobile only view for 'left' entries -->
<div class="block md:hidden">
<span class="mb-2 inline-block text-xs font-bold uppercase tracking-widest text-emerald-600/90 dark:text-emerald-500/80">
{{ entry.step }} &bull; {{ entry.phase }}
</span>
<h3 class="mb-2 text-xl font-semibold text-zinc-800 transition-colors group-hover:text-emerald-600 dark:text-slate-100 dark:group-hover:text-emerald-400">
{{ entry.title }}
</h3>
<p class="text-base text-zinc-600 leading-relaxed dark:text-slate-400">
{{ entry.subtitle }}
</p>
</div>
}
</div>
</div>
}
</div>
</div>
</div>
</app-section>
`
})
export class HomeSectionComponent {
readonly sectionId = 'home';
readonly timeline: TimelineEntry[] = [
{
step: '2025 - Present',
phase: 'Unija Smart Accounting BiH',
title: 'Senior Software Engineer',
subtitle: 'Currently focused on software engineering in the Unija Smart Accounting environment.',
side: 'left',
pulsating: true
},
{
step: 'Aug 2023 - Present',
phase: 'COMP-2000 Ltd',
title: 'Product Owner',
subtitle: 'Led product planning, backlog management, sprint reviews, retrospectives, and cross-functional delivery for a commercialist mobile app.',
side: 'right'
},
{
step: 'Nov 2020 - Present',
phase: 'COMP-2000 Ltd',
title: 'Software Developer',
subtitle: 'Designed, developed, tested, and maintained C# applications across .NET, Blazor, Angular, Ionic, Web API, MSSQL, and .NET MAUI.',
side: 'left'
},
{
step: 'Nov 2018 - 2020',
phase: 'COMP-2000 Ltd',
title: 'Junior Software Developer',
subtitle: 'Built a foundation in C#, OOP, application lifecycle practices, MSSQL, Xamarin Forms, Angular, WPF, and Windows Forms.',
side: 'right'
}
];
}

View File

@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { LandingPageComponent } from './landing-page.component';
describe('LandingPageComponent', () => {
it('leads with the approved positioning and four case studies', async () => {
await TestBed.configureTestingModule({
imports: [LandingPageComponent],
providers: [provideRouter([])]
}).compileComponents();
const fixture = TestBed.createComponent(LandingPageComponent);
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('h1')?.textContent).toContain(
'I modernize complex software and connect it safely to AI.'
);
expect(page.querySelectorAll('[data-testid="project-row"]').length).toBe(4);
expect(page.textContent).toContain('Secure AI Integration Gateway');
expect(page.textContent).toContain('Travel Operations & Automation Platform');
expect(page.textContent).toContain('Multitenant Service Operations Platform');
expect(page.textContent).toContain('Biodiversity Data & Field Research Platform');
});
it('offers direct contact, CV, LinkedIn, and the complete role history', async () => {
await TestBed.configureTestingModule({
imports: [LandingPageComponent],
providers: [provideRouter([])]
}).compileComponents();
const fixture = TestBed.createComponent(LandingPageComponent);
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
expect(page.querySelector('a[href="/cv"]')).toBeTruthy();
expect(page.querySelector('a[href="https://www.linkedin.com/in/amardzanan/"]')).toBeTruthy();
expect(page.textContent).toContain('Senior Software Engineer');
expect(page.textContent).toContain('Product Owner');
expect(page.textContent).toContain('Software Developer');
expect(page.textContent).toContain('Junior Software Developer');
});
});

View File

@@ -1,28 +1,127 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { RouterLink } from '@angular/router';
import { PageShellComponent } from '../../core/layout/page-shell.component';
import { HomeSectionComponent } from '../home/home-section.component';
import { AboutMeSectionComponent } from '../about/about-me-section.component';
import { AboutItSectionComponent } from '../about/about-it-section.component';
import { SeoService } from '../../core/services/seo.service';
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
import { PORTFOLIO_PROJECTS } from '../work/portfolio-projects.data';
@Component({
selector: 'app-landing-page',
standalone: true,
imports: [
PageShellComponent,
HomeSectionComponent,
AboutMeSectionComponent,
AboutItSectionComponent
NgOptimizedImage,
RouterLink
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-shell [sections]="sections">
<app-home-section />
<app-about-me-section />
<app-about-it-section />
<section class="hero shell" aria-labelledby="hero-title">
<div class="hero-kicker mono"><span>01</span> Senior full-stack engineer · AI integration</div>
<div class="hero-grid">
<h1 id="hero-title">I modernize complex software and connect it safely to AI.</h1>
<div class="hero-aside">
<p>I work across product, frontend, backend, integrations, and delivery to make mature systems easier to operate and evolve.</p>
<div class="action-row">
<a class="button button-primary" href="mailto:amar@dzanan.net">Start a conversation <span aria-hidden="true">↗</span></a>
<a class="text-link" routerLink="/cv">View CV <span aria-hidden="true">→</span></a>
</div>
</div>
</div>
<div class="hero-foot mono"><span>Based in Sarajevo · Working across Europe</span><span>Scroll to inspect selected systems ↓</span></div>
</section>
<section id="work" class="section-block shell" aria-labelledby="work-title">
<div class="section-heading">
<div><p class="eyebrow mono">Selected work / 2025—2026</p><h2 id="work-title">Systems I’ve helped move forward.</h2></div>
<p>Four anonymized case studies from production software: integration boundaries, operational workflows, modernization, and AI-assisted tooling.</p>
</div>
<div class="project-index">
@for (project of projects; track project.id) {
<a data-testid="project-row" class="project-row" [routerLink]="['/work', project.slug]">
<span class="project-number mono">{{ project.index }}</span>
<span class="project-main"><strong>{{ project.title }}</strong><small>{{ project.summary }}</small></span>
<span class="project-meta mono">{{ project.category }}<br>{{ project.period }}</span>
<span class="project-arrow" aria-hidden="true">↗</span>
</a>
}
</div>
</section>
<section id="profile" class="profile-band" aria-labelledby="profile-title">
<div class="shell profile-grid">
<img ngSrc="/images/profile.jpg" width="360" height="360" alt="Portrait of Amar Džanan" class="profile-photo" />
<div>
<p class="eyebrow mono">Profile / Amar Džanan</p>
<h2 id="profile-title">Engineering judgment across the whole product.</h2>
<p class="profile-lead">I’m a senior software engineer and product owner who is most useful where business rules, legacy systems, and new capabilities meet.</p>
</div>
<div class="profile-notes">
<p>I turn incomplete requirements into maintainable paths through the UI, domain model, integrations, deployment, and operations.</p>
<p>My recent work focuses on enterprise .NET and Angular platforms, reliable automation, and narrow AI interfaces with clear trust boundaries.</p>
<a href="https://www.linkedin.com/in/amardzanan/">LinkedIn profile <span aria-hidden="true">↗</span></a>
</div>
</div>
</section>
<section id="experience" class="section-block shell" aria-labelledby="experience-title">
<div class="section-heading compact"><div><p class="eyebrow mono">Experience / 2018—Present</p><h2 id="experience-title">Work that overlaps by design.</h2></div><a class="text-link" routerLink="/cv">Full CV →</a></div>
<div class="experience-list">
@for (role of experience; track role.period + role.title) {
<article class="experience-row"><time class="mono">{{ role.period }}</time><div><h3>{{ role.title }}</h3><p>{{ role.company }}</p></div><p>{{ role.focus }}</p></article>
}
</div>
</section>
<section class="section-block shell capabilities" aria-labelledby="capabilities-title">
<div><p class="eyebrow mono">Capabilities / How I contribute</p><h2 id="capabilities-title">From uncertain problem to running system.</h2></div>
<div class="capability-grid">
@for (capability of capabilities; track capability.number) {
<article><span class="mono">{{ capability.number }}</span><h3>{{ capability.title }}</h3><p>{{ capability.detail }}</p></article>
}
</div>
</section>
<section class="contact-block shell" aria-labelledby="contact-title">
<p class="eyebrow mono">Available for the right problem</p>
<h2 id="contact-title">Need a senior engineer who can understand the system before changing it?</h2>
<div class="contact-actions"><a class="button button-light" href="mailto:amar@dzanan.net">amar&#64;dzanan.net <span aria-hidden="true">↗</span></a><span class="mono">Sarajevo · CET / CEST</span></div>
</section>
</app-page-shell>
`
})
export class LandingPageComponent {
readonly sections = MAIN_NAVIGATION;
readonly projects = PORTFOLIO_PROJECTS;
readonly experience = [
{ period: '2025—Present', company: 'Unija Smart Accounting BiH', title: 'Senior Software Engineer', focus: 'Enterprise platforms, modernization, automation, and AI integration.' },
{ period: 'Aug 2023—Present', company: 'COMP-2000', title: 'Product Owner', focus: 'Product direction, backlog ownership, cross-functional delivery, and user feedback.' },
{ period: 'Nov 2020—Present', company: 'COMP-2000', title: 'Software Developer', focus: 'Full-stack .NET, Angular, mobile, APIs, data, and production support.' },
{ period: 'Nov 2018—2020', company: 'COMP-2000', title: 'Junior Software Developer', focus: 'C#, SQL, desktop, web, mobile, and application lifecycle foundations.' }
];
readonly capabilities = [
{ number: '01', title: 'System modernization', detail: 'Upgrade frameworks, identity, routing, delivery, and diagnostics without losing working domain knowledge.' },
{ number: '02', title: 'Full-stack product delivery', detail: 'Carry business workflows through interface design, APIs, domain rules, persistence, integrations, and tests.' },
{ number: '03', title: 'Safe AI integration', detail: 'Expose useful capabilities through narrow tools, validated boundaries, verified writes, and observable operations.' }
];
constructor() {
const description = 'Senior full-stack engineer modernizing complex enterprise software and connecting it safely to AI.';
inject(SeoService).update({
title: 'Amar Džanan — Senior Software Engineer & AI Integration',
description,
path: '/',
structuredData: {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Amar Džanan',
url: 'https://dzanan.net/',
email: 'mailto:amar@dzanan.net',
jobTitle: 'Senior Software Engineer',
sameAs: ['https://www.linkedin.com/in/amardzanan/'],
knowsAbout: ['.NET', 'Angular', 'TypeScript', 'Software architecture', 'Model Context Protocol', 'AI integration']
}
});
}
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PageShellComponent } from '../../core/layout/page-shell.component';
import { SeoService } from '../../core/services/seo.service';
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
@Component({
selector: 'app-not-found-page',
standalone: true,
imports: [PageShellComponent, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-shell [sections]="sections">
<section class="not-found shell">
<p class="eyebrow mono">404 / Route not found</p>
<h1>This path ends here.</h1>
<p>The page may have moved. The selected systems and current CV are available from the homepage.</p>
<a class="button button-primary" routerLink="/">Return to selected work →</a>
</section>
</app-page-shell>
`
})
export class NotFoundPageComponent {
readonly sections = MAIN_NAVIGATION;
constructor() {
inject(SeoService).update({
title: 'Page not found — Amar Džanan',
description: 'The requested page could not be found.',
path: '/404'
});
}
}

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { PortfolioPageComponent } from './portfolio-page.component';
describe('PortfolioPageComponent', () => {
it('renders a complete project narrative from route data', async () => {
await TestBed.configureTestingModule({
imports: [PortfolioPageComponent],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { snapshot: { data: { projectId: 'ai-integration' } } } }
]
}).compileComponents();
const fixture = TestBed.createComponent(PortfolioPageComponent);
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('h1')?.textContent).toContain('Secure AI Integration Gateway');
expect(page.textContent).toContain('The challenge');
expect(page.textContent).toContain('What I contributed');
expect(page.textContent).toContain('Key decisions');
expect(page.textContent).toContain('The outcome');
expect(page.querySelector('[data-testid="system-diagram"]')).toBeTruthy();
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
expect(TestBed.inject(Title).getTitle()).toContain('Secure AI Integration Gateway');
});
});

105
src/app/features/portfolio/portfolio-page.component.ts Executable file → Normal file
View File

@@ -1,19 +1,116 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { PageShellComponent } from '../../core/layout/page-shell.component';
import { PortfolioSectionComponent } from './portfolio-section.component';
import { SeoService } from '../../core/services/seo.service';
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
import { PortfolioProjectId } from '../work/portfolio-project.model';
import { getPortfolioProject, PORTFOLIO_PROJECTS } from '../work/portfolio-projects.data';
import { SystemDiagramComponent } from '../work/system-diagram.component';
@Component({
selector: 'app-portfolio-page',
standalone: true,
imports: [PageShellComponent, PortfolioSectionComponent],
imports: [PageShellComponent, RouterLink, SystemDiagramComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-shell [sections]="sections">
<app-portfolio-section />
<article>
<header class="case-hero shell">
<nav class="breadcrumb mono" aria-label="Breadcrumb"><a routerLink="/">Work</a><span>/</span><span>{{ project.index }}</span></nav>
<div class="case-heading">
<div>
<p class="eyebrow mono">{{ project.category }} · {{ project.period }}</p>
<h1>{{ project.title }}</h1>
</div>
<div class="case-intro">
<p>{{ project.summary }}</p>
<dl><div><dt>Role</dt><dd>{{ project.role }}</dd></div><div><dt>Period</dt><dd>{{ project.period }}</dd></div></dl>
</div>
</div>
</header>
<section class="case-section shell narrative" aria-labelledby="challenge-title">
<p class="case-index mono">01 / Context</p>
<div><h2 id="challenge-title">The challenge</h2><p class="large-copy">{{ project.challenge }}</p></div>
</section>
<section class="case-section shell" aria-labelledby="contributions-title">
<p class="case-index mono">02 / Delivery</p>
<div>
<h2 id="contributions-title">What I contributed</h2>
<ol class="contribution-list">
@for (contribution of project.contributions; track contribution; let index = $index) {
<li><span class="mono">{{ contributionNumber(index) }}</span><p>{{ contribution }}</p></li>
}
</ol>
</div>
</section>
<section class="diagram-wrap">
<div class="shell"><app-system-diagram [kind]="project.diagram" [title]="project.title" /></div>
</section>
<section class="case-section shell" aria-labelledby="decisions-title">
<p class="case-index mono">03 / Architecture</p>
<div>
<h2 id="decisions-title">Key decisions</h2>
<div class="decision-grid">
@for (decision of project.decisions; track decision.title; let index = $index) {
<article><span class="mono">{{ contributionNumber(index) }}</span><h3>{{ decision.title }}</h3><p>{{ decision.detail }}</p></article>
}
</div>
</div>
</section>
<section class="case-section shell narrative outcome" aria-labelledby="outcome-title">
<p class="case-index mono">04 / Result</p>
<div><h2 id="outcome-title">The outcome</h2><p class="large-copy">{{ project.outcome }}</p></div>
</section>
<section class="case-stack shell" aria-label="Technology stack">
<p class="case-index mono">Technology</p>
<ul>@for (technology of project.stack; track technology) { <li>{{ technology }}</li> }</ul>
</section>
<nav class="case-pagination shell" aria-label="Case studies">
<a [routerLink]="['/work', previous.slug]"><span class="mono">← Previous</span><strong>{{ previous.title }}</strong></a>
<a [routerLink]="['/work', next.slug]"><span class="mono">Next →</span><strong>{{ next.title }}</strong></a>
</nav>
<section class="contact-block shell case-contact" aria-labelledby="case-contact-title">
<p class="eyebrow mono">Have a complex system?</p>
<h2 id="case-contact-title">Let’s make the next change safer and more useful.</h2>
<div class="contact-actions"><a class="button button-light" href="mailto:amar@dzanan.net">Start a conversation <span aria-hidden="true">↗</span></a><a class="text-link light" routerLink="/cv">View full CV →</a></div>
</section>
</article>
</app-page-shell>
`
})
export class PortfolioPageComponent {
readonly sections = MAIN_NAVIGATION;
readonly project = getPortfolioProject(inject(ActivatedRoute).snapshot.data['projectId'] as PortfolioProjectId);
readonly projectIndex = PORTFOLIO_PROJECTS.findIndex(item => item.id === this.project.id);
readonly previous = PORTFOLIO_PROJECTS[(this.projectIndex - 1 + PORTFOLIO_PROJECTS.length) % PORTFOLIO_PROJECTS.length];
readonly next = PORTFOLIO_PROJECTS[(this.projectIndex + 1) % PORTFOLIO_PROJECTS.length];
constructor() {
inject(SeoService).update({
title: `${this.project.title} — Amar Džanan`,
description: this.project.seoDescription,
path: `/work/${this.project.slug}`,
type: 'article',
structuredData: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: this.project.title,
description: this.project.seoDescription,
author: { '@type': 'Person', name: 'Amar Džanan', url: 'https://dzanan.net/' },
url: `https://dzanan.net/work/${this.project.slug}`
}
});
}
contributionNumber(index: number): string {
return String(index + 1).padStart(2, '0');
}
}

View File

@@ -1,93 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SectionComponent } from '../../shared/ui/section/section.component';
type PortfolioItem = {
title: string;
summary: string;
period: string;
tags: string[];
};
@Component({
selector: 'app-portfolio-section',
standalone: true,
imports: [CommonModule, SectionComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-section
[id]="sectionId"
eyebrow="Portfolio"
title="Project and community work"
lead="Selected project work from LinkedIn, covering youth development, cultural programs, community safety, and rescue service coordination."
>
<div class="grid gap-6 md:grid-cols-2">
@for (item of items; track item.title) {
<article
class="flex h-full flex-col gap-3 rounded-xl border border-zinc-200/70 bg-[#fbfcfa]/80 p-5 shadow-sm shadow-zinc-200/40 dark:border-slate-800/60 dark:bg-slate-900/40 dark:shadow-none"
>
<div class="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400">
<span>{{ item.period }}</span>
<div class="flex flex-wrap gap-1">
@for (tag of item.tags; track tag) {
<span
class="rounded-full bg-emerald-50/80 px-2 py-1 text-[11px] text-zinc-700 ring-1 ring-emerald-100/70 dark:bg-slate-800/80 dark:text-zinc-200 dark:ring-0"
>
{{ tag }}
</span>
}
</div>
</div>
<h3 class="text-lg font-semibold text-zinc-800 dark:text-zinc-50">
{{ item.title }}
</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-300">
{{ item.summary }}
</p>
</article>
}
</div>
</app-section>
`
})
export class PortfolioSectionComponent {
readonly sectionId = 'portfolio';
readonly items: PortfolioItem[] = [
{
title: 'Cultural Sports Center CSC "Kakanj"',
period: '2016',
summary:
'Supported cultural and sports development in Kakanj, including translation work for Italian students on an EU project and curator work through the local museum.',
tags: ['Culture', 'Translation', 'EU project']
},
{
title: 'Institute for youth development "KULT"',
period: '2016',
summary:
'Coordinated youth-focused project work with an NGO that develops and advocates legal and strategic solutions for young citizens, including education on alcohol, drugs, and STDs.',
tags: ['Youth', 'Education', 'NGO']
},
{
title: 'WorldVision',
period: '2015',
summary:
'Contributed to a two-day workshop focused on revising and designing hazard assessments, elemental disaster estimates, and safety planning.',
tags: ['Workshop', 'Safety planning', 'Risk assessment']
},
{
title: 'Project Manager Alternative Wave (AlterVal)',
period: '2015 - 2016',
summary:
'Managed local projects in Kakanj addressing modern social challenges, including writing and realizing initiatives financed by WorldVision.',
tags: ['Project management', 'Community', 'WorldVision']
},
{
title: 'Mountain Rescue Service - Kakanj and Zavidovici',
period: '2013 - Present',
summary:
'Organized search and rescue groups with registered rescuers, managed equipment, and helped write and realize rescue service projects.',
tags: ['Rescue service', 'Operations', 'Equipment']
}
];
}

View File

@@ -0,0 +1,34 @@
export type PortfolioProjectId =
| 'ai-integration'
| 'travel-operations'
| 'service-operations'
| 'biodiversity-data';
export type ProjectDiagramKind =
| 'trust-boundaries'
| 'travel-platform'
| 'multitenant-platform'
| 'research-platform';
export interface ProjectDecision {
readonly title: string;
readonly detail: string;
}
export interface PortfolioProject {
readonly id: PortfolioProjectId;
readonly index: string;
readonly slug: string;
readonly title: string;
readonly category: string;
readonly period: string;
readonly role: string;
readonly summary: string;
readonly challenge: string;
readonly contributions: readonly string[];
readonly decisions: readonly ProjectDecision[];
readonly outcome: string;
readonly stack: readonly string[];
readonly diagram: ProjectDiagramKind;
readonly seoDescription: string;
}

View File

@@ -0,0 +1,34 @@
import { getPortfolioProject, PORTFOLIO_PROJECTS } from './portfolio-projects.data';
describe('portfolio project registry', () => {
it('contains four complete, uniquely addressable case studies', () => {
expect(PORTFOLIO_PROJECTS.length).toBe(4);
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.id)).size).toBe(4);
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.slug)).size).toBe(4);
expect(PORTFOLIO_PROJECTS.map(project => project.index)).toEqual(['01', '02', '03', '04']);
for (const project of PORTFOLIO_PROJECTS) {
expect(project.contributions.length).toBeGreaterThan(2);
expect(project.decisions.length).toBeGreaterThan(1);
expect(project.stack.length).toBeGreaterThan(3);
expect(getPortfolioProject(project.id)).toBe(project);
}
});
it('keeps private source names and internal references out of public copy', () => {
const publicCopy = JSON.stringify(PORTFOLIO_PROJECTS);
const restrictedTerms = [
'WorkLogManager',
'intrix-mcp',
'Unija',
'git.dzanan.net',
'/run/media/',
'OTM',
'BIOS'
];
for (const term of restrictedTerms) {
expect(publicCopy).not.toContain(term);
}
});
});

View File

@@ -0,0 +1,191 @@
import { PortfolioProject, PortfolioProjectId } from './portfolio-project.model';
export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
{
id: 'ai-integration',
index: '01',
slug: 'secure-ai-integration-gateway',
title: 'Secure AI Integration Gateway for HRM & CRM',
category: 'AI integration · Featured case study',
period: '2026',
role: 'Primary engineer',
summary:
'A production Model Context Protocol service that gives AI clients useful access to business workflows while preserving authentication, validation, and operational safety.',
challenge:
'The gateway had to bridge AI clients with established HRM and CRM interfaces without widening the trust surface. Tool inputs, remote responses, credentials, sessions, attachments, and state-changing operations all needed explicit boundaries.',
contributions: [
'Designed the TypeScript service from HTTP hosting through MCP tool registration, application services, integration gateways, and external clients.',
'Exposed focused capabilities for jobs, candidates, attachments, companies, users, tasks, work orders, custodians, and controlled status changes.',
'Introduced schema validation for environment configuration, tool input, and every external response boundary.',
'Built safe write workflows with pre-write reads, stale-state checks, post-write verification, and sanitized error reporting.',
'Prepared IIS hosting, TLS, rollout, integration, and implementer documentation alongside broad automated coverage.'
],
decisions: [
{
title: 'Validate where trust changes',
detail:
'Configuration, MCP arguments, SOAP payloads, and webhook responses are parsed at their owning boundary before entering application logic.'
},
{
title: 'Verify every write',
detail:
'State-changing tools read current state, apply one explicit update, then read again to confirm the saved value.'
},
{
title: 'Keep composition explicit',
detail:
'Small services depend on narrow gateway contracts and are assembled in one composition root without reflection-heavy infrastructure.'
},
{
title: 'Treat operations as product work',
detail:
'Request limits, lifecycle handling, deployment guidance, sanitized diagnostics, and integration contracts ship with the application.'
}
],
outcome:
'The result is a narrow, testable AI integration surface that can support useful HRM and CRM operations while keeping failure behavior, write safety, and production ownership visible.',
stack: ['TypeScript', 'Node.js', 'MCP', 'Zod', 'SOAP', 'HTTP', 'IIS'],
diagram: 'trust-boundaries',
seoDescription:
'Case study about designing a secure production MCP gateway for HRM and CRM systems with validated boundaries and verified writes.'
},
{
id: 'travel-operations',
index: '02',
slug: 'travel-operations-platform',
title: 'Travel Operations & Automation Platform',
category: 'Enterprise platform · Full-stack delivery',
period: '2025 — Present',
role: 'Senior full-stack contributor',
summary:
'A large operations platform connecting travel products, departures, reservations, passengers, finance, customer communication, portals, and partner systems.',
challenge:
'Operational changes crossed several applications and business boundaries at once. New automation had to preserve established travel and accounting rules, remain observable, and evolve safely inside a mature platform.',
contributions: [
'Built reliable pre-travel communication workflows with scheduling, delivery state, retries, audit history, preview, and safe test delivery.',
'Delivered guide-expense and receipt workflows across portal UI, application services, persistence, invoice review, and archival.',
'Extended OCR-assisted invoice processing and accounting integrations for currencies, tax handling, validation, and cancellation paths.',
'Improved booking and departure behavior including room pricing, passenger pricing, gallery ordering, reports, and publishing state.',
'Contributed to framework upgrades, asynchronous CQRS paths, identity modernization, regression tests, and restricted MCP access.'
],
decisions: [
{
title: 'Model automation as a lifecycle',
detail:
'Communication state records ownership, attempts, retry eligibility, and uncertain delivery outcomes instead of reducing the workflow to one scheduled job.'
},
{
title: 'Share production rendering',
detail:
'Automatic delivery, previews, and test sends use the same rendering path so operational checks reflect production output.'
},
{
title: 'Keep domain changes end to end',
detail:
'UI, API contracts, domain rules, data migrations, integrations, and regression tests change together when the workflow requires it.'
}
],
outcome:
'The platform gained more reliable customer communication, clearer operational auditability, stronger finance workflows, and a safer path for modern framework and AI-assisted capabilities.',
stack: ['.NET', 'C#', 'Angular', 'TypeScript', 'EF Core', 'SQL Server', 'Identity', 'MCP'],
diagram: 'travel-platform',
seoDescription:
'Case study about full-stack modernization, finance automation, customer communication, and secure AI access in a travel operations platform.'
},
{
id: 'service-operations',
index: '03',
slug: 'service-operations-platform',
title: 'Multitenant Service Operations Platform',
category: 'Business platform · Product evolution',
period: '2025 — Present',
role: 'Senior full-stack contributor',
summary:
'A multitenant business system for tickets, worklogs, contracts, customers, teams, accounting, recurring work, reporting, and partner integrations.',
challenge:
'The product combines tenant-specific data, shared identity, real-time collaboration, background processing, and dense operational workflows. Improvements needed to respect those boundaries while keeping a mature product moving.',
contributions: [
'Delivered ticket and worklog capabilities including reports, search, conversation separation, status behavior, and traceable history.',
'Extended contract, categorization, pillar, and default-contract rules across Angular, APIs, persistence, migrations, and regression tests.',
'Built reminders, recurring-task behavior, notifications, and read-state workflows for day-to-day service operations.',
'Improved SignalR correctness and addressed memory leaks in long-lived frontend and backend paths.',
'Modernized routing and frameworks while extending MCP tools for controlled ticket, contract, team, and assignment workflows.'
],
decisions: [
{
title: 'Respect tenant boundaries',
detail:
'Shared identity and tenant-owned operational data remain distinct when shaping sync, access, and business behavior.'
},
{
title: 'Separate reads from writes',
detail:
'Query handlers shape efficient read models while command handlers own state changes and domain behavior.'
},
{
title: 'Modernize incrementally',
detail:
'Routing, framework, and real-time improvements are delivered in focused slices with regression coverage instead of a single replacement effort.'
}
],
outcome:
'The work strengthened everyday service workflows, reporting, real-time behavior, and maintainability while preserving the domain knowledge accumulated in a mature multitenant product.',
stack: ['.NET', 'C#', 'Angular', 'TypeScript', 'EF Core', 'SQL Server', 'SignalR', 'MCP'],
diagram: 'multitenant-platform',
seoDescription:
'Case study about evolving a multitenant service operations platform across domain workflows, real-time UX, reporting, and modernization.'
},
{
id: 'biodiversity-data',
index: '04',
slug: 'biodiversity-data-platform',
title: 'Biodiversity Data & Field Research Platform',
category: 'Scientific system · Platform modernization',
period: '2026',
role: 'Platform modernization contributor',
summary:
'A specialized research system for taxonomy, field sampling, specimens, biometrics, habitats, localities, media, and map-based geographic work.',
challenge:
'A mature scientific application with an older frontend and interconnected data model needed targeted modernization without disrupting field and research workflows.',
contributions: [
'Contributed backend migration work toward a current .NET runtime and updated identity-facing application paths.',
'Diagnosed and repaired continuous-delivery pipeline behavior during platform changes.',
'Added production diagnostics and log collection to make operational failures easier to investigate.',
'Resolved container-build compatibility constraints around the established Angular frontend.',
'Maintained the public certificate chain required for reliable browser and service access.'
],
decisions: [
{
title: 'Modernize around stable workflows',
detail:
'Platform and identity changes are isolated from the scientific model so researchers keep familiar data-entry behavior.'
},
{
title: 'Make deployment diagnosable',
detail:
'Pipeline fixes and targeted logging provide evidence at build, host, and certificate boundaries.'
},
{
title: 'Preserve compatibility deliberately',
detail:
'Container tooling accommodates the established frontend while backend and identity work move forward independently.'
}
],
outcome:
'The modernization work improved deployment confidence and operational visibility while protecting a specialized scientific workflow from unnecessary product change.',
stack: ['.NET', 'C#', 'Angular', 'PostgreSQL', 'PostGIS', 'OpenLayers', 'Docker', 'CI/CD'],
diagram: 'research-platform',
seoDescription:
'Case study about identity, deployment, diagnostics, and platform modernization for a biodiversity field research system.'
}
] as const;
export function getPortfolioProject(id: PortfolioProjectId): PortfolioProject {
const project = PORTFOLIO_PROJECTS.find(item => item.id === id);
if (!project) {
throw new Error(`Unknown portfolio project: ${id}`);
}
return project;
}

View File

@@ -0,0 +1,58 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ProjectDiagramKind } from './portfolio-project.model';
@Component({
selector: 'app-system-diagram',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<figure class="system-diagram" data-testid="system-diagram">
<figcaption><span class="mono">System view</span><strong>{{ title }}</strong></figcaption>
@switch (kind) {
@case ('trust-boundaries') {
<svg viewBox="0 0 900 410" role="img" aria-label="AI gateway trust-boundary architecture">
<g class="diagram-lines"><path d="M176 205H294M430 205h94M660 205h70"/><path d="M362 141V92M592 141V92M592 269v50"/></g>
<g class="diagram-node"><rect x="30" y="151" width="146" height="108"/><text x="103" y="194">AI CLIENTS</text><text class="small" x="103" y="218">MCP / HTTP</text></g>
<g class="diagram-node accent"><rect x="294" y="141" width="136" height="128"/><text x="362" y="194">GATEWAY</text><text class="small" x="362" y="218">AUTH · LIMITS</text><text class="small" x="362" y="238">VALIDATION</text></g>
<g class="diagram-node"><rect x="524" y="141" width="136" height="128"/><text x="592" y="194">SERVICES</text><text class="small" x="592" y="218">READ · WRITE</text><text class="small" x="592" y="238">VERIFY</text></g>
<g class="diagram-node"><rect x="730" y="151" width="140" height="108"/><text x="800" y="194">BUSINESS</text><text class="small" x="800" y="218">HRM · CRM</text></g>
<g class="diagram-note"><text x="362" y="72">SCHEMA BOUNDARY</text><text x="592" y="72">APPLICATION BOUNDARY</text><text x="592" y="342">SANITIZED OPERATIONS</text></g>
</svg>
}
@case ('travel-platform') {
<svg viewBox="0 0 900 410" role="img" aria-label="Travel operations platform architecture">
<g class="diagram-lines"><path d="M180 100H345M555 100h165M450 158v94M180 310h165M555 310h165"/><path d="M180 100v210M720 100v210"/></g>
<g class="diagram-node"><rect x="35" y="55" width="145" height="90"/><text x="108" y="96">PORTALS</text><text class="small" x="108" y="119">STAFF · GUIDE</text></g>
<g class="diagram-node accent"><rect x="345" y="42" width="210" height="116"/><text x="450" y="92">OPERATIONS CORE</text><text class="small" x="450" y="118">TRAVEL · BOOKING</text></g>
<g class="diagram-node"><rect x="720" y="55" width="145" height="90"/><text x="792" y="96">CUSTOMERS</text><text class="small" x="792" y="119">MAIL · WEB</text></g>
<g class="diagram-node"><rect x="35" y="265" width="145" height="90"/><text x="108" y="306">PARTNERS</text><text class="small" x="108" y="329">SUPPLIERS</text></g>
<g class="diagram-node"><rect x="345" y="252" width="210" height="116"/><text x="450" y="302">WORKFLOWS</text><text class="small" x="450" y="328">SCHEDULE · AUDIT</text></g>
<g class="diagram-node"><rect x="720" y="265" width="145" height="90"/><text x="792" y="306">FINANCE</text><text class="small" x="792" y="329">OCR · ACCOUNTING</text></g>
</svg>
}
@case ('multitenant-platform') {
<svg viewBox="0 0 900 410" role="img" aria-label="Multitenant service platform architecture">
<g class="diagram-lines"><path d="M172 205H320M580 205h148M450 126V62M450 284v64"/></g>
<g class="diagram-node"><rect x="34" y="90" width="138" height="95"/><text x="103" y="132">TENANT A</text><text class="small" x="103" y="155">TEAMS · DATA</text><rect x="34" y="225" width="138" height="95"/><text x="103" y="267">TENANT B</text><text class="small" x="103" y="290">TEAMS · DATA</text></g>
<g class="diagram-node accent"><rect x="320" y="126" width="260" height="158"/><text x="450" y="184">SERVICE CORE</text><text class="small" x="450" y="211">TICKETS · WORKLOGS</text><text class="small" x="450" y="234">CONTRACTS · REPORTS</text></g>
<g class="diagram-node"><rect x="728" y="90" width="138" height="95"/><text x="797" y="132">REAL TIME</text><text class="small" x="797" y="155">SIGNALR</text><rect x="728" y="225" width="138" height="95"/><text x="797" y="267">AUTOMATION</text><text class="small" x="797" y="290">JOBS · MCP</text></g>
<g class="diagram-note"><text x="450" y="44">SHARED IDENTITY</text><text x="450" y="374">COMMAND / QUERY BOUNDARY</text></g>
</svg>
}
@case ('research-platform') {
<svg viewBox="0 0 900 410" role="img" aria-label="Biodiversity research platform architecture">
<g class="diagram-lines"><path d="M173 205H314M586 205h141M450 124V62M450 286v62"/></g>
<g class="diagram-node"><rect x="28" y="151" width="145" height="108"/><text x="101" y="194">FIELD WORK</text><text class="small" x="101" y="218">SAMPLES · MEDIA</text></g>
<g class="diagram-node accent"><rect x="314" y="124" width="272" height="162"/><text x="450" y="180">RESEARCH SYSTEM</text><text class="small" x="450" y="208">TAXONOMY · BIOMETRICS</text><text class="small" x="450" y="231">HABITATS · LOCALITIES</text></g>
<g class="diagram-node"><rect x="727" y="151" width="145" height="108"/><text x="800" y="194">GEOSPATIAL</text><text class="small" x="800" y="218">MAPS · POSTGIS</text></g>
<g class="diagram-note"><text x="450" y="44">IDENTITY / WEB</text><text x="450" y="374">CONTAINERS / DELIVERY / LOGS</text></g>
</svg>
}
}
</figure>
`
})
export class SystemDiagramComponent {
@Input({ required: true }) kind!: ProjectDiagramKind;
@Input({ required: true }) title = '';
}

View File

@@ -1,9 +1,8 @@
import { SectionNavItem } from '../models/section-nav.model';
export const MAIN_NAVIGATION: SectionNavItem[] = [
{ label: 'Home', path: '/', fragment: 'home' },
{ label: 'About me', path: '/', fragment: 'about-me' },
{ label: 'About IT', path: '/', fragment: 'about-it' },
{ label: 'CV', path: '/cv' },
{ label: 'Portfolio', path: '/portfolio' }
{ label: 'Work', path: '/', fragment: 'work' },
{ label: 'Profile', path: '/', fragment: 'profile' },
{ label: 'Experience', path: '/', fragment: 'experience' },
{ label: 'CV', path: '/cv' }
];

View File

@@ -1,46 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-section',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section
[id]="id"
class="scroll-mt-24 border-b border-zinc-200/70 last:border-b-0 dark:border-zinc-800/30"
>
<div
class="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-16 md:px-8 md:py-24"
>
<div class="flex flex-col gap-3">
@if (eyebrow) {
<p
class="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-400"
>
{{ eyebrow }}
</p>
}
<div class="flex flex-col gap-3">
<h2 class="text-3xl font-semibold tracking-tight md:text-4xl">
{{ title }}
</h2>
@if (lead) {
<p class="max-w-3xl text-lg text-zinc-600 dark:text-zinc-300">
{{ lead }}
</p>
}
</div>
</div>
<ng-content />
</div>
</section>
`
})
export class SectionComponent {
@Input({ required: true }) id!: string;
@Input({ required: true }) title!: string;
@Input() eyebrow?: string;
@Input() lead?: string;
}

View File

@@ -8,15 +8,10 @@ import { ThemeService, Theme } from '../../../core/services/theme.service';
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="relative flex items-center rounded-full border border-zinc-200/70 bg-[#f8faf7]/70 p-1 backdrop-blur dark:border-slate-800 dark:bg-slate-950/50">
<div class="theme-toggle" aria-label="Color theme">
<button
type="button"
class="relative flex h-7 w-7 items-center justify-center rounded-full text-zinc-500 transition hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:text-slate-400 dark:hover:text-slate-100"
[class.bg-zinc-50]="themeService.theme() === 'light'"
[class.text-emerald-600]="themeService.theme() === 'light'"
[class.shadow-sm]="themeService.theme() === 'light'"
[class.dark:bg-slate-800]="themeService.theme() === 'light'"
[class.dark:text-emerald-400]="themeService.theme() === 'light'"
[class.active]="themeService.theme() === 'light'"
(click)="setTheme('light')"
aria-label="Light theme"
>
@@ -27,12 +22,7 @@ import { ThemeService, Theme } from '../../../core/services/theme.service';
<button
type="button"
class="relative flex h-7 w-7 items-center justify-center rounded-full text-zinc-500 transition hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:text-slate-400 dark:hover:text-slate-100"
[class.bg-zinc-50]="themeService.theme() === 'system'"
[class.text-emerald-600]="themeService.theme() === 'system'"
[class.shadow-sm]="themeService.theme() === 'system'"
[class.dark:bg-slate-800]="themeService.theme() === 'system'"
[class.dark:text-emerald-400]="themeService.theme() === 'system'"
[class.active]="themeService.theme() === 'system'"
(click)="setTheme('system')"
aria-label="System theme"
>
@@ -43,12 +33,7 @@ import { ThemeService, Theme } from '../../../core/services/theme.service';
<button
type="button"
class="relative flex h-7 w-7 items-center justify-center rounded-full text-zinc-500 transition hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:text-slate-400 dark:hover:text-slate-100"
[class.bg-zinc-50]="themeService.theme() === 'dark'"
[class.text-emerald-600]="themeService.theme() === 'dark'"
[class.shadow-sm]="themeService.theme() === 'dark'"
[class.dark:bg-slate-800]="themeService.theme() === 'dark'"
[class.dark:text-emerald-400]="themeService.theme() === 'dark'"
[class.active]="themeService.theme() === 'dark'"
(click)="setTheme('dark')"
aria-label="Dark theme"
>

View File

@@ -2,10 +2,22 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>SWE - Amar Džanan</title>
<title>Amar Džanan — Senior Software Engineer &amp; AI Integration</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Senior full-stack engineer modernizing complex enterprise software and connecting it safely to AI.">
<meta name="theme-color" content="#eee9df">
<link rel="canonical" href="https://dzanan.net/">
<link rel="icon" type="image/x-icon" href="duck.png">
<script>
(function () {
try {
var theme = localStorage.getItem('theme') || 'system';
var dark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
} catch (_) {}
})();
</script>
</head>
<body>
<app-root></app-root>

298
src/styles.scss Executable file → Normal file
View File

@@ -1,54 +1,258 @@
@use 'tailwindcss';
@theme {
--font-saira: 'Saira', sans-serif;
}
@font-face {
font-family: 'Saira';
src: url('/fonts/Saira-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Saira';
src: url('/fonts/Saira-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Saira';
src: url('/fonts/Saira-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Saira';
src: url('/fonts/Saira-SemiBoldItalic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
font-display: swap;
}
@variant dark (&:where(.dark, .dark *));
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
:root {
color-scheme: light;
--paper: #eee9df;
--paper-2: #e5ded2;
--ink: #20211f;
--muted: #66645f;
--line: #bdb5a9;
--accent: #a94728;
--accent-deep: #84371f;
--panel: rgba(249, 246, 239, 0.72);
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html {
scroll-behavior: smooth;
:root.dark {
color-scheme: dark;
--paper: #181817;
--paper-2: #22211f;
--ink: #ebe6dc;
--muted: #a7a198;
--line: #4a4640;
--accent: #dc704c;
--accent-deep: #ef8a67;
--panel: rgba(32, 31, 29, 0.78);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 6rem; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 17px; text-rendering: optimizeLegibility; }
a { color: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
::selection { background: var(--accent); color: #fff8ef; }
.site-frame { min-height: 100vh; background-color: var(--paper); background-image: linear-gradient(rgba(45,42,38,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(45,42,38,.035) 1px, transparent 1px); background-size: 48px 48px; }
.dark .site-frame { background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.02) 1px, transparent 1px); }
.shell { width: min(1180px, calc(100% - 40px)); margin-inline: auto; }
.mono, .eyebrow { font-family: var(--mono); }
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: var(--ink); color: var(--paper); padding: .7rem 1rem; }
.skip-link:focus { top: 1rem; }
.site-header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid #413e39; color: #eee8dc; background: rgba(24, 24, 23, .96); backdrop-filter: blur(14px); }
.header-inner { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 2rem; }
.wordmark { display: flex; align-items: center; gap: .85rem; text-decoration: none; }
.wordmark-mark { color: var(--accent); font: 700 1.25rem/1 var(--mono); letter-spacing: -.08em; }
.wordmark-copy { display: grid; font-weight: 650; font-size: .92rem; line-height: 1.15; letter-spacing: -.01em; }
.wordmark-copy small { margin-top: .25rem; color: #aaa49a; font: 400 .7rem/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
.desktop-nav { display: flex; align-items: center; gap: 1.5rem; }
.desktop-nav nav { display: flex; gap: 1.35rem; }
.desktop-nav nav a, .mobile-nav nav a { color: #bbb4aa; font: 500 .8rem/1 var(--mono); text-decoration: none; text-transform: uppercase; letter-spacing: .045em; }
.desktop-nav nav a:hover, .mobile-nav nav a:hover { color: var(--accent); }
.theme-toggle { display: flex; gap: 2px; border-left: 1px solid #4d4943; padding-left: 1rem; }
.theme-toggle button { width: 29px; height: 29px; border: 0; border-radius: 2px; display: grid; place-items: center; color: #aaa49a; background: transparent; cursor: pointer; }
.theme-toggle button svg { width: 14px; }
.theme-toggle button:hover, .theme-toggle button.active { color: #181817; background: #eee8dc; }
.menu-button { display: none; align-items: center; gap: 1rem; border: 0; background: transparent; color: #eee8dc; font: 500 .8rem/1 var(--mono); text-transform: uppercase; }
.mobile-nav { display: none; border-top: 1px solid #413e39; padding-block: 1rem 1.4rem; }
.mobile-nav nav { display: grid; gap: 1rem; }
.hero { padding-block: clamp(5rem, 11vw, 9.5rem) 2rem; }
.hero-kicker { color: var(--muted); font-size: .78rem; line-height: 1.5; text-transform: uppercase; letter-spacing: .065em; }
.hero-kicker span { display: inline-block; margin-right: 1rem; color: var(--accent); }
.hero-grid { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(260px, .65fr); align-items: end; gap: clamp(3rem, 8vw, 7rem); margin-top: 2.2rem; }
.hero h1 { max-width: 850px; margin: 0; font-size: clamp(3.1rem, 7vw, 6.9rem); line-height: .94; letter-spacing: -.068em; font-weight: 600; text-wrap: balance; }
.hero h1::first-letter { color: var(--accent); }
.hero-aside { padding-bottom: .5rem; }
.hero-aside p { margin: 0; color: var(--muted); font-size: 1.08rem; line-height: 1.7; }
.action-row { display: flex; align-items: center; gap: 1.4rem; margin-top: 2rem; flex-wrap: wrap; }
.button { display: inline-flex; align-items: center; justify-content: space-between; gap: 1.4rem; min-height: 50px; padding: 0 1.15rem; border: 1px solid currentColor; font: 600 .78rem/1 var(--mono); letter-spacing: .03em; text-decoration: none; text-transform: uppercase; }
.button-primary { background: var(--ink); border-color: var(--ink); color: var(--paper); }
.button-primary:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.text-link { color: var(--ink); font: 600 .78rem/1.4 var(--mono); text-transform: uppercase; text-decoration-color: var(--accent); text-underline-offset: .35rem; }
.text-link:hover { color: var(--accent); }
.hero-foot { display: flex; justify-content: space-between; gap: 2rem; margin-top: clamp(5rem, 10vw, 9rem); padding-top: 1rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .72rem; line-height: 1.5; text-transform: uppercase; letter-spacing: .045em; }
.section-block { padding-block: clamp(5rem, 9vw, 8rem); }
.section-heading { display: grid; grid-template-columns: 1.5fr .75fr; gap: clamp(3rem, 8vw, 8rem); align-items: end; margin-bottom: 3.5rem; }
.section-heading.compact { grid-template-columns: 1fr auto; }
.eyebrow { margin: 0 0 1.1rem; color: var(--accent); font-size: .75rem; line-height: 1.45; letter-spacing: .07em; text-transform: uppercase; }
.section-heading h2, .profile-band h2, .capabilities h2, .contact-block h2 { max-width: 760px; margin: 0; font-size: clamp(2.2rem, 4.4vw, 4.4rem); line-height: 1; letter-spacing: -.052em; font-weight: 580; text-wrap: balance; }
.section-heading > p { color: var(--muted); margin: 0; font-size: 1.05rem; line-height: 1.7; }
.project-index { border-top: 1px solid var(--ink); }
.project-row { display: grid; grid-template-columns: 58px minmax(0, 1fr) 235px 28px; gap: 1.25rem; align-items: start; padding: 2rem 0; border-bottom: 1px solid var(--line); text-decoration: none; transition: color .18s ease, padding .18s ease; }
.project-row:hover { color: var(--accent); padding-left: .75rem; }
.project-number, .project-meta { color: var(--muted); font-size: .72rem; line-height: 1.65; text-transform: uppercase; }
.project-main { display: grid; gap: .65rem; }
.project-main strong { font-size: clamp(1.35rem, 2.1vw, 2rem); letter-spacing: -.03em; font-weight: 590; }
.project-main small { max-width: 690px; color: var(--muted); font-size: .98rem; line-height: 1.58; }
.project-arrow { font-size: 1.2rem; color: var(--accent); text-align: right; }
.profile-band { padding-block: clamp(4rem, 7vw, 6.5rem); color: #eee8dc; background: #252522; }
.dark .profile-band { color: #eee8dc; background: #2a2926; }
.profile-grid { display: grid; grid-template-columns: 180px minmax(0, 1.25fr) minmax(270px, .65fr); gap: clamp(2rem, 5vw, 5rem); align-items: center; }
.profile-photo { width: 100%; aspect-ratio: 1; object-fit: cover; filter: grayscale(1) contrast(1.06); border-radius: 50%; }
.profile-lead { max-width: 720px; margin: 1.6rem 0 0; color: #bcb6ac; font-size: 1.05rem; line-height: 1.65; }
.profile-notes { border-left: 1px solid #595650; padding-left: 2rem; color: #c8c1b7; line-height: 1.65; font-size: 1rem; }
.profile-notes a { display: inline-block; margin-top: 1rem; color: #eee8dc; font: 600 .76rem/1 var(--mono); text-transform: uppercase; text-underline-offset: .35rem; }
.experience-list { border-top: 1px solid var(--ink); }
.experience-row { display: grid; grid-template-columns: 180px 1fr 1.35fr; gap: 2rem; padding: 1.6rem 0; border-bottom: 1px solid var(--line); align-items: start; }
.experience-row time { color: var(--accent); font-size: .74rem; line-height: 1.45; }
.experience-row h3 { margin: 0; font-size: 1.08rem; font-weight: 650; }
.experience-row div p, .experience-row > p { margin: .35rem 0 0; color: var(--muted); font-size: .94rem; line-height: 1.6; }
.experience-row > p { margin-top: 0; }
.capabilities { display: grid; grid-template-columns: .8fr 1.4fr; gap: clamp(3rem, 8vw, 8rem); border-top: 1px solid var(--line); }
.capability-grid { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--ink); }
.capability-grid article { padding: 1.5rem; border-right: 1px solid var(--line); }
.capability-grid article:last-child { border-right: 0; }
.capability-grid span { color: var(--accent); font-size: .74rem; }
.capability-grid h3 { margin: 3rem 0 .75rem; font-size: 1.08rem; }
.capability-grid p { margin: 0; color: var(--muted); font-size: .94rem; line-height: 1.65; }
.contact-block { margin-bottom: 5rem; padding: clamp(3rem, 7vw, 6rem); color: #f0eadd; background: var(--accent-deep); }
.contact-block .eyebrow { color: #f4b19b; }
.contact-block h2 { max-width: 900px; }
.contact-actions { display: flex; justify-content: space-between; align-items: end; gap: 2rem; margin-top: 3rem; }
.button-light { color: #f0eadd; }
.button-light:hover { color: var(--accent-deep); background: #f0eadd; }
.contact-actions > span { color: #e0aa98; font-size: .74rem; text-transform: uppercase; }
.site-footer { border-top: 1px solid var(--line); }
.footer-inner { display: flex; justify-content: space-between; gap: 1rem; padding-block: 1.7rem; color: var(--muted); font: 400 .72rem/1.6 var(--mono); text-transform: uppercase; }
.footer-inner a { text-underline-offset: .25rem; }
.case-hero { padding-block: 3rem clamp(5rem, 10vw, 9rem); }
.breadcrumb { display: flex; gap: .7rem; color: var(--muted); font-size: .74rem; text-transform: uppercase; }
.breadcrumb a { text-underline-offset: .25rem; }
.case-heading { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(270px, .55fr); gap: clamp(3rem, 8vw, 7rem); align-items: end; margin-top: clamp(4rem, 9vw, 8rem); }
.case-heading h1 { max-width: 850px; margin: 0; font-size: clamp(3rem, 6.6vw, 6.7rem); font-weight: 590; line-height: .94; letter-spacing: -.065em; text-wrap: balance; }
.case-intro > p { margin: 0; color: var(--muted); font-size: 1.08rem; line-height: 1.72; }
.case-intro dl { margin: 2.2rem 0 0; border-top: 1px solid var(--line); }
.case-intro dl div { display: grid; grid-template-columns: 70px 1fr; gap: 1rem; padding: .85rem 0; border-bottom: 1px solid var(--line); }
.case-intro dt { color: var(--muted); font: 400 .71rem/1.5 var(--mono); text-transform: uppercase; }
.case-intro dd { margin: 0; font-size: .88rem; }
.case-section { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: clamp(2rem, 6vw, 6rem); padding-block: clamp(4rem, 8vw, 7rem); border-top: 1px solid var(--line); }
.case-index { margin: .45rem 0 0; color: var(--accent); font-size: .72rem; line-height: 1.45; text-transform: uppercase; }
.case-section h2 { margin: 0 0 2.2rem; font-size: clamp(2rem, 3.5vw, 3.5rem); line-height: 1; letter-spacing: -.045em; font-weight: 580; }
.large-copy { max-width: 900px; margin: 0; font-size: clamp(1.35rem, 2.25vw, 2rem); line-height: 1.48; letter-spacing: -.025em; }
.contribution-list { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--ink); }
.contribution-list li { display: grid; grid-template-columns: 52px 1fr; gap: 1rem; padding: 1.3rem 0; border-bottom: 1px solid var(--line); }
.contribution-list span, .decision-grid span { padding-top: .2rem; color: var(--accent); font-size: .72rem; }
.contribution-list p { max-width: 850px; margin: 0; font-size: 1rem; line-height: 1.62; }
.diagram-wrap { padding-block: clamp(4rem, 7vw, 6rem); background: #262624; color: #eee8dc; }
.system-diagram { margin: 0; }
.system-diagram figcaption { display: flex; align-items: baseline; justify-content: space-between; gap: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #55514b; }
.system-diagram figcaption span { color: #d87958; font-size: .72rem; text-transform: uppercase; }
.system-diagram figcaption strong { color: #bbb4aa; font-size: .8rem; font-weight: 400; }
.system-diagram svg { display: block; width: 100%; height: auto; margin-top: 1.7rem; }
.diagram-lines { fill: none; stroke: #777169; stroke-width: 1; }
.diagram-node rect { fill: #2d2d2a; stroke: #6b665f; stroke-width: 1; }
.diagram-node text { fill: #e8e1d5; font: 600 15px var(--mono); text-anchor: middle; letter-spacing: .07em; }
.diagram-node text.small { fill: #bdb5aa; font-size: 12px; font-weight: 400; }
.diagram-node.accent rect { fill: #803920; stroke: #d87958; }
.diagram-note text { fill: #df815f; font: 400 11px var(--mono); text-anchor: middle; letter-spacing: .08em; }
.decision-grid { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid var(--ink); }
.decision-grid article { min-height: 220px; padding: 1.4rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.decision-grid article:nth-child(even) { border-right: 0; }
.decision-grid h3 { margin: 2.5rem 0 .7rem; font-size: 1.15rem; }
.decision-grid p { margin: 0; color: var(--muted); font-size: .96rem; line-height: 1.65; }
.outcome { border-bottom: 1px solid var(--line); }
.case-stack { display: grid; grid-template-columns: 180px 1fr; gap: clamp(2rem, 6vw, 6rem); padding-block: 2rem; }
.case-stack ul { display: flex; flex-wrap: wrap; gap: .55rem; list-style: none; padding: 0; margin: 0; }
.case-stack li { padding: .6rem .75rem; border: 1px solid var(--line); font: 500 .73rem/1 var(--mono); text-transform: uppercase; }
.case-pagination { display: grid; grid-template-columns: 1fr 1fr; margin-block: clamp(4rem, 8vw, 7rem); border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
.case-pagination a { display: grid; gap: 1rem; min-height: 150px; padding: 1.5rem 0; text-decoration: none; }
.case-pagination a + a { border-left: 1px solid var(--line); padding-left: 1.5rem; text-align: right; }
.case-pagination span { color: var(--accent); font-size: .72rem; text-transform: uppercase; }
.case-pagination strong { max-width: 400px; font-size: clamp(1rem, 2vw, 1.5rem); line-height: 1.2; letter-spacing: -.025em; }
.case-pagination a:last-child strong { justify-self: end; }
.case-pagination a:hover strong { color: var(--accent); }
.case-contact .text-link.light { align-self: center; color: #f0eadd; text-decoration-color: #f4b19b; }
.cv-hero { padding-block: 3rem clamp(5rem, 10vw, 9rem); }
.cv-heading { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(250px, .5fr); gap: clamp(3rem, 9vw, 8rem); align-items: end; margin-top: clamp(4rem, 9vw, 8rem); }
.cv-heading h1 { max-width: 900px; margin: 0; font-size: clamp(3rem, 6.6vw, 6.7rem); line-height: .94; letter-spacing: -.065em; font-weight: 590; text-wrap: balance; }
.cv-contact { display: grid; gap: .8rem; }
.cv-contact p { margin: 0 0 1rem; color: var(--muted); font-size: 1rem; line-height: 1.65; }
.cv-contact a { width: fit-content; font: 600 .76rem/1.4 var(--mono); text-transform: uppercase; text-underline-offset: .3rem; }
.cv-section { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: clamp(2rem, 6vw, 6rem); padding-block: clamp(4rem, 8vw, 7rem); border-top: 1px solid var(--line); }
.cv-section h2 { margin: 0 0 2.5rem; font-size: clamp(2rem, 3.5vw, 3.5rem); line-height: 1; letter-spacing: -.045em; font-weight: 580; }
.cv-roles { border-top: 1px solid var(--ink); }
.cv-roles article { display: grid; grid-template-columns: 1fr 1.2fr; gap: 2rem; padding: 1.6rem 0; border-bottom: 1px solid var(--line); }
.cv-role-head { display: grid; grid-template-columns: 145px 1fr; gap: 1.2rem; }
.cv-role-head time { color: var(--accent); font-size: .72rem; line-height: 1.45; }
.cv-role-head h3 { margin: 0; font-size: 1.05rem; }
.cv-role-head p, .cv-roles article > p { margin: .35rem 0 0; color: var(--muted); font-size: .94rem; line-height: 1.65; }
.cv-roles article > p { margin-top: 0; }
.cv-skill-grid { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid var(--ink); }
.cv-skill-grid article { min-height: 150px; padding: 1.4rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.cv-skill-grid article:nth-child(even) { border-right: 0; }
.cv-skill-grid h3 { margin: 0 0 2rem; font-size: 1.02rem; }
.cv-skill-grid p { margin: 0; color: var(--muted); font-size: .93rem; line-height: 1.65; }
.course-list { border-top: 1px solid var(--ink); }
.course-list article { display: grid; grid-template-columns: 120px 1fr 160px; gap: 1.2rem; padding: 1rem 0; border-bottom: 1px solid var(--line); align-items: baseline; }
.course-list time { color: var(--accent); font-size: .72rem; }
.course-list h3 { margin: 0; font-size: .96rem; }
.course-list p { margin: 0; color: var(--muted); font-size: .86rem; text-align: right; }
.not-found { min-height: calc(100vh - 160px); display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding-block: 5rem; }
.not-found h1 { max-width: 850px; margin: 0; font-size: clamp(4rem, 12vw, 10rem); line-height: .88; letter-spacing: -.075em; font-weight: 590; }
.not-found > p:not(.eyebrow) { max-width: 500px; margin: 2rem 0; color: var(--muted); line-height: 1.65; }
@media (max-width: 900px) {
.desktop-nav { display: none; }
.menu-button { display: flex; }
.mobile-nav { display: flex; justify-content: space-between; }
.hero-grid, .section-heading, .profile-grid, .capabilities, .case-heading, .cv-heading { grid-template-columns: 1fr; }
.hero-aside { max-width: 580px; }
.profile-photo { width: 130px; }
.profile-notes { max-width: 650px; }
.project-row { grid-template-columns: 45px 1fr 25px; }
.project-meta { display: none; }
.experience-row { grid-template-columns: 140px 1fr; }
.experience-row > p { grid-column: 2; }
.case-section { grid-template-columns: 120px 1fr; }
.cv-section { grid-template-columns: 120px 1fr; }
.cv-roles article { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
.shell { width: min(100% - 28px, 1180px); }
.header-inner { min-height: 68px; }
.wordmark-copy small { display: none; }
.hero { padding-top: 4.5rem; }
.hero h1 { font-size: clamp(2.75rem, 14vw, 4.2rem); }
.hero-foot { align-items: flex-start; flex-direction: column; margin-top: 4rem; }
.section-heading { gap: 1.3rem; margin-bottom: 2.5rem; }
.section-heading.compact { grid-template-columns: 1fr; }
.project-row { grid-template-columns: 32px 1fr 22px; gap: .75rem; padding: 1.5rem 0; }
.project-main small { display: none; }
.profile-notes { border-left: 0; border-top: 1px solid #595650; padding: 1.5rem 0 0; }
.experience-row { grid-template-columns: 1fr; gap: .65rem; }
.experience-row > p { grid-column: 1; }
.capability-grid { grid-template-columns: 1fr; }
.capability-grid article { border-right: 0; border-bottom: 1px solid var(--line); padding-inline: 0; }
.capability-grid h3 { margin-top: 1.7rem; }
.contact-block { width: 100%; margin-bottom: 0; }
.contact-actions, .footer-inner { align-items: flex-start; flex-direction: column; }
.case-section, .case-stack { grid-template-columns: 1fr; gap: 1.5rem; }
.cv-section { grid-template-columns: 1fr; gap: 1.5rem; }
.decision-grid { grid-template-columns: 1fr; }
.decision-grid article { min-height: auto; padding-inline: 0; border-right: 0; }
.case-pagination { grid-template-columns: 1fr; }
.case-pagination a { min-height: 120px; }
.case-pagination a + a { border-top: 1px solid var(--line); border-left: 0; padding-left: 0; }
.cv-role-head { grid-template-columns: 1fr; gap: .65rem; }
.cv-skill-grid { grid-template-columns: 1fr; }
.cv-skill-grid article { min-height: auto; padding-inline: 0; border-right: 0; }
.course-list article { grid-template-columns: 1fr; gap: .4rem; }
.course-list p { text-align: left; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}