Compare commits

...

6 Commits

Author SHA1 Message Date
amar.dzanan
986fa61c5e added petrol station project
All checks were successful
Deploy dzanan.net / deploy (push) Successful in 41s
2026-09-07 10:50:08 +02:00
amar.dzanan
4d754f33c1 redesign complete
All checks were successful
Deploy dzanan.net / deploy (push) Successful in 1m0s
2026-09-06 22:16:19 +02:00
amar.dzanan
886292aca9 Angular 22 upgrade
All checks were successful
Deploy dzanan.net / deploy (push) Successful in 52s
2026-09-06 20:16:55 +02:00
v7
e64239fbae Merge pull request 'feature/portfolio-redesign' (#1) from feature/portfolio-redesign into main
All checks were successful
Deploy dzanan.net / deploy (push) Successful in 34s
Reviewed-on: #1
2026-09-06 19:57:59 +02:00
v7
c4ce5df076 Improve typography scale and header contrast 2026-09-06 19:53:30 +02:00
v7
384c8563de Redesign portfolio around systems case studies 2026-09-06 19:39:23 +02:00
55 changed files with 5518 additions and 3781 deletions

View File

@@ -0,0 +1,173 @@
# dzanan.net — "Dark & technical" redesign
Date: 2026-09-06 · Status: approved by Amar (direction C of three mockups)
## Goal
Replace the current warm/editorial design of dzanan.net with the "Dark & technical"
direction: near-black surfaces with a faint grid, monospace labels, a terminal-style
profile panel, and a single glowing green accent. The site serves both employers and
consulting clients equally. The CV becomes a PDF that Amar provides; the site links to it.
Non-goals: new content for the case studies (text stays), a light theme, a CMS, new routes.
## Scope
Kept: Angular 22 app structure, SSR + prerendering, `SeoService`, `PageShellComponent`
(restyled), landing page, four `/work/*` case-study pages, `SystemDiagramComponent`
(recoloured), not-found page, Tailwind v4 install, Docker deploy.
Removed: `CvPageComponent` + its spec, `ThemeService`, `ThemeToggleComponent`, the theme
bootstrap script in `src/index.html`, the `cv` client route, the `cv` prerender route.
Added: `public/cv.pdf` (placeholder until Amar supplies the file), a 301 from `/cv` to
`/cv.pdf` in `src/server.ts`, self-hosted fonts.
## Visual system
Defined as CSS custom properties on `:root` in `src/styles.scss`; no `.dark` variant.
| token | value | use |
|---|---|---|
| `--bg` | `#0D1015` | page background |
| `--panel` | `#12161D` | cards, list panel, code panel |
| `--panel-2` | `#0F1318` | panel headers / inset |
| `--line` | `#232A33` | borders, dividers |
| `--line-strong` | `#2A323D` | tag borders, button outline |
| `--text` | `#E6EAF0` | primary text |
| `--muted` | `#8B95A5` | secondary text |
| `--dim` | `#5C6672` | tertiary text, comments |
| `--accent` | `#8BE04A` | labels, links, primary button, status dot |
| `--accent-glow` | `rgba(139,224,74,.22)` | button and dot glow |
| `--code-key` / `--code-str` / `--code-num` | `#7AA2F7` / `#C3E88D` / `#F78C6C` | profile.json panel |
Grid: `linear-gradient` lines at 2.8% white, 64px cell, fixed to the page background.
Type: JetBrains Mono (labels, nav, meta, code, buttons) and IBM Plex Sans (headings and
body), self-hosted via `@fontsource-variable/jetbrains-mono` and `@fontsource/ibm-plex-sans`
(weights 400/500/600) imported in `styles.scss`. Fallbacks: Consolas/Menlo/monospace and
"Segoe UI"/system-ui/sans-serif. Base body 16px; headline scale `clamp(2.4rem, 5vw, 3.9rem)`
for h1, `clamp(1.75rem, 3vw, 2.1rem)` for h2, letter-spacing -0.03em/-0.025em, weight 600.
Labels (`.label`): mono, 12px, uppercase, 0.08em tracking, accent colour, prefixed with
`// ` in markup (e.g. `// selected work`). Tags (`.tag`): mono 12px, 24px tall, 1px
`--line-strong` border, 4px radius. Panels (`.panel`): `--panel` fill, 1px `--line`
border, 10px radius. Buttons: 46px tall, 6px radius, mono 14px; `.btn-accent` accent fill
with dark text and glow, `.btn-line` outlined.
Container: `.shell` = `min(1160px, 100% - 40px)` centred. Section spacing 96px desktop,
64px below 720px.
## Pages
### Shell (header, footer, mobile menu)
Header: sticky, `--bg` at 92% with blur, bottom border. Left: mono wordmark `~/` (accent)
+ `dzanan.net` (600). Right (desktop): mono nav `work · experience · contact · cv.pdf`,
then a divider and the status `● available` (accent dot with glow). `cv.pdf` is an `<a
href="/cv.pdf">` with a download icon, opens in a new tab. Below 720px the nav collapses to
the existing signal-driven menu button; the status stays visible.
Navigation config: `[{work,'/',#work},{experience,'/',#experience},{contact,'/',#contact}]`
plus the CV link rendered separately as a plain anchor (not a `SectionNavItem`).
Footer: mono 12px, `Amar Džanan · Sarajevo, Bosnia and Herzegovina` left; email and
LinkedIn right.
### Landing (`/`)
1. Hero — two columns (1.05fr / .95fr, 64px gap), stacks below 1024px.
Left: label `Senior full-stack engineer — .NET · Angular · MCP`; h1 "Complex systems,
<accent>understood before changed.</accent>"; lead paragraph "I modernize enterprise
software and connect it to AI through narrow, validated, verified interfaces — the kind
you can run in production and sleep at night."; buttons `amar@dzanan.net` (accent,
mailto) and `cv.pdf` (outline, download).
Right: `profile.json` terminal panel — title bar with three dots and file name, then a
`$ whoami --json` line and a JSON object: name, role, stack `[".NET","Angular",
"TypeScript"]`, focus `["modernization","safe AI integration"]`, since `2018`, location
`"Sarajevo · CET"`, open_to `["senior roles","consulting"]`, closing prompt with a
blinking cursor. Rendered from a small typed data structure in the component so it can
change without touching markup.
2. `// selected work` (`#work`) — heading "Production systems, anonymized." and a right-aligned
mono count `4 case studies`. One `.panel` containing four rows (`data-testid="project-row"`,
`routerLink` to `/work/<slug>`): index (accent on the featured first row), title + one-line
summary (a new short `tagline` field on `PortfolioProject`, ~60 chars), up to four stack
tags (first four of `stack`), arrow. Hover: row background `--panel-2`, arrow turns accent.
Below 720px: grid collapses to index+title, tagline, tags stacked.
3. `// experience` (`#experience`) — grid 340px / 1fr. Left: 96px square grayscale portrait
(`NgOptimizedImage`, 8px radius, 1px border), label, h2 "Eight years, one product at a
time.", about paragraph, LinkedIn link. Right: four role rows (period mono `2025 — now`,
title + company, focus sentence), 1px dividers.
4. `// capabilities` — three equal `.panel` cards: `01 System modernization`, `02 Full-stack
product delivery`, `03 Safe AI integration`, each with the existing detail text.
Single column below 720px.
5. Contact (`#contact`) — a `.panel` with a 1px accent-tinted border and inner glow:
label `// contact`, h2 "Need a senior engineer who can understand the system before
changing it?", `.btn-accent` mailto button, mono `Sarajevo · CET / CEST`.
Structured data (schema.org Person) unchanged.
### Case study (`/work/:slug`)
Breadcrumb as a path: `~/work/` (dim) + slug (text), mono. Header: label
`<category> · <period>`, h1 title, lead summary, then a meta strip in a `.panel`: `role`,
`period`, stack tags. Sections keep content; labels become `// 01 context`, `// 02 delivery`,
`// 03 architecture`, `// 04 result`; contributions as a numbered list with mono indices;
decisions as a 2×2 `.panel` grid (single column < 720px); technology as a tag row.
`SystemDiagramComponent` stays, recoloured: node fill `--panel`, stroke `--line-strong`,
accent node stroke/text `--accent`, lines `--line-strong`, notes `--dim`, mono text.
Prev/next as two `.panel` links. Contact block reused. The "View full CV" link points to
`/cv.pdf`.
### Not found
Terminal panel: `$ open <path>` then `command not found: <path>` in `--code-num`, a short
sentence, and a `.btn-line` back to `/`. Server still returns 404.
## Behaviour
- Motion: hero children fade-up (opacity 0→1, translateY 12px→0, 480ms, 80ms stagger) once
on load; cursor blink 1s step; row hover transitions 150ms. All wrapped in
`@media (prefers-reduced-motion: no-preference)`.
- Smooth anchor scrolling with `scroll-padding-top` for the sticky header (kept).
- Focus rings: 2px accent outline, 3px offset.
- `<meta name="theme-color">` → `#0D1015`; `color-scheme: dark`.
## Server
`src/server.ts`: before the Angular handler, `app.get('/cv', (_, res) => res.redirect(301,
'/cv.pdf'))`. `public/cv.pdf` is served by the existing static middleware. Until Amar
supplies the file, a one-page placeholder PDF saying "CV coming soon — amar@dzanan.net" is
committed so the link never 404s. `public/sitemap.xml` drops `/cv`.
## Files
Modified: `styles.scss`, `index.html`, `server.ts`, `app.ts`, `app.routes.ts`,
`app.routes.server.ts`, `app.routes.spec.ts`, `page-shell.component.ts`,
`navigation.config.ts`, `landing-page.component.ts` + spec, `portfolio-page.component.ts`
+ spec, `system-diagram.component.ts`, `not-found-page.component.ts`,
`portfolio-project.model.ts`, `portfolio-projects.data.ts`, `public/sitemap.xml`,
`package.json`/lock (fontsource packages).
Removed: `features/cv/cv-page.component.ts`, `features/cv/cv-page.component.spec.ts`,
`core/services/theme.service.ts`, `shared/ui/theme-toggle/theme-toggle.component.ts`.
Added: `public/cv.pdf` (placeholder), this spec, the implementation plan.
## Testing
- Unit: route spec lists routes without `cv`; landing spec asserts the new h1, four project
rows, `a[href="/cv.pdf"]`, mailto and LinkedIn links, the four role titles; portfolio spec
unchanged in intent; new not-found assertion optional. Run with headless Chromium.
- Build: `ng build` (production, SSR, prerender of `/` and the four work pages) with zero
errors and within budgets (component styles are inline SCSS; keep under 8kB each).
- Visual: serve `dist` and screenshot `/`, one case study and `/404` at 1440px and 390px;
fix wrapping, overflow and contrast issues found.
- Smoke: `curl -I /cv` → 301 to `/cv.pdf`; `/cv.pdf` → 200.
## Delivery
Work happens in the cloud copy of the repo; changed and new files are written back to the
`dzanan.net` folder on Amar's machine, removed files are moved into `_to_delete/`. Amar runs
`npm install` locally afterwards (new font packages) and drops the real PDF over
`public/cv.pdf`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@@ -27,6 +27,10 @@
} }
], ],
"styles": [ "styles": [
"node_modules/@fontsource-variable/jetbrains-mono/index.css",
"node_modules/@fontsource/ibm-plex-sans/400.css",
"node_modules/@fontsource/ibm-plex-sans/500.css",
"node_modules/@fontsource/ibm-plex-sans/600.css",
"src/styles.scss" "src/styles.scss"
], ],
"server": "src/main.server.ts", "server": "src/main.server.ts",
@@ -94,8 +98,13 @@
} }
], ],
"styles": [ "styles": [
"node_modules/@fontsource-variable/jetbrains-mono/index.css",
"node_modules/@fontsource/ibm-plex-sans/400.css",
"node_modules/@fontsource/ibm-plex-sans/500.css",
"node_modules/@fontsource/ibm-plex-sans/600.css",
"src/styles.scss" "src/styles.scss"
] ],
"karmaConfig": "karma.conf.cjs"
} }
} }
} }

