This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
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.Server,
|
||||
status: 404
|
||||
}
|
||||
];
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{ path: '', renderMode: RenderMode.Prerender },
|
||||
{ path: 'work', renderMode: RenderMode.Prerender },
|
||||
{ path: 'experience', renderMode: RenderMode.Prerender },
|
||||
{ path: 'cv', renderMode: RenderMode.Prerender },
|
||||
{ path: 'contact', renderMode: RenderMode.Prerender },
|
||||
{ path: 'work/secure-ai-integration-gateway', renderMode: RenderMode.Prerender },
|
||||
{ path: 'work/field-sales-mobile-platform', 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.Server,
|
||||
status: 404
|
||||
}
|
||||
];
|
||||
|
||||
@@ -4,8 +4,12 @@ describe('app routes', () => {
|
||||
it('registers every public portfolio route', () => {
|
||||
expect(routes.map(route => route.path)).toEqual([
|
||||
'',
|
||||
'work',
|
||||
'experience',
|
||||
'cv',
|
||||
'contact',
|
||||
'work/secure-ai-integration-gateway',
|
||||
'work/field-sales-mobile-platform',
|
||||
'work/travel-operations-platform',
|
||||
'work/service-operations-platform',
|
||||
'work/biodiversity-data-platform',
|
||||
@@ -28,6 +32,7 @@ describe('app routes', () => {
|
||||
|
||||
expect(projectIds).toEqual([
|
||||
'ai-integration',
|
||||
'field-sales',
|
||||
'travel-operations',
|
||||
'service-operations',
|
||||
'biodiversity-data'
|
||||
|
||||
@@ -6,15 +6,32 @@ export const routes: Routes = [
|
||||
path: '',
|
||||
component: LandingPageComponent
|
||||
},
|
||||
{
|
||||
path: 'work',
|
||||
loadComponent: () => import('./features/work/work-page.component').then(m => m.WorkPageComponent)
|
||||
},
|
||||
{
|
||||
path: 'experience',
|
||||
loadComponent: () => import('./features/experience/experience-page.component').then(m => m.ExperiencePageComponent)
|
||||
},
|
||||
{
|
||||
path: 'cv',
|
||||
loadComponent: () => import('./features/cv/cv-page.component').then(m => m.CvPageComponent)
|
||||
},
|
||||
{
|
||||
path: 'contact',
|
||||
loadComponent: () => import('./features/contact/contact-page.component').then(m => m.ContactPageComponent)
|
||||
},
|
||||
{
|
||||
path: 'work/secure-ai-integration-gateway',
|
||||
data: { projectId: 'ai-integration' },
|
||||
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
|
||||
},
|
||||
{
|
||||
path: 'work/field-sales-mobile-platform',
|
||||
data: { projectId: 'field-sales' },
|
||||
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
|
||||
},
|
||||
{
|
||||
path: 'work/travel-operations-platform',
|
||||
data: { projectId: 'travel-operations' },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { ThemeService } from './core/services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -9,6 +8,4 @@ import { ThemeService } from './core/services/theme.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './app.html'
|
||||
})
|
||||
export class App {
|
||||
private readonly themeService = inject(ThemeService);
|
||||
}
|
||||
export class App {}
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { SectionNavItem } from '../../shared/models/section-nav.model';
|
||||
import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, ThemeToggleComponent],
|
||||
imports: [RouterLink, RouterLinkActive],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<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>
|
||||
<a routerLink="/" class="wordmark" aria-label="Amar Džanan, home"><span class="wordmark-tilde">~/</span>dzanan.net</a>
|
||||
|
||||
<div class="desktop-nav">
|
||||
<nav aria-label="Primary navigation">
|
||||
@for (section of sections; track section.label) {
|
||||
<a [routerLink]="section.path || []" [fragment]="section.fragment">{{ section.label }}</a>
|
||||
<a [routerLink]="section.path || []" [fragment]="section.fragment" routerLinkActive="active">{{ section.label }}</a>
|
||||
}
|
||||
</nav>
|
||||
<app-theme-toggle />
|
||||
<span class="status"><span class="status-dot" aria-hidden="true"></span>available</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span>menu</span><span aria-hidden="true">{{ menuOpen() ? '×' : '+' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (menuOpen()) {
|
||||
@@ -39,7 +34,6 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
|
||||
<a [routerLink]="section.path || []" [fragment]="section.fragment" (click)="closeMenu()">{{ section.label }}</a>
|
||||
}
|
||||
</nav>
|
||||
<app-theme-toggle />
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
@@ -48,8 +42,8 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
|
||||
</main>
|
||||
<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@dzanan.net</a><span aria-hidden="true"> / </span><a href="https://www.linkedin.com/in/amardzanan/">LinkedIn</a></div>
|
||||
<span>Amar Džanan · Ljubljana, Slovenia</span>
|
||||
<span><a href="mailto:amar@dzanan.net">amar@dzanan.net</a> · <a href="https://www.linkedin.com/in/amardzanan/" target="_blank" rel="noopener">LinkedIn</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Injectable, signal, effect, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
|
||||
readonly theme = signal<Theme>('system');
|
||||
|
||||
constructor() {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
// Load saved theme
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme) {
|
||||
this.theme.set(savedTheme);
|
||||
}
|
||||
|
||||
// Apply theme effect
|
||||
effect(() => {
|
||||
const currentTheme = this.theme();
|
||||
const root = window.document.documentElement;
|
||||
|
||||
localStorage.setItem('theme', currentTheme);
|
||||
|
||||
if (currentTheme === 'system') {
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (systemDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
} else if (currentTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for system changes if in system mode
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (this.theme() === 'system') {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(theme: Theme) {
|
||||
this.theme.set(theme);
|
||||
}
|
||||
}
|
||||
45
src/app/features/contact/contact-page.component.ts
Normal file
45
src/app/features/contact/contact-page.component.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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-contact-page',
|
||||
standalone: true,
|
||||
imports: [PageShellComponent, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-page-shell [sections]="sections">
|
||||
<header class="page-head shell">
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>contact</span></nav>
|
||||
<span class="label">// contact</span>
|
||||
<h1>Need a senior engineer who can understand the system before changing it?</h1>
|
||||
<p class="lead">Email is the fastest way to reach me. I'm open to senior engineering roles and consulting across Europe.</p>
|
||||
</header>
|
||||
|
||||
<section class="section tight shell" aria-label="Contact details">
|
||||
<div class="panel contact">
|
||||
<div class="contact-actions"><a class="btn btn-accent" href="mailto:amar@dzanan.net">amar@dzanan.net</a><span class="meta">Ljubljana · CET / CEST</span></div>
|
||||
<dl class="contact-details">
|
||||
<div><dt>email</dt><dd><a href="mailto:amar@dzanan.net">amar@dzanan.net</a></dd></div>
|
||||
<div><dt>linkedin</dt><dd><a href="https://www.linkedin.com/in/amardzanan/" target="_blank" rel="noopener">linkedin.com/in/amardzanan</a></dd></div>
|
||||
<div><dt>location</dt><dd>Ljubljana, Slovenia — working across Europe</dd></div>
|
||||
<div><dt>cv</dt><dd><a routerLink="/cv">cv.pdf →</a></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</app-page-shell>
|
||||
`
|
||||
})
|
||||
export class ContactPageComponent {
|
||||
readonly sections = MAIN_NAVIGATION;
|
||||
|
||||
constructor() {
|
||||
inject(SeoService).update({
|
||||
title: 'Contact — Amar Džanan',
|
||||
description: 'Get in touch with Amar Džanan, senior full-stack engineer in Ljubljana, for senior engineering roles and consulting across Europe.',
|
||||
path: '/contact'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { CvPageComponent } from './cv-page.component';
|
||||
|
||||
describe('CvPageComponent', () => {
|
||||
it('renders a complete, contactable CV with curated learning', async () => {
|
||||
it('embeds the PDF and offers a download link', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CvPageComponent],
|
||||
providers: [provideRouter([])]
|
||||
@@ -13,13 +13,7 @@ describe('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();
|
||||
expect(page.querySelector('object[data="/cv.pdf"]')).toBeTruthy();
|
||||
expect(page.querySelector('a[href="/cv.pdf"][download]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { CV_URL, MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cv-page',
|
||||
@@ -11,93 +11,41 @@ import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-page-shell [sections]="sections">
|
||||
<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@dzanan.net ↗</a>
|
||||
<a href="https://www.linkedin.com/in/amardzanan/">LinkedIn ↗</a>
|
||||
</div>
|
||||
<header class="page-head shell">
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>cv.pdf</span></nav>
|
||||
<span class="label">// curriculum vitae</span>
|
||||
<h1>Amar Džanan — CV</h1>
|
||||
<p class="lead">Read it here, or take the PDF with you.</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-accent" [href]="cvUrl" download="Amar-Dzanan-CV.pdf">download cv.pdf
|
||||
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M8 2v9M4.5 7.5 8 11l3.5-3.5M3 13.5h10"/></svg>
|
||||
</a>
|
||||
<a class="btn btn-line" [href]="cvUrl" target="_blank" rel="noopener">open in new tab ↗</a>
|
||||
</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>
|
||||
<section class="section tight shell" aria-label="CV document">
|
||||
<div class="panel cv-frame">
|
||||
<div class="terminal-bar"><div class="terminal-dots" aria-hidden="true"><span></span><span></span><span></span></div><span class="terminal-title">cv.pdf</span><span class="terminal-spacer"></span></div>
|
||||
<object data="/cv.pdf" type="application/pdf" aria-label="Amar Džanan CV (PDF)">
|
||||
<div class="cv-fallback">
|
||||
<p>Your browser doesn't display PDFs inline.</p>
|
||||
<a class="btn btn-accent" [href]="cvUrl" download="Amar-Dzanan-CV.pdf">download cv.pdf</a>
|
||||
</div>
|
||||
</object>
|
||||
</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@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' }
|
||||
];
|
||||
readonly cvUrl = CV_URL;
|
||||
|
||||
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.',
|
||||
description: 'Curriculum vitae of Amar Džanan: senior full-stack engineer and product owner working with .NET, Angular, and AI integration.',
|
||||
path: '/cv'
|
||||
});
|
||||
}
|
||||
|
||||
80
src/app/features/experience/experience-page.component.ts
Normal file
80
src/app/features/experience/experience-page.component.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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 { SeoService } from '../../core/services/seo.service';
|
||||
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||
|
||||
@Component({
|
||||
selector: 'app-experience-page',
|
||||
standalone: true,
|
||||
imports: [PageShellComponent, NgOptimizedImage, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-page-shell [sections]="sections">
|
||||
<header class="page-head shell">
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>experience</span></nav>
|
||||
<span class="label">// experience</span>
|
||||
<h1>Eight years, one product at a time.</h1>
|
||||
</header>
|
||||
|
||||
<section class="section tight shell experience" aria-labelledby="about-title">
|
||||
<div class="experience-intro">
|
||||
<img ngSrc="/images/profile.jpg" width="96" height="96" alt="Portrait of Amar Džanan" class="portrait" />
|
||||
<h2 id="about-title">Engineering judgment across the whole product.</h2>
|
||||
<p>Senior software engineer and product owner — most useful where business rules, legacy systems and new capabilities meet. I turn incomplete requirements into maintainable paths through the UI, domain model, integrations, deployment and operations.</p>
|
||||
<p>I build full-stack systems end to end: ERP and business platforms in .NET and Angular, offline-first mobile apps in Ionic and .NET MAUI with the realtime backends behind them, rule-driven expert systems, and — most recently — narrow AI interfaces with clear trust boundaries.</p>
|
||||
<a class="text-link" href="https://www.linkedin.com/in/amardzanan/" target="_blank" rel="noopener">LinkedIn ↗</a>
|
||||
</div>
|
||||
<div class="roles">
|
||||
@for (role of experience; track role.period + role.title) {
|
||||
<article class="role"><time class="mono">{{ role.period }}</time><div><h4>{{ role.title }}</h4><span class="company">{{ role.company }}</span></div><p>{{ role.focus }}</p></article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section tight shell" aria-labelledby="capabilities-title">
|
||||
<div class="section-head"><div><span class="label">// capabilities</span><h2 id="capabilities-title">From uncertain problem to running system.</h2></div></div>
|
||||
<div class="capabilities">
|
||||
@for (capability of capabilities; track capability.number) {
|
||||
<article class="panel capability"><span class="index">{{ capability.number }}</span><h3>{{ capability.title }}</h3><p>{{ capability.detail }}</p></article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section tight shell" aria-label="Next steps">
|
||||
<div class="hero-actions"><a class="btn btn-line" routerLink="/cv">cv.pdf →</a><a class="btn btn-line" routerLink="/work">selected work →</a></div>
|
||||
</section>
|
||||
</app-page-shell>
|
||||
`
|
||||
})
|
||||
export class ExperiencePageComponent {
|
||||
readonly sections = MAIN_NAVIGATION;
|
||||
readonly experience = [
|
||||
{ period: 'Aug 2026 — now', company: 'Unija Smart Accounting Slovenija · Ljubljana', title: 'Full Stack Engineer', focus: 'End-to-end delivery across Angular interfaces, .NET services, data and integrations.' },
|
||||
{ period: 'Aug 2026 — now', company: 'Unija Smart Accounting Slovenija · Škofja Loka', title: 'Senior Software Engineer', focus: 'Architecture and modernization of enterprise accounting platforms.' },
|
||||
{ period: 'Aug 2026 — now', company: 'Unija Smart Accounting Slovenija · Škofja Loka', title: 'AI & LLM Integration', focus: 'AI libraries spanning multiple LLM providers, remote MCP servers for custom models, API integration and optimization for AI-powered applications.' },
|
||||
{ period: 'Nov 2025 — Aug 2026', company: 'Unija Smart Accounting BiH', title: 'AI & LLM Integration', focus: 'AI libraries integrating other LLM providers; design and implementation of remote MCP servers, including the production HRM & CRM gateway.' },
|
||||
{ period: 'Aug 2025 — Aug 2026', company: 'Unija Smart Accounting BiH · Sarajevo', title: 'Senior Software Developer', focus: 'Enterprise platform modernization, full-stack workflows and operational automation.' },
|
||||
{ period: 'Jan 2025 — Apr 2025', company: 'Teloptic', title: 'DevOps Engineer', focus: 'Hybrid Windows Server 2022/2025 infrastructure, high-availability clusters, deployment pipelines for C# and Angular applications, Cloudflare.' },
|
||||
{ period: 'Oct 2024 — Feb 2025', company: 'RoyalFood', title: 'DevOps Engineer', focus: 'Windows Server environments, GitHub and Jenkins CI/CD, backend and frontend deployments, backups and Cloudflare.' },
|
||||
{ period: 'Aug 2023 — Aug 2025', company: 'COMP-2000', title: 'Product Owner', focus: 'Product direction for the field sales mobile app: roadmap, backlog, sprint reviews, retrospectives, user feedback and KPIs.' },
|
||||
{ period: 'Jan 2023 — Aug 2025', company: 'COMP-2000', title: 'DevOps Engineer', focus: 'A fleet of 10+ Linux VPS instances: hardening, automated backups, monitoring, Docker and Podman.' },
|
||||
{ period: 'Nov 2020 — Aug 2025', company: 'COMP-2000', title: 'Software Developer', focus: 'Design, implementation and support across .NET, Blazor, Angular, Ionic, Web API, SQL Server and .NET MAUI.' },
|
||||
{ period: 'Nov 2018 — Nov 2020', company: 'COMP-2000', title: 'Junior Software Developer', focus: 'C#, object-oriented design, SQL, Windows Forms, WPF, Xamarin Forms and Angular foundations.' }
|
||||
];
|
||||
readonly capabilities = [
|
||||
{ number: '01', title: 'ERP & business systems', detail: 'Carry orders, documents, pricing rules and accounting hooks through interfaces, APIs, domain rules, persistence, integrations and tests.' },
|
||||
{ number: '02', title: 'Mobile & field systems', detail: 'Offline-first Ionic and .NET MAUI apps with local replicas, sync queues, barcode and GPS — and the realtime backends behind them.' },
|
||||
{ number: '03', title: 'System modernization', detail: 'Upgrade frameworks, identity, routing, delivery and diagnostics without losing working domain knowledge.' },
|
||||
{ number: '04', title: 'Expert systems & safe AI', detail: 'Encode business expertise as rule-driven systems, and expose capabilities to AI through narrow tools, validated boundaries and verified writes.' }
|
||||
];
|
||||
|
||||
constructor() {
|
||||
inject(SeoService).update({
|
||||
title: 'Experience — Amar Džanan',
|
||||
description: 'Roles and capabilities of senior full-stack engineer and product owner Amar Džanan: .NET, Angular, Ionic, ERP and expert systems, modernization, and safe AI integration.',
|
||||
path: '/experience'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { LandingPageComponent } from './landing-page.component';
|
||||
|
||||
describe('LandingPageComponent', () => {
|
||||
it('leads with the approved positioning and four case studies', async () => {
|
||||
async function render(): Promise<HTMLElement> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LandingPageComponent],
|
||||
providers: [provideRouter([])]
|
||||
@@ -11,34 +11,22 @@ describe('LandingPageComponent', () => {
|
||||
|
||||
const fixture = TestBed.createComponent(LandingPageComponent);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.nativeElement as HTMLElement;
|
||||
return 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('leads with the positioning and the profile terminal', async () => {
|
||||
const page = await render();
|
||||
|
||||
expect(page.querySelector('h1')?.textContent).toContain('Complex systems, understood before changed.');
|
||||
expect(page.querySelector('[data-testid="profile-terminal"]')?.textContent).toContain('"open_to"');
|
||||
});
|
||||
|
||||
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;
|
||||
it('indexes every section page and offers direct contact', async () => {
|
||||
const page = await render();
|
||||
|
||||
for (const href of ['/work', '/experience', '/cv', '/contact']) {
|
||||
expect(page.querySelector(`[data-testid="index-row"][href="${href}"]`)).withContext(href).toBeTruthy();
|
||||
}
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,116 +1,104 @@
|
||||
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 { SeoService } from '../../core/services/seo.service';
|
||||
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||
import { PORTFOLIO_PROJECTS } from '../work/portfolio-projects.data';
|
||||
import { MAIN_NAVIGATION, PAGE_INDEX } from '../../shared/config/navigation.config';
|
||||
|
||||
type ProfileValue = string | number | readonly string[];
|
||||
type TokenClass = 'k' | 's' | 'n' | 'p';
|
||||
interface TerminalToken { readonly cls: TokenClass; readonly text: string; }
|
||||
interface TerminalLine { readonly tokens: readonly TerminalToken[]; }
|
||||
|
||||
/** The `profile.json` shown in the hero. Order is render order. */
|
||||
const PROFILE: readonly (readonly [string, ProfileValue])[] = [
|
||||
['name', 'Amar Džanan'],
|
||||
['role', 'Senior Full-Stack Engineer · Product Owner'],
|
||||
['stack', ['.NET', 'Angular', 'Ionic', 'SQL Server']],
|
||||
['focus', ['ERP & business systems', 'mobile', 'expert systems', 'safe AI integration']],
|
||||
['since', 2018],
|
||||
['location', 'Ljubljana · CET'],
|
||||
['open_to', ['senior roles', 'consulting']]
|
||||
];
|
||||
|
||||
function toTerminalLines(profile: readonly (readonly [string, ProfileValue])[]): TerminalLine[] {
|
||||
return profile.map(([key, value], index) => {
|
||||
const tokens: TerminalToken[] = [{ cls: 'k', text: `"${key}"` }, { cls: 'p', text: ': ' }];
|
||||
if (typeof value === 'number') {
|
||||
tokens.push({ cls: 'n', text: String(value) });
|
||||
} else if (typeof value === 'string') {
|
||||
tokens.push({ cls: 's', text: `"${value}"` });
|
||||
} else {
|
||||
tokens.push({ cls: 'p', text: '[' });
|
||||
value.forEach((item, itemIndex) => {
|
||||
if (itemIndex > 0) tokens.push({ cls: 'p', text: ', ' });
|
||||
tokens.push({ cls: 's', text: `"${item}"` });
|
||||
});
|
||||
tokens.push({ cls: 'p', text: ']' });
|
||||
}
|
||||
if (index < profile.length - 1) tokens.push({ cls: 'p', text: ',' });
|
||||
return { tokens };
|
||||
});
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-landing-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
NgOptimizedImage,
|
||||
RouterLink
|
||||
],
|
||||
imports: [PageShellComponent, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-page-shell [sections]="sections">
|
||||
<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 class="hero-copy">
|
||||
<p class="label">Senior full-stack engineer<span class="label-extra"> · product owner</span></p>
|
||||
<h1 id="hero-title">Complex systems, <span>understood before changed.</span></h1>
|
||||
<p class="hero-lead">I build and modernize full-stack business systems — ERP software, offline-first mobile apps for the field, rule-driven expert systems — and connect them to AI through narrow, validated, verified interfaces. The kind you can run in production and sleep at night.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-accent" href="mailto:amar@dzanan.net">amar@dzanan.net</a>
|
||||
<a class="btn btn-line" routerLink="/cv">cv.pdf →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel terminal" data-testid="profile-terminal" role="img" aria-label="Profile summary rendered as a JSON document">
|
||||
<div class="terminal-bar"><div class="terminal-dots" aria-hidden="true"><span></span><span></span><span></span></div><span class="terminal-title">profile.json</span><span class="terminal-spacer"></span></div>
|
||||
<div class="terminal-body">
|
||||
<div class="terminal-line"><span class="c">$ </span>whoami<span class="c"> --json</span></div>
|
||||
<div class="terminal-line">{{ '{' }}</div>
|
||||
@for (line of terminalLines; track $index) {
|
||||
<div class="terminal-line indent">@for (token of line.tokens; track $index) {<span [class]="token.cls">{{ token.text }}</span>}</div>
|
||||
}
|
||||
<div class="terminal-line">{{ '}' }}</div>
|
||||
<div class="terminal-line prompt"><span class="c">$ </span><span class="cursor" aria-hidden="true"></span></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>
|
||||
<section class="section tight shell" aria-labelledby="index-title">
|
||||
<div class="section-head padded">
|
||||
<div><span class="label">// index</span><h2 id="index-title">ls ~/</h2></div>
|
||||
<span class="count">{{ pages.length }} entries</span>
|
||||
</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>
|
||||
<div class="panel index-list">
|
||||
@for (page of pages; track page.path) {
|
||||
<a data-testid="index-row" class="index-row" [routerLink]="page.path">
|
||||
<span class="name">{{ page.name }}</span>
|
||||
<p>{{ page.description }}</p>
|
||||
<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@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.' }
|
||||
];
|
||||
readonly pages = PAGE_INDEX;
|
||||
readonly terminalLines = toTerminalLines(PROFILE);
|
||||
|
||||
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,
|
||||
description: 'Senior full-stack engineer building and modernizing ERP, mobile and expert systems — and connecting them safely to AI.',
|
||||
path: '/',
|
||||
structuredData: {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -120,7 +108,8 @@ export class LandingPageComponent {
|
||||
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']
|
||||
address: { '@type': 'PostalAddress', addressLocality: 'Ljubljana', addressCountry: 'SI' },
|
||||
knowsAbout: ['.NET', 'Angular', 'TypeScript', 'Ionic', 'ERP software', 'Expert systems', 'Software architecture', 'Model Context Protocol', 'AI integration']
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
||||
import { SeoService } from '../../core/services/seo.service';
|
||||
@@ -12,16 +13,24 @@ import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||
template: `
|
||||
<app-page-shell [sections]="sections">
|
||||
<section class="not-found shell">
|
||||
<p class="eyebrow mono">404 / Route not found</p>
|
||||
<div class="panel terminal" role="img" aria-label="Terminal reporting that the path was not found">
|
||||
<div class="terminal-bar"><div class="terminal-dots" aria-hidden="true"><span></span><span></span><span></span></div><span class="terminal-title">404</span><span class="terminal-spacer"></span></div>
|
||||
<div class="terminal-body">
|
||||
<div class="terminal-line"><span class="c">$ </span>open {{ path }}</div>
|
||||
<div class="terminal-line"><span class="n">command not found:</span> {{ path }}</div>
|
||||
<div class="terminal-line prompt"><span class="c">$ </span><span class="cursor" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p class="muted">The page may have moved. The selected systems and the CV are one click away.</p>
|
||||
<a class="btn btn-line" routerLink="/">cd ~/</a>
|
||||
</section>
|
||||
</app-page-shell>
|
||||
`
|
||||
})
|
||||
export class NotFoundPageComponent {
|
||||
readonly sections = MAIN_NAVIGATION;
|
||||
readonly path = inject(DOCUMENT).location?.pathname || '/unknown';
|
||||
|
||||
constructor() {
|
||||
inject(SeoService).update({
|
||||
|
||||
@@ -24,6 +24,8 @@ describe('PortfolioPageComponent', () => {
|
||||
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(page.querySelector('a[href="/cv"]')).toBeTruthy();
|
||||
expect(page.textContent).toContain('// 01 context');
|
||||
expect(TestBed.inject(Title).getTitle()).toContain('Secure AI Integration Gateway');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,71 +16,66 @@ import { SystemDiagramComponent } from '../work/system-diagram.component';
|
||||
<app-page-shell [sections]="sections">
|
||||
<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>
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/work">~/work/</a><span>{{ project.slug }}</span></nav>
|
||||
<p class="label">{{ project.category }} · {{ project.period }}</p>
|
||||
<h1>{{ project.title }}</h1>
|
||||
<p class="lead">{{ project.summary }}</p>
|
||||
<dl class="panel case-meta">
|
||||
<div><dt>role</dt><dd>{{ project.role }}</dd></div>
|
||||
<div><dt>period</dt><dd>{{ project.period }}</dd></div>
|
||||
<div><dt>stack</dt><dd class="tag-row">@for (technology of project.stack; track technology) {<span class="tag">{{ technology }}</span>}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section class="case-section shell narrative" aria-labelledby="challenge-title">
|
||||
<p class="case-index mono">01 / Context</p>
|
||||
<section class="case-section shell" aria-labelledby="challenge-title">
|
||||
<span class="label">// 01 context</span>
|
||||
<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>
|
||||
<span class="label">// 02 delivery</span>
|
||||
<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>
|
||||
<li><span>{{ 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 class="diagram-wrap shell">
|
||||
<app-system-diagram [kind]="project.diagram" [title]="project.title" />
|
||||
</section>
|
||||
|
||||
<section class="case-section shell" aria-labelledby="decisions-title">
|
||||
<p class="case-index mono">03 / Architecture</p>
|
||||
<span class="label">// 03 architecture</span>
|
||||
<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>
|
||||
<article class="panel decision"><span>{{ 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>
|
||||
<section class="case-section shell" aria-labelledby="outcome-title">
|
||||
<span class="label">// 04 result</span>
|
||||
<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>
|
||||
<a class="panel" [routerLink]="['/work', previous.slug]"><span class="mono">← previous</span><strong>{{ previous.title }}</strong></a>
|
||||
<a class="panel" [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 class="shell case-contact" aria-labelledby="case-contact-title">
|
||||
<div class="panel contact">
|
||||
<span class="label">// contact</span>
|
||||
<h2 id="case-contact-title">Have a complex system? Let’s make the next change safer and more useful.</h2>
|
||||
<div class="contact-actions"><a class="btn btn-accent" href="mailto:amar@dzanan.net">amar@dzanan.net</a><a class="text-link" routerLink="/cv">cv.pdf →</a></div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</app-page-shell>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export type PortfolioProjectId =
|
||||
| 'ai-integration'
|
||||
| 'field-sales'
|
||||
| 'travel-operations'
|
||||
| 'service-operations'
|
||||
| 'biodiversity-data';
|
||||
|
||||
export type ProjectDiagramKind =
|
||||
| 'trust-boundaries'
|
||||
| 'field-sales-platform'
|
||||
| 'travel-platform'
|
||||
| 'multitenant-platform'
|
||||
| 'research-platform';
|
||||
@@ -24,6 +26,8 @@ export interface PortfolioProject {
|
||||
readonly period: string;
|
||||
readonly role: string;
|
||||
readonly summary: string;
|
||||
/** One line (≤ 70 chars) shown in the work list. */
|
||||
readonly tagline: string;
|
||||
readonly challenge: string;
|
||||
readonly contributions: readonly string[];
|
||||
readonly decisions: readonly ProjectDecision[];
|
||||
|
||||
@@ -2,15 +2,17 @@ import { getPortfolioProject, PORTFOLIO_PROJECTS } from './portfolio-projects.da
|
||||
|
||||
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']);
|
||||
expect(PORTFOLIO_PROJECTS.length).toBe(5);
|
||||
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.id)).size).toBe(5);
|
||||
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.slug)).size).toBe(5);
|
||||
expect(PORTFOLIO_PROJECTS.map(project => project.index)).toEqual(['01', '02', '03', '04', '05']);
|
||||
|
||||
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(project.tagline.length).toBeGreaterThan(10);
|
||||
expect(project.tagline.length).toBeLessThanOrEqual(70);
|
||||
expect(getPortfolioProject(project.id)).toBe(project);
|
||||
}
|
||||
});
|
||||
@@ -19,6 +21,8 @@ describe('portfolio project registry', () => {
|
||||
const publicCopy = JSON.stringify(PORTFOLIO_PROJECTS);
|
||||
const restrictedTerms = [
|
||||
'WorkLogManager',
|
||||
'OrderCom',
|
||||
'ordc',
|
||||
'intrix-mcp',
|
||||
'Unija',
|
||||
'git.dzanan.net',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
||||
category: 'AI integration · Featured case study',
|
||||
period: '2026',
|
||||
role: 'Primary engineer',
|
||||
tagline: 'Production MCP service · validated boundaries · verified writes',
|
||||
summary:
|
||||
'A production Model Context Protocol service that gives AI clients useful access to business workflows while preserving authentication, validation, and operational safety.',
|
||||
challenge:
|
||||
@@ -50,13 +51,64 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
||||
'Case study about designing a secure production MCP gateway for HRM and CRM systems with validated boundaries and verified writes.'
|
||||
},
|
||||
{
|
||||
id: 'travel-operations',
|
||||
id: 'field-sales',
|
||||
index: '02',
|
||||
slug: 'field-sales-mobile-platform',
|
||||
title: 'Offline-First Field Sales App & Realtime Tracking',
|
||||
category: 'Mobile & realtime · Full-stack delivery',
|
||||
period: '2024 — 2025',
|
||||
role: 'Full-stack engineer',
|
||||
tagline: 'Ionic app with a local ERP replica, offline orders · SignalR tracking',
|
||||
summary:
|
||||
'A mobile ordering system for the field sales representatives of a regional distribution business: a full local replica of ERP master data, orders and credit notes composed offline and sent when connectivity returns, and a realtime tracking backend that shows directors where their teams are.',
|
||||
challenge:
|
||||
'Sales representatives work in shops and warehouses with unreliable connectivity, yet every order depends on current customers, price lists, promotional discounts, stock and payment terms held in the ERP. The app had to work fully offline, reproduce the ERP’s pricing rules exactly, never lose a document, and later feed a live map of representatives and their customer visits without draining phone batteries.',
|
||||
contributions: [
|
||||
'Built the Ionic and Angular app on Capacitor with a local SQLite replica of ERP master data — customers, delivery sites, products, price lists, promotions, stock, payment terms — refreshed on demand and once a day.',
|
||||
'Implemented order and credit-note workflows with a three-state document lifecycle (draft, closed, sent) so that a failed send degrades safely to “closed” and is retried later instead of being lost.',
|
||||
'Reproduced ERP pricing on the device: customer rebates, time-boxed promotional discounts with wildcard matching, VAT-inclusive and VAT-exclusive totals, and a confirm-amount step for credit notes.',
|
||||
'Added barcode scanning scoped to the active sales programme, a map-based picker to geotag delivery sites, and an offline queue that replays location updates when the network returns.',
|
||||
'Designed and implemented the realtime tracking backend: an ASP.NET Core SignalR hub with per-user latest-location storage, visit logging, route stops and a director dashboard filtered to each director’s team.',
|
||||
'Shipped self-updating app distribution, connectivity-aware notifications, local schema migrations and automatic purging of old documents.'
|
||||
],
|
||||
decisions: [
|
||||
{
|
||||
title: 'Replicate, don’t request',
|
||||
detail:
|
||||
'The device holds the whole master-data set it needs; the network is only for sending documents and refreshing the replica, so a lost signal never blocks a sale.'
|
||||
},
|
||||
{
|
||||
title: 'Closed is a safe state',
|
||||
detail:
|
||||
'Documents move draft → closed → sent. Anything that fails on the way to the server falls back to closed — visible, retryable, never lost.'
|
||||
},
|
||||
{
|
||||
title: 'Presence from the connection',
|
||||
detail:
|
||||
'Online status derives from the SignalR connection lifecycle stored on the user record; live positions live in one row per user, upserted, instead of an in-memory cache that dies with the process.'
|
||||
},
|
||||
{
|
||||
title: 'Trade history for battery',
|
||||
detail:
|
||||
'The tracker keeps the latest location per representative and explicit visit events rather than a GPS trail — enough for the dashboard, cheap on the phone.'
|
||||
}
|
||||
],
|
||||
outcome:
|
||||
'Representatives compose and send orders from anywhere with the ERP’s own prices, documents survive bad connectivity, and directors see their teams and visits live on a map — all on top of the existing ERP without changing how it stores its data.',
|
||||
stack: ['Ionic', 'Angular', 'Capacitor', 'SQLite', 'Leaflet', 'ASP.NET Core', 'SignalR', 'SQL Server'],
|
||||
diagram: 'field-sales-platform',
|
||||
seoDescription:
|
||||
'Case study: an offline-first Ionic field sales app with a local ERP replica, order and credit-note sync, barcode and GPS, and a SignalR realtime tracking backend.'
|
||||
},
|
||||
{
|
||||
id: 'travel-operations',
|
||||
index: '03',
|
||||
slug: 'travel-operations-platform',
|
||||
title: 'Travel Operations & Automation Platform',
|
||||
category: 'Enterprise platform · Full-stack delivery',
|
||||
period: '2025 — Present',
|
||||
role: 'Senior full-stack contributor',
|
||||
tagline: 'Products, departures, reservations, finance, partner systems',
|
||||
summary:
|
||||
'A large operations platform connecting travel products, departures, reservations, passengers, finance, customer communication, portals, and partner systems.',
|
||||
challenge:
|
||||
@@ -94,12 +146,13 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
||||
},
|
||||
{
|
||||
id: 'service-operations',
|
||||
index: '03',
|
||||
index: '04',
|
||||
slug: 'service-operations-platform',
|
||||
title: 'Multitenant Service Operations Platform',
|
||||
category: 'Business platform · Product evolution',
|
||||
period: '2025 — Present',
|
||||
role: 'Senior full-stack contributor',
|
||||
tagline: 'Tickets, worklogs, contracts, accounting · CQRS · SignalR',
|
||||
summary:
|
||||
'A multitenant business system for tickets, worklogs, contracts, customers, teams, accounting, recurring work, reporting, and partner integrations.',
|
||||
challenge:
|
||||
@@ -137,12 +190,13 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
||||
},
|
||||
{
|
||||
id: 'biodiversity-data',
|
||||
index: '04',
|
||||
index: '05',
|
||||
slug: 'biodiversity-data-platform',
|
||||
title: 'Biodiversity Data & Field Research Platform',
|
||||
category: 'Scientific system · Platform modernization',
|
||||
period: '2026',
|
||||
role: 'Platform modernization contributor',
|
||||
tagline: 'Taxonomy, sampling, specimens, habitats, map-based GIS',
|
||||
summary:
|
||||
'A specialized research system for taxonomy, field sampling, specimens, biometrics, habitats, localities, media, and map-based geographic work.',
|
||||
challenge:
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProjectDiagramKind } from './portfolio-project.model';
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<figure class="system-diagram" data-testid="system-diagram">
|
||||
<figure class="system-diagram panel" data-testid="system-diagram">
|
||||
<figcaption><span class="mono">System view</span><strong>{{ title }}</strong></figcaption>
|
||||
@switch (kind) {
|
||||
@case ('trust-boundaries') {
|
||||
@@ -19,6 +19,17 @@ import { ProjectDiagramKind } from './portfolio-project.model';
|
||||
<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 ('field-sales-platform') {
|
||||
<svg viewBox="0 0 900 410" role="img" aria-label="Offline-first field sales app and realtime tracking architecture">
|
||||
<g class="diagram-lines"><path d="M200 120H330M550 120h150M440 178v60M700 165v73M550 300h150"/><path d="M110 165v70M110 235h220"/></g>
|
||||
<g class="diagram-node accent"><rect x="20" y="70" width="180" height="95"/><text x="110" y="110">MOBILE APP</text><text class="small" x="110" y="134">IONIC · SQLITE REPLICA</text></g>
|
||||
<g class="diagram-node"><rect x="330" y="60" width="220" height="118"/><text x="440" y="108">SYNC API</text><text class="small" x="440" y="134">MASTER DATA · ORDERS</text><text class="small" x="440" y="156">CREDIT NOTES</text></g>
|
||||
<g class="diagram-node"><rect x="700" y="70" width="170" height="95"/><text x="785" y="110">ERP</text><text class="small" x="785" y="134">SQL SERVER</text></g>
|
||||
<g class="diagram-node accent"><rect x="330" y="238" width="220" height="118"/><text x="440" y="286">TRACKING HUB</text><text class="small" x="440" y="312">SIGNALR · PRESENCE</text><text class="small" x="440" y="334">VISITS · ROUTES</text></g>
|
||||
<g class="diagram-node"><rect x="700" y="252" width="170" height="95"/><text x="785" y="292">DIRECTORS</text><text class="small" x="785" y="316">LIVE MAP</text></g>
|
||||
<g class="diagram-note"><text x="440" y="40">OFFLINE QUEUE · RETRY</text><text x="110" y="270">GPS · VISITS</text><text x="440" y="390">LATEST LOCATION PER USER</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>
|
||||
|
||||
24
src/app/features/work/work-page.component.spec.ts
Normal file
24
src/app/features/work/work-page.component.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { WorkPageComponent } from './work-page.component';
|
||||
|
||||
describe('WorkPageComponent', () => {
|
||||
it('lists all four case studies with links to their pages', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkPageComponent],
|
||||
providers: [provideRouter([])]
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(WorkPageComponent);
|
||||
fixture.detectChanges();
|
||||
const page = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(page.querySelectorAll('[data-testid="project-row"]').length).toBe(5);
|
||||
expect(page.querySelector('a[href="/work/secure-ai-integration-gateway"]')).toBeTruthy();
|
||||
expect(page.textContent).toContain('Secure AI Integration Gateway');
|
||||
expect(page.textContent).toContain('Offline-First Field Sales App & Realtime Tracking');
|
||||
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');
|
||||
});
|
||||
});
|
||||
48
src/app/features/work/work-page.component.ts
Normal file
48
src/app/features/work/work-page.component.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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';
|
||||
import { PORTFOLIO_PROJECTS } from './portfolio-projects.data';
|
||||
|
||||
@Component({
|
||||
selector: 'app-work-page',
|
||||
standalone: true,
|
||||
imports: [PageShellComponent, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-page-shell [sections]="sections">
|
||||
<header class="page-head shell">
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>work</span></nav>
|
||||
<span class="label">// selected work</span>
|
||||
<h1>Production systems, anonymized.</h1>
|
||||
<p class="lead">Five case studies from software that is running today: ERP-grade operations platforms, an offline-first mobile app with realtime tracking, incremental modernization, and AI integration with real trust boundaries. Names and internals are withheld; the decisions are real.</p>
|
||||
</header>
|
||||
|
||||
<section class="section tight shell" aria-label="Case studies">
|
||||
<div class="panel work-list">
|
||||
@for (project of projects; track project.id; let first = $first) {
|
||||
<a data-testid="project-row" class="project-row" [class.featured]="first" [routerLink]="['/work', project.slug]">
|
||||
<span class="project-index">{{ project.index }}</span>
|
||||
<span class="project-title"><h3>{{ project.title }}</h3><p>{{ project.tagline }}</p></span>
|
||||
<span class="tag-row">@for (tech of project.stack.slice(0, 4); track tech) {<span class="tag">{{ tech }}</span>}</span>
|
||||
<span class="project-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</app-page-shell>
|
||||
`
|
||||
})
|
||||
export class WorkPageComponent {
|
||||
readonly sections = MAIN_NAVIGATION;
|
||||
readonly projects = PORTFOLIO_PROJECTS;
|
||||
|
||||
constructor() {
|
||||
inject(SeoService).update({
|
||||
title: 'Work — Amar Džanan',
|
||||
description: 'Five anonymized case studies from production software: AI integration, ERP operations platforms, an offline-first field sales app with realtime tracking, and scientific data.',
|
||||
path: '/work'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import { SectionNavItem } from '../models/section-nav.model';
|
||||
|
||||
export const MAIN_NAVIGATION: SectionNavItem[] = [
|
||||
{ label: 'Work', path: '/', fragment: 'work' },
|
||||
{ label: 'Profile', path: '/', fragment: 'profile' },
|
||||
{ label: 'Experience', path: '/', fragment: 'experience' },
|
||||
{ label: 'CV', path: '/cv' }
|
||||
{ label: 'work', path: '/work' },
|
||||
{ label: 'experience', path: '/experience' },
|
||||
{ label: 'cv', path: '/cv' },
|
||||
{ label: 'contact', path: '/contact' }
|
||||
];
|
||||
|
||||
/** The CV document itself, served from /public. */
|
||||
export const CV_URL = '/cv.pdf';
|
||||
|
||||
/** Home-page index of the section pages. */
|
||||
export const PAGE_INDEX: readonly { path: string; name: string; description: string }[] = [
|
||||
{ path: '/work', name: 'work/', description: 'Five anonymized case studies from production systems.' },
|
||||
{ path: '/experience', name: 'experience/', description: 'Eight years of roles, and the capabilities they built.' },
|
||||
{ path: '/cv', name: 'cv.pdf', description: 'The full CV — read it here or download it.' },
|
||||
{ path: '/contact', name: 'contact/', description: 'Email, LinkedIn, and where to find me.' }
|
||||
];
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ThemeService, Theme } from '../../../core/services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-toggle',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="theme-toggle" aria-label="Color theme">
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="themeService.theme() === 'light'"
|
||||
(click)="setTheme('light')"
|
||||
aria-label="Light theme"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="themeService.theme() === 'system'"
|
||||
(click)="setTheme('system')"
|
||||
aria-label="System theme"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm2 0v8h10V5H5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="themeService.theme() === 'dark'"
|
||||
(click)="setTheme('dark')"
|
||||
aria-label="Dark theme"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ThemeToggleComponent {
|
||||
readonly themeService = inject(ThemeService);
|
||||
|
||||
setTheme(theme: Theme) {
|
||||
this.themeService.setTheme(theme);
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Amar Džanan — Senior Software Engineer & AI Integration</title>
|
||||
<title>Amar Džanan — Senior Software Engineer & 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>
|
||||
<meta name="description" content="Senior full-stack engineer building and modernizing ERP, mobile and expert systems — and connecting them safely to AI.">
|
||||
<meta name="theme-color" content="#0D1015">
|
||||
<link rel="canonical" href="https://dzanan.net/">
|
||||
<link rel="icon" type="image/x-icon" href="duck.png">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
511
src/styles.scss
511
src/styles.scss
@@ -1,258 +1,299 @@
|
||||
@use 'tailwindcss';
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
: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);
|
||||
--bg: #0D1015;
|
||||
--panel: #12161D;
|
||||
--panel-2: #0F1318;
|
||||
--line: #232A33;
|
||||
--line-strong: #2A323D;
|
||||
--text: #E6EAF0;
|
||||
--muted: #8B95A5;
|
||||
--dim: #5C6672;
|
||||
--accent: #8BE04A;
|
||||
--accent-hover: #A9F26F;
|
||||
--accent-glow: rgba(139, 224, 74, .22);
|
||||
--code-key: #7AA2F7;
|
||||
--code-str: #C3E88D;
|
||||
--code-num: #F78C6C;
|
||||
--mono: "JetBrains Mono Variable", "JetBrains Mono", Consolas, Menlo, monospace;
|
||||
--sans: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { 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; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:hover { color: var(--accent); }
|
||||
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; }
|
||||
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
|
||||
::selection { background: var(--accent); color: var(--bg); }
|
||||
h1, h2, h3, h4, p { margin: 0; }
|
||||
h1, h2 { font-weight: 600; letter-spacing: -.03em; text-wrap: balance; }
|
||||
h1 { font-size: clamp(2.4rem, 5vw, 3.9rem); line-height: 1.04; }
|
||||
h2 { font-size: clamp(1.75rem, 3vw, 2.1rem); line-height: 1.1; letter-spacing: -.025em; }
|
||||
h3 { font-size: 1.25rem; font-weight: 500; letter-spacing: -.01em; line-height: 1.25; }
|
||||
h4 { font-size: 1rem; font-weight: 500; }
|
||||
|
||||
.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; }
|
||||
/* ---- primitives ---- */
|
||||
.site-frame {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, .028) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, .028) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
}
|
||||
.shell { width: min(1160px, calc(100% - 40px)); margin-inline: auto; }
|
||||
.mono { font-family: var(--mono); }
|
||||
.label { font-family: var(--mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); }
|
||||
.dim { color: var(--dim); }
|
||||
.muted { color: var(--muted); }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 4px; border: 1px solid var(--line-strong); color: var(--muted); font: 400 12px/1 var(--mono); white-space: nowrap; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 20px; border-radius: 6px; font: 500 14px/1 var(--mono); white-space: nowrap; transition: background-color .15s ease, color .15s ease, box-shadow .15s ease, border-color .15s ease; }
|
||||
.btn-accent { background: var(--accent); color: var(--bg); box-shadow: 0 0 0 1px rgba(139, 224, 74, .4), 0 0 32px var(--accent-glow); }
|
||||
.btn-accent:hover { color: var(--bg); background: var(--accent-hover); box-shadow: 0 0 0 1px rgba(139, 224, 74, .6), 0 0 40px var(--accent-glow); }
|
||||
.btn-line { border: 1px solid var(--line-strong); color: var(--text); background: rgba(255, 255, 255, .02); }
|
||||
.btn-line:hover { color: var(--text); border-color: var(--accent); background: rgba(139, 224, 74, .06); }
|
||||
.icon { width: 14px; height: 14px; flex: none; }
|
||||
.status-dot { position: relative; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); }
|
||||
.status-dot::after { content: ''; position: absolute; inset: -2px; border-radius: 50%; border: 2px solid var(--accent); opacity: 0; }
|
||||
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: var(--accent); color: var(--bg); padding: .7rem 1rem; font-family: var(--mono); font-size: 13px; border-radius: 6px; }
|
||||
.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; }
|
||||
.section { padding-block: 96px; }
|
||||
.section.tight { padding-top: 0; }
|
||||
.section-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 24px; }
|
||||
.section-head .label { display: block; margin-bottom: 10px; }
|
||||
.section-head .count { font: 400 12px/1 var(--mono); color: var(--dim); }
|
||||
.section-head.padded { padding-inline: 24px; }
|
||||
|
||||
.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; }
|
||||
/* ---- header / footer ---- */
|
||||
.site-header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid var(--line); background: rgba(13, 16, 21, .92); backdrop-filter: blur(12px); }
|
||||
.header-inner { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.wordmark { display: inline-flex; align-items: center; gap: 10px; font: 600 14px/1 var(--mono); }
|
||||
.wordmark:hover { color: var(--text); }
|
||||
.wordmark-tilde { color: var(--accent); font-weight: 400; }
|
||||
.desktop-nav { display: flex; align-items: center; gap: 28px; }
|
||||
.desktop-nav nav { display: flex; gap: 28px; align-items: center; }
|
||||
.desktop-nav nav a, .mobile-nav nav a { color: var(--muted); font: 500 13px/1 var(--mono); letter-spacing: .02em; }
|
||||
.desktop-nav nav a:hover, .mobile-nav nav a:hover, .desktop-nav nav a.active, .mobile-nav nav a.active { color: var(--accent); }
|
||||
.nav-cv { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.status { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font: 400 12px/1 var(--mono); padding-left: 20px; border-left: 1px solid var(--line); }
|
||||
.menu-button { display: none; align-items: center; gap: 12px; border: 0; background: transparent; color: var(--text); font: 500 13px/1 var(--mono); cursor: pointer; padding: 8px 0; }
|
||||
.mobile-nav { display: none; border-top: 1px solid var(--line); padding-block: 16px 20px; }
|
||||
.mobile-nav nav { display: grid; gap: 16px; }
|
||||
.site-footer { border-top: 1px solid var(--line); padding-block: 28px; margin-top: 40px; }
|
||||
.footer-inner { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--dim); font: 400 12px/1.6 var(--mono); }
|
||||
.footer-inner a { color: var(--muted); }
|
||||
.footer-inner a:hover { color: var(--accent); }
|
||||
|
||||
.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; }
|
||||
/* ---- hero ---- */
|
||||
.hero { display: grid; grid-template-columns: 1.05fr .95fr; gap: 64px; align-items: center; padding-block: 88px 96px; }
|
||||
.hero-copy { display: flex; flex-direction: column; gap: 26px; }
|
||||
.hero-copy .label { letter-spacing: .06em; }
|
||||
.hero-copy h1 span { color: var(--accent); }
|
||||
.hero-lead { max-width: 560px; color: var(--muted); font-size: 18px; line-height: 1.6; }
|
||||
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 4px; }
|
||||
|
||||
.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; }
|
||||
.terminal { padding: 0; overflow: hidden; box-shadow: 0 30px 80px rgba(0, 0, 0, .5); font-family: var(--mono); }
|
||||
.terminal-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2); }
|
||||
.terminal-dots { display: flex; gap: 6px; width: 42px; }
|
||||
.terminal-dots span { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); }
|
||||
.terminal-title { color: var(--dim); font-size: 12px; }
|
||||
.terminal-spacer { width: 42px; }
|
||||
.terminal-body { padding: 22px 24px; font-size: 14px; line-height: 1.75; display: flex; flex-direction: column; overflow-x: auto; }
|
||||
.terminal-body .k { color: var(--code-key); }
|
||||
.terminal-body .s { color: var(--code-str); }
|
||||
.terminal-body .n { color: var(--code-num); }
|
||||
.terminal-body .c { color: var(--dim); }
|
||||
.terminal-line { white-space: pre; }
|
||||
.terminal-line.indent { padding-left: 2ch; }
|
||||
.terminal-line.prompt { margin-top: 6px; }
|
||||
.cursor { display: inline-block; width: 9px; height: 16px; background: var(--accent); vertical-align: -3px; margin-left: 2px; }
|
||||
|
||||
.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; }
|
||||
/* ---- work list ---- */
|
||||
.work-list { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.project-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 300px 40px; gap: 24px; padding: 26px 24px; border-top: 1px solid var(--line); align-items: center; color: var(--text); transition: background-color .15s ease; }
|
||||
.project-row:first-child { border-top: 0; }
|
||||
.project-row:hover { background: var(--panel-2); color: var(--text); }
|
||||
.project-index { font: 400 13px/1 var(--mono); color: var(--dim); }
|
||||
.project-row.featured .project-index, .project-row.featured .project-arrow { color: var(--accent); }
|
||||
.project-title { display: flex; flex-direction: column; gap: 6px; }
|
||||
.project-title p { color: var(--muted); font-size: 14.5px; }
|
||||
.project-arrow { color: var(--dim); text-align: right; font-size: 18px; transition: color .15s ease, transform .15s ease; }
|
||||
.project-row:hover .project-arrow { color: var(--accent); transform: translateX(4px); }
|
||||
|
||||
.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; }
|
||||
/* ---- experience ---- */
|
||||
.experience { display: grid; grid-template-columns: 340px 1fr; gap: 64px; align-items: start; }
|
||||
.experience-intro { display: flex; flex-direction: column; gap: 18px; }
|
||||
.experience-intro p { color: var(--muted); font-size: 15px; line-height: 1.6; }
|
||||
.experience-intro .text-link { align-self: flex-start; }
|
||||
.portrait { width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block; filter: grayscale(1) contrast(1.05); border: 1px solid var(--line); }
|
||||
.text-link { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); font: 500 13px/1 var(--mono); }
|
||||
.text-link:hover { color: var(--accent-hover); }
|
||||
.roles { display: flex; flex-direction: column; }
|
||||
.role { display: grid; grid-template-columns: 170px 1fr 1.2fr; gap: 28px; padding: 20px 0; border-top: 1px solid var(--line); align-items: baseline; }
|
||||
.role:last-child { border-bottom: 1px solid var(--line); }
|
||||
.role time { color: var(--dim); font: 400 13px/1.4 var(--mono); }
|
||||
.role h4 { font-size: 16px; }
|
||||
.role .company { display: block; color: var(--muted); font-size: 13.5px; margin-top: 2px; }
|
||||
.role p { color: var(--muted); font-size: 14.5px; line-height: 1.5; }
|
||||
|
||||
.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; }
|
||||
/* ---- capabilities ---- */
|
||||
.capabilities { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.capability { padding: 28px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.capability .index { font: 400 12px/1 var(--mono); color: var(--accent); }
|
||||
.capability p { color: var(--muted); font-size: 14.5px; line-height: 1.55; }
|
||||
|
||||
.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; }
|
||||
/* ---- contact ---- */
|
||||
.contact { padding: clamp(32px, 5vw, 56px); border-color: rgba(139, 224, 74, .35); box-shadow: inset 0 0 80px rgba(139, 224, 74, .05); display: flex; flex-direction: column; gap: 22px; }
|
||||
.contact h2 { max-width: 760px; }
|
||||
.contact-actions { display: flex; justify-content: space-between; align-items: center; gap: 24px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.contact-actions .meta { color: var(--dim); font: 400 12px/1 var(--mono); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* ---- case study ---- */
|
||||
.breadcrumb { display: flex; font: 400 13px/1 var(--mono); color: var(--text); }
|
||||
.breadcrumb a { color: var(--dim); }
|
||||
.breadcrumb a:hover { color: var(--accent); }
|
||||
.case-hero { padding-block: 64px 48px; display: flex; flex-direction: column; gap: 22px; }
|
||||
.case-hero h1 { max-width: 900px; }
|
||||
.case-hero .lead { max-width: 760px; color: var(--muted); font-size: 18px; line-height: 1.6; }
|
||||
.case-meta { display: grid; grid-template-columns: auto auto 1fr; gap: 32px; padding: 18px 24px; margin: 8px 0 0; align-items: start; }
|
||||
.case-meta dt { font: 400 11px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--dim); margin-bottom: 8px; }
|
||||
.case-meta dd { margin: 0; font-size: 14.5px; }
|
||||
.case-section { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 40px; padding-block: 56px; border-top: 1px solid var(--line); }
|
||||
.case-section .label { padding-top: 6px; }
|
||||
.case-section h2 { margin-bottom: 20px; }
|
||||
.large-copy { max-width: 720px; color: var(--muted); font-size: 17px; line-height: 1.65; }
|
||||
.contribution-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.contribution-list li { display: grid; grid-template-columns: 40px 1fr; gap: 16px; padding: 16px 0; border-top: 1px solid var(--line); }
|
||||
.contribution-list li:last-child { border-bottom: 1px solid var(--line); }
|
||||
.contribution-list li span { font: 400 13px/1.6 var(--mono); color: var(--accent); }
|
||||
.contribution-list li p { color: var(--muted); font-size: 15px; line-height: 1.6; }
|
||||
.decision-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.decision { padding: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.decision span { font: 400 12px/1 var(--mono); color: var(--accent); }
|
||||
.decision p { color: var(--muted); font-size: 14.5px; line-height: 1.55; }
|
||||
.diagram-wrap { padding-block: 8px 56px; }
|
||||
.system-diagram { margin: 0; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.system-diagram figcaption { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||
.system-diagram figcaption .mono { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); }
|
||||
.system-diagram figcaption strong { font-size: 15px; font-weight: 500; }
|
||||
.system-diagram svg { width: 100%; height: auto; display: block; font-family: var(--mono); }
|
||||
.diagram-lines path { fill: none; stroke: var(--line-strong); stroke-width: 1.5; }
|
||||
.diagram-node rect { fill: var(--panel-2); stroke: var(--line-strong); stroke-width: 1.5; rx: 6; }
|
||||
.diagram-node text { fill: var(--text); font-size: 14px; font-weight: 500; text-anchor: middle; letter-spacing: .04em; }
|
||||
.diagram-node text.small { fill: var(--muted); font-size: 11px; font-weight: 400; }
|
||||
.diagram-node.accent rect { stroke: var(--accent); fill: rgba(139, 224, 74, .06); }
|
||||
.diagram-node.accent text { fill: var(--accent); }
|
||||
.diagram-node.accent text.small { fill: var(--code-str); }
|
||||
.diagram-note text { fill: var(--dim); font-size: 11px; letter-spacing: .1em; text-anchor: middle; }
|
||||
.case-pagination { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding-block: 16px 64px; }
|
||||
.case-pagination a { padding: 22px 24px; display: flex; flex-direction: column; gap: 10px; color: var(--text); transition: border-color .15s ease; }
|
||||
.case-pagination a:hover { border-color: var(--accent); color: var(--text); }
|
||||
.case-pagination a .mono { font-size: 12px; color: var(--dim); }
|
||||
.case-pagination a:last-child { text-align: right; }
|
||||
.case-pagination strong { font-weight: 500; font-size: 15px; }
|
||||
.case-contact { margin-bottom: 64px; }
|
||||
|
||||
/* ---- sub-page head ---- */
|
||||
.page-head { padding-block: 56px 40px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.page-head .breadcrumb { margin-bottom: 8px; }
|
||||
.page-head h1 { max-width: 860px; }
|
||||
.page-head .lead { max-width: 680px; color: var(--muted); font-size: 17px; line-height: 1.6; }
|
||||
.page-head .actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; }
|
||||
|
||||
/* ---- home index ---- */
|
||||
.index-list { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.index-row { display: grid; grid-template-columns: 180px minmax(0, 1fr) 40px; gap: 24px; padding: 22px 24px; border-top: 1px solid var(--line); align-items: center; color: var(--text); transition: background-color .15s ease; }
|
||||
.index-row:first-child { border-top: 0; }
|
||||
.index-row:hover { background: var(--panel-2); color: var(--text); }
|
||||
.index-row .name { font: 500 14px/1 var(--mono); color: var(--accent); }
|
||||
.index-row p { color: var(--muted); font-size: 14.5px; }
|
||||
.index-row:hover .project-arrow { color: var(--accent); transform: translateX(4px); }
|
||||
|
||||
/* ---- cv ---- */
|
||||
.cv-frame { overflow: hidden; }
|
||||
.cv-frame object { display: block; width: 100%; height: min(1400px, calc(100vh - 120px)); border: 0; background: var(--panel-2); }
|
||||
.cv-fallback { padding: 48px 24px; display: flex; flex-direction: column; align-items: center; gap: 18px; color: var(--muted); text-align: center; }
|
||||
|
||||
/* ---- contact page ---- */
|
||||
.contact-details { margin: 8px 0 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 32px; padding-top: 24px; border-top: 1px solid var(--line); }
|
||||
.contact-details dt { font: 400 11px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--dim); margin-bottom: 8px; }
|
||||
.contact-details dd { margin: 0; font-size: 15px; color: var(--text); }
|
||||
.contact-details dd a { color: var(--accent); font-family: var(--mono); font-size: 14px; }
|
||||
|
||||
/* ---- not found ---- */
|
||||
.not-found { padding-block: 96px; display: flex; flex-direction: column; gap: 24px; max-width: 720px; }
|
||||
.not-found .terminal-body { gap: 2px; }
|
||||
.not-found .btn { align-self: flex-start; }
|
||||
.not-found p.muted { font-size: 16px; line-height: 1.6; }
|
||||
|
||||
/* ---- motion ---- */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hero-copy > * { animation: rise .48s ease both; }
|
||||
.hero-copy > :nth-child(2) { animation-delay: .08s; }
|
||||
.hero-copy > :nth-child(3) { animation-delay: .16s; }
|
||||
.hero-copy > :nth-child(4) { animation-delay: .24s; }
|
||||
.terminal { animation: rise .6s ease .2s both; }
|
||||
.cursor { animation: blink 1s steps(1) infinite; }
|
||||
.status-dot { animation: status-glow 2s ease-in-out infinite; }
|
||||
.status-dot::after { animation: status-ping 2s ease-out infinite; }
|
||||
}
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@keyframes status-glow { 0%, 100% { box-shadow: 0 0 6px var(--accent); } 50% { box-shadow: 0 0 14px var(--accent), 0 0 24px var(--accent-glow); } }
|
||||
@keyframes status-ping { 0% { transform: scale(.7); opacity: .9; } 80%, 100% { transform: scale(2.8); opacity: 0; } }
|
||||
|
||||
/* ---- responsive ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.hero { grid-template-columns: 1fr; gap: 40px; padding-block: 56px 64px; }
|
||||
.experience { grid-template-columns: 1fr; gap: 32px; }
|
||||
.project-row { grid-template-columns: 48px minmax(0, 1fr) 40px; row-gap: 12px; }
|
||||
.project-row .tag-row { grid-column: 2; }
|
||||
.case-section { grid-template-columns: 1fr; gap: 16px; }
|
||||
.capabilities { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.section { padding-block: 64px; }
|
||||
.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; }
|
||||
.menu-button { display: inline-flex; }
|
||||
.mobile-nav { display: block; }
|
||||
.hero-copy h1 { font-size: clamp(2rem, 9vw, 2.6rem); }
|
||||
.project-row { grid-template-columns: minmax(0, 1fr) 24px; padding: 20px 18px; gap: 10px 12px; }
|
||||
.project-index { display: none; }
|
||||
.project-title { grid-column: 1; grid-row: 1; }
|
||||
.project-arrow { grid-column: 2; grid-row: 1; }
|
||||
.project-row .tag-row { grid-column: 1 / -1; grid-row: 2; }
|
||||
.terminal-line { white-space: pre-wrap; }
|
||||
.system-diagram { overflow-x: auto; }
|
||||
.system-diagram svg { min-width: 640px; }
|
||||
.role { grid-template-columns: 1fr; gap: 6px; }
|
||||
.capabilities, .decision-grid, .case-pagination, .contact-details { grid-template-columns: 1fr; }
|
||||
.index-row { grid-template-columns: minmax(0, 1fr) 24px; padding: 18px; gap: 6px 12px; }
|
||||
.index-row .name { grid-column: 1; }
|
||||
.index-row p { grid-column: 1; }
|
||||
.index-row .project-arrow { grid-column: 2; grid-row: 1 / span 2; }
|
||||
.cv-frame object { height: 70vh; }
|
||||
.case-pagination a:last-child { text-align: left; }
|
||||
.case-meta { grid-template-columns: 1fr; gap: 16px; }
|
||||
.section-head { flex-direction: column; align-items: flex-start; }
|
||||
.section-head.padded { padding-inline: 0; }
|
||||
.terminal-body { font-size: 12.5px; padding: 16px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
@media (max-width: 360px) {
|
||||
.label-extra { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user