redesign complete
All checks were successful
Deploy dzanan.net / deploy (push) Successful in 1m0s

This commit is contained in:
amar.dzanan
2026-09-06 22:16:19 +02:00
parent 886292aca9
commit 4d754f33c1
43 changed files with 2123 additions and 691 deletions

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.