View File

@@ -0,0 +1,886 @@
# Dark & Technical Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild the dzanan.net UI in the approved "Dark & technical" direction, drop the CV page in favour of a PDF link, and keep SSR, SEO and tests green.
**Architecture:** Angular 22 standalone components with inline templates; all visual styling lives in one global `src/styles.scss` driven by CSS custom properties (the codebase's existing pattern). Pages compose the shared `PageShellComponent`. Content data stays in `portfolio-projects.data.ts`. Express (`src/server.ts`) serves static assets and adds one redirect.
**Tech Stack:** Angular 22.1, @angular/ssr + Express 5, SCSS, Tailwind v4 (installed, unused by handwritten styles), Karma/Jasmine, self-hosted fonts via `@fontsource-variable/jetbrains-mono` and `@fontsource/ibm-plex-sans`.
**Spec:** `docs/superpowers/specs/2026-09-06-dark-technical-redesign-design.md`
## Global Constraints
- Angular 22.1.x, Node ≥ 22.22.3 (24.15 in the cloud workspace); use `ng` via `npx`.
- No light theme; no `.dark` selectors; `color-scheme: dark`.
- Tokens exactly as the spec table: `--bg #0D1015`, `--panel #12161D`, `--panel-2 #0F1318`, `--line #232A33`, `--line-strong #2A323D`, `--text #E6EAF0`, `--muted #8B95A5`, `--dim #5C6672`, `--accent #8BE04A`, `--accent-glow rgba(139,224,74,.22)`, `--code-key #7AA2F7`, `--code-str #C3E88D`, `--code-num #F78C6C`.
- Fonts self-hosted (no Google Fonts requests). Mono = JetBrains Mono; sans = IBM Plex Sans.
- Labels are prefixed `// ` in markup and use `.label`.
- Case-study text content unchanged; `PORTFOLIO_PROJECTS` must not contain "Unija" (existing test).
- Every task ends with `npx ng build` still succeeding or tests passing as stated.
- Commit after each task with the trailer:
`Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and
`Claude-Session: https://claude.ai/code/session_01LoRUjpFxfDpRFGEUrtEzP4`.
- Unit tests run headless: `CHROME_BIN=<chromium path> npx ng test --watch=false --browsers=ChromeHeadlessNoSandbox` after Task 1 adds the launcher.
---
### Task 1: Fonts, tokens and base stylesheet
**Files:**
- Modify: `package.json` (devDependencies unaffected; dependencies gain two font packages)
- Modify: `angular.json` (`styles` arrays in `build.options`, and `test.options`)
- Modify: `src/index.html`
- Rewrite: `src/styles.scss`
- Modify: `karma.conf` — none exists; add `ChromeHeadlessNoSandbox` via `angular.json` test `browsers`? Karma builder accepts `--browsers` and a custom launcher needs a config file. Create `karma.conf.cjs` and point `test.options.karmaConfig` at it.
**Interfaces:**
- Produces: global classes used by every later task — `.shell`, `.mono`, `.label`, `.panel`, `.tag`, `.tag-row`, `.btn`, `.btn-accent`, `.btn-line`, `.section`, `.section-head`, `.eyebrow-row`, `.status-dot`, `.icon`, plus component-specific classes named in Tasks 3–7 (they are all defined here so the stylesheet is written once).
- [x] **Step 1: Install fonts**
```bash
npm install @fontsource-variable/jetbrains-mono@5.3.0 @fontsource/ibm-plex-sans@5.3.0 --no-audit --no-fund
```
- [x] **Step 2: Register font CSS and karma config in `angular.json`**
In `projects.dzanan.net.architect.build.options.styles` replace the array with:
```json
"styles": [
"node_modules/@fontsource-variable/jetbrains-mono/index.css",
"node_modules/@fontsource/ibm-plex-sans/400.css",
"node_modules/@fontsource/ibm-plex-sans/500.css",
"node_modules/@fontsource/ibm-plex-sans/600.css",
"src/styles.scss"
]
```
Do the same in `architect.test.options.styles`, and add `"karmaConfig": "karma.conf.cjs"` to `architect.test.options`.
Create `karma.conf.cjs`:
```js
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular/build:unit-test'.length ? 'jasmine' : 'jasmine'],
plugins: [require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage')],
client: { jasmine: {}, clearContext: false },
reporters: ['progress'],
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: { ChromeHeadlessNoSandbox: { base: 'ChromeHeadless', flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] } },
restartOnFileChange: false
});
};
```
(The `frameworks` line is just `['jasmine']`; keep it simple: `frameworks: ['jasmine']`.)
- [x] **Step 3: Update `src/index.html`**
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Amar Džanan — Senior Software Engineer &amp; AI Integration</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Senior full-stack engineer modernizing complex enterprise software and connecting it safely to AI.">
<meta name="theme-color" content="#0D1015">
<link rel="canonical" href="https://dzanan.net/">
<link rel="icon" type="image/x-icon" href="duck.png">
</head>
<body>
<app-root></app-root>
</body>
</html>
```
- [x] **Step 4: Rewrite `src/styles.scss`**
```scss
@use 'tailwindcss';
:root {
color-scheme: dark;
--bg: #0D1015;
--panel: #12161D;
--panel-2: #0F1318;
--line: #232A33;
--line-strong: #2A323D;
--text: #E6EAF0;
--muted: #8B95A5;
--dim: #5C6672;
--accent: #8BE04A;
--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(--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: 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; }
/* ---- 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: #A9F26F; 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 { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); }
.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; }
.section { padding-block: 96px; }
.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); }
/* ---- 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; }
.desktop-nav nav a, .mobile-nav nav a, .nav-cv { color: var(--muted); font: 500 13px/1 var(--mono); letter-spacing: .02em; }
.desktop-nav nav a:hover, .mobile-nav nav a:hover, .nav-cv:hover { 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; }
.mobile-nav { display: none; border-top: 1px solid var(--line); padding-block: 16px 20px; }
.mobile-nav nav { display: grid; gap: 14px; }
.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); }
/* ---- 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; }
.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; }
.terminal-dots span { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); }
.terminal-title { color: var(--dim); font-size: 12px; }
.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; }
.cursor { display: inline-block; width: 9px; height: 16px; background: var(--accent); vertical-align: -3px; margin-left: 2px; }
/* ---- 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); }
.section-head.padded { padding-inline: 24px; }
/* ---- 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: #A9F26F; }
.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; }
/* ---- capabilities ---- */
.capabilities { display: grid; grid-template-columns: repeat(3, 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; }
/* ---- 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); }
/* ---- case study ---- */
.breadcrumb { display: flex; gap: 0; font: 400 13px/1 var(--mono); color: var(--text); margin-bottom: 28px; }
.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; align-items: center; }
.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; }
.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; }
/* ---- not found ---- */
.not-found { padding-block: 96px; display: flex; flex-direction: column; gap: 24px; max-width: 720px; }
.not-found .terminal-body { gap: 4px; }
.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; }
}
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes blink { 50% { 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; }
.project-row .tag-row { grid-column: 2; }
.case-section { grid-template-columns: 1fr; gap: 16px; }
}
@media (max-width: 720px) {
.section { padding-block: 64px; }
.desktop-nav { display: none; }
.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-row .tag-row { grid-column: 1 / -1; }
.role { grid-template-columns: 1fr; gap: 6px; }
.capabilities, .decision-grid, .case-pagination { grid-template-columns: 1fr; }
.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; }
.terminal-body { font-size: 12.5px; padding: 16px; }
}
```
- [x] **Step 5: Build to verify the stylesheet compiles**
Run: `npx ng build 2>&1 | tail -5`
Expected: "Application bundle generation complete" (templates still reference old classes — that's fine, they'll be rewritten).
- [x] **Step 6: Commit**
```bash
git add package.json package-lock.json angular.json karma.conf.cjs src/index.html src/styles.scss
git commit -m "feat(ui): dark-technical tokens, self-hosted fonts, base stylesheet"
```
---
### Task 2: Remove theme + CV page; `/cv` → `/cv.pdf`
**Files:**
- Modify: `src/app/app.routes.spec.ts`, `src/app/app.routes.ts`, `src/app/app.routes.server.ts`, `src/app/app.ts`, `src/server.ts`, `public/sitemap.xml`
- Delete: `src/app/features/cv/cv-page.component.ts`, `src/app/features/cv/cv-page.component.spec.ts`, `src/app/core/services/theme.service.ts`, `src/app/shared/ui/theme-toggle/theme-toggle.component.ts`
- Create: `public/cv.pdf` (placeholder)
**Interfaces:**
- Produces: route list without `cv`; `/cv.pdf` static file; nothing else depends on it.
- [x] **Step 1: Update the route spec (failing)**
In `src/app/app.routes.spec.ts` change the expected path list to:
```ts
expect(routes.map(route => route.path)).toEqual([
'',
'work/secure-ai-integration-gateway',
'work/travel-operations-platform',
'work/service-operations-platform',
'work/biodiversity-data-platform',
'portfolio',
'**'
]);
```
- [x] **Step 2: Run tests to see it fail**
Run: `CHROME_BIN=$CHROME npx ng test --watch=false 2>&1 | tail -20` — expected: 1 FAILED (routes contain `cv`). (`$CHROME` = Playwright chromium binary; see Global Constraints.)
- [x] **Step 3: Remove the `cv` route and theme wiring**
`src/app/app.routes.ts`: delete the `{ path: 'cv', ... }` object.
`src/app/app.routes.server.ts`: delete the `{ path: 'cv', renderMode: RenderMode.Prerender }` line.
`src/app/app.ts`:
```ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html'
})
export class App {}
```
Delete the four files listed above (`git rm`). `PageShellComponent` still imports the toggle — Task 3 fixes it; for this task's test run, remove `ThemeToggleComponent` from its `imports` array and the two `<app-theme-toggle />` tags now.
- [x] **Step 4: Server redirect + sitemap + placeholder PDF**
`src/server.ts`, insert before the `express.static` block:
```ts
/** The CV is a static PDF; keep the old /cv URL working. */
app.get('/cv', (_req, res) => {
res.redirect(301, '/cv.pdf');
});
```
`public/sitemap.xml`: remove the `<url><loc>https://dzanan.net/cv</loc></url>` line.
Placeholder PDF (until Amar supplies the real one), generated with Python:
```bash
python3 - <<'EOF'
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
c = canvas.Canvas('public/cv.pdf', pagesize=A4)
c.setFont('Helvetica-Bold', 22); c.drawString(72, 760, 'Amar Dzanan')
c.setFont('Helvetica', 12); c.drawString(72, 736, 'CV coming soon - amar@dzanan.net')
c.save()
EOF
```
(If reportlab is missing: `pip install reportlab --break-system-packages`.)
- [x] **Step 5: Run tests — pass**
Run: `CHROME_BIN=$CHROME npx ng test --watch=false 2>&1 | tail -5` — expected: all SUCCESS (the landing spec still expects `a[href="/cv"]`, which still exists until Task 5 — it passes for now).
- [x] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: drop CV page and theme toggle; redirect /cv to /cv.pdf"
```
---
### Task 3: Navigation config and page shell
**Files:**
- Modify: `src/app/shared/config/navigation.config.ts`, `src/app/core/layout/page-shell.component.ts`
**Interfaces:**
- Produces: `MAIN_NAVIGATION: SectionNavItem[]` = work / experience / contact; `PageShellComponent` with `@Input() sections`, unchanged selector `app-page-shell`.
- [x] **Step 1: Navigation config**
```ts
import { SectionNavItem } from '../models/section-nav.model';
export const MAIN_NAVIGATION: SectionNavItem[] = [
{ label: 'work', path: '/', fragment: 'work' },
{ label: 'experience', path: '/', fragment: 'experience' },
{ label: 'contact', path: '/', fragment: 'contact' }
];
export const CV_URL = '/cv.pdf';
```
- [x] **Step 2: Page shell**
```ts
import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { SectionNavItem } from '../../shared/models/section-nav.model';
import { CV_URL } from '../../shared/config/navigation.config';
@Component({
selector: 'app-page-shell',
standalone: true,
imports: [RouterLink],
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-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 class="nav-cv" [href]="cvUrl" target="_blank" rel="noopener">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>
</nav>
<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>
</button>
</div>
@if (menuOpen()) {
<div id="mobile-navigation" class="mobile-nav shell">
<nav aria-label="Mobile navigation">
@for (section of sections; track section.label) {
<a [routerLink]="section.path || []" [fragment]="section.fragment" (click)="closeMenu()">{{ section.label }}</a>
}
<a class="nav-cv" [href]="cvUrl" target="_blank" rel="noopener" (click)="closeMenu()">cv.pdf ↓</a>
</nav>
</div>
}
</header>
<main id="main-content">
<ng-content />
</main>
<footer class="site-footer">
<div class="shell footer-inner">
<span>Amar Džanan · Sarajevo, Bosnia and Herzegovina</span>
<span><a href="mailto:amar@dzanan.net">amar&#64;dzanan.net</a> · <a href="https://www.linkedin.com/in/amardzanan/">LinkedIn</a></span>
</div>
</footer>
</div>
`
})
export class PageShellComponent {
@Input({ required: true }) sections: SectionNavItem[] = [];
readonly cvUrl = CV_URL;
readonly menuOpen = signal(false);
toggleMenu(): void { this.menuOpen.update(open => !open); }
closeMenu(): void { this.menuOpen.set(false); }
}
```
- [x] **Step 3: Build**
Run: `npx ng build 2>&1 | grep -E "error|complete"` — expected: "complete", no errors.
- [x] **Step 4: Commit**
```bash
git add src/app/shared/config/navigation.config.ts src/app/core/layout/page-shell.component.ts
git commit -m "feat(shell): mono header with cv.pdf link and availability status"
```
---
### Task 4: `tagline` on portfolio projects
**Files:**
- Modify: `src/app/features/work/portfolio-project.model.ts`, `src/app/features/work/portfolio-projects.data.ts`, `src/app/features/work/portfolio-projects.data.spec.ts`
**Interfaces:**
- Produces: `PortfolioProject.tagline: string` (≤ 70 chars, no trailing period).
- [x] **Step 1: Failing test** — add inside the `for (const project of PORTFOLIO_PROJECTS)` loop in the data spec:
```ts
expect(project.tagline.length).toBeGreaterThan(10);
expect(project.tagline.length).toBeLessThanOrEqual(70);
```
- [x] **Step 2: Run** — expected: TypeScript error `tagline does not exist` (compile failure counts as red).
- [x] **Step 3: Implement** — model: add `readonly tagline: string;` after `summary`. Data, one per project (insert after each `summary:` value):
| id | tagline |
|---|---|
| ai-integration | `Production MCP service · validated boundaries · verified writes` |
| travel-operations | `Products, departures, reservations, finance, partner systems` |
| service-operations | `Tickets, worklogs, contracts, accounting · CQRS · SignalR` |
| biodiversity-data | `Taxonomy, sampling, specimens, habitats, map-based GIS` |
- [x] **Step 4: Run tests** — expected: SUCCESS.
- [x] **Step 5: Commit** — `git commit -am "feat(data): short taglines for the work list"`
---
### Task 5: Landing page
**Files:**
- Modify: `src/app/features/landing/landing-page.component.spec.ts`, `src/app/features/landing/landing-page.component.ts`
- [x] **Step 1: Update spec (failing)** — replace the two `expect`s that change:
```ts
expect(page.querySelector('h1')?.textContent).toContain('Complex systems, understood before changed.');
// ...
expect(page.querySelector('a[href="/cv.pdf"]')).toBeTruthy();
```
and add to the first test: `expect(page.querySelector('[data-testid="profile-terminal"]')?.textContent).toContain('"open_to"');`
- [x] **Step 2: Run** — expected: FAIL on h1 text.
- [x] **Step 3: Implement `landing-page.component.ts`**
```ts
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 { CV_URL, MAIN_NAVIGATION } from '../../shared/config/navigation.config';
import { PORTFOLIO_PROJECTS } from '../work/portfolio-projects.data';
type TerminalValue = { kind: 'str'; value: string } | { kind: 'num'; value: number } | { kind: 'list'; value: string[] };
@Component({
selector: 'app-landing-page',
standalone: true,
imports: [PageShellComponent, NgOptimizedImage, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-shell [sections]="sections">
<section class="hero shell" aria-labelledby="hero-title">
<div class="hero-copy">
<p class="label">Senior full-stack engineer — .NET · Angular · MCP</p>
<h1 id="hero-title">Complex systems, <span>understood before changed.</span></h1>
<p class="hero-lead">I modernize enterprise software and connect it 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&#64;dzanan.net</a>
<a class="btn btn-line" [href]="cvUrl" target="_blank" rel="noopener">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>
</div>
</div>
<div class="panel terminal" data-testid="profile-terminal" aria-label="Profile summary as JSON">
<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 style="width:42px"></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 (entry of profile; track entry.key; let last = $last) {
<div class="terminal-line indent"><span class="k">"{{ entry.key }}"</span>: @switch (entry.value.kind) {
@case ('str') { <span class="s">"{{ entry.value.value }}"</span> }
@case ('num') { <span class="n">{{ entry.value.value }}</span> }
@case ('list') { [@for (item of entry.value.value; track item; let lastItem = $last) {<span class="s">"{{ item }}"</span>@if (!lastItem) {, }}] }
}@if (!last) {,}</div>
}
<div class="terminal-line">{{ '}' }}</div>
<div class="terminal-line" style="margin-top:6px"><span class="c">$</span> <span class="cursor" aria-hidden="true"></span></div>
</div>
</div>
</section>
<section id="work" class="section shell" aria-labelledby="work-title" style="padding-top:0">
<div class="section-head padded">
<div><span class="label">// selected work</span><h2 id="work-title">Production systems, anonymized.</h2></div>
<span class="count">{{ projects.length }} case studies</span>
</div>
<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>
<section id="experience" class="section shell experience" aria-labelledby="experience-title">
<div class="experience-intro">
<img ngSrc="/images/profile.jpg" width="96" height="96" alt="Portrait of Amar Džanan" class="portrait" />
<span class="label">// experience</span>
<h2 id="experience-title">Eight years, one product at a time.</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>
<a class="text-link" href="https://www.linkedin.com/in/amardzanan/">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 shell" aria-labelledby="capabilities-title" style="padding-top:0">
<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 id="contact" class="section shell" aria-labelledby="contact-title" style="padding-top:0">
<div class="panel contact">
<span class="label">// contact</span>
<h2 id="contact-title">Need a senior engineer who can understand the system before changing it?</h2>
<div class="contact-actions"><a class="btn btn-accent" href="mailto:amar@dzanan.net">amar&#64;dzanan.net</a><span class="meta">Sarajevo · CET / CEST</span></div>
</div>
</section>
</app-page-shell>
`
})
export class LandingPageComponent {
readonly sections = MAIN_NAVIGATION;
readonly cvUrl = CV_URL;
readonly projects = PORTFOLIO_PROJECTS;
readonly profile: { key: string; value: TerminalValue }[] = [
{ key: 'name', value: { kind: 'str', value: 'Amar Džanan' } },
{ key: 'role', value: { kind: 'str', value: 'Senior Full-Stack Engineer · Product Owner' } },
{ key: 'stack', value: { kind: 'list', value: ['.NET', 'Angular', 'TypeScript'] } },
{ key: 'focus', value: { kind: 'list', value: ['modernization', 'safe AI integration'] } },
{ key: 'since', value: { kind: 'num', value: 2018 } },
{ key: 'location', value: { kind: 'str', value: 'Sarajevo · CET' } },
{ key: 'open_to', value: { kind: 'list', value: ['senior roles', 'consulting'] } }
];
readonly experience = [
{ period: '2025 — now', company: 'Unija Smart Accounting BiH', title: 'Senior Software Engineer', focus: 'Enterprise platforms, modernization, automation and AI integration.' },
{ period: '2023 — now', company: 'COMP-2000', title: 'Product Owner', focus: 'Product direction, backlog ownership, cross-functional delivery and user feedback.' },
{ period: '2020 — now', company: 'COMP-2000', title: 'Software Developer', focus: 'Full-stack .NET, Angular, mobile, APIs, data and production support.' },
{ period: '2018 — 2020', company: 'COMP-2000', title: 'Junior Software Developer', focus: 'C#, SQL, desktop, web, mobile and application lifecycle foundations.' }
];
readonly capabilities = [
{ number: '01', title: 'System modernization', detail: 'Upgrade frameworks, identity, routing, delivery and diagnostics without losing working domain knowledge.' },
{ number: '02', title: 'Full-stack product delivery', detail: 'Carry business workflows through interface design, APIs, domain rules, persistence, integrations and tests.' },
{ number: '03', title: 'Safe AI integration', detail: 'Expose useful capabilities through narrow tools, validated boundaries, verified writes and observable operations.' }
];
constructor() {
inject(SeoService).update({
title: 'Amar Džanan — Senior Software Engineer & AI Integration',
description: 'Senior full-stack engineer modernizing complex enterprise software and connecting it safely to AI.',
path: '/',
structuredData: {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Amar Džanan',
url: 'https://dzanan.net/',
email: 'mailto:amar@dzanan.net',
jobTitle: 'Senior Software Engineer',
sameAs: ['https://www.linkedin.com/in/amardzanan/'],
knowsAbout: ['.NET', 'Angular', 'TypeScript', 'Software architecture', 'Model Context Protocol', 'AI integration']
}
});
}
}
```
- [x] **Step 4: Run tests** — expected: SUCCESS.
- [x] **Step 5: Commit** — `git commit -am "feat(landing): dark-technical hero, work list, experience, capabilities, contact"`
---
### Task 6: Case study page + diagram recolour
**Files:**
- Modify: `src/app/features/portfolio/portfolio-page.component.spec.ts`, `src/app/features/portfolio/portfolio-page.component.ts`
- `system-diagram.component.ts`: no change needed — its classes (`diagram-lines`, `diagram-node`, `accent`, `diagram-note`, `small`) are restyled in Task 1. Only wrap `<figure>` with class `panel`: change `class="system-diagram"` to `class="system-diagram panel"`.
- [x] **Step 1: Spec (failing)** — add `expect(page.querySelector('a[href="/cv.pdf"]')).toBeTruthy();` and `expect(page.textContent).toContain('// 01 context');`.
- [x] **Step 2: Run** — expected: FAIL.
- [x] **Step 3: Implement template** (class and constructor unchanged; add `readonly cvUrl = CV_URL;` and import `CV_URL`):
```html
<app-page-shell [sections]="sections">
<article>
<header class="case-hero shell">
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/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" 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">
<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>{{ contributionNumber(index) }}</span><p>{{ contribution }}</p></li>
}
</ol>
</div>
</section>
<section class="diagram-wrap shell">
<app-system-diagram [kind]="project.diagram" [title]="project.title" />
</section>
<section class="case-section shell" aria-labelledby="decisions-title">
<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 class="panel decision"><span>{{ contributionNumber(index) }}</span><h3>{{ decision.title }}</h3><p>{{ decision.detail }}</p></article>
}
</div>
</div>
</section>
<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>
<nav class="case-pagination shell" aria-label="Case studies">
<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="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&#64;dzanan.net</a><a class="text-link" [href]="cvUrl" target="_blank" rel="noopener">cv.pdf ↓</a></div>
</div>
</section>
</article>
</app-page-shell>
```
- [x] **Step 4: Run tests** — expected: SUCCESS.
- [x] **Step 5: Commit** — `git commit -am "feat(work): dark-technical case study layout and diagram recolour"`
---
### Task 7: Not-found page
**Files:** `src/app/features/not-found/not-found-page.component.ts`
- [x] **Step 1: Implement**
```html
<app-page-shell [sections]="sections">
<section class="not-found shell">
<div class="panel terminal">
<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 style="width:42px"></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" style="margin-top:6px"><span class="c">$</span> <span class="cursor" aria-hidden="true"></span></div>
</div>
</div>
<h1>This path ends here.</h1>
<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>
```
Component gets `readonly path = inject(DOCUMENT).location?.pathname ?? '/unknown';` — use `inject(DOCUMENT)` from `@angular/common` (SSR-safe: on the server `document.location` exists in Domino as the request URL).
- [x] **Step 2: Build + commit** — `npx ng build`; `git commit -am "feat(404): terminal-style not-found page"`
---
### Task 8: Verification
- [x] Unit tests: `CHROME_BIN=$CHROME npx ng test --watch=false` → all SUCCESS.
- [x] Production build: `npx ng build` → zero errors, prerendered 5 routes, budgets not exceeded.
- [x] Serve: `PORT=4100 node dist/dzanan.net/server/server.mjs &`; `curl -sI localhost:4100/cv | head -3` → `301` + `Location: /cv.pdf`; `curl -sI localhost:4100/cv.pdf | head -1` → 200; `curl -s localhost:4100/nope -o /dev/null -w "%{http_code}"` → 404.
- [x] Screenshots with Playwright at 1440×900 and 390×844 of `/`, `/work/secure-ai-integration-gateway`, `/nope`; view them; fix wrapping/overflow issues and re-run.
- [x] Commit fixes; update the plan checkboxes.

View File

@@ -0,0 +1,173 @@
# dzanan.net — "Dark & technical" redesign
Date: 2026-09-06 · Status: approved by Amar (direction C of three mockups)
## Goal
Replace the current warm/editorial design of dzanan.net with the "Dark & technical"
direction: near-black surfaces with a faint grid, monospace labels, a terminal-style
profile panel, and a single glowing green accent. The site serves both employers and
consulting clients equally. The CV becomes a PDF that Amar provides; the site links to it.
Non-goals: new content for the case studies (text stays), a light theme, a CMS, new routes.
## Scope
Kept: Angular 22 app structure, SSR + prerendering, `SeoService`, `PageShellComponent`
(restyled), landing page, four `/work/*` case-study pages, `SystemDiagramComponent`
(recoloured), not-found page, Tailwind v4 install, Docker deploy.
Removed: `CvPageComponent` + its spec, `ThemeService`, `ThemeToggleComponent`, the theme
bootstrap script in `src/index.html`, the `cv` client route, the `cv` prerender route.
Added: `public/cv.pdf` (placeholder until Amar supplies the file), a 301 from `/cv` to
`/cv.pdf` in `src/server.ts`, self-hosted fonts.
## Visual system
Defined as CSS custom properties on `:root` in `src/styles.scss`; no `.dark` variant.
| token | value | use |
|---|---|---|
| `--bg` | `#0D1015` | page background |
| `--panel` | `#12161D` | cards, list panel, code panel |
| `--panel-2` | `#0F1318` | panel headers / inset |
| `--line` | `#232A33` | borders, dividers |
| `--line-strong` | `#2A323D` | tag borders, button outline |
| `--text` | `#E6EAF0` | primary text |
| `--muted` | `#8B95A5` | secondary text |
| `--dim` | `#5C6672` | tertiary text, comments |
| `--accent` | `#8BE04A` | labels, links, primary button, status dot |
| `--accent-glow` | `rgba(139,224,74,.22)` | button and dot glow |
| `--code-key` / `--code-str` / `--code-num` | `#7AA2F7` / `#C3E88D` / `#F78C6C` | profile.json panel |
Grid: `linear-gradient` lines at 2.8% white, 64px cell, fixed to the page background.
Type: JetBrains Mono (labels, nav, meta, code, buttons) and IBM Plex Sans (headings and
body), self-hosted via `@fontsource-variable/jetbrains-mono` and `@fontsource/ibm-plex-sans`
(weights 400/500/600) imported in `styles.scss`. Fallbacks: Consolas/Menlo/monospace and
"Segoe UI"/system-ui/sans-serif. Base body 16px; headline scale `clamp(2.4rem, 5vw, 3.9rem)`
for h1, `clamp(1.75rem, 3vw, 2.1rem)` for h2, letter-spacing -0.03em/-0.025em, weight 600.
Labels (`.label`): mono, 12px, uppercase, 0.08em tracking, accent colour, prefixed with
`// ` in markup (e.g. `// selected work`). Tags (`.tag`): mono 12px, 24px tall, 1px
`--line-strong` border, 4px radius. Panels (`.panel`): `--panel` fill, 1px `--line`
border, 10px radius. Buttons: 46px tall, 6px radius, mono 14px; `.btn-accent` accent fill
with dark text and glow, `.btn-line` outlined.
Container: `.shell` = `min(1160px, 100% - 40px)` centred. Section spacing 96px desktop,
64px below 720px.
## Pages
### Shell (header, footer, mobile menu)
Header: sticky, `--bg` at 92% with blur, bottom border. Left: mono wordmark `~/` (accent)
+ `dzanan.net` (600). Right (desktop): mono nav `work · experience · contact · cv.pdf`,
then a divider and the status `● available` (accent dot with glow). `cv.pdf` is an `<a
href="/cv.pdf">` with a download icon, opens in a new tab. Below 720px the nav collapses to
the existing signal-driven menu button; the status stays visible.
Navigation config: `[{work,'/',#work},{experience,'/',#experience},{contact,'/',#contact}]`
plus the CV link rendered separately as a plain anchor (not a `SectionNavItem`).
Footer: mono 12px, `Amar Džanan · Sarajevo, Bosnia and Herzegovina` left; email and
LinkedIn right.
### Landing (`/`)
1. Hero — two columns (1.05fr / .95fr, 64px gap), stacks below 1024px.
Left: label `Senior full-stack engineer — .NET · Angular · MCP`; h1 "Complex systems,
<accent>understood before changed.</accent>"; lead paragraph "I modernize enterprise
software and connect it to AI through narrow, validated, verified interfaces — the kind
you can run in production and sleep at night."; buttons `amar@dzanan.net` (accent,
mailto) and `cv.pdf` (outline, download).
Right: `profile.json` terminal panel — title bar with three dots and file name, then a
`$ whoami --json` line and a JSON object: name, role, stack `[".NET","Angular",
"TypeScript"]`, focus `["modernization","safe AI integration"]`, since `2018`, location
`"Sarajevo · CET"`, open_to `["senior roles","consulting"]`, closing prompt with a
blinking cursor. Rendered from a small typed data structure in the component so it can
change without touching markup.
2. `// selected work` (`#work`) — heading "Production systems, anonymized." and a right-aligned
mono count `4 case studies`. One `.panel` containing four rows (`data-testid="project-row"`,
`routerLink` to `/work/<slug>`): index (accent on the featured first row), title + one-line
summary (a new short `tagline` field on `PortfolioProject`, ~60 chars), up to four stack
tags (first four of `stack`), arrow. Hover: row background `--panel-2`, arrow turns accent.
Below 720px: grid collapses to index+title, tagline, tags stacked.
3. `// experience` (`#experience`) — grid 340px / 1fr. Left: 96px square grayscale portrait
(`NgOptimizedImage`, 8px radius, 1px border), label, h2 "Eight years, one product at a
time.", about paragraph, LinkedIn link. Right: four role rows (period mono `2025 — now`,
title + company, focus sentence), 1px dividers.
4. `// capabilities` — three equal `.panel` cards: `01 System modernization`, `02 Full-stack
product delivery`, `03 Safe AI integration`, each with the existing detail text.
Single column below 720px.
5. Contact (`#contact`) — a `.panel` with a 1px accent-tinted border and inner glow:
label `// contact`, h2 "Need a senior engineer who can understand the system before
changing it?", `.btn-accent` mailto button, mono `Sarajevo · CET / CEST`.
Structured data (schema.org Person) unchanged.
### Case study (`/work/:slug`)
Breadcrumb as a path: `~/work/` (dim) + slug (text), mono. Header: label
`<category> · <period>`, h1 title, lead summary, then a meta strip in a `.panel`: `role`,
`period`, stack tags. Sections keep content; labels become `// 01 context`, `// 02 delivery`,
`// 03 architecture`, `// 04 result`; contributions as a numbered list with mono indices;
decisions as a 2×2 `.panel` grid (single column < 720px); technology as a tag row.
`SystemDiagramComponent` stays, recoloured: node fill `--panel`, stroke `--line-strong`,
accent node stroke/text `--accent`, lines `--line-strong`, notes `--dim`, mono text.
Prev/next as two `.panel` links. Contact block reused. The "View full CV" link points to
`/cv.pdf`.
### Not found
Terminal panel: `$ open <path>` then `command not found: <path>` in `--code-num`, a short
sentence, and a `.btn-line` back to `/`. Server still returns 404.
## Behaviour
- Motion: hero children fade-up (opacity 0→1, translateY 12px→0, 480ms, 80ms stagger) once
on load; cursor blink 1s step; row hover transitions 150ms. All wrapped in
`@media (prefers-reduced-motion: no-preference)`.
- Smooth anchor scrolling with `scroll-padding-top` for the sticky header (kept).
- Focus rings: 2px accent outline, 3px offset.
- `<meta name="theme-color">` → `#0D1015`; `color-scheme: dark`.
## Server
`src/server.ts`: before the Angular handler, `app.get('/cv', (_, res) => res.redirect(301,
'/cv.pdf'))`. `public/cv.pdf` is served by the existing static middleware. Until Amar
supplies the file, a one-page placeholder PDF saying "CV coming soon — amar@dzanan.net" is
committed so the link never 404s. `public/sitemap.xml` drops `/cv`.
## Files
Modified: `styles.scss`, `index.html`, `server.ts`, `app.ts`, `app.routes.ts`,
`app.routes.server.ts`, `app.routes.spec.ts`, `page-shell.component.ts`,
`navigation.config.ts`, `landing-page.component.ts` + spec, `portfolio-page.component.ts`
+ spec, `system-diagram.component.ts`, `not-found-page.component.ts`,
`portfolio-project.model.ts`, `portfolio-projects.data.ts`, `public/sitemap.xml`,
`package.json`/lock (fontsource packages).
Removed: `features/cv/cv-page.component.ts`, `features/cv/cv-page.component.spec.ts`,
`core/services/theme.service.ts`, `shared/ui/theme-toggle/theme-toggle.component.ts`.
Added: `public/cv.pdf` (placeholder), this spec, the implementation plan.
## Testing
- Unit: route spec lists routes without `cv`; landing spec asserts the new h1, four project
rows, `a[href="/cv.pdf"]`, mailto and LinkedIn links, the four role titles; portfolio spec
unchanged in intent; new not-found assertion optional. Run with headless Chromium.
- Build: `ng build` (production, SSR, prerender of `/` and the four work pages) with zero
errors and within budgets (component styles are inline SCSS; keep under 8kB each).
- Visual: serve `dist` and screenshot `/`, one case study and `/404` at 1440px and 390px;
fix wrapping, overflow and contrast issues found.
- Smoke: `curl -I /cv` → 301 to `/cv.pdf`; `/cv.pdf` → 200.
## Delivery
Work happens in the cloud copy of the repo; changed and new files are written back to the
`dzanan.net` folder on Amar's machine, removed files are moved into `_to_delete/`. Amar runs
`npm install` locally afterwards (new font packages) and drops the real PDF over
`public/cv.pdf`.

22
karma.conf.cjs Normal file
View File

@@ -0,0 +1,22 @@
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage')
],
client: { jasmine: {}, clearContext: false },
reporters: ['progress'],
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
}
},
restartOnFileChange: false
});
};

5488
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,15 +21,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^21.2.11", "@angular/cdk": "^22.1.5",
"@angular/common": "^21.2.13", "@angular/common": "^22.1.5",
"@angular/compiler": "^21.2.13", "@angular/compiler": "^22.1.5",
"@angular/core": "^21.2.13", "@angular/core": "^22.1.5",
"@angular/forms": "^21.2.13", "@angular/forms": "^22.1.5",
"@angular/platform-browser": "^21.2.13", "@angular/platform-browser": "^22.1.5",
"@angular/platform-server": "^21.2.13", "@angular/platform-server": "^22.1.5",
"@angular/router": "^21.2.13", "@angular/router": "^22.1.5",
"@angular/ssr": "^21.2.11", "@angular/ssr": "^22.1.7",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"express": "^5.1.0", "express": "^5.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -38,12 +40,13 @@
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.2.11", "@angular/build": "^22.1.7",
"@angular/cli": "^21.2.11", "@angular/cli": "^22.1.7",
"@angular/compiler-cli": "^21.2.13", "@angular/compiler-cli": "^22.1.5",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"istanbul-lib-instrument": "^6.0.3",
"jasmine-core": "~5.8.0", "jasmine-core": "~5.8.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
@@ -51,6 +54,6 @@
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"typescript": "~5.9.3" "typescript": "~6.0.3"
} }
} }

BIN
public/cv.pdf Normal file

Binary file not shown.

4
public/robots.txt Normal file
View File

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

14
public/sitemap.xml Normal file
View File

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

View File

@@ -2,7 +2,7 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessC
import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideClientHydration, withEventReplay, withNoIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -14,6 +14,6 @@ export const appConfig: ApplicationConfig = {
scrollPositionRestoration: 'enabled' scrollPositionRestoration: 'enabled'
}) })
), ),
provideClientHydration(withEventReplay()) provideClientHydration(withEventReplay(), withNoIncrementalHydration())
] ]
}; };

View File

@@ -1,8 +1,21 @@
import { RenderMode, ServerRoute } from '@angular/ssr'; import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [ 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/fuel-station-management-system', 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: '**', path: '**',
renderMode: RenderMode.Prerender renderMode: RenderMode.Server,
status: 404
} }
]; ];

View File

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

View File

@@ -6,12 +6,59 @@ export const routes: Routes = [
path: '', path: '',
component: LandingPageComponent 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', path: 'cv',
loadComponent: () => import('./features/cv/cv-page.component').then(m => m.CvPageComponent) loadComponent: () => import('./features/cv/cv-page.component').then(m => m.CvPageComponent)
}, },
{ {
path: 'portfolio', 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) 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/fuel-station-management-system',
data: { projectId: 'fuel-management' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'work/travel-operations-platform',
data: { projectId: 'travel-operations' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'work/service-operations-platform',
data: { projectId: 'service-operations' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'work/biodiversity-data-platform',
data: { projectId: 'biodiversity-data' },
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
},
{
path: 'portfolio',
redirectTo: '',
pathMatch: 'full'
},
{
path: '**',
loadComponent: () => import('./features/not-found/not-found-page.component').then(m => m.NotFoundPageComponent)
} }
]; ];

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { ThemeService } from './core/services/theme.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -9,6 +8,4 @@ import { ThemeService } from './core/services/theme.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html' templateUrl: './app.html'
}) })
export class App { export class App {}
private readonly themeService = inject(ThemeService);
}

View File

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

View File

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

View File

@@ -1,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);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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&#64;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&#64;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'
});
}
}

View File

@@ -0,0 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { CvPageComponent } from './cv-page.component';
describe('CvPageComponent', () => {
it('embeds the PDF and offers a download link', async () => {
await TestBed.configureTestingModule({
imports: [CvPageComponent],
providers: [provideRouter([])]
}).compileComponents();
const fixture = TestBed.createComponent(CvPageComponent);
fixture.detectChanges();
const page = fixture.nativeElement as HTMLElement;
expect(page.querySelector('object[data="/cv.pdf"]')).toBeTruthy();
expect(page.querySelector('a[href="/cv.pdf"][download]')).toBeTruthy();
});
});

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

@@ -1,19 +1,52 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PageShellComponent } from '../../core/layout/page-shell.component'; import { PageShellComponent } from '../../core/layout/page-shell.component';
import { CvSectionComponent } from './cv-section.component'; import { SeoService } from '../../core/services/seo.service';
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config'; import { CV_URL, MAIN_NAVIGATION } from '../../shared/config/navigation.config';
@Component({ @Component({
selector: 'app-cv-page', selector: 'app-cv-page',
standalone: true, standalone: true,
imports: [PageShellComponent, CvSectionComponent], imports: [PageShellComponent, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<app-page-shell [sections]="sections"> <app-page-shell [sections]="sections">
<app-cv-section /> <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="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>
</app-page-shell> </app-page-shell>
` `
}) })
export class CvPageComponent { export class CvPageComponent {
readonly sections = MAIN_NAVIGATION; readonly sections = MAIN_NAVIGATION;
readonly cvUrl = CV_URL;
constructor() {
inject(SeoService).update({
title: 'CV — Amar Džanan, Senior Software Engineer',
description: 'Curriculum vitae of Amar Džanan: senior full-stack engineer and product owner working with .NET, Angular, and AI integration.',
path: '/cv'
});
}
} }

View File

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

View File

@@ -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'
});
}
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { LandingPageComponent } from './landing-page.component';
describe('LandingPageComponent', () => {
async function render(): Promise<HTMLElement> {
await TestBed.configureTestingModule({
imports: [LandingPageComponent],
providers: [provideRouter([])]
}).compileComponents();
const fixture = TestBed.createComponent(LandingPageComponent);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
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('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();
});
});

View File

@@ -1,28 +1,116 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PageShellComponent } from '../../core/layout/page-shell.component'; import { PageShellComponent } from '../../core/layout/page-shell.component';
import { HomeSectionComponent } from '../home/home-section.component'; import { SeoService } from '../../core/services/seo.service';
import { AboutMeSectionComponent } from '../about/about-me-section.component'; import { MAIN_NAVIGATION, PAGE_INDEX } from '../../shared/config/navigation.config';
import { AboutItSectionComponent } from '../about/about-it-section.component';
import { MAIN_NAVIGATION } 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({ @Component({
selector: 'app-landing-page', selector: 'app-landing-page',
standalone: true, standalone: true,
imports: [ imports: [PageShellComponent, RouterLink],
PageShellComponent,
HomeSectionComponent,
AboutMeSectionComponent,
AboutItSectionComponent
],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<app-page-shell [sections]="sections"> <app-page-shell [sections]="sections">
<app-home-section /> <section class="hero shell" aria-labelledby="hero-title">
<app-about-me-section /> <div class="hero-copy">
<app-about-it-section /> <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&#64;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>
</section>
<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="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>
</app-page-shell> </app-page-shell>
` `
}) })
export class LandingPageComponent { export class LandingPageComponent {
readonly sections = MAIN_NAVIGATION; readonly sections = MAIN_NAVIGATION;
readonly pages = PAGE_INDEX;
readonly terminalLines = toTerminalLines(PROFILE);
constructor() {
inject(SeoService).update({
title: 'Amar Džanan — Senior Software Engineer & AI Integration',
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',
'@type': 'Person',
name: 'Amar Džanan',
url: 'https://dzanan.net/',
email: 'mailto:amar@dzanan.net',
jobTitle: 'Senior Software Engineer',
sameAs: ['https://www.linkedin.com/in/amardzanan/'],
address: { '@type': 'PostalAddress', addressLocality: 'Ljubljana', addressCountry: 'SI' },
knowsAbout: ['.NET', 'Angular', 'TypeScript', 'Ionic', 'ERP software', 'Expert systems', 'Software architecture', 'Model Context Protocol', 'AI integration']
}
});
}
} }

View File

@@ -0,0 +1,42 @@
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';
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
@Component({
selector: 'app-not-found-page',
standalone: true,
imports: [PageShellComponent, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-page-shell [sections]="sections">
<section class="not-found shell">
<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 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({
title: 'Page not found — Amar Džanan',
description: 'The requested page could not be found.',
path: '/404'
});
}
}

View File

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

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

@@ -1,19 +1,111 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { PageShellComponent } from '../../core/layout/page-shell.component'; import { PageShellComponent } from '../../core/layout/page-shell.component';
import { PortfolioSectionComponent } from './portfolio-section.component'; import { SeoService } from '../../core/services/seo.service';
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config'; import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
import { PortfolioProjectId } from '../work/portfolio-project.model';
import { getPortfolioProject, PORTFOLIO_PROJECTS } from '../work/portfolio-projects.data';
import { SystemDiagramComponent } from '../work/system-diagram.component';
@Component({ @Component({
selector: 'app-portfolio-page', selector: 'app-portfolio-page',
standalone: true, standalone: true,
imports: [PageShellComponent, PortfolioSectionComponent], imports: [PageShellComponent, RouterLink, SystemDiagramComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<app-page-shell [sections]="sections"> <app-page-shell [sections]="sections">
<app-portfolio-section /> <article>
<header class="case-hero shell">
<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" 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">
<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>{{ contributionNumber(index) }}</span><p>{{ contribution }}</p></li>
}
</ol>
</div>
</section>
<section class="diagram-wrap shell">
<app-system-diagram [kind]="project.diagram" [title]="project.title" />
</section>
<section class="case-section shell" aria-labelledby="decisions-title">
<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 class="panel decision"><span>{{ contributionNumber(index) }}</span><h3>{{ decision.title }}</h3><p>{{ decision.detail }}</p></article>
}
</div>
</div>
</section>
<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>
<nav class="case-pagination shell" aria-label="Case studies">
<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="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&#64;dzanan.net</a><a class="text-link" routerLink="/cv">cv.pdf →</a></div>
</div>
</section>
</article>
</app-page-shell> </app-page-shell>
` `
}) })
export class PortfolioPageComponent { export class PortfolioPageComponent {
readonly sections = MAIN_NAVIGATION; readonly sections = MAIN_NAVIGATION;
readonly project = getPortfolioProject(inject(ActivatedRoute).snapshot.data['projectId'] as PortfolioProjectId);
readonly projectIndex = PORTFOLIO_PROJECTS.findIndex(item => item.id === this.project.id);
readonly previous = PORTFOLIO_PROJECTS[(this.projectIndex - 1 + PORTFOLIO_PROJECTS.length) % PORTFOLIO_PROJECTS.length];
readonly next = PORTFOLIO_PROJECTS[(this.projectIndex + 1) % PORTFOLIO_PROJECTS.length];
constructor() {
inject(SeoService).update({
title: `${this.project.title} — Amar Džanan`,
description: this.project.seoDescription,
path: `/work/${this.project.slug}`,
type: 'article',
structuredData: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: this.project.title,
description: this.project.seoDescription,
author: { '@type': 'Person', name: 'Amar Džanan', url: 'https://dzanan.net/' },
url: `https://dzanan.net/work/${this.project.slug}`
}
});
}
contributionNumber(index: number): string {
return String(index + 1).padStart(2, '0');
}
} }

View File

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

View File

@@ -0,0 +1,40 @@
export type PortfolioProjectId =
| 'ai-integration'
| 'field-sales'
| 'fuel-management'
| 'travel-operations'
| 'service-operations'
| 'biodiversity-data';
export type ProjectDiagramKind =
| 'trust-boundaries'
| 'field-sales-platform'
| 'fuel-station-platform'
| 'travel-platform'
| 'multitenant-platform'
| 'research-platform';
export interface ProjectDecision {
readonly title: string;
readonly detail: string;
}
export interface PortfolioProject {
readonly id: PortfolioProjectId;
readonly index: string;
readonly slug: string;
readonly title: string;
readonly category: string;
readonly period: string;
readonly role: string;
readonly summary: string;
/** One line (≤ 70 chars) shown in the work list. */
readonly tagline: string;
readonly challenge: string;
readonly contributions: readonly string[];
readonly decisions: readonly ProjectDecision[];
readonly outcome: string;
readonly stack: readonly string[];
readonly diagram: ProjectDiagramKind;
readonly seoDescription: string;
}

