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

45 KiB
Raw Blame History

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).

  • Step 1: Install fonts

npm install @fontsource-variable/jetbrains-mono@5.3.0 @fontsource/ibm-plex-sans@5.3.0 --no-audit --no-fund
  • Step 2: Register font CSS and karma config in angular.json

In projects.dzanan.net.architect.build.options.styles replace the array with:

"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:

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'].)

  • Step 3: Update src/index.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>
  • Step 4: Rewrite src/styles.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; }
}
  • 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).

  • Step 6: Commit
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.

  • Step 1: Update the route spec (failing)

In src/app/app.routes.spec.ts change the expected path list to:

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',
  '**'
]);
  • 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.)

  • 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:

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.

  • Step 4: Server redirect + sitemap + placeholder PDF

src/server.ts, insert before the express.static block:

/** 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:

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.)

  • 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).

  • Step 6: Commit
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.

  • Step 1: Navigation config

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';
  • Step 2: Page shell
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); }
}
  • Step 3: Build

Run: npx ng build 2>&1 | grep -E "error|complete" — expected: "complete", no errors.

  • Step 4: Commit
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).

  • Step 1: Failing test — add inside the for (const project of PORTFOLIO_PROJECTS) loop in the data spec:

expect(project.tagline.length).toBeGreaterThan(10);
expect(project.tagline.length).toBeLessThanOrEqual(70);
  • Step 2: Run — expected: TypeScript error tagline does not exist (compile failure counts as red).

  • 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
  • Step 4: Run tests — expected: SUCCESS.

  • 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

  • Step 1: Update spec (failing) — replace the two expects that change:

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"');

  • Step 2: Run — expected: FAIL on h1 text.

  • Step 3: Implement landing-page.component.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']
      }
    });
  }
}
  • Step 4: Run tests — expected: SUCCESS.
  • 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".

  • Step 1: Spec (failing) — add expect(page.querySelector('a[href="/cv.pdf"]')).toBeTruthy(); and expect(page.textContent).toContain('// 01 context');.

  • Step 2: Run — expected: FAIL.

  • Step 3: Implement template (class and constructor unchanged; add readonly cvUrl = CV_URL; and import CV_URL):

<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>
  • Step 4: Run tests — expected: SUCCESS.
  • 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

  • Step 1: Implement
<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).

  • Step 2: Build + commit — npx ng build; git commit -am "feat(404): terminal-style not-found page"

Task 8: Verification

  • Unit tests: CHROME_BIN=$CHROME npx ng test --watch=false → all SUCCESS.
  • Production build: npx ng build → zero errors, prerendered 5 routes, budgets not exceeded.
  • 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.
  • 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.
  • Commit fixes; update the plan checkboxes.