Files
dzanan.net/docs/superpowers/plans/2026-09-06-dark-technical-redesign.md
amar.dzanan 4d754f33c1
All checks were successful
Deploy dzanan.net / deploy (push) Successful in 1m0s
redesign complete
2026-09-06 22:16:19 +02:00

887 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.