View File

@@ -0,0 +1,49 @@
import { getPortfolioProject, PORTFOLIO_PROJECTS } from './portfolio-projects.data';
describe('portfolio project registry', () => {
it('contains four complete, uniquely addressable case studies', () => {
expect(PORTFOLIO_PROJECTS.length).toBe(6);
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.id)).size).toBe(6);
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.slug)).size).toBe(6);
expect(PORTFOLIO_PROJECTS.map(project => project.index)).toEqual(['01', '02', '03', '04', '05', '06']);
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);
}
});
it('keeps private source names and internal references out of public copy', () => {
const publicCopy = JSON.stringify(PORTFOLIO_PROJECTS);
const restrictedTerms = [
'WorkLogManager',
'OrderCom',
'ordc',
'Delta Petrol',
'DeltaPetrol',
'Delta-Petrol',
'Trgosped',
'E-Line',
'ELine',
'DOMS',
'Hesel',
'Avera',
'POSInterfejs',
'InterfejsCentral',
'intrix-mcp',
'Unija',
'git.dzanan.net',
'/run/media/',
'OTM',
'BIOS'
];
for (const term of restrictedTerms) {
expect(publicCopy).not.toContain(term);
}
});
});

View File

@@ -0,0 +1,296 @@
import { PortfolioProject, PortfolioProjectId } from './portfolio-project.model';
export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
{
id: 'ai-integration',
index: '01',
slug: 'secure-ai-integration-gateway',
title: 'Secure AI Integration Gateway for HRM & CRM',
category: 'AI integration · Featured case study',
period: '2026',
role: 'Primary engineer',
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:
'The gateway had to bridge AI clients with established HRM and CRM interfaces without widening the trust surface. Tool inputs, remote responses, credentials, sessions, attachments, and state-changing operations all needed explicit boundaries.',
contributions: [
'Designed the TypeScript service from HTTP hosting through MCP tool registration, application services, integration gateways, and external clients.',
'Exposed focused capabilities for jobs, candidates, attachments, companies, users, tasks, work orders, custodians, and controlled status changes.',
'Introduced schema validation for environment configuration, tool input, and every external response boundary.',
'Built safe write workflows with pre-write reads, stale-state checks, post-write verification, and sanitized error reporting.',
'Prepared IIS hosting, TLS, rollout, integration, and implementer documentation alongside broad automated coverage.'
],
decisions: [
{
title: 'Validate where trust changes',
detail:
'Configuration, MCP arguments, SOAP payloads, and webhook responses are parsed at their owning boundary before entering application logic.'
},
{
title: 'Verify every write',
detail:
'State-changing tools read current state, apply one explicit update, then read again to confirm the saved value.'
},
{
title: 'Keep composition explicit',
detail:
'Small services depend on narrow gateway contracts and are assembled in one composition root without reflection-heavy infrastructure.'
},
{
title: 'Treat operations as product work',
detail:
'Request limits, lifecycle handling, deployment guidance, sanitized diagnostics, and integration contracts ship with the application.'
}
],
outcome:
'The result is a narrow, testable AI integration surface that can support useful HRM and CRM operations while keeping failure behavior, write safety, and production ownership visible.',
stack: ['TypeScript', 'Node.js', 'MCP', 'Zod', 'SOAP', 'HTTP', 'IIS'],
diagram: 'trust-boundaries',
seoDescription:
'Case study about designing a secure production MCP gateway for HRM and CRM systems with validated boundaries and verified writes.'
},
{
id: '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: 'fuel-management',
index: '03',
slug: 'fuel-station-management-system',
title: 'Fuel Station Management System',
category: 'Ground-up system · Desktop, web & realtime',
period: '2026',
role: 'Architect and sole engineer',
tagline: 'Forecourt & payment-terminal middleware, multi-tenant fleet limits',
summary:
'A complete fuel-management system for gas stations, built from the ground up: a Windows desktop middleware that drives forecourt controllers and outdoor payment terminals and hands every sale to the store POS, plus a multi-tenant central platform with an Angular admin app that enforces fleet-card fuel limits at the pump in real time.',
challenge:
'Fuel dispensers, an outdoor payment terminal, bank pre-authorizations, the store POS and the operator’s ERP all had to agree on every litre sold — including at night, unattended, when the terminal is the only cashier. Fleet customers needed monthly fuel limits enforced at the pump even though the ERP’s balances lag, and nothing could ever release money or lose a transaction because a network link or the application went down.',
contributions: [
'Built the station desktop middleware in VB.NET: forecourt controller integration — pump status, money and volume presets, transaction lock and clear for multi-POS sites, emergency stop, price pushes — over both a serial driver and the vendor’s COM API.',
'Implemented the outdoor payment terminal as a screen-by-screen state machine: pump selection, barcode, amount, PIN, bank pre-authorization and completion, fuelling and receipt printing, with reconnect timers and recovery of uncompleted card transactions after a restart.',
'Defined the file-based POS contract — one transaction file per sale, written temp-then-atomic-rename, with encrypted per-pump recovery files — so the existing POS and ERP agent consume sales unchanged.',
'Designed the central platform on .NET 10: a multi-tenant minimal API with a master database plus one database per tenant, hashed station API keys, JWT roles, EF Core, a worker service syncing companies and vehicles from the ERP, and an Angular 22 zoneless admin app.',
'Wrote the fleet-limit engine: available = monthly limit − fuel this business month − active reservations, with an idempotent create → authorize → complete/cancel protocol, SQL Server application locks per company, and a maintenance worker that moves unresolved reservations to review instead of releasing money.',
'Extracted a pure, unit-tested reliability core — journal state machine, ordered outbox with exponential retry, recovery planner — and wired it into the desktop so fleet fuelling is fail-closed while card and manual sales keep working when the backend is unreachable.',
'Ran the infrastructure end to end: VPN for stations, Caddy TLS in front of IIS, Gitea CI/CD with staged deploys and rollback, a private wiki, and a security hardening pass (secrets out of the repo, fixed-time PIN checks, log redaction, firewall audit).'
],
decisions: [
{
title: 'Reserve before you authorize',
detail:
'The ERP balance lags, so the platform reserves limit first and only then authorizes the pump preset — the dispenser pumps against money that is already accounted for.'
},
{
title: 'Never release money automatically',
detail:
'Authorized reservations that go unresolved are moved to review with an incident, not cancelled. A person decides; the system only makes the state visible.'
},
{
title: 'Fail closed for fleet cards, open for everything else',
detail:
'If the central platform is unreachable, fleet-card fuelling stops but card and manual sales continue — a station never goes dark because a backend did.'
},
{
title: 'Keep the legacy contract',
detail:
'The store POS reads the same one-line transaction files it always did; atomic writes and recovery files sit behind that contract instead of replacing it.'
}
],
outcome:
'The operator runs the system in production across its stations with a second company onboarding: unattended night fuelling against prepayment, fleet limits enforced at the pump in real time, remote diagnostics and pump-incident dashboards for the back office, and a codebase with a tested reliability core rather than defensive patches.',
stack: ['VB.NET', '.NET 10', 'ASP.NET Core', 'SQL Server', 'EF Core', 'SignalR', 'Angular', 'Caddy', 'Gitea CI/CD'],
diagram: 'fuel-station-platform',
seoDescription:
'Case study: a ground-up fuel-management system for gas stations — forecourt and payment-terminal middleware, a file-based POS contract, and a multi-tenant .NET 10 platform enforcing fleet fuel limits in real time.'
},
{
id: 'travel-operations',
index: '04',
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:
'Operational changes crossed several applications and business boundaries at once. New automation had to preserve established travel and accounting rules, remain observable, and evolve safely inside a mature platform.',
contributions: [
'Built reliable pre-travel communication workflows with scheduling, delivery state, retries, audit history, preview, and safe test delivery.',
'Delivered guide-expense and receipt workflows across portal UI, application services, persistence, invoice review, and archival.',
'Extended OCR-assisted invoice processing and accounting integrations for currencies, tax handling, validation, and cancellation paths.',
'Improved booking and departure behavior including room pricing, passenger pricing, gallery ordering, reports, and publishing state.',
'Contributed to framework upgrades, asynchronous CQRS paths, identity modernization, regression tests, and restricted MCP access.'
],
decisions: [
{
title: 'Model automation as a lifecycle',
detail:
'Communication state records ownership, attempts, retry eligibility, and uncertain delivery outcomes instead of reducing the workflow to one scheduled job.'
},
{
title: 'Share production rendering',
detail:
'Automatic delivery, previews, and test sends use the same rendering path so operational checks reflect production output.'
},
{
title: 'Keep domain changes end to end',
detail:
'UI, API contracts, domain rules, data migrations, integrations, and regression tests change together when the workflow requires it.'
}
],
outcome:
'The platform gained more reliable customer communication, clearer operational auditability, stronger finance workflows, and a safer path for modern framework and AI-assisted capabilities.',
stack: ['.NET', 'C#', 'Angular', 'TypeScript', 'EF Core', 'SQL Server', 'Identity', 'MCP'],
diagram: 'travel-platform',
seoDescription:
'Case study about full-stack modernization, finance automation, customer communication, and secure AI access in a travel operations platform.'
},
{
id: 'service-operations',
index: '05',
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:
'The product combines tenant-specific data, shared identity, real-time collaboration, background processing, and dense operational workflows. Improvements needed to respect those boundaries while keeping a mature product moving.',
contributions: [
'Delivered ticket and worklog capabilities including reports, search, conversation separation, status behavior, and traceable history.',
'Extended contract, categorization, pillar, and default-contract rules across Angular, APIs, persistence, migrations, and regression tests.',
'Built reminders, recurring-task behavior, notifications, and read-state workflows for day-to-day service operations.',
'Improved SignalR correctness and addressed memory leaks in long-lived frontend and backend paths.',
'Modernized routing and frameworks while extending MCP tools for controlled ticket, contract, team, and assignment workflows.'
],
decisions: [
{
title: 'Respect tenant boundaries',
detail:
'Shared identity and tenant-owned operational data remain distinct when shaping sync, access, and business behavior.'
},
{
title: 'Separate reads from writes',
detail:
'Query handlers shape efficient read models while command handlers own state changes and domain behavior.'
},
{
title: 'Modernize incrementally',
detail:
'Routing, framework, and real-time improvements are delivered in focused slices with regression coverage instead of a single replacement effort.'
}
],
outcome:
'The work strengthened everyday service workflows, reporting, real-time behavior, and maintainability while preserving the domain knowledge accumulated in a mature multitenant product.',
stack: ['.NET', 'C#', 'Angular', 'TypeScript', 'EF Core', 'SQL Server', 'SignalR', 'MCP'],
diagram: 'multitenant-platform',
seoDescription:
'Case study about evolving a multitenant service operations platform across domain workflows, real-time UX, reporting, and modernization.'
},
{
id: 'biodiversity-data',
index: '06',
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:
'A mature scientific application with an older frontend and interconnected data model needed targeted modernization without disrupting field and research workflows.',
contributions: [
'Contributed backend migration work toward a current .NET runtime and updated identity-facing application paths.',
'Diagnosed and repaired continuous-delivery pipeline behavior during platform changes.',
'Added production diagnostics and log collection to make operational failures easier to investigate.',
'Resolved container-build compatibility constraints around the established Angular frontend.',
'Maintained the public certificate chain required for reliable browser and service access.'
],
decisions: [
{
title: 'Modernize around stable workflows',
detail:
'Platform and identity changes are isolated from the scientific model so researchers keep familiar data-entry behavior.'
},
{
title: 'Make deployment diagnosable',
detail:
'Pipeline fixes and targeted logging provide evidence at build, host, and certificate boundaries.'
},
{
title: 'Preserve compatibility deliberately',
detail:
'Container tooling accommodates the established frontend while backend and identity work move forward independently.'
}
],
outcome:
'The modernization work improved deployment confidence and operational visibility while protecting a specialized scientific workflow from unnecessary product change.',
stack: ['.NET', 'C#', 'Angular', 'PostgreSQL', 'PostGIS', 'OpenLayers', 'Docker', 'CI/CD'],
diagram: 'research-platform',
seoDescription:
'Case study about identity, deployment, diagnostics, and platform modernization for a biodiversity field research system.'
}
] as const;
export function getPortfolioProject(id: PortfolioProjectId): PortfolioProject {
const project = PORTFOLIO_PROJECTS.find(item => item.id === id);
if (!project) {
throw new Error(`Unknown portfolio project: ${id}`);
}
return project;
}

View File

@@ -0,0 +1,80 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ProjectDiagramKind } from './portfolio-project.model';
@Component({
selector: 'app-system-diagram',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<figure class="system-diagram panel" data-testid="system-diagram">
<figcaption><span class="mono">System view</span><strong>{{ title }}</strong></figcaption>
@switch (kind) {
@case ('trust-boundaries') {
<svg viewBox="0 0 900 410" role="img" aria-label="AI gateway trust-boundary architecture">
<g class="diagram-lines"><path d="M176 205H294M430 205h94M660 205h70"/><path d="M362 141V92M592 141V92M592 269v50"/></g>
<g class="diagram-node"><rect x="30" y="151" width="146" height="108"/><text x="103" y="194">AI CLIENTS</text><text class="small" x="103" y="218">MCP / HTTP</text></g>
<g class="diagram-node accent"><rect x="294" y="141" width="136" height="128"/><text x="362" y="194">GATEWAY</text><text class="small" x="362" y="218">AUTH · LIMITS</text><text class="small" x="362" y="238">VALIDATION</text></g>
<g class="diagram-node"><rect x="524" y="141" width="136" height="128"/><text x="592" y="194">SERVICES</text><text class="small" x="592" y="218">READ · WRITE</text><text class="small" x="592" y="238">VERIFY</text></g>
<g class="diagram-node"><rect x="730" y="151" width="140" height="108"/><text x="800" y="194">BUSINESS</text><text class="small" x="800" y="218">HRM · CRM</text></g>
<g class="diagram-note"><text x="362" y="72">SCHEMA BOUNDARY</text><text x="592" y="72">APPLICATION BOUNDARY</text><text x="592" y="342">SANITIZED OPERATIONS</text></g>
</svg>
}
@case ('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 ('fuel-station-platform') {
<svg viewBox="0 0 900 410" role="img" aria-label="Fuel station management system architecture">
<g class="diagram-lines"><path d="M200 107H290V209H330M200 302H290V209M550 209H620V107H700M550 209H620V305H660"/></g>
<g class="diagram-node"><rect x="30" y="60" width="170" height="95"/><text x="115" y="100">FORECOURT</text><text class="small" x="115" y="124">PUMPS · CONTROLLER</text></g>
<g class="diagram-node"><rect x="30" y="255" width="170" height="95"/><text x="115" y="295">PAYMENT TERMINAL</text><text class="small" x="115" y="319">CARD · PIN · RECEIPT</text></g>
<g class="diagram-node accent"><rect x="330" y="150" width="220" height="118"/><text x="440" y="198">STATION MIDDLEWARE</text><text class="small" x="440" y="224">VB.NET · STATE MACHINES</text><text class="small" x="440" y="246">JOURNAL · OUTBOX</text></g>
<g class="diagram-node"><rect x="700" y="60" width="170" height="95"/><text x="785" y="100">STORE POS · ERP</text><text class="small" x="785" y="124">TRANSACTION FILES</text></g>
<g class="diagram-node accent"><rect x="660" y="255" width="210" height="100"/><text x="765" y="296">CENTRAL PLATFORM</text><text class="small" x="765" y="320">.NET 10 · FLEET LIMITS</text><text class="small" x="765" y="340">SIGNALR · ADMIN WEB</text></g>
<g class="diagram-note"><text x="440" y="40">FAIL CLOSED FOR FLEET · OPEN FOR CASH</text><text x="785" y="40">ATOMIC WRITE · RENAME CONTRACT</text><text x="440" y="390">RESERVE → AUTHORIZE → COMPLETE</text></g>
</svg>
}
@case ('travel-platform') {
<svg viewBox="0 0 900 410" role="img" aria-label="Travel operations platform architecture">
<g class="diagram-lines"><path d="M180 100H345M555 100h165M450 158v94M180 310h165M555 310h165"/><path d="M180 100v210M720 100v210"/></g>
<g class="diagram-node"><rect x="35" y="55" width="145" height="90"/><text x="108" y="96">PORTALS</text><text class="small" x="108" y="119">STAFF · GUIDE</text></g>
<g class="diagram-node accent"><rect x="345" y="42" width="210" height="116"/><text x="450" y="92">OPERATIONS CORE</text><text class="small" x="450" y="118">TRAVEL · BOOKING</text></g>
<g class="diagram-node"><rect x="720" y="55" width="145" height="90"/><text x="792" y="96">CUSTOMERS</text><text class="small" x="792" y="119">MAIL · WEB</text></g>
<g class="diagram-node"><rect x="35" y="265" width="145" height="90"/><text x="108" y="306">PARTNERS</text><text class="small" x="108" y="329">SUPPLIERS</text></g>
<g class="diagram-node"><rect x="345" y="252" width="210" height="116"/><text x="450" y="302">WORKFLOWS</text><text class="small" x="450" y="328">SCHEDULE · AUDIT</text></g>
<g class="diagram-node"><rect x="720" y="265" width="145" height="90"/><text x="792" y="306">FINANCE</text><text class="small" x="792" y="329">OCR · ACCOUNTING</text></g>
</svg>
}
@case ('multitenant-platform') {
<svg viewBox="0 0 900 410" role="img" aria-label="Multitenant service platform architecture">
<g class="diagram-lines"><path d="M172 205H320M580 205h148M450 126V62M450 284v64"/></g>
<g class="diagram-node"><rect x="34" y="90" width="138" height="95"/><text x="103" y="132">TENANT A</text><text class="small" x="103" y="155">TEAMS · DATA</text><rect x="34" y="225" width="138" height="95"/><text x="103" y="267">TENANT B</text><text class="small" x="103" y="290">TEAMS · DATA</text></g>
<g class="diagram-node accent"><rect x="320" y="126" width="260" height="158"/><text x="450" y="184">SERVICE CORE</text><text class="small" x="450" y="211">TICKETS · WORKLOGS</text><text class="small" x="450" y="234">CONTRACTS · REPORTS</text></g>
<g class="diagram-node"><rect x="728" y="90" width="138" height="95"/><text x="797" y="132">REAL TIME</text><text class="small" x="797" y="155">SIGNALR</text><rect x="728" y="225" width="138" height="95"/><text x="797" y="267">AUTOMATION</text><text class="small" x="797" y="290">JOBS · MCP</text></g>
<g class="diagram-note"><text x="450" y="44">SHARED IDENTITY</text><text x="450" y="374">COMMAND / QUERY BOUNDARY</text></g>
</svg>
}
@case ('research-platform') {
<svg viewBox="0 0 900 410" role="img" aria-label="Biodiversity research platform architecture">
<g class="diagram-lines"><path d="M173 205H314M586 205h141M450 124V62M450 286v62"/></g>
<g class="diagram-node"><rect x="28" y="151" width="145" height="108"/><text x="101" y="194">FIELD WORK</text><text class="small" x="101" y="218">SAMPLES · MEDIA</text></g>
<g class="diagram-node accent"><rect x="314" y="124" width="272" height="162"/><text x="450" y="180">RESEARCH SYSTEM</text><text class="small" x="450" y="208">TAXONOMY · BIOMETRICS</text><text class="small" x="450" y="231">HABITATS · LOCALITIES</text></g>
<g class="diagram-node"><rect x="727" y="151" width="145" height="108"/><text x="800" y="194">GEOSPATIAL</text><text class="small" x="800" y="218">MAPS · POSTGIS</text></g>
<g class="diagram-note"><text x="450" y="44">IDENTITY / WEB</text><text x="450" y="374">CONTAINERS / DELIVERY / LOGS</text></g>
</svg>
}
}
</figure>
`
})
export class SystemDiagramComponent {
@Input({ required: true }) kind!: ProjectDiagramKind;
@Input({ required: true }) title = '';
}

View File

@@ -0,0 +1,25 @@
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(6);
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('Fuel Station Management System');
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');
});
});

View 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">Six case studies from software that is running today: a ground-up fuel-management system for gas stations, 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: 'Six anonymized case studies from production software: AI integration, a fuel-station management system, ERP operations platforms, an offline-first field sales app with realtime tracking, and scientific data.',
path: '/work'
});
}
}

View File

@@ -1,9 +1,19 @@
import { SectionNavItem } from '../models/section-nav.model'; import { SectionNavItem } from '../models/section-nav.model';
export const MAIN_NAVIGATION: SectionNavItem[] = [ export const MAIN_NAVIGATION: SectionNavItem[] = [
{ label: 'Home', path: '/', fragment: 'home' }, { label: 'work', path: '/work' },
{ label: 'About me', path: '/', fragment: 'about-me' }, { label: 'experience', path: '/experience' },
{ label: 'About IT', path: '/', fragment: 'about-it' }, { label: 'cv', path: '/cv' },
{ label: 'CV', path: '/cv' }, { label: 'contact', path: '/contact' }
{ label: 'Portfolio', path: '/portfolio' } ];
/** 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: 'Six 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.' }
]; ];

View File

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

View File

@@ -1,68 +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="relative flex items-center rounded-full border border-zinc-200/70 bg-[#f8faf7]/70 p-1 backdrop-blur dark:border-slate-800 dark:bg-slate-950/50">
<button
type="button"
class="relative flex h-7 w-7 items-center justify-center rounded-full text-zinc-500 transition hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:text-slate-400 dark:hover:text-slate-100"
[class.bg-zinc-50]="themeService.theme() === 'light'"
[class.text-emerald-600]="themeService.theme() === 'light'"
[class.shadow-sm]="themeService.theme() === 'light'"
[class.dark:bg-slate-800]="themeService.theme() === 'light'"
[class.dark:text-emerald-400]="themeService.theme() === 'light'"
(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="relative flex h-7 w-7 items-center justify-center rounded-full text-zinc-500 transition hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:text-slate-400 dark:hover:text-slate-100"
[class.bg-zinc-50]="themeService.theme() === 'system'"
[class.text-emerald-600]="themeService.theme() === 'system'"
[class.shadow-sm]="themeService.theme() === 'system'"
[class.dark:bg-slate-800]="themeService.theme() === 'system'"
[class.dark:text-emerald-400]="themeService.theme() === 'system'"
(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="relative flex h-7 w-7 items-center justify-center rounded-full text-zinc-500 transition hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:text-slate-400 dark:hover:text-slate-100"
[class.bg-zinc-50]="themeService.theme() === 'dark'"
[class.text-emerald-600]="themeService.theme() === 'dark'"
[class.shadow-sm]="themeService.theme() === 'dark'"
[class.dark:bg-slate-800]="themeService.theme() === 'dark'"
[class.dark:text-emerald-400]="themeService.theme() === 'dark'"
(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);
}
}

View File

@@ -2,9 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>SWE - Amar Džanan</title> <title>Amar Džanan — Senior Software Engineer &amp; AI Integration</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<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"> <link rel="icon" type="image/x-icon" href="duck.png">
</head> </head>
<body> <body>

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

@@ -1,54 +1,299 @@
@use 'tailwindcss'; @use 'tailwindcss';
@theme { :root {
--font-saira: 'Saira', sans-serif; color-scheme: dark;
--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;
} }
@font-face { * { box-sizing: border-box; }
font-family: 'Saira'; html { scroll-behavior: smooth; scroll-padding-top: 6rem; }
src: url('/fonts/Saira-Regular.ttf') format('truetype'); body {
font-weight: 400; margin: 0;
font-style: normal; background: var(--bg);
font-display: swap; 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: 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; }
@font-face { /* ---- primitives ---- */
font-family: 'Saira'; .site-frame {
src: url('/fonts/Saira-Italic.ttf') format('truetype'); min-height: 100vh;
font-weight: 400; background-color: var(--bg);
font-style: italic; background-image:
font-display: swap; 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; }
@font-face { .section { padding-block: 96px; }
font-family: 'Saira'; .section.tight { padding-top: 0; }
src: url('/fonts/Saira-SemiBold.ttf') format('truetype'); .section-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 24px; }
font-weight: 600; .section-head .label { display: block; margin-bottom: 10px; }
font-style: normal; .section-head .count { font: 400 12px/1 var(--mono); color: var(--dim); }
font-display: swap; .section-head.padded { padding-inline: 24px; }
/* ---- 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); }
/* ---- 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; }
.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; }
/* ---- 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); }
/* ---- 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; }
/* ---- 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; }
/* ---- 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); }
/* ---- 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; } }
@font-face { /* ---- responsive ---- */
font-family: 'Saira'; @media (max-width: 1024px) {
src: url('/fonts/Saira-SemiBoldItalic.ttf') format('truetype'); .hero { grid-template-columns: 1fr; gap: 40px; padding-block: 56px 64px; }
font-weight: 600; .experience { grid-template-columns: 1fr; gap: 32px; }
font-style: italic; .project-row { grid-template-columns: 48px minmax(0, 1fr) 40px; row-gap: 12px; }
font-display: swap; .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) {
@variant dark (&:where(.dark, .dark *)); .section { padding-block: 64px; }
.desktop-nav { display: none; }
@keyframes fade-in-up { .menu-button { display: inline-flex; }
0% { .mobile-nav { display: block; }
opacity: 0; .hero-copy h1 { font-size: clamp(2rem, 9vw, 2.6rem); }
transform: translateY(12px); .project-row { grid-template-columns: minmax(0, 1fr) 24px; padding: 20px 18px; gap: 10px 12px; }
} .project-index { display: none; }
100% { .project-title { grid-column: 1; grid-row: 1; }
opacity: 1; .project-arrow { grid-column: 2; grid-row: 1; }
transform: translateY(0); .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: 360px) {
html { .label-extra { display: none; }
scroll-behavior: smooth;
} }

View File

@@ -13,5 +13,13 @@
], ],
"exclude": [ "exclude": [
"src/**/*.spec.ts" "src/**/*.spec.ts"
] ],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
} }

View File

@@ -10,5 +10,13 @@
}, },
"include": [ "include": [
"src/**/*.ts" "src/**/*.ts"
] ],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
} }