Compare commits
4 Commits
c4ce5df076
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986fa61c5e | ||
|
|
4d754f33c1 | ||
|
|
886292aca9 | ||
| e64239fbae |
173
Claude outputs/2026-09-06-dark-technical-redesign-design.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# dzanan.net — "Dark & technical" redesign
|
||||||
|
|
||||||
|
Date: 2026-09-06 · Status: approved by Amar (direction C of three mockups)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the current warm/editorial design of dzanan.net with the "Dark & technical"
|
||||||
|
direction: near-black surfaces with a faint grid, monospace labels, a terminal-style
|
||||||
|
profile panel, and a single glowing green accent. The site serves both employers and
|
||||||
|
consulting clients equally. The CV becomes a PDF that Amar provides; the site links to it.
|
||||||
|
|
||||||
|
Non-goals: new content for the case studies (text stays), a light theme, a CMS, new routes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Kept: Angular 22 app structure, SSR + prerendering, `SeoService`, `PageShellComponent`
|
||||||
|
(restyled), landing page, four `/work/*` case-study pages, `SystemDiagramComponent`
|
||||||
|
(recoloured), not-found page, Tailwind v4 install, Docker deploy.
|
||||||
|
|
||||||
|
Removed: `CvPageComponent` + its spec, `ThemeService`, `ThemeToggleComponent`, the theme
|
||||||
|
bootstrap script in `src/index.html`, the `cv` client route, the `cv` prerender route.
|
||||||
|
|
||||||
|
Added: `public/cv.pdf` (placeholder until Amar supplies the file), a 301 from `/cv` to
|
||||||
|
`/cv.pdf` in `src/server.ts`, self-hosted fonts.
|
||||||
|
|
||||||
|
## Visual system
|
||||||
|
|
||||||
|
Defined as CSS custom properties on `:root` in `src/styles.scss`; no `.dark` variant.
|
||||||
|
|
||||||
|
| token | value | use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#0D1015` | page background |
|
||||||
|
| `--panel` | `#12161D` | cards, list panel, code panel |
|
||||||
|
| `--panel-2` | `#0F1318` | panel headers / inset |
|
||||||
|
| `--line` | `#232A33` | borders, dividers |
|
||||||
|
| `--line-strong` | `#2A323D` | tag borders, button outline |
|
||||||
|
| `--text` | `#E6EAF0` | primary text |
|
||||||
|
| `--muted` | `#8B95A5` | secondary text |
|
||||||
|
| `--dim` | `#5C6672` | tertiary text, comments |
|
||||||
|
| `--accent` | `#8BE04A` | labels, links, primary button, status dot |
|
||||||
|
| `--accent-glow` | `rgba(139,224,74,.22)` | button and dot glow |
|
||||||
|
| `--code-key` / `--code-str` / `--code-num` | `#7AA2F7` / `#C3E88D` / `#F78C6C` | profile.json panel |
|
||||||
|
|
||||||
|
Grid: `linear-gradient` lines at 2.8% white, 64px cell, fixed to the page background.
|
||||||
|
|
||||||
|
Type: JetBrains Mono (labels, nav, meta, code, buttons) and IBM Plex Sans (headings and
|
||||||
|
body), self-hosted via `@fontsource-variable/jetbrains-mono` and `@fontsource/ibm-plex-sans`
|
||||||
|
(weights 400/500/600) imported in `styles.scss`. Fallbacks: Consolas/Menlo/monospace and
|
||||||
|
"Segoe UI"/system-ui/sans-serif. Base body 16px; headline scale `clamp(2.4rem, 5vw, 3.9rem)`
|
||||||
|
for h1, `clamp(1.75rem, 3vw, 2.1rem)` for h2, letter-spacing -0.03em/-0.025em, weight 600.
|
||||||
|
|
||||||
|
Labels (`.label`): mono, 12px, uppercase, 0.08em tracking, accent colour, prefixed with
|
||||||
|
`// ` in markup (e.g. `// selected work`). Tags (`.tag`): mono 12px, 24px tall, 1px
|
||||||
|
`--line-strong` border, 4px radius. Panels (`.panel`): `--panel` fill, 1px `--line`
|
||||||
|
border, 10px radius. Buttons: 46px tall, 6px radius, mono 14px; `.btn-accent` accent fill
|
||||||
|
with dark text and glow, `.btn-line` outlined.
|
||||||
|
|
||||||
|
Container: `.shell` = `min(1160px, 100% - 40px)` centred. Section spacing 96px desktop,
|
||||||
|
64px below 720px.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### Shell (header, footer, mobile menu)
|
||||||
|
|
||||||
|
Header: sticky, `--bg` at 92% with blur, bottom border. Left: mono wordmark `~/` (accent)
|
||||||
|
+ `dzanan.net` (600). Right (desktop): mono nav `work · experience · contact · cv.pdf`,
|
||||||
|
then a divider and the status `● available` (accent dot with glow). `cv.pdf` is an `<a
|
||||||
|
href="/cv.pdf">` with a download icon, opens in a new tab. Below 720px the nav collapses to
|
||||||
|
the existing signal-driven menu button; the status stays visible.
|
||||||
|
|
||||||
|
Navigation config: `[{work,'/',#work},{experience,'/',#experience},{contact,'/',#contact}]`
|
||||||
|
plus the CV link rendered separately as a plain anchor (not a `SectionNavItem`).
|
||||||
|
|
||||||
|
Footer: mono 12px, `Amar Džanan · Sarajevo, Bosnia and Herzegovina` left; email and
|
||||||
|
LinkedIn right.
|
||||||
|
|
||||||
|
### Landing (`/`)
|
||||||
|
|
||||||
|
1. Hero — two columns (1.05fr / .95fr, 64px gap), stacks below 1024px.
|
||||||
|
Left: label `Senior full-stack engineer — .NET · Angular · MCP`; h1 "Complex systems,
|
||||||
|
<accent>understood before changed.</accent>"; lead paragraph "I modernize enterprise
|
||||||
|
software and connect it to AI through narrow, validated, verified interfaces — the kind
|
||||||
|
you can run in production and sleep at night."; buttons `amar@dzanan.net` (accent,
|
||||||
|
mailto) and `cv.pdf` (outline, download).
|
||||||
|
Right: `profile.json` terminal panel — title bar with three dots and file name, then a
|
||||||
|
`$ whoami --json` line and a JSON object: name, role, stack `[".NET","Angular",
|
||||||
|
"TypeScript"]`, focus `["modernization","safe AI integration"]`, since `2018`, location
|
||||||
|
`"Sarajevo · CET"`, open_to `["senior roles","consulting"]`, closing prompt with a
|
||||||
|
blinking cursor. Rendered from a small typed data structure in the component so it can
|
||||||
|
change without touching markup.
|
||||||
|
2. `// selected work` (`#work`) — heading "Production systems, anonymized." and a right-aligned
|
||||||
|
mono count `4 case studies`. One `.panel` containing four rows (`data-testid="project-row"`,
|
||||||
|
`routerLink` to `/work/<slug>`): index (accent on the featured first row), title + one-line
|
||||||
|
summary (a new short `tagline` field on `PortfolioProject`, ~60 chars), up to four stack
|
||||||
|
tags (first four of `stack`), arrow. Hover: row background `--panel-2`, arrow turns accent.
|
||||||
|
Below 720px: grid collapses to index+title, tagline, tags stacked.
|
||||||
|
3. `// experience` (`#experience`) — grid 340px / 1fr. Left: 96px square grayscale portrait
|
||||||
|
(`NgOptimizedImage`, 8px radius, 1px border), label, h2 "Eight years, one product at a
|
||||||
|
time.", about paragraph, LinkedIn link. Right: four role rows (period mono `2025 — now`,
|
||||||
|
title + company, focus sentence), 1px dividers.
|
||||||
|
4. `// capabilities` — three equal `.panel` cards: `01 System modernization`, `02 Full-stack
|
||||||
|
product delivery`, `03 Safe AI integration`, each with the existing detail text.
|
||||||
|
Single column below 720px.
|
||||||
|
5. Contact (`#contact`) — a `.panel` with a 1px accent-tinted border and inner glow:
|
||||||
|
label `// contact`, h2 "Need a senior engineer who can understand the system before
|
||||||
|
changing it?", `.btn-accent` mailto button, mono `Sarajevo · CET / CEST`.
|
||||||
|
|
||||||
|
Structured data (schema.org Person) unchanged.
|
||||||
|
|
||||||
|
### Case study (`/work/:slug`)
|
||||||
|
|
||||||
|
Breadcrumb as a path: `~/work/` (dim) + slug (text), mono. Header: label
|
||||||
|
`<category> · <period>`, h1 title, lead summary, then a meta strip in a `.panel`: `role`,
|
||||||
|
`period`, stack tags. Sections keep content; labels become `// 01 context`, `// 02 delivery`,
|
||||||
|
`// 03 architecture`, `// 04 result`; contributions as a numbered list with mono indices;
|
||||||
|
decisions as a 2×2 `.panel` grid (single column < 720px); technology as a tag row.
|
||||||
|
`SystemDiagramComponent` stays, recoloured: node fill `--panel`, stroke `--line-strong`,
|
||||||
|
accent node stroke/text `--accent`, lines `--line-strong`, notes `--dim`, mono text.
|
||||||
|
Prev/next as two `.panel` links. Contact block reused. The "View full CV" link points to
|
||||||
|
`/cv.pdf`.
|
||||||
|
|
||||||
|
### Not found
|
||||||
|
|
||||||
|
Terminal panel: `$ open <path>` then `command not found: <path>` in `--code-num`, a short
|
||||||
|
sentence, and a `.btn-line` back to `/`. Server still returns 404.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- Motion: hero children fade-up (opacity 0→1, translateY 12px→0, 480ms, 80ms stagger) once
|
||||||
|
on load; cursor blink 1s step; row hover transitions 150ms. All wrapped in
|
||||||
|
`@media (prefers-reduced-motion: no-preference)`.
|
||||||
|
- Smooth anchor scrolling with `scroll-padding-top` for the sticky header (kept).
|
||||||
|
- Focus rings: 2px accent outline, 3px offset.
|
||||||
|
- `<meta name="theme-color">` → `#0D1015`; `color-scheme: dark`.
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
`src/server.ts`: before the Angular handler, `app.get('/cv', (_, res) => res.redirect(301,
|
||||||
|
'/cv.pdf'))`. `public/cv.pdf` is served by the existing static middleware. Until Amar
|
||||||
|
supplies the file, a one-page placeholder PDF saying "CV coming soon — amar@dzanan.net" is
|
||||||
|
committed so the link never 404s. `public/sitemap.xml` drops `/cv`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
Modified: `styles.scss`, `index.html`, `server.ts`, `app.ts`, `app.routes.ts`,
|
||||||
|
`app.routes.server.ts`, `app.routes.spec.ts`, `page-shell.component.ts`,
|
||||||
|
`navigation.config.ts`, `landing-page.component.ts` + spec, `portfolio-page.component.ts`
|
||||||
|
+ spec, `system-diagram.component.ts`, `not-found-page.component.ts`,
|
||||||
|
`portfolio-project.model.ts`, `portfolio-projects.data.ts`, `public/sitemap.xml`,
|
||||||
|
`package.json`/lock (fontsource packages).
|
||||||
|
|
||||||
|
Removed: `features/cv/cv-page.component.ts`, `features/cv/cv-page.component.spec.ts`,
|
||||||
|
`core/services/theme.service.ts`, `shared/ui/theme-toggle/theme-toggle.component.ts`.
|
||||||
|
|
||||||
|
Added: `public/cv.pdf` (placeholder), this spec, the implementation plan.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: route spec lists routes without `cv`; landing spec asserts the new h1, four project
|
||||||
|
rows, `a[href="/cv.pdf"]`, mailto and LinkedIn links, the four role titles; portfolio spec
|
||||||
|
unchanged in intent; new not-found assertion optional. Run with headless Chromium.
|
||||||
|
- Build: `ng build` (production, SSR, prerender of `/` and the four work pages) with zero
|
||||||
|
errors and within budgets (component styles are inline SCSS; keep under 8kB each).
|
||||||
|
- Visual: serve `dist` and screenshot `/`, one case study and `/404` at 1440px and 390px;
|
||||||
|
fix wrapping, overflow and contrast issues found.
|
||||||
|
- Smoke: `curl -I /cv` → 301 to `/cv.pdf`; `/cv.pdf` → 200.
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
Work happens in the cloud copy of the repo; changed and new files are written back to the
|
||||||
|
`dzanan.net` folder on Amar's machine, removed files are moved into `_to_delete/`. Amar runs
|
||||||
|
`npm install` locally afterwards (new font packages) and drops the real PDF over
|
||||||
|
`public/cv.pdf`.
|
||||||
BIN
Claude outputs/case-study-field-sales.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
Claude outputs/case-study-fuel-station.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
Claude outputs/home-updated.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
Claude outputs/pages-experience-contact.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
Claude outputs/pages-home.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
Claude outputs/pages-work.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
Claude outputs/redesign-case-study-desktop.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
Claude outputs/redesign-home-desktop.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
Claude outputs/redesign-home-phone.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
11
angular.json
@@ -27,6 +27,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"node_modules/@fontsource-variable/jetbrains-mono/index.css",
|
||||||
|
"node_modules/@fontsource/ibm-plex-sans/400.css",
|
||||||
|
"node_modules/@fontsource/ibm-plex-sans/500.css",
|
||||||
|
"node_modules/@fontsource/ibm-plex-sans/600.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"server": "src/main.server.ts",
|
"server": "src/main.server.ts",
|
||||||
@@ -94,8 +98,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"node_modules/@fontsource-variable/jetbrains-mono/index.css",
|
||||||
|
"node_modules/@fontsource/ibm-plex-sans/400.css",
|
||||||
|
"node_modules/@fontsource/ibm-plex-sans/500.css",
|
||||||
|
"node_modules/@fontsource/ibm-plex-sans/600.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
],
|
||||||
|
"karmaConfig": "karma.conf.cjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
886
docs/superpowers/plans/2026-09-06-dark-technical-redesign.md
Normal 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 & 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@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@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@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@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.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# dzanan.net — "Dark & technical" redesign
|
||||||
|
|
||||||
|
Date: 2026-09-06 · Status: approved by Amar (direction C of three mockups)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the current warm/editorial design of dzanan.net with the "Dark & technical"
|
||||||
|
direction: near-black surfaces with a faint grid, monospace labels, a terminal-style
|
||||||
|
profile panel, and a single glowing green accent. The site serves both employers and
|
||||||
|
consulting clients equally. The CV becomes a PDF that Amar provides; the site links to it.
|
||||||
|
|
||||||
|
Non-goals: new content for the case studies (text stays), a light theme, a CMS, new routes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Kept: Angular 22 app structure, SSR + prerendering, `SeoService`, `PageShellComponent`
|
||||||
|
(restyled), landing page, four `/work/*` case-study pages, `SystemDiagramComponent`
|
||||||
|
(recoloured), not-found page, Tailwind v4 install, Docker deploy.
|
||||||
|
|
||||||
|
Removed: `CvPageComponent` + its spec, `ThemeService`, `ThemeToggleComponent`, the theme
|
||||||
|
bootstrap script in `src/index.html`, the `cv` client route, the `cv` prerender route.
|
||||||
|
|
||||||
|
Added: `public/cv.pdf` (placeholder until Amar supplies the file), a 301 from `/cv` to
|
||||||
|
`/cv.pdf` in `src/server.ts`, self-hosted fonts.
|
||||||
|
|
||||||
|
## Visual system
|
||||||
|
|
||||||
|
Defined as CSS custom properties on `:root` in `src/styles.scss`; no `.dark` variant.
|
||||||
|
|
||||||
|
| token | value | use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#0D1015` | page background |
|
||||||
|
| `--panel` | `#12161D` | cards, list panel, code panel |
|
||||||
|
| `--panel-2` | `#0F1318` | panel headers / inset |
|
||||||
|
| `--line` | `#232A33` | borders, dividers |
|
||||||
|
| `--line-strong` | `#2A323D` | tag borders, button outline |
|
||||||
|
| `--text` | `#E6EAF0` | primary text |
|
||||||
|
| `--muted` | `#8B95A5` | secondary text |
|
||||||
|
| `--dim` | `#5C6672` | tertiary text, comments |
|
||||||
|
| `--accent` | `#8BE04A` | labels, links, primary button, status dot |
|
||||||
|
| `--accent-glow` | `rgba(139,224,74,.22)` | button and dot glow |
|
||||||
|
| `--code-key` / `--code-str` / `--code-num` | `#7AA2F7` / `#C3E88D` / `#F78C6C` | profile.json panel |
|
||||||
|
|
||||||
|
Grid: `linear-gradient` lines at 2.8% white, 64px cell, fixed to the page background.
|
||||||
|
|
||||||
|
Type: JetBrains Mono (labels, nav, meta, code, buttons) and IBM Plex Sans (headings and
|
||||||
|
body), self-hosted via `@fontsource-variable/jetbrains-mono` and `@fontsource/ibm-plex-sans`
|
||||||
|
(weights 400/500/600) imported in `styles.scss`. Fallbacks: Consolas/Menlo/monospace and
|
||||||
|
"Segoe UI"/system-ui/sans-serif. Base body 16px; headline scale `clamp(2.4rem, 5vw, 3.9rem)`
|
||||||
|
for h1, `clamp(1.75rem, 3vw, 2.1rem)` for h2, letter-spacing -0.03em/-0.025em, weight 600.
|
||||||
|
|
||||||
|
Labels (`.label`): mono, 12px, uppercase, 0.08em tracking, accent colour, prefixed with
|
||||||
|
`// ` in markup (e.g. `// selected work`). Tags (`.tag`): mono 12px, 24px tall, 1px
|
||||||
|
`--line-strong` border, 4px radius. Panels (`.panel`): `--panel` fill, 1px `--line`
|
||||||
|
border, 10px radius. Buttons: 46px tall, 6px radius, mono 14px; `.btn-accent` accent fill
|
||||||
|
with dark text and glow, `.btn-line` outlined.
|
||||||
|
|
||||||
|
Container: `.shell` = `min(1160px, 100% - 40px)` centred. Section spacing 96px desktop,
|
||||||
|
64px below 720px.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### Shell (header, footer, mobile menu)
|
||||||
|
|
||||||
|
Header: sticky, `--bg` at 92% with blur, bottom border. Left: mono wordmark `~/` (accent)
|
||||||
|
+ `dzanan.net` (600). Right (desktop): mono nav `work · experience · contact · cv.pdf`,
|
||||||
|
then a divider and the status `● available` (accent dot with glow). `cv.pdf` is an `<a
|
||||||
|
href="/cv.pdf">` with a download icon, opens in a new tab. Below 720px the nav collapses to
|
||||||
|
the existing signal-driven menu button; the status stays visible.
|
||||||
|
|
||||||
|
Navigation config: `[{work,'/',#work},{experience,'/',#experience},{contact,'/',#contact}]`
|
||||||
|
plus the CV link rendered separately as a plain anchor (not a `SectionNavItem`).
|
||||||
|
|
||||||
|
Footer: mono 12px, `Amar Džanan · Sarajevo, Bosnia and Herzegovina` left; email and
|
||||||
|
LinkedIn right.
|
||||||
|
|
||||||
|
### Landing (`/`)
|
||||||
|
|
||||||
|
1. Hero — two columns (1.05fr / .95fr, 64px gap), stacks below 1024px.
|
||||||
|
Left: label `Senior full-stack engineer — .NET · Angular · MCP`; h1 "Complex systems,
|
||||||
|
<accent>understood before changed.</accent>"; lead paragraph "I modernize enterprise
|
||||||
|
software and connect it to AI through narrow, validated, verified interfaces — the kind
|
||||||
|
you can run in production and sleep at night."; buttons `amar@dzanan.net` (accent,
|
||||||
|
mailto) and `cv.pdf` (outline, download).
|
||||||
|
Right: `profile.json` terminal panel — title bar with three dots and file name, then a
|
||||||
|
`$ whoami --json` line and a JSON object: name, role, stack `[".NET","Angular",
|
||||||
|
"TypeScript"]`, focus `["modernization","safe AI integration"]`, since `2018`, location
|
||||||
|
`"Sarajevo · CET"`, open_to `["senior roles","consulting"]`, closing prompt with a
|
||||||
|
blinking cursor. Rendered from a small typed data structure in the component so it can
|
||||||
|
change without touching markup.
|
||||||
|
2. `// selected work` (`#work`) — heading "Production systems, anonymized." and a right-aligned
|
||||||
|
mono count `4 case studies`. One `.panel` containing four rows (`data-testid="project-row"`,
|
||||||
|
`routerLink` to `/work/<slug>`): index (accent on the featured first row), title + one-line
|
||||||
|
summary (a new short `tagline` field on `PortfolioProject`, ~60 chars), up to four stack
|
||||||
|
tags (first four of `stack`), arrow. Hover: row background `--panel-2`, arrow turns accent.
|
||||||
|
Below 720px: grid collapses to index+title, tagline, tags stacked.
|
||||||
|
3. `// experience` (`#experience`) — grid 340px / 1fr. Left: 96px square grayscale portrait
|
||||||
|
(`NgOptimizedImage`, 8px radius, 1px border), label, h2 "Eight years, one product at a
|
||||||
|
time.", about paragraph, LinkedIn link. Right: four role rows (period mono `2025 — now`,
|
||||||
|
title + company, focus sentence), 1px dividers.
|
||||||
|
4. `// capabilities` — three equal `.panel` cards: `01 System modernization`, `02 Full-stack
|
||||||
|
product delivery`, `03 Safe AI integration`, each with the existing detail text.
|
||||||
|
Single column below 720px.
|
||||||
|
5. Contact (`#contact`) — a `.panel` with a 1px accent-tinted border and inner glow:
|
||||||
|
label `// contact`, h2 "Need a senior engineer who can understand the system before
|
||||||
|
changing it?", `.btn-accent` mailto button, mono `Sarajevo · CET / CEST`.
|
||||||
|
|
||||||
|
Structured data (schema.org Person) unchanged.
|
||||||
|
|
||||||
|
### Case study (`/work/:slug`)
|
||||||
|
|
||||||
|
Breadcrumb as a path: `~/work/` (dim) + slug (text), mono. Header: label
|
||||||
|
`<category> · <period>`, h1 title, lead summary, then a meta strip in a `.panel`: `role`,
|
||||||
|
`period`, stack tags. Sections keep content; labels become `// 01 context`, `// 02 delivery`,
|
||||||
|
`// 03 architecture`, `// 04 result`; contributions as a numbered list with mono indices;
|
||||||
|
decisions as a 2×2 `.panel` grid (single column < 720px); technology as a tag row.
|
||||||
|
`SystemDiagramComponent` stays, recoloured: node fill `--panel`, stroke `--line-strong`,
|
||||||
|
accent node stroke/text `--accent`, lines `--line-strong`, notes `--dim`, mono text.
|
||||||
|
Prev/next as two `.panel` links. Contact block reused. The "View full CV" link points to
|
||||||
|
`/cv.pdf`.
|
||||||
|
|
||||||
|
### Not found
|
||||||
|
|
||||||
|
Terminal panel: `$ open <path>` then `command not found: <path>` in `--code-num`, a short
|
||||||
|
sentence, and a `.btn-line` back to `/`. Server still returns 404.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- Motion: hero children fade-up (opacity 0→1, translateY 12px→0, 480ms, 80ms stagger) once
|
||||||
|
on load; cursor blink 1s step; row hover transitions 150ms. All wrapped in
|
||||||
|
`@media (prefers-reduced-motion: no-preference)`.
|
||||||
|
- Smooth anchor scrolling with `scroll-padding-top` for the sticky header (kept).
|
||||||
|
- Focus rings: 2px accent outline, 3px offset.
|
||||||
|
- `<meta name="theme-color">` → `#0D1015`; `color-scheme: dark`.
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
`src/server.ts`: before the Angular handler, `app.get('/cv', (_, res) => res.redirect(301,
|
||||||
|
'/cv.pdf'))`. `public/cv.pdf` is served by the existing static middleware. Until Amar
|
||||||
|
supplies the file, a one-page placeholder PDF saying "CV coming soon — amar@dzanan.net" is
|
||||||
|
committed so the link never 404s. `public/sitemap.xml` drops `/cv`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
Modified: `styles.scss`, `index.html`, `server.ts`, `app.ts`, `app.routes.ts`,
|
||||||
|
`app.routes.server.ts`, `app.routes.spec.ts`, `page-shell.component.ts`,
|
||||||
|
`navigation.config.ts`, `landing-page.component.ts` + spec, `portfolio-page.component.ts`
|
||||||
|
+ spec, `system-diagram.component.ts`, `not-found-page.component.ts`,
|
||||||
|
`portfolio-project.model.ts`, `portfolio-projects.data.ts`, `public/sitemap.xml`,
|
||||||
|
`package.json`/lock (fontsource packages).
|
||||||
|
|
||||||
|
Removed: `features/cv/cv-page.component.ts`, `features/cv/cv-page.component.spec.ts`,
|
||||||
|
`core/services/theme.service.ts`, `shared/ui/theme-toggle/theme-toggle.component.ts`.
|
||||||
|
|
||||||
|
Added: `public/cv.pdf` (placeholder), this spec, the implementation plan.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: route spec lists routes without `cv`; landing spec asserts the new h1, four project
|
||||||
|
rows, `a[href="/cv.pdf"]`, mailto and LinkedIn links, the four role titles; portfolio spec
|
||||||
|
unchanged in intent; new not-found assertion optional. Run with headless Chromium.
|
||||||
|
- Build: `ng build` (production, SSR, prerender of `/` and the four work pages) with zero
|
||||||
|
errors and within budgets (component styles are inline SCSS; keep under 8kB each).
|
||||||
|
- Visual: serve `dist` and screenshot `/`, one case study and `/404` at 1440px and 390px;
|
||||||
|
fix wrapping, overflow and contrast issues found.
|
||||||
|
- Smoke: `curl -I /cv` → 301 to `/cv.pdf`; `/cv.pdf` → 200.
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
Work happens in the cloud copy of the repo; changed and new files are written back to the
|
||||||
|
`dzanan.net` folder on Amar's machine, removed files are moved into `_to_delete/`. Amar runs
|
||||||
|
`npm install` locally afterwards (new font packages) and drops the real PDF over
|
||||||
|
`public/cv.pdf`.
|
||||||
22
karma.conf.cjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = function (config) {
|
||||||
|
config.set({
|
||||||
|
basePath: '',
|
||||||
|
frameworks: ['jasmine'],
|
||||||
|
plugins: [
|
||||||
|
require('karma-jasmine'),
|
||||||
|
require('karma-chrome-launcher'),
|
||||||
|
require('karma-jasmine-html-reporter'),
|
||||||
|
require('karma-coverage')
|
||||||
|
],
|
||||||
|
client: { jasmine: {}, clearContext: false },
|
||||||
|
reporters: ['progress'],
|
||||||
|
browsers: ['ChromeHeadlessNoSandbox'],
|
||||||
|
customLaunchers: {
|
||||||
|
ChromeHeadlessNoSandbox: {
|
||||||
|
base: 'ChromeHeadless',
|
||||||
|
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restartOnFileChange: false
|
||||||
|
});
|
||||||
|
};
|
||||||
5488
package-lock.json
generated
31
package.json
@@ -21,15 +21,17 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^21.2.11",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^21.2.13",
|
"@angular/common": "^22.1.5",
|
||||||
"@angular/compiler": "^21.2.13",
|
"@angular/compiler": "^22.1.5",
|
||||||
"@angular/core": "^21.2.13",
|
"@angular/core": "^22.1.5",
|
||||||
"@angular/forms": "^21.2.13",
|
"@angular/forms": "^22.1.5",
|
||||||
"@angular/platform-browser": "^21.2.13",
|
"@angular/platform-browser": "^22.1.5",
|
||||||
"@angular/platform-server": "^21.2.13",
|
"@angular/platform-server": "^22.1.5",
|
||||||
"@angular/router": "^21.2.13",
|
"@angular/router": "^22.1.5",
|
||||||
"@angular/ssr": "^21.2.11",
|
"@angular/ssr": "^22.1.7",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
@@ -38,12 +40,13 @@
|
|||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.2.11",
|
"@angular/build": "^22.1.7",
|
||||||
"@angular/cli": "^21.2.11",
|
"@angular/cli": "^22.1.7",
|
||||||
"@angular/compiler-cli": "^21.2.13",
|
"@angular/compiler-cli": "^22.1.5",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
|
"istanbul-lib-instrument": "^6.0.3",
|
||||||
"jasmine-core": "~5.8.0",
|
"jasmine-core": "~5.8.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
@@ -51,6 +54,6 @@
|
|||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/cv.pdf
Normal file
@@ -1,8 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url><loc>https://dzanan.net/</loc></url>
|
<url><loc>https://dzanan.net/</loc></url>
|
||||||
|
<url><loc>https://dzanan.net/work</loc></url>
|
||||||
|
<url><loc>https://dzanan.net/experience</loc></url>
|
||||||
<url><loc>https://dzanan.net/cv</loc></url>
|
<url><loc>https://dzanan.net/cv</loc></url>
|
||||||
|
<url><loc>https://dzanan.net/contact</loc></url>
|
||||||
<url><loc>https://dzanan.net/work/secure-ai-integration-gateway</loc></url>
|
<url><loc>https://dzanan.net/work/secure-ai-integration-gateway</loc></url>
|
||||||
|
<url><loc>https://dzanan.net/work/field-sales-mobile-platform</loc></url>
|
||||||
|
<url><loc>https://dzanan.net/work/fuel-station-management-system</loc></url>
|
||||||
<url><loc>https://dzanan.net/work/travel-operations-platform</loc></url>
|
<url><loc>https://dzanan.net/work/travel-operations-platform</loc></url>
|
||||||
<url><loc>https://dzanan.net/work/service-operations-platform</loc></url>
|
<url><loc>https://dzanan.net/work/service-operations-platform</loc></url>
|
||||||
<url><loc>https://dzanan.net/work/biodiversity-data-platform</loc></url>
|
<url><loc>https://dzanan.net/work/biodiversity-data-platform</loc></url>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessC
|
|||||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
import { provideClientHydration, withEventReplay, withNoIncrementalHydration } from '@angular/platform-browser';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -14,6 +14,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
scrollPositionRestoration: 'enabled'
|
scrollPositionRestoration: 'enabled'
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
provideClientHydration(withEventReplay())
|
provideClientHydration(withEventReplay(), withNoIncrementalHydration())
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
export const serverRoutes: ServerRoute[] = [
|
||||||
{ path: '', renderMode: RenderMode.Prerender },
|
{ path: '', renderMode: RenderMode.Prerender },
|
||||||
{ path: 'cv', renderMode: RenderMode.Prerender },
|
{ path: 'work', renderMode: RenderMode.Prerender },
|
||||||
{ path: 'work/secure-ai-integration-gateway', renderMode: RenderMode.Prerender },
|
{ path: 'experience', renderMode: RenderMode.Prerender },
|
||||||
{ path: 'work/travel-operations-platform', renderMode: RenderMode.Prerender },
|
{ path: 'cv', renderMode: RenderMode.Prerender },
|
||||||
{ path: 'work/service-operations-platform', renderMode: RenderMode.Prerender },
|
{ path: 'contact', renderMode: RenderMode.Prerender },
|
||||||
{ path: 'work/biodiversity-data-platform', renderMode: RenderMode.Prerender },
|
{ path: 'work/secure-ai-integration-gateway', renderMode: RenderMode.Prerender },
|
||||||
{ path: 'portfolio', renderMode: RenderMode.Server },
|
{ path: 'work/field-sales-mobile-platform', renderMode: RenderMode.Prerender },
|
||||||
{
|
{ path: 'work/fuel-station-management-system', renderMode: RenderMode.Prerender },
|
||||||
path: '**',
|
{ path: 'work/travel-operations-platform', renderMode: RenderMode.Prerender },
|
||||||
renderMode: RenderMode.Server,
|
{ path: 'work/service-operations-platform', renderMode: RenderMode.Prerender },
|
||||||
status: 404
|
{ path: 'work/biodiversity-data-platform', renderMode: RenderMode.Prerender },
|
||||||
}
|
{ path: 'portfolio', renderMode: RenderMode.Server },
|
||||||
];
|
{
|
||||||
|
path: '**',
|
||||||
|
renderMode: RenderMode.Server,
|
||||||
|
status: 404
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ describe('app routes', () => {
|
|||||||
it('registers every public portfolio route', () => {
|
it('registers every public portfolio route', () => {
|
||||||
expect(routes.map(route => route.path)).toEqual([
|
expect(routes.map(route => route.path)).toEqual([
|
||||||
'',
|
'',
|
||||||
|
'work',
|
||||||
|
'experience',
|
||||||
'cv',
|
'cv',
|
||||||
|
'contact',
|
||||||
'work/secure-ai-integration-gateway',
|
'work/secure-ai-integration-gateway',
|
||||||
|
'work/field-sales-mobile-platform',
|
||||||
|
'work/fuel-station-management-system',
|
||||||
'work/travel-operations-platform',
|
'work/travel-operations-platform',
|
||||||
'work/service-operations-platform',
|
'work/service-operations-platform',
|
||||||
'work/biodiversity-data-platform',
|
'work/biodiversity-data-platform',
|
||||||
@@ -28,6 +33,8 @@ describe('app routes', () => {
|
|||||||
|
|
||||||
expect(projectIds).toEqual([
|
expect(projectIds).toEqual([
|
||||||
'ai-integration',
|
'ai-integration',
|
||||||
|
'field-sales',
|
||||||
|
'fuel-management',
|
||||||
'travel-operations',
|
'travel-operations',
|
||||||
'service-operations',
|
'service-operations',
|
||||||
'biodiversity-data'
|
'biodiversity-data'
|
||||||
|
|||||||
@@ -6,15 +6,37 @@ export const routes: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
component: LandingPageComponent
|
component: LandingPageComponent
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'work',
|
||||||
|
loadComponent: () => import('./features/work/work-page.component').then(m => m.WorkPageComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'experience',
|
||||||
|
loadComponent: () => import('./features/experience/experience-page.component').then(m => m.ExperiencePageComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'cv',
|
path: 'cv',
|
||||||
loadComponent: () => import('./features/cv/cv-page.component').then(m => m.CvPageComponent)
|
loadComponent: () => import('./features/cv/cv-page.component').then(m => m.CvPageComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'contact',
|
||||||
|
loadComponent: () => import('./features/contact/contact-page.component').then(m => m.ContactPageComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'work/secure-ai-integration-gateway',
|
path: 'work/secure-ai-integration-gateway',
|
||||||
data: { projectId: 'ai-integration' },
|
data: { projectId: 'ai-integration' },
|
||||||
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
|
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'work/field-sales-mobile-platform',
|
||||||
|
data: { projectId: 'field-sales' },
|
||||||
|
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'work/fuel-station-management-system',
|
||||||
|
data: { projectId: 'fuel-management' },
|
||||||
|
loadComponent: () => import('./features/portfolio/portfolio-page.component').then(m => m.PortfolioPageComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'work/travel-operations-platform',
|
path: 'work/travel-operations-platform',
|
||||||
data: { projectId: 'travel-operations' },
|
data: { projectId: 'travel-operations' },
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { ThemeService } from './core/services/theme.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -9,6 +8,4 @@ import { ThemeService } from './core/services/theme.service';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
templateUrl: './app.html'
|
templateUrl: './app.html'
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {}
|
||||||
private readonly themeService = inject(ThemeService);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +1,30 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
import { SectionNavItem } from '../../shared/models/section-nav.model';
|
import { SectionNavItem } from '../../shared/models/section-nav.model';
|
||||||
import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-page-shell',
|
selector: 'app-page-shell',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, ThemeToggleComponent],
|
imports: [RouterLink, RouterLinkActive],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="site-frame">
|
<div class="site-frame">
|
||||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell header-inner">
|
<div class="shell header-inner">
|
||||||
<a routerLink="/" class="wordmark" aria-label="Amar Džanan, home">
|
<a routerLink="/" class="wordmark" aria-label="Amar Džanan, home"><span class="wordmark-tilde">~/</span>dzanan.net</a>
|
||||||
<span class="wordmark-mark">AD/</span>
|
|
||||||
<span class="wordmark-copy">Amar Džanan<small>Software systems & AI</small></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="desktop-nav">
|
<div class="desktop-nav">
|
||||||
<nav aria-label="Primary navigation">
|
<nav aria-label="Primary navigation">
|
||||||
@for (section of sections; track section.label) {
|
@for (section of sections; track section.label) {
|
||||||
<a [routerLink]="section.path || []" [fragment]="section.fragment">{{ section.label }}</a>
|
<a [routerLink]="section.path || []" [fragment]="section.fragment" routerLinkActive="active">{{ section.label }}</a>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
<app-theme-toggle />
|
<span class="status"><span class="status-dot" aria-hidden="true"></span>available</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="menu-button" type="button" (click)="toggleMenu()" [attr.aria-expanded]="menuOpen()" aria-controls="mobile-navigation">
|
<button class="menu-button" type="button" (click)="toggleMenu()" [attr.aria-expanded]="menuOpen()" aria-controls="mobile-navigation">
|
||||||
<span>Menu</span><span aria-hidden="true">{{ menuOpen() ? '×' : '+' }}</span>
|
<span>menu</span><span aria-hidden="true">{{ menuOpen() ? '×' : '+' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (menuOpen()) {
|
@if (menuOpen()) {
|
||||||
@@ -39,7 +34,6 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
|
|||||||
<a [routerLink]="section.path || []" [fragment]="section.fragment" (click)="closeMenu()">{{ section.label }}</a>
|
<a [routerLink]="section.path || []" [fragment]="section.fragment" (click)="closeMenu()">{{ section.label }}</a>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
<app-theme-toggle />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</header>
|
</header>
|
||||||
@@ -48,8 +42,8 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
|
|||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="shell footer-inner">
|
<div class="shell footer-inner">
|
||||||
<span>Amar Džanan · Sarajevo, Bosnia and Herzegovina</span>
|
<span>Amar Džanan · Ljubljana, Slovenia</span>
|
||||||
<div><a href="mailto:amar@dzanan.net">amar@dzanan.net</a><span aria-hidden="true"> / </span><a href="https://www.linkedin.com/in/amardzanan/">LinkedIn</a></div>
|
<span><a href="mailto:amar@dzanan.net">amar@dzanan.net</a> · <a href="https://www.linkedin.com/in/amardzanan/" target="_blank" rel="noopener">LinkedIn</a></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Injectable, signal, effect, inject, PLATFORM_ID } from '@angular/core';
|
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
|
||||||
|
|
||||||
export type Theme = 'light' | 'dark' | 'system';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class ThemeService {
|
|
||||||
private readonly platformId = inject(PLATFORM_ID);
|
|
||||||
|
|
||||||
readonly theme = signal<Theme>('system');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
|
||||||
// Load saved theme
|
|
||||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
|
||||||
if (savedTheme) {
|
|
||||||
this.theme.set(savedTheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply theme effect
|
|
||||||
effect(() => {
|
|
||||||
const currentTheme = this.theme();
|
|
||||||
const root = window.document.documentElement;
|
|
||||||
|
|
||||||
localStorage.setItem('theme', currentTheme);
|
|
||||||
|
|
||||||
if (currentTheme === 'system') {
|
|
||||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (systemDark) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
root.classList.remove('dark');
|
|
||||||
}
|
|
||||||
} else if (currentTheme === 'dark') {
|
|
||||||
root.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
root.classList.remove('dark');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for system changes if in system mode
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
||||||
if (this.theme() === 'system') {
|
|
||||||
if (e.matches) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(theme: Theme) {
|
|
||||||
this.theme.set(theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
src/app/features/contact/contact-page.component.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
||||||
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
|
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-contact-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [PageShellComponent, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<app-page-shell [sections]="sections">
|
||||||
|
<header class="page-head shell">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>contact</span></nav>
|
||||||
|
<span class="label">// contact</span>
|
||||||
|
<h1>Need a senior engineer who can understand the system before changing it?</h1>
|
||||||
|
<p class="lead">Email is the fastest way to reach me. I'm open to senior engineering roles and consulting across Europe.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section tight shell" aria-label="Contact details">
|
||||||
|
<div class="panel contact">
|
||||||
|
<div class="contact-actions"><a class="btn btn-accent" href="mailto:amar@dzanan.net">amar@dzanan.net</a><span class="meta">Ljubljana · CET / CEST</span></div>
|
||||||
|
<dl class="contact-details">
|
||||||
|
<div><dt>email</dt><dd><a href="mailto:amar@dzanan.net">amar@dzanan.net</a></dd></div>
|
||||||
|
<div><dt>linkedin</dt><dd><a href="https://www.linkedin.com/in/amardzanan/" target="_blank" rel="noopener">linkedin.com/in/amardzanan</a></dd></div>
|
||||||
|
<div><dt>location</dt><dd>Ljubljana, Slovenia — working across Europe</dd></div>
|
||||||
|
<div><dt>cv</dt><dd><a routerLink="/cv">cv.pdf →</a></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</app-page-shell>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ContactPageComponent {
|
||||||
|
readonly sections = MAIN_NAVIGATION;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
inject(SeoService).update({
|
||||||
|
title: 'Contact — Amar Džanan',
|
||||||
|
description: 'Get in touch with Amar Džanan, senior full-stack engineer in Ljubljana, for senior engineering roles and consulting across Europe.',
|
||||||
|
path: '/contact'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { CvPageComponent } from './cv-page.component';
|
import { CvPageComponent } from './cv-page.component';
|
||||||
|
|
||||||
describe('CvPageComponent', () => {
|
describe('CvPageComponent', () => {
|
||||||
it('renders a complete, contactable CV with curated learning', async () => {
|
it('embeds the PDF and offers a download link', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CvPageComponent],
|
imports: [CvPageComponent],
|
||||||
providers: [provideRouter([])]
|
providers: [provideRouter([])]
|
||||||
@@ -13,13 +13,7 @@ describe('CvPageComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const page = fixture.nativeElement as HTMLElement;
|
const page = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
expect(page.querySelector('h1')?.textContent).toContain('Senior full-stack engineer');
|
expect(page.querySelector('object[data="/cv.pdf"]')).toBeTruthy();
|
||||||
expect(page.textContent).toContain('Senior Software Engineer');
|
expect(page.querySelector('a[href="/cv.pdf"][download]')).toBeTruthy();
|
||||||
expect(page.textContent).toContain('Product Owner');
|
|
||||||
expect(page.textContent).toContain('Software Developer');
|
|
||||||
expect(page.textContent).toContain('Junior Software Developer');
|
|
||||||
expect(page.querySelectorAll('[data-testid="course"]').length).toBe(8);
|
|
||||||
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
|
|
||||||
expect(page.querySelector('a[href="https://www.linkedin.com/in/amardzanan/"]')).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
import { CV_URL, MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cv-page',
|
selector: 'app-cv-page',
|
||||||
@@ -11,93 +11,41 @@ import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell [sections]="sections">
|
<app-page-shell [sections]="sections">
|
||||||
<header class="cv-hero shell">
|
<header class="page-head shell">
|
||||||
<nav class="breadcrumb mono" aria-label="Breadcrumb"><a routerLink="/">Home</a><span>/</span><span>CV</span></nav>
|
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>cv.pdf</span></nav>
|
||||||
<div class="cv-heading">
|
<span class="label">// curriculum vitae</span>
|
||||||
<div><p class="eyebrow mono">Curriculum vitae / Amar Džanan</p><h1>Senior full-stack engineer with product judgment.</h1></div>
|
<h1>Amar Džanan — CV</h1>
|
||||||
<div class="cv-contact">
|
<p class="lead">Read it here, or take the PDF with you.</p>
|
||||||
<p>Sarajevo, Bosnia and Herzegovina<br>Available for senior engineering work across Europe.</p>
|
<div class="actions">
|
||||||
<a href="mailto:amar@dzanan.net">amar@dzanan.net ↗</a>
|
<a class="btn btn-accent" [href]="cvUrl" download="Amar-Dzanan-CV.pdf">download cv.pdf
|
||||||
<a href="https://www.linkedin.com/in/amardzanan/">LinkedIn ↗</a>
|
<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>
|
||||||
</div>
|
</a>
|
||||||
|
<a class="btn btn-line" [href]="cvUrl" target="_blank" rel="noopener">open in new tab ↗</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="cv-section shell" aria-labelledby="summary-title">
|
<section class="section tight shell" aria-label="CV document">
|
||||||
<p class="case-index mono">01 / Summary</p>
|
<div class="panel cv-frame">
|
||||||
<div><h2 id="summary-title">Profile</h2><p class="large-copy">I help teams understand and improve mature software. My work spans product decisions, Angular interfaces, .NET services, data, integrations, delivery, and production diagnostics—with recent focus on safe AI access to business systems.</p></div>
|
<div class="terminal-bar"><div class="terminal-dots" aria-hidden="true"><span></span><span></span><span></span></div><span class="terminal-title">cv.pdf</span><span class="terminal-spacer"></span></div>
|
||||||
</section>
|
<object data="/cv.pdf" type="application/pdf" aria-label="Amar Džanan CV (PDF)">
|
||||||
|
<div class="cv-fallback">
|
||||||
<section class="cv-section shell" aria-labelledby="cv-experience-title">
|
<p>Your browser doesn't display PDFs inline.</p>
|
||||||
<p class="case-index mono">02 / Career</p>
|
<a class="btn btn-accent" [href]="cvUrl" download="Amar-Dzanan-CV.pdf">download cv.pdf</a>
|
||||||
<div><h2 id="cv-experience-title">Experience</h2>
|
</div>
|
||||||
<div class="cv-roles">
|
</object>
|
||||||
@for (role of roles; track role.period + role.title) {
|
|
||||||
<article><div class="cv-role-head"><time class="mono">{{ role.period }}</time><div><h3>{{ role.title }}</h3><p>{{ role.company }}</p></div></div><p>{{ role.description }}</p></article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="cv-section shell" aria-labelledby="skills-title">
|
|
||||||
<p class="case-index mono">03 / Practice</p>
|
|
||||||
<div><h2 id="skills-title">Capabilities & tools</h2>
|
|
||||||
<div class="cv-skill-grid">
|
|
||||||
@for (group of skillGroups; track group.title) { <article><h3>{{ group.title }}</h3><p>{{ group.items }}</p></article> }
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="cv-section shell" aria-labelledby="learning-title">
|
|
||||||
<p class="case-index mono">04 / Learning</p>
|
|
||||||
<div><h2 id="learning-title">Selected courses</h2>
|
|
||||||
<div class="course-list">
|
|
||||||
@for (course of courses; track course.title) {
|
|
||||||
<article data-testid="course"><time class="mono">{{ course.period }}</time><h3>{{ course.title }}</h3><p>{{ course.provider }}</p></article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="contact-block shell" aria-labelledby="cv-contact-title">
|
|
||||||
<p class="eyebrow mono">Contact / Direct</p>
|
|
||||||
<h2 id="cv-contact-title">Let’s talk about the system you need to move forward.</h2>
|
|
||||||
<div class="contact-actions"><a class="button button-light" href="mailto:amar@dzanan.net">amar@dzanan.net ↗</a><a class="text-link light" routerLink="/">Selected work →</a></div>
|
|
||||||
</section>
|
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class CvPageComponent {
|
export class CvPageComponent {
|
||||||
readonly sections = MAIN_NAVIGATION;
|
readonly sections = MAIN_NAVIGATION;
|
||||||
readonly roles = [
|
readonly cvUrl = CV_URL;
|
||||||
{ period: '2025—Present', company: 'Unija Smart Accounting BiH', title: 'Senior Software Engineer', description: 'Modernize enterprise platforms and deliver full-stack workflows, operational automation, secure integration paths, and AI-assisted capabilities.' },
|
|
||||||
{ period: 'Aug 2023—Present', company: 'COMP-2000', title: 'Product Owner', description: 'Shape product direction, manage priorities and backlog, align stakeholders, run reviews and retrospectives, and turn user feedback into delivery decisions.' },
|
|
||||||
{ period: 'Nov 2020—Present', company: 'COMP-2000', title: 'Software Developer', description: 'Design, implement, test, and maintain applications across .NET, C#, Angular, Blazor, Ionic, Web API, SQL Server, and .NET MAUI.' },
|
|
||||||
{ period: 'Nov 2018—2020', company: 'COMP-2000', title: 'Junior Software Developer', description: 'Built core engineering experience in C#, object-oriented design, SQL, Angular, Xamarin Forms, WPF, Windows Forms, and application lifecycle practices.' }
|
|
||||||
];
|
|
||||||
readonly skillGroups = [
|
|
||||||
{ title: 'Backend & data', items: '.NET, C#, ASP.NET Core, Web API, Entity Framework Core, SQL Server, PostgreSQL, PostGIS' },
|
|
||||||
{ title: 'Frontend & mobile', items: 'Angular, TypeScript, RxJS, Blazor, Ionic, .NET MAUI, Xamarin Forms, OpenLayers' },
|
|
||||||
{ title: 'Architecture & integration', items: 'CQRS, multitenancy, SignalR, identity, SOAP and HTTP integrations, Model Context Protocol' },
|
|
||||||
{ title: 'Delivery & operations', items: 'Docker, CI/CD, IIS, TLS, diagnostics, automated testing, incremental modernization' },
|
|
||||||
{ title: 'Product practice', items: 'Product ownership, backlog shaping, stakeholder alignment, sprint reviews, retrospectives, user feedback' },
|
|
||||||
{ title: 'Working style', items: 'End-to-end ownership, clear boundaries, pragmatic design, production awareness, cross-functional communication' }
|
|
||||||
];
|
|
||||||
readonly courses = [
|
|
||||||
{ title: 'Learn Go Course', provider: 'Codecademy', period: 'Apr 2024' },
|
|
||||||
{ title: 'Reactive Angular RxJS (Angular 17)', provider: 'Udemy', period: 'Mar 2024' },
|
|
||||||
{ title: 'C# Advanced Topics: Prepare for Technical Interviews', provider: 'Udemy', period: 'Sep 2021' },
|
|
||||||
{ title: 'Learning Docker', provider: 'LinkedIn Learning', period: 'Sep 2021' },
|
|
||||||
{ title: 'Learning Entity Framework Core', provider: 'LinkedIn Learning', period: 'May 2021' },
|
|
||||||
{ title: 'Building Web APIs with ASP.NET Core', provider: 'LinkedIn Learning', period: 'Aug 2020' },
|
|
||||||
{ title: 'Software Architecture Foundations', provider: 'LinkedIn Learning', period: 'Jul 2020' },
|
|
||||||
{ title: 'Linux Command Line', provider: 'Udemy', period: 'Jul 2018' }
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
inject(SeoService).update({
|
inject(SeoService).update({
|
||||||
title: 'CV — Amar Džanan, Senior Software Engineer',
|
title: 'CV — Amar Džanan, Senior Software Engineer',
|
||||||
description: 'Experience, capabilities, and selected learning from senior full-stack engineer and product owner Amar Džanan.',
|
description: 'Curriculum vitae of Amar Džanan: senior full-stack engineer and product owner working with .NET, Angular, and AI integration.',
|
||||||
path: '/cv'
|
path: '/cv'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/app/features/experience/experience-page.component.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
||||||
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
|
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-experience-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [PageShellComponent, NgOptimizedImage, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<app-page-shell [sections]="sections">
|
||||||
|
<header class="page-head shell">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>experience</span></nav>
|
||||||
|
<span class="label">// experience</span>
|
||||||
|
<h1>Eight years, one product at a time.</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section tight shell experience" aria-labelledby="about-title">
|
||||||
|
<div class="experience-intro">
|
||||||
|
<img ngSrc="/images/profile.jpg" width="96" height="96" alt="Portrait of Amar Džanan" class="portrait" />
|
||||||
|
<h2 id="about-title">Engineering judgment across the whole product.</h2>
|
||||||
|
<p>Senior software engineer and product owner — most useful where business rules, legacy systems and new capabilities meet. I turn incomplete requirements into maintainable paths through the UI, domain model, integrations, deployment and operations.</p>
|
||||||
|
<p>I build full-stack systems end to end: ERP and business platforms in .NET and Angular, offline-first mobile apps in Ionic and .NET MAUI with the realtime backends behind them, rule-driven expert systems, and — most recently — narrow AI interfaces with clear trust boundaries.</p>
|
||||||
|
<a class="text-link" href="https://www.linkedin.com/in/amardzanan/" target="_blank" rel="noopener">LinkedIn ↗</a>
|
||||||
|
</div>
|
||||||
|
<div class="roles">
|
||||||
|
@for (role of experience; track role.period + role.title) {
|
||||||
|
<article class="role"><time class="mono">{{ role.period }}</time><div><h4>{{ role.title }}</h4><span class="company">{{ role.company }}</span></div><p>{{ role.focus }}</p></article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section tight shell" aria-labelledby="capabilities-title">
|
||||||
|
<div class="section-head"><div><span class="label">// capabilities</span><h2 id="capabilities-title">From uncertain problem to running system.</h2></div></div>
|
||||||
|
<div class="capabilities">
|
||||||
|
@for (capability of capabilities; track capability.number) {
|
||||||
|
<article class="panel capability"><span class="index">{{ capability.number }}</span><h3>{{ capability.title }}</h3><p>{{ capability.detail }}</p></article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section tight shell" aria-label="Next steps">
|
||||||
|
<div class="hero-actions"><a class="btn btn-line" routerLink="/cv">cv.pdf →</a><a class="btn btn-line" routerLink="/work">selected work →</a></div>
|
||||||
|
</section>
|
||||||
|
</app-page-shell>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ExperiencePageComponent {
|
||||||
|
readonly sections = MAIN_NAVIGATION;
|
||||||
|
readonly experience = [
|
||||||
|
{ period: 'Aug 2026 — now', company: 'Unija Smart Accounting Slovenija · Ljubljana', title: 'Full Stack Engineer', focus: 'End-to-end delivery across Angular interfaces, .NET services, data and integrations.' },
|
||||||
|
{ period: 'Aug 2026 — now', company: 'Unija Smart Accounting Slovenija · Škofja Loka', title: 'Senior Software Engineer', focus: 'Architecture and modernization of enterprise accounting platforms.' },
|
||||||
|
{ period: 'Aug 2026 — now', company: 'Unija Smart Accounting Slovenija · Škofja Loka', title: 'AI & LLM Integration', focus: 'AI libraries spanning multiple LLM providers, remote MCP servers for custom models, API integration and optimization for AI-powered applications.' },
|
||||||
|
{ period: 'Nov 2025 — Aug 2026', company: 'Unija Smart Accounting BiH', title: 'AI & LLM Integration', focus: 'AI libraries integrating other LLM providers; design and implementation of remote MCP servers, including the production HRM & CRM gateway.' },
|
||||||
|
{ period: 'Aug 2025 — Aug 2026', company: 'Unija Smart Accounting BiH · Sarajevo', title: 'Senior Software Developer', focus: 'Enterprise platform modernization, full-stack workflows and operational automation.' },
|
||||||
|
{ period: 'Jan 2025 — Apr 2025', company: 'Teloptic', title: 'DevOps Engineer', focus: 'Hybrid Windows Server 2022/2025 infrastructure, high-availability clusters, deployment pipelines for C# and Angular applications, Cloudflare.' },
|
||||||
|
{ period: 'Oct 2024 — Feb 2025', company: 'RoyalFood', title: 'DevOps Engineer', focus: 'Windows Server environments, GitHub and Jenkins CI/CD, backend and frontend deployments, backups and Cloudflare.' },
|
||||||
|
{ period: 'Aug 2023 — Aug 2025', company: 'COMP-2000', title: 'Product Owner', focus: 'Product direction for the field sales mobile app: roadmap, backlog, sprint reviews, retrospectives, user feedback and KPIs.' },
|
||||||
|
{ period: 'Jan 2023 — Aug 2025', company: 'COMP-2000', title: 'DevOps Engineer', focus: 'A fleet of 10+ Linux VPS instances: hardening, automated backups, monitoring, Docker and Podman.' },
|
||||||
|
{ period: 'Nov 2020 — Aug 2025', company: 'COMP-2000', title: 'Software Developer', focus: 'Design, implementation and support across .NET, Blazor, Angular, Ionic, Web API, SQL Server and .NET MAUI.' },
|
||||||
|
{ period: 'Nov 2018 — Nov 2020', company: 'COMP-2000', title: 'Junior Software Developer', focus: 'C#, object-oriented design, SQL, Windows Forms, WPF, Xamarin Forms and Angular foundations.' }
|
||||||
|
];
|
||||||
|
readonly capabilities = [
|
||||||
|
{ number: '01', title: 'ERP & business systems', detail: 'Carry orders, documents, pricing rules and accounting hooks through interfaces, APIs, domain rules, persistence, integrations and tests.' },
|
||||||
|
{ number: '02', title: 'Mobile & field systems', detail: 'Offline-first Ionic and .NET MAUI apps with local replicas, sync queues, barcode and GPS — and the realtime backends behind them.' },
|
||||||
|
{ number: '03', title: 'System modernization', detail: 'Upgrade frameworks, identity, routing, delivery and diagnostics without losing working domain knowledge.' },
|
||||||
|
{ number: '04', title: 'Expert systems & safe AI', detail: 'Encode business expertise as rule-driven systems, and expose capabilities to AI through narrow tools, validated boundaries and verified writes.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
inject(SeoService).update({
|
||||||
|
title: 'Experience — Amar Džanan',
|
||||||
|
description: 'Roles and capabilities of senior full-stack engineer and product owner Amar Džanan: .NET, Angular, Ionic, ERP and expert systems, modernization, and safe AI integration.',
|
||||||
|
path: '/experience'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { LandingPageComponent } from './landing-page.component';
|
import { LandingPageComponent } from './landing-page.component';
|
||||||
|
|
||||||
describe('LandingPageComponent', () => {
|
describe('LandingPageComponent', () => {
|
||||||
it('leads with the approved positioning and four case studies', async () => {
|
async function render(): Promise<HTMLElement> {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [LandingPageComponent],
|
imports: [LandingPageComponent],
|
||||||
providers: [provideRouter([])]
|
providers: [provideRouter([])]
|
||||||
@@ -11,34 +11,22 @@ describe('LandingPageComponent', () => {
|
|||||||
|
|
||||||
const fixture = TestBed.createComponent(LandingPageComponent);
|
const fixture = TestBed.createComponent(LandingPageComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const page = fixture.nativeElement as HTMLElement;
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
expect(page.querySelector('h1')?.textContent).toContain(
|
it('leads with the positioning and the profile terminal', async () => {
|
||||||
'I modernize complex software and connect it safely to AI.'
|
const page = await render();
|
||||||
);
|
|
||||||
expect(page.querySelectorAll('[data-testid="project-row"]').length).toBe(4);
|
expect(page.querySelector('h1')?.textContent).toContain('Complex systems, understood before changed.');
|
||||||
expect(page.textContent).toContain('Secure AI Integration Gateway');
|
expect(page.querySelector('[data-testid="profile-terminal"]')?.textContent).toContain('"open_to"');
|
||||||
expect(page.textContent).toContain('Travel Operations & Automation Platform');
|
|
||||||
expect(page.textContent).toContain('Multitenant Service Operations Platform');
|
|
||||||
expect(page.textContent).toContain('Biodiversity Data & Field Research Platform');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers direct contact, CV, LinkedIn, and the complete role history', async () => {
|
it('indexes every section page and offers direct contact', async () => {
|
||||||
await TestBed.configureTestingModule({
|
const page = await render();
|
||||||
imports: [LandingPageComponent],
|
|
||||||
providers: [provideRouter([])]
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(LandingPageComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
const page = fixture.nativeElement as HTMLElement;
|
|
||||||
|
|
||||||
|
for (const href of ['/work', '/experience', '/cv', '/contact']) {
|
||||||
|
expect(page.querySelector(`[data-testid="index-row"][href="${href}"]`)).withContext(href).toBeTruthy();
|
||||||
|
}
|
||||||
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
|
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
|
||||||
expect(page.querySelector('a[href="/cv"]')).toBeTruthy();
|
|
||||||
expect(page.querySelector('a[href="https://www.linkedin.com/in/amardzanan/"]')).toBeTruthy();
|
|
||||||
expect(page.textContent).toContain('Senior Software Engineer');
|
|
||||||
expect(page.textContent).toContain('Product Owner');
|
|
||||||
expect(page.textContent).toContain('Software Developer');
|
|
||||||
expect(page.textContent).toContain('Junior Software Developer');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,116 +1,104 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
import { MAIN_NAVIGATION, PAGE_INDEX } from '../../shared/config/navigation.config';
|
||||||
import { PORTFOLIO_PROJECTS } from '../work/portfolio-projects.data';
|
|
||||||
|
type ProfileValue = string | number | readonly string[];
|
||||||
|
type TokenClass = 'k' | 's' | 'n' | 'p';
|
||||||
|
interface TerminalToken { readonly cls: TokenClass; readonly text: string; }
|
||||||
|
interface TerminalLine { readonly tokens: readonly TerminalToken[]; }
|
||||||
|
|
||||||
|
/** The `profile.json` shown in the hero. Order is render order. */
|
||||||
|
const PROFILE: readonly (readonly [string, ProfileValue])[] = [
|
||||||
|
['name', 'Amar Džanan'],
|
||||||
|
['role', 'Senior Full-Stack Engineer · Product Owner'],
|
||||||
|
['stack', ['.NET', 'Angular', 'Ionic', 'SQL Server']],
|
||||||
|
['focus', ['ERP & business systems', 'mobile', 'expert systems', 'safe AI integration']],
|
||||||
|
['since', 2018],
|
||||||
|
['location', 'Ljubljana · CET'],
|
||||||
|
['open_to', ['senior roles', 'consulting']]
|
||||||
|
];
|
||||||
|
|
||||||
|
function toTerminalLines(profile: readonly (readonly [string, ProfileValue])[]): TerminalLine[] {
|
||||||
|
return profile.map(([key, value], index) => {
|
||||||
|
const tokens: TerminalToken[] = [{ cls: 'k', text: `"${key}"` }, { cls: 'p', text: ': ' }];
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
tokens.push({ cls: 'n', text: String(value) });
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
tokens.push({ cls: 's', text: `"${value}"` });
|
||||||
|
} else {
|
||||||
|
tokens.push({ cls: 'p', text: '[' });
|
||||||
|
value.forEach((item, itemIndex) => {
|
||||||
|
if (itemIndex > 0) tokens.push({ cls: 'p', text: ', ' });
|
||||||
|
tokens.push({ cls: 's', text: `"${item}"` });
|
||||||
|
});
|
||||||
|
tokens.push({ cls: 'p', text: ']' });
|
||||||
|
}
|
||||||
|
if (index < profile.length - 1) tokens.push({ cls: 'p', text: ',' });
|
||||||
|
return { tokens };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-landing-page',
|
selector: 'app-landing-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [PageShellComponent, RouterLink],
|
||||||
PageShellComponent,
|
|
||||||
NgOptimizedImage,
|
|
||||||
RouterLink
|
|
||||||
],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell [sections]="sections">
|
<app-page-shell [sections]="sections">
|
||||||
<section class="hero shell" aria-labelledby="hero-title">
|
<section class="hero shell" aria-labelledby="hero-title">
|
||||||
<div class="hero-kicker mono"><span>01</span> Senior full-stack engineer · AI integration</div>
|
<div class="hero-copy">
|
||||||
<div class="hero-grid">
|
<p class="label">Senior full-stack engineer<span class="label-extra"> · product owner</span></p>
|
||||||
<h1 id="hero-title">I modernize complex software and connect it safely to AI.</h1>
|
<h1 id="hero-title">Complex systems, <span>understood before changed.</span></h1>
|
||||||
<div class="hero-aside">
|
<p class="hero-lead">I build and modernize full-stack business systems — ERP software, offline-first mobile apps for the field, rule-driven expert systems — and connect them to AI through narrow, validated, verified interfaces. The kind you can run in production and sleep at night.</p>
|
||||||
<p>I work across product, frontend, backend, integrations, and delivery to make mature systems easier to operate and evolve.</p>
|
<div class="hero-actions">
|
||||||
<div class="action-row">
|
<a class="btn btn-accent" href="mailto:amar@dzanan.net">amar@dzanan.net</a>
|
||||||
<a class="button button-primary" href="mailto:amar@dzanan.net">Start a conversation <span aria-hidden="true">↗</span></a>
|
<a class="btn btn-line" routerLink="/cv">cv.pdf →</a>
|
||||||
<a class="text-link" routerLink="/cv">View CV <span aria-hidden="true">→</span></a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel terminal" data-testid="profile-terminal" role="img" aria-label="Profile summary rendered as a JSON document">
|
||||||
|
<div class="terminal-bar"><div class="terminal-dots" aria-hidden="true"><span></span><span></span><span></span></div><span class="terminal-title">profile.json</span><span class="terminal-spacer"></span></div>
|
||||||
|
<div class="terminal-body">
|
||||||
|
<div class="terminal-line"><span class="c">$ </span>whoami<span class="c"> --json</span></div>
|
||||||
|
<div class="terminal-line">{{ '{' }}</div>
|
||||||
|
@for (line of terminalLines; track $index) {
|
||||||
|
<div class="terminal-line indent">@for (token of line.tokens; track $index) {<span [class]="token.cls">{{ token.text }}</span>}</div>
|
||||||
|
}
|
||||||
|
<div class="terminal-line">{{ '}' }}</div>
|
||||||
|
<div class="terminal-line prompt"><span class="c">$ </span><span class="cursor" aria-hidden="true"></span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-foot mono"><span>Based in Sarajevo · Working across Europe</span><span>Scroll to inspect selected systems ↓</span></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="work" class="section-block shell" aria-labelledby="work-title">
|
<section class="section tight shell" aria-labelledby="index-title">
|
||||||
<div class="section-heading">
|
<div class="section-head padded">
|
||||||
<div><p class="eyebrow mono">Selected work / 2025—2026</p><h2 id="work-title">Systems I’ve helped move forward.</h2></div>
|
<div><span class="label">// index</span><h2 id="index-title">ls ~/</h2></div>
|
||||||
<p>Four anonymized case studies from production software: integration boundaries, operational workflows, modernization, and AI-assisted tooling.</p>
|
<span class="count">{{ pages.length }} entries</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-index">
|
<div class="panel index-list">
|
||||||
@for (project of projects; track project.id) {
|
@for (page of pages; track page.path) {
|
||||||
<a data-testid="project-row" class="project-row" [routerLink]="['/work', project.slug]">
|
<a data-testid="index-row" class="index-row" [routerLink]="page.path">
|
||||||
<span class="project-number mono">{{ project.index }}</span>
|
<span class="name">{{ page.name }}</span>
|
||||||
<span class="project-main"><strong>{{ project.title }}</strong><small>{{ project.summary }}</small></span>
|
<p>{{ page.description }}</p>
|
||||||
<span class="project-meta mono">{{ project.category }}<br>{{ project.period }}</span>
|
<span class="project-arrow" aria-hidden="true">→</span>
|
||||||
<span class="project-arrow" aria-hidden="true">↗</span>
|
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="profile" class="profile-band" aria-labelledby="profile-title">
|
|
||||||
<div class="shell profile-grid">
|
|
||||||
<img ngSrc="/images/profile.jpg" width="360" height="360" alt="Portrait of Amar Džanan" class="profile-photo" />
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow mono">Profile / Amar Džanan</p>
|
|
||||||
<h2 id="profile-title">Engineering judgment across the whole product.</h2>
|
|
||||||
<p class="profile-lead">I’m a senior software engineer and product owner who is most useful where business rules, legacy systems, and new capabilities meet.</p>
|
|
||||||
</div>
|
|
||||||
<div class="profile-notes">
|
|
||||||
<p>I turn incomplete requirements into maintainable paths through the UI, domain model, integrations, deployment, and operations.</p>
|
|
||||||
<p>My recent work focuses on enterprise .NET and Angular platforms, reliable automation, and narrow AI interfaces with clear trust boundaries.</p>
|
|
||||||
<a href="https://www.linkedin.com/in/amardzanan/">LinkedIn profile <span aria-hidden="true">↗</span></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="experience" class="section-block shell" aria-labelledby="experience-title">
|
|
||||||
<div class="section-heading compact"><div><p class="eyebrow mono">Experience / 2018—Present</p><h2 id="experience-title">Work that overlaps by design.</h2></div><a class="text-link" routerLink="/cv">Full CV →</a></div>
|
|
||||||
<div class="experience-list">
|
|
||||||
@for (role of experience; track role.period + role.title) {
|
|
||||||
<article class="experience-row"><time class="mono">{{ role.period }}</time><div><h3>{{ role.title }}</h3><p>{{ role.company }}</p></div><p>{{ role.focus }}</p></article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section-block shell capabilities" aria-labelledby="capabilities-title">
|
|
||||||
<div><p class="eyebrow mono">Capabilities / How I contribute</p><h2 id="capabilities-title">From uncertain problem to running system.</h2></div>
|
|
||||||
<div class="capability-grid">
|
|
||||||
@for (capability of capabilities; track capability.number) {
|
|
||||||
<article><span class="mono">{{ capability.number }}</span><h3>{{ capability.title }}</h3><p>{{ capability.detail }}</p></article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="contact-block shell" aria-labelledby="contact-title">
|
|
||||||
<p class="eyebrow mono">Available for the right problem</p>
|
|
||||||
<h2 id="contact-title">Need a senior engineer who can understand the system before changing it?</h2>
|
|
||||||
<div class="contact-actions"><a class="button button-light" href="mailto:amar@dzanan.net">amar@dzanan.net <span aria-hidden="true">↗</span></a><span class="mono">Sarajevo · CET / CEST</span></div>
|
|
||||||
</section>
|
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class LandingPageComponent {
|
export class LandingPageComponent {
|
||||||
readonly sections = MAIN_NAVIGATION;
|
readonly sections = MAIN_NAVIGATION;
|
||||||
readonly projects = PORTFOLIO_PROJECTS;
|
readonly pages = PAGE_INDEX;
|
||||||
readonly experience = [
|
readonly terminalLines = toTerminalLines(PROFILE);
|
||||||
{ period: '2025—Present', company: 'Unija Smart Accounting BiH', title: 'Senior Software Engineer', focus: 'Enterprise platforms, modernization, automation, and AI integration.' },
|
|
||||||
{ period: 'Aug 2023—Present', company: 'COMP-2000', title: 'Product Owner', focus: 'Product direction, backlog ownership, cross-functional delivery, and user feedback.' },
|
|
||||||
{ period: 'Nov 2020—Present', company: 'COMP-2000', title: 'Software Developer', focus: 'Full-stack .NET, Angular, mobile, APIs, data, and production support.' },
|
|
||||||
{ period: 'Nov 2018—2020', company: 'COMP-2000', title: 'Junior Software Developer', focus: 'C#, SQL, desktop, web, mobile, and application lifecycle foundations.' }
|
|
||||||
];
|
|
||||||
readonly capabilities = [
|
|
||||||
{ number: '01', title: 'System modernization', detail: 'Upgrade frameworks, identity, routing, delivery, and diagnostics without losing working domain knowledge.' },
|
|
||||||
{ number: '02', title: 'Full-stack product delivery', detail: 'Carry business workflows through interface design, APIs, domain rules, persistence, integrations, and tests.' },
|
|
||||||
{ number: '03', title: 'Safe AI integration', detail: 'Expose useful capabilities through narrow tools, validated boundaries, verified writes, and observable operations.' }
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const description = 'Senior full-stack engineer modernizing complex enterprise software and connecting it safely to AI.';
|
|
||||||
inject(SeoService).update({
|
inject(SeoService).update({
|
||||||
title: 'Amar Džanan — Senior Software Engineer & AI Integration',
|
title: 'Amar Džanan — Senior Software Engineer & AI Integration',
|
||||||
description,
|
description: 'Senior full-stack engineer building and modernizing ERP, mobile and expert systems — and connecting them safely to AI.',
|
||||||
path: '/',
|
path: '/',
|
||||||
structuredData: {
|
structuredData: {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -120,7 +108,8 @@ export class LandingPageComponent {
|
|||||||
email: 'mailto:amar@dzanan.net',
|
email: 'mailto:amar@dzanan.net',
|
||||||
jobTitle: 'Senior Software Engineer',
|
jobTitle: 'Senior Software Engineer',
|
||||||
sameAs: ['https://www.linkedin.com/in/amardzanan/'],
|
sameAs: ['https://www.linkedin.com/in/amardzanan/'],
|
||||||
knowsAbout: ['.NET', 'Angular', 'TypeScript', 'Software architecture', 'Model Context Protocol', 'AI integration']
|
address: { '@type': 'PostalAddress', addressLocality: 'Ljubljana', addressCountry: 'SI' },
|
||||||
|
knowsAbout: ['.NET', 'Angular', 'TypeScript', 'Ionic', 'ERP software', 'Expert systems', 'Software architecture', 'Model Context Protocol', 'AI integration']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
@@ -12,16 +13,24 @@ import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
|||||||
template: `
|
template: `
|
||||||
<app-page-shell [sections]="sections">
|
<app-page-shell [sections]="sections">
|
||||||
<section class="not-found shell">
|
<section class="not-found shell">
|
||||||
<p class="eyebrow mono">404 / Route not found</p>
|
<div class="panel terminal" role="img" aria-label="Terminal reporting that the path was not found">
|
||||||
|
<div class="terminal-bar"><div class="terminal-dots" aria-hidden="true"><span></span><span></span><span></span></div><span class="terminal-title">404</span><span class="terminal-spacer"></span></div>
|
||||||
|
<div class="terminal-body">
|
||||||
|
<div class="terminal-line"><span class="c">$ </span>open {{ path }}</div>
|
||||||
|
<div class="terminal-line"><span class="n">command not found:</span> {{ path }}</div>
|
||||||
|
<div class="terminal-line prompt"><span class="c">$ </span><span class="cursor" aria-hidden="true"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h1>This path ends here.</h1>
|
<h1>This path ends here.</h1>
|
||||||
<p>The page may have moved. The selected systems and current CV are available from the homepage.</p>
|
<p class="muted">The page may have moved. The selected systems and the CV are one click away.</p>
|
||||||
<a class="button button-primary" routerLink="/">Return to selected work →</a>
|
<a class="btn btn-line" routerLink="/">cd ~/</a>
|
||||||
</section>
|
</section>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class NotFoundPageComponent {
|
export class NotFoundPageComponent {
|
||||||
readonly sections = MAIN_NAVIGATION;
|
readonly sections = MAIN_NAVIGATION;
|
||||||
|
readonly path = inject(DOCUMENT).location?.pathname || '/unknown';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
inject(SeoService).update({
|
inject(SeoService).update({
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ describe('PortfolioPageComponent', () => {
|
|||||||
expect(page.textContent).toContain('The outcome');
|
expect(page.textContent).toContain('The outcome');
|
||||||
expect(page.querySelector('[data-testid="system-diagram"]')).toBeTruthy();
|
expect(page.querySelector('[data-testid="system-diagram"]')).toBeTruthy();
|
||||||
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
|
expect(page.querySelector('a[href="mailto:amar@dzanan.net"]')).toBeTruthy();
|
||||||
|
expect(page.querySelector('a[href="/cv"]')).toBeTruthy();
|
||||||
|
expect(page.textContent).toContain('// 01 context');
|
||||||
expect(TestBed.inject(Title).getTitle()).toContain('Secure AI Integration Gateway');
|
expect(TestBed.inject(Title).getTitle()).toContain('Secure AI Integration Gateway');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,71 +16,66 @@ import { SystemDiagramComponent } from '../work/system-diagram.component';
|
|||||||
<app-page-shell [sections]="sections">
|
<app-page-shell [sections]="sections">
|
||||||
<article>
|
<article>
|
||||||
<header class="case-hero shell">
|
<header class="case-hero shell">
|
||||||
<nav class="breadcrumb mono" aria-label="Breadcrumb"><a routerLink="/">Work</a><span>/</span><span>{{ project.index }}</span></nav>
|
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/work">~/work/</a><span>{{ project.slug }}</span></nav>
|
||||||
<div class="case-heading">
|
<p class="label">{{ project.category }} · {{ project.period }}</p>
|
||||||
<div>
|
<h1>{{ project.title }}</h1>
|
||||||
<p class="eyebrow mono">{{ project.category }} · {{ project.period }}</p>
|
<p class="lead">{{ project.summary }}</p>
|
||||||
<h1>{{ project.title }}</h1>
|
<dl class="panel case-meta">
|
||||||
</div>
|
<div><dt>role</dt><dd>{{ project.role }}</dd></div>
|
||||||
<div class="case-intro">
|
<div><dt>period</dt><dd>{{ project.period }}</dd></div>
|
||||||
<p>{{ project.summary }}</p>
|
<div><dt>stack</dt><dd class="tag-row">@for (technology of project.stack; track technology) {<span class="tag">{{ technology }}</span>}</dd></div>
|
||||||
<dl><div><dt>Role</dt><dd>{{ project.role }}</dd></div><div><dt>Period</dt><dd>{{ project.period }}</dd></div></dl>
|
</dl>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="case-section shell narrative" aria-labelledby="challenge-title">
|
<section class="case-section shell" aria-labelledby="challenge-title">
|
||||||
<p class="case-index mono">01 / Context</p>
|
<span class="label">// 01 context</span>
|
||||||
<div><h2 id="challenge-title">The challenge</h2><p class="large-copy">{{ project.challenge }}</p></div>
|
<div><h2 id="challenge-title">The challenge</h2><p class="large-copy">{{ project.challenge }}</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="case-section shell" aria-labelledby="contributions-title">
|
<section class="case-section shell" aria-labelledby="contributions-title">
|
||||||
<p class="case-index mono">02 / Delivery</p>
|
<span class="label">// 02 delivery</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="contributions-title">What I contributed</h2>
|
<h2 id="contributions-title">What I contributed</h2>
|
||||||
<ol class="contribution-list">
|
<ol class="contribution-list">
|
||||||
@for (contribution of project.contributions; track contribution; let index = $index) {
|
@for (contribution of project.contributions; track contribution; let index = $index) {
|
||||||
<li><span class="mono">{{ contributionNumber(index) }}</span><p>{{ contribution }}</p></li>
|
<li><span>{{ contributionNumber(index) }}</span><p>{{ contribution }}</p></li>
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="diagram-wrap">
|
<section class="diagram-wrap shell">
|
||||||
<div class="shell"><app-system-diagram [kind]="project.diagram" [title]="project.title" /></div>
|
<app-system-diagram [kind]="project.diagram" [title]="project.title" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="case-section shell" aria-labelledby="decisions-title">
|
<section class="case-section shell" aria-labelledby="decisions-title">
|
||||||
<p class="case-index mono">03 / Architecture</p>
|
<span class="label">// 03 architecture</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="decisions-title">Key decisions</h2>
|
<h2 id="decisions-title">Key decisions</h2>
|
||||||
<div class="decision-grid">
|
<div class="decision-grid">
|
||||||
@for (decision of project.decisions; track decision.title; let index = $index) {
|
@for (decision of project.decisions; track decision.title; let index = $index) {
|
||||||
<article><span class="mono">{{ contributionNumber(index) }}</span><h3>{{ decision.title }}</h3><p>{{ decision.detail }}</p></article>
|
<article class="panel decision"><span>{{ contributionNumber(index) }}</span><h3>{{ decision.title }}</h3><p>{{ decision.detail }}</p></article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="case-section shell narrative outcome" aria-labelledby="outcome-title">
|
<section class="case-section shell" aria-labelledby="outcome-title">
|
||||||
<p class="case-index mono">04 / Result</p>
|
<span class="label">// 04 result</span>
|
||||||
<div><h2 id="outcome-title">The outcome</h2><p class="large-copy">{{ project.outcome }}</p></div>
|
<div><h2 id="outcome-title">The outcome</h2><p class="large-copy">{{ project.outcome }}</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="case-stack shell" aria-label="Technology stack">
|
|
||||||
<p class="case-index mono">Technology</p>
|
|
||||||
<ul>@for (technology of project.stack; track technology) { <li>{{ technology }}</li> }</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<nav class="case-pagination shell" aria-label="Case studies">
|
<nav class="case-pagination shell" aria-label="Case studies">
|
||||||
<a [routerLink]="['/work', previous.slug]"><span class="mono">← Previous</span><strong>{{ previous.title }}</strong></a>
|
<a class="panel" [routerLink]="['/work', previous.slug]"><span class="mono">← previous</span><strong>{{ previous.title }}</strong></a>
|
||||||
<a [routerLink]="['/work', next.slug]"><span class="mono">Next →</span><strong>{{ next.title }}</strong></a>
|
<a class="panel" [routerLink]="['/work', next.slug]"><span class="mono">next →</span><strong>{{ next.title }}</strong></a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section class="contact-block shell case-contact" aria-labelledby="case-contact-title">
|
<section class="shell case-contact" aria-labelledby="case-contact-title">
|
||||||
<p class="eyebrow mono">Have a complex system?</p>
|
<div class="panel contact">
|
||||||
<h2 id="case-contact-title">Let’s make the next change safer and more useful.</h2>
|
<span class="label">// contact</span>
|
||||||
<div class="contact-actions"><a class="button button-light" href="mailto:amar@dzanan.net">Start a conversation <span aria-hidden="true">↗</span></a><a class="text-link light" routerLink="/cv">View full CV →</a></div>
|
<h2 id="case-contact-title">Have a complex system? Let’s make the next change safer and more useful.</h2>
|
||||||
|
<div class="contact-actions"><a class="btn btn-accent" href="mailto:amar@dzanan.net">amar@dzanan.net</a><a class="text-link" routerLink="/cv">cv.pdf →</a></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
export type PortfolioProjectId =
|
export type PortfolioProjectId =
|
||||||
| 'ai-integration'
|
| 'ai-integration'
|
||||||
|
| 'field-sales'
|
||||||
|
| 'fuel-management'
|
||||||
| 'travel-operations'
|
| 'travel-operations'
|
||||||
| 'service-operations'
|
| 'service-operations'
|
||||||
| 'biodiversity-data';
|
| 'biodiversity-data';
|
||||||
|
|
||||||
export type ProjectDiagramKind =
|
export type ProjectDiagramKind =
|
||||||
| 'trust-boundaries'
|
| 'trust-boundaries'
|
||||||
|
| 'field-sales-platform'
|
||||||
|
| 'fuel-station-platform'
|
||||||
| 'travel-platform'
|
| 'travel-platform'
|
||||||
| 'multitenant-platform'
|
| 'multitenant-platform'
|
||||||
| 'research-platform';
|
| 'research-platform';
|
||||||
@@ -24,6 +28,8 @@ export interface PortfolioProject {
|
|||||||
readonly period: string;
|
readonly period: string;
|
||||||
readonly role: string;
|
readonly role: string;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
|
/** One line (≤ 70 chars) shown in the work list. */
|
||||||
|
readonly tagline: string;
|
||||||
readonly challenge: string;
|
readonly challenge: string;
|
||||||
readonly contributions: readonly string[];
|
readonly contributions: readonly string[];
|
||||||
readonly decisions: readonly ProjectDecision[];
|
readonly decisions: readonly ProjectDecision[];
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import { getPortfolioProject, PORTFOLIO_PROJECTS } from './portfolio-projects.da
|
|||||||
|
|
||||||
describe('portfolio project registry', () => {
|
describe('portfolio project registry', () => {
|
||||||
it('contains four complete, uniquely addressable case studies', () => {
|
it('contains four complete, uniquely addressable case studies', () => {
|
||||||
expect(PORTFOLIO_PROJECTS.length).toBe(4);
|
expect(PORTFOLIO_PROJECTS.length).toBe(6);
|
||||||
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.id)).size).toBe(4);
|
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.id)).size).toBe(6);
|
||||||
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.slug)).size).toBe(4);
|
expect(new Set(PORTFOLIO_PROJECTS.map(project => project.slug)).size).toBe(6);
|
||||||
expect(PORTFOLIO_PROJECTS.map(project => project.index)).toEqual(['01', '02', '03', '04']);
|
expect(PORTFOLIO_PROJECTS.map(project => project.index)).toEqual(['01', '02', '03', '04', '05', '06']);
|
||||||
|
|
||||||
for (const project of PORTFOLIO_PROJECTS) {
|
for (const project of PORTFOLIO_PROJECTS) {
|
||||||
expect(project.contributions.length).toBeGreaterThan(2);
|
expect(project.contributions.length).toBeGreaterThan(2);
|
||||||
expect(project.decisions.length).toBeGreaterThan(1);
|
expect(project.decisions.length).toBeGreaterThan(1);
|
||||||
expect(project.stack.length).toBeGreaterThan(3);
|
expect(project.stack.length).toBeGreaterThan(3);
|
||||||
|
expect(project.tagline.length).toBeGreaterThan(10);
|
||||||
|
expect(project.tagline.length).toBeLessThanOrEqual(70);
|
||||||
expect(getPortfolioProject(project.id)).toBe(project);
|
expect(getPortfolioProject(project.id)).toBe(project);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -19,6 +21,19 @@ describe('portfolio project registry', () => {
|
|||||||
const publicCopy = JSON.stringify(PORTFOLIO_PROJECTS);
|
const publicCopy = JSON.stringify(PORTFOLIO_PROJECTS);
|
||||||
const restrictedTerms = [
|
const restrictedTerms = [
|
||||||
'WorkLogManager',
|
'WorkLogManager',
|
||||||
|
'OrderCom',
|
||||||
|
'ordc',
|
||||||
|
'Delta Petrol',
|
||||||
|
'DeltaPetrol',
|
||||||
|
'Delta-Petrol',
|
||||||
|
'Trgosped',
|
||||||
|
'E-Line',
|
||||||
|
'ELine',
|
||||||
|
'DOMS',
|
||||||
|
'Hesel',
|
||||||
|
'Avera',
|
||||||
|
'POSInterfejs',
|
||||||
|
'InterfejsCentral',
|
||||||
'intrix-mcp',
|
'intrix-mcp',
|
||||||
'Unija',
|
'Unija',
|
||||||
'git.dzanan.net',
|
'git.dzanan.net',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
|||||||
category: 'AI integration · Featured case study',
|
category: 'AI integration · Featured case study',
|
||||||
period: '2026',
|
period: '2026',
|
||||||
role: 'Primary engineer',
|
role: 'Primary engineer',
|
||||||
|
tagline: 'Production MCP service · validated boundaries · verified writes',
|
||||||
summary:
|
summary:
|
||||||
'A production Model Context Protocol service that gives AI clients useful access to business workflows while preserving authentication, validation, and operational safety.',
|
'A production Model Context Protocol service that gives AI clients useful access to business workflows while preserving authentication, validation, and operational safety.',
|
||||||
challenge:
|
challenge:
|
||||||
@@ -50,13 +51,115 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
|||||||
'Case study about designing a secure production MCP gateway for HRM and CRM systems with validated boundaries and verified writes.'
|
'Case study about designing a secure production MCP gateway for HRM and CRM systems with validated boundaries and verified writes.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'travel-operations',
|
id: 'field-sales',
|
||||||
index: '02',
|
index: '02',
|
||||||
|
slug: 'field-sales-mobile-platform',
|
||||||
|
title: 'Offline-First Field Sales App & Realtime Tracking',
|
||||||
|
category: 'Mobile & realtime · Full-stack delivery',
|
||||||
|
period: '2024 — 2025',
|
||||||
|
role: 'Full-stack engineer',
|
||||||
|
tagline: 'Ionic app with a local ERP replica, offline orders · SignalR tracking',
|
||||||
|
summary:
|
||||||
|
'A mobile ordering system for the field sales representatives of a regional distribution business: a full local replica of ERP master data, orders and credit notes composed offline and sent when connectivity returns, and a realtime tracking backend that shows directors where their teams are.',
|
||||||
|
challenge:
|
||||||
|
'Sales representatives work in shops and warehouses with unreliable connectivity, yet every order depends on current customers, price lists, promotional discounts, stock and payment terms held in the ERP. The app had to work fully offline, reproduce the ERP’s pricing rules exactly, never lose a document, and later feed a live map of representatives and their customer visits without draining phone batteries.',
|
||||||
|
contributions: [
|
||||||
|
'Built the Ionic and Angular app on Capacitor with a local SQLite replica of ERP master data — customers, delivery sites, products, price lists, promotions, stock, payment terms — refreshed on demand and once a day.',
|
||||||
|
'Implemented order and credit-note workflows with a three-state document lifecycle (draft, closed, sent) so that a failed send degrades safely to “closed” and is retried later instead of being lost.',
|
||||||
|
'Reproduced ERP pricing on the device: customer rebates, time-boxed promotional discounts with wildcard matching, VAT-inclusive and VAT-exclusive totals, and a confirm-amount step for credit notes.',
|
||||||
|
'Added barcode scanning scoped to the active sales programme, a map-based picker to geotag delivery sites, and an offline queue that replays location updates when the network returns.',
|
||||||
|
'Designed and implemented the realtime tracking backend: an ASP.NET Core SignalR hub with per-user latest-location storage, visit logging, route stops and a director dashboard filtered to each director’s team.',
|
||||||
|
'Shipped self-updating app distribution, connectivity-aware notifications, local schema migrations and automatic purging of old documents.'
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
title: 'Replicate, don’t request',
|
||||||
|
detail:
|
||||||
|
'The device holds the whole master-data set it needs; the network is only for sending documents and refreshing the replica, so a lost signal never blocks a sale.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Closed is a safe state',
|
||||||
|
detail:
|
||||||
|
'Documents move draft → closed → sent. Anything that fails on the way to the server falls back to closed — visible, retryable, never lost.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Presence from the connection',
|
||||||
|
detail:
|
||||||
|
'Online status derives from the SignalR connection lifecycle stored on the user record; live positions live in one row per user, upserted, instead of an in-memory cache that dies with the process.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trade history for battery',
|
||||||
|
detail:
|
||||||
|
'The tracker keeps the latest location per representative and explicit visit events rather than a GPS trail — enough for the dashboard, cheap on the phone.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
outcome:
|
||||||
|
'Representatives compose and send orders from anywhere with the ERP’s own prices, documents survive bad connectivity, and directors see their teams and visits live on a map — all on top of the existing ERP without changing how it stores its data.',
|
||||||
|
stack: ['Ionic', 'Angular', 'Capacitor', 'SQLite', 'Leaflet', 'ASP.NET Core', 'SignalR', 'SQL Server'],
|
||||||
|
diagram: 'field-sales-platform',
|
||||||
|
seoDescription:
|
||||||
|
'Case study: an offline-first Ionic field sales app with a local ERP replica, order and credit-note sync, barcode and GPS, and a SignalR realtime tracking backend.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fuel-management',
|
||||||
|
index: '03',
|
||||||
|
slug: 'fuel-station-management-system',
|
||||||
|
title: 'Fuel Station Management System',
|
||||||
|
category: 'Ground-up system · Desktop, web & realtime',
|
||||||
|
period: '2026',
|
||||||
|
role: 'Architect and sole engineer',
|
||||||
|
tagline: 'Forecourt & payment-terminal middleware, multi-tenant fleet limits',
|
||||||
|
summary:
|
||||||
|
'A complete fuel-management system for gas stations, built from the ground up: a Windows desktop middleware that drives forecourt controllers and outdoor payment terminals and hands every sale to the store POS, plus a multi-tenant central platform with an Angular admin app that enforces fleet-card fuel limits at the pump in real time.',
|
||||||
|
challenge:
|
||||||
|
'Fuel dispensers, an outdoor payment terminal, bank pre-authorizations, the store POS and the operator’s ERP all had to agree on every litre sold — including at night, unattended, when the terminal is the only cashier. Fleet customers needed monthly fuel limits enforced at the pump even though the ERP’s balances lag, and nothing could ever release money or lose a transaction because a network link or the application went down.',
|
||||||
|
contributions: [
|
||||||
|
'Built the station desktop middleware in VB.NET: forecourt controller integration — pump status, money and volume presets, transaction lock and clear for multi-POS sites, emergency stop, price pushes — over both a serial driver and the vendor’s COM API.',
|
||||||
|
'Implemented the outdoor payment terminal as a screen-by-screen state machine: pump selection, barcode, amount, PIN, bank pre-authorization and completion, fuelling and receipt printing, with reconnect timers and recovery of uncompleted card transactions after a restart.',
|
||||||
|
'Defined the file-based POS contract — one transaction file per sale, written temp-then-atomic-rename, with encrypted per-pump recovery files — so the existing POS and ERP agent consume sales unchanged.',
|
||||||
|
'Designed the central platform on .NET 10: a multi-tenant minimal API with a master database plus one database per tenant, hashed station API keys, JWT roles, EF Core, a worker service syncing companies and vehicles from the ERP, and an Angular 22 zoneless admin app.',
|
||||||
|
'Wrote the fleet-limit engine: available = monthly limit − fuel this business month − active reservations, with an idempotent create → authorize → complete/cancel protocol, SQL Server application locks per company, and a maintenance worker that moves unresolved reservations to review instead of releasing money.',
|
||||||
|
'Extracted a pure, unit-tested reliability core — journal state machine, ordered outbox with exponential retry, recovery planner — and wired it into the desktop so fleet fuelling is fail-closed while card and manual sales keep working when the backend is unreachable.',
|
||||||
|
'Ran the infrastructure end to end: VPN for stations, Caddy TLS in front of IIS, Gitea CI/CD with staged deploys and rollback, a private wiki, and a security hardening pass (secrets out of the repo, fixed-time PIN checks, log redaction, firewall audit).'
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
title: 'Reserve before you authorize',
|
||||||
|
detail:
|
||||||
|
'The ERP balance lags, so the platform reserves limit first and only then authorizes the pump preset — the dispenser pumps against money that is already accounted for.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Never release money automatically',
|
||||||
|
detail:
|
||||||
|
'Authorized reservations that go unresolved are moved to review with an incident, not cancelled. A person decides; the system only makes the state visible.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fail closed for fleet cards, open for everything else',
|
||||||
|
detail:
|
||||||
|
'If the central platform is unreachable, fleet-card fuelling stops but card and manual sales continue — a station never goes dark because a backend did.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Keep the legacy contract',
|
||||||
|
detail:
|
||||||
|
'The store POS reads the same one-line transaction files it always did; atomic writes and recovery files sit behind that contract instead of replacing it.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
outcome:
|
||||||
|
'The operator runs the system in production across its stations with a second company onboarding: unattended night fuelling against prepayment, fleet limits enforced at the pump in real time, remote diagnostics and pump-incident dashboards for the back office, and a codebase with a tested reliability core rather than defensive patches.',
|
||||||
|
stack: ['VB.NET', '.NET 10', 'ASP.NET Core', 'SQL Server', 'EF Core', 'SignalR', 'Angular', 'Caddy', 'Gitea CI/CD'],
|
||||||
|
diagram: 'fuel-station-platform',
|
||||||
|
seoDescription:
|
||||||
|
'Case study: a ground-up fuel-management system for gas stations — forecourt and payment-terminal middleware, a file-based POS contract, and a multi-tenant .NET 10 platform enforcing fleet fuel limits in real time.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'travel-operations',
|
||||||
|
index: '04',
|
||||||
slug: 'travel-operations-platform',
|
slug: 'travel-operations-platform',
|
||||||
title: 'Travel Operations & Automation Platform',
|
title: 'Travel Operations & Automation Platform',
|
||||||
category: 'Enterprise platform · Full-stack delivery',
|
category: 'Enterprise platform · Full-stack delivery',
|
||||||
period: '2025 — Present',
|
period: '2025 — Present',
|
||||||
role: 'Senior full-stack contributor',
|
role: 'Senior full-stack contributor',
|
||||||
|
tagline: 'Products, departures, reservations, finance, partner systems',
|
||||||
summary:
|
summary:
|
||||||
'A large operations platform connecting travel products, departures, reservations, passengers, finance, customer communication, portals, and partner systems.',
|
'A large operations platform connecting travel products, departures, reservations, passengers, finance, customer communication, portals, and partner systems.',
|
||||||
challenge:
|
challenge:
|
||||||
@@ -94,12 +197,13 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'service-operations',
|
id: 'service-operations',
|
||||||
index: '03',
|
index: '05',
|
||||||
slug: 'service-operations-platform',
|
slug: 'service-operations-platform',
|
||||||
title: 'Multitenant Service Operations Platform',
|
title: 'Multitenant Service Operations Platform',
|
||||||
category: 'Business platform · Product evolution',
|
category: 'Business platform · Product evolution',
|
||||||
period: '2025 — Present',
|
period: '2025 — Present',
|
||||||
role: 'Senior full-stack contributor',
|
role: 'Senior full-stack contributor',
|
||||||
|
tagline: 'Tickets, worklogs, contracts, accounting · CQRS · SignalR',
|
||||||
summary:
|
summary:
|
||||||
'A multitenant business system for tickets, worklogs, contracts, customers, teams, accounting, recurring work, reporting, and partner integrations.',
|
'A multitenant business system for tickets, worklogs, contracts, customers, teams, accounting, recurring work, reporting, and partner integrations.',
|
||||||
challenge:
|
challenge:
|
||||||
@@ -137,12 +241,13 @@ export const PORTFOLIO_PROJECTS: readonly PortfolioProject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'biodiversity-data',
|
id: 'biodiversity-data',
|
||||||
index: '04',
|
index: '06',
|
||||||
slug: 'biodiversity-data-platform',
|
slug: 'biodiversity-data-platform',
|
||||||
title: 'Biodiversity Data & Field Research Platform',
|
title: 'Biodiversity Data & Field Research Platform',
|
||||||
category: 'Scientific system · Platform modernization',
|
category: 'Scientific system · Platform modernization',
|
||||||
period: '2026',
|
period: '2026',
|
||||||
role: 'Platform modernization contributor',
|
role: 'Platform modernization contributor',
|
||||||
|
tagline: 'Taxonomy, sampling, specimens, habitats, map-based GIS',
|
||||||
summary:
|
summary:
|
||||||
'A specialized research system for taxonomy, field sampling, specimens, biometrics, habitats, localities, media, and map-based geographic work.',
|
'A specialized research system for taxonomy, field sampling, specimens, biometrics, habitats, localities, media, and map-based geographic work.',
|
||||||
challenge:
|
challenge:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProjectDiagramKind } from './portfolio-project.model';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<figure class="system-diagram" data-testid="system-diagram">
|
<figure class="system-diagram panel" data-testid="system-diagram">
|
||||||
<figcaption><span class="mono">System view</span><strong>{{ title }}</strong></figcaption>
|
<figcaption><span class="mono">System view</span><strong>{{ title }}</strong></figcaption>
|
||||||
@switch (kind) {
|
@switch (kind) {
|
||||||
@case ('trust-boundaries') {
|
@case ('trust-boundaries') {
|
||||||
@@ -19,6 +19,28 @@ import { ProjectDiagramKind } from './portfolio-project.model';
|
|||||||
<g class="diagram-note"><text x="362" y="72">SCHEMA BOUNDARY</text><text x="592" y="72">APPLICATION BOUNDARY</text><text x="592" y="342">SANITIZED OPERATIONS</text></g>
|
<g class="diagram-note"><text x="362" y="72">SCHEMA BOUNDARY</text><text x="592" y="72">APPLICATION BOUNDARY</text><text x="592" y="342">SANITIZED OPERATIONS</text></g>
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
|
@case ('field-sales-platform') {
|
||||||
|
<svg viewBox="0 0 900 410" role="img" aria-label="Offline-first field sales app and realtime tracking architecture">
|
||||||
|
<g class="diagram-lines"><path d="M200 120H330M550 120h150M440 178v60M700 165v73M550 300h150"/><path d="M110 165v70M110 235h220"/></g>
|
||||||
|
<g class="diagram-node accent"><rect x="20" y="70" width="180" height="95"/><text x="110" y="110">MOBILE APP</text><text class="small" x="110" y="134">IONIC · SQLITE REPLICA</text></g>
|
||||||
|
<g class="diagram-node"><rect x="330" y="60" width="220" height="118"/><text x="440" y="108">SYNC API</text><text class="small" x="440" y="134">MASTER DATA · ORDERS</text><text class="small" x="440" y="156">CREDIT NOTES</text></g>
|
||||||
|
<g class="diagram-node"><rect x="700" y="70" width="170" height="95"/><text x="785" y="110">ERP</text><text class="small" x="785" y="134">SQL SERVER</text></g>
|
||||||
|
<g class="diagram-node accent"><rect x="330" y="238" width="220" height="118"/><text x="440" y="286">TRACKING HUB</text><text class="small" x="440" y="312">SIGNALR · PRESENCE</text><text class="small" x="440" y="334">VISITS · ROUTES</text></g>
|
||||||
|
<g class="diagram-node"><rect x="700" y="252" width="170" height="95"/><text x="785" y="292">DIRECTORS</text><text class="small" x="785" y="316">LIVE MAP</text></g>
|
||||||
|
<g class="diagram-note"><text x="440" y="40">OFFLINE QUEUE · RETRY</text><text x="110" y="270">GPS · VISITS</text><text x="440" y="390">LATEST LOCATION PER USER</text></g>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
@case ('fuel-station-platform') {
|
||||||
|
<svg viewBox="0 0 900 410" role="img" aria-label="Fuel station management system architecture">
|
||||||
|
<g class="diagram-lines"><path d="M200 107H290V209H330M200 302H290V209M550 209H620V107H700M550 209H620V305H660"/></g>
|
||||||
|
<g class="diagram-node"><rect x="30" y="60" width="170" height="95"/><text x="115" y="100">FORECOURT</text><text class="small" x="115" y="124">PUMPS · CONTROLLER</text></g>
|
||||||
|
<g class="diagram-node"><rect x="30" y="255" width="170" height="95"/><text x="115" y="295">PAYMENT TERMINAL</text><text class="small" x="115" y="319">CARD · PIN · RECEIPT</text></g>
|
||||||
|
<g class="diagram-node accent"><rect x="330" y="150" width="220" height="118"/><text x="440" y="198">STATION MIDDLEWARE</text><text class="small" x="440" y="224">VB.NET · STATE MACHINES</text><text class="small" x="440" y="246">JOURNAL · OUTBOX</text></g>
|
||||||
|
<g class="diagram-node"><rect x="700" y="60" width="170" height="95"/><text x="785" y="100">STORE POS · ERP</text><text class="small" x="785" y="124">TRANSACTION FILES</text></g>
|
||||||
|
<g class="diagram-node accent"><rect x="660" y="255" width="210" height="100"/><text x="765" y="296">CENTRAL PLATFORM</text><text class="small" x="765" y="320">.NET 10 · FLEET LIMITS</text><text class="small" x="765" y="340">SIGNALR · ADMIN WEB</text></g>
|
||||||
|
<g class="diagram-note"><text x="440" y="40">FAIL CLOSED FOR FLEET · OPEN FOR CASH</text><text x="785" y="40">ATOMIC WRITE · RENAME CONTRACT</text><text x="440" y="390">RESERVE → AUTHORIZE → COMPLETE</text></g>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
@case ('travel-platform') {
|
@case ('travel-platform') {
|
||||||
<svg viewBox="0 0 900 410" role="img" aria-label="Travel operations platform architecture">
|
<svg viewBox="0 0 900 410" role="img" aria-label="Travel operations platform architecture">
|
||||||
<g class="diagram-lines"><path d="M180 100H345M555 100h165M450 158v94M180 310h165M555 310h165"/><path d="M180 100v210M720 100v210"/></g>
|
<g class="diagram-lines"><path d="M180 100H345M555 100h165M450 158v94M180 310h165M555 310h165"/><path d="M180 100v210M720 100v210"/></g>
|
||||||
|
|||||||
25
src/app/features/work/work-page.component.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { WorkPageComponent } from './work-page.component';
|
||||||
|
|
||||||
|
describe('WorkPageComponent', () => {
|
||||||
|
it('lists all four case studies with links to their pages', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [WorkPageComponent],
|
||||||
|
providers: [provideRouter([])]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(WorkPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const page = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(page.querySelectorAll('[data-testid="project-row"]').length).toBe(6);
|
||||||
|
expect(page.querySelector('a[href="/work/secure-ai-integration-gateway"]')).toBeTruthy();
|
||||||
|
expect(page.textContent).toContain('Secure AI Integration Gateway');
|
||||||
|
expect(page.textContent).toContain('Offline-First Field Sales App & Realtime Tracking');
|
||||||
|
expect(page.textContent).toContain('Fuel Station Management System');
|
||||||
|
expect(page.textContent).toContain('Travel Operations & Automation Platform');
|
||||||
|
expect(page.textContent).toContain('Multitenant Service Operations Platform');
|
||||||
|
expect(page.textContent).toContain('Biodiversity Data & Field Research Platform');
|
||||||
|
});
|
||||||
|
});
|
||||||
48
src/app/features/work/work-page.component.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { PageShellComponent } from '../../core/layout/page-shell.component';
|
||||||
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
|
import { MAIN_NAVIGATION } from '../../shared/config/navigation.config';
|
||||||
|
import { PORTFOLIO_PROJECTS } from './portfolio-projects.data';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-work-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [PageShellComponent, RouterLink],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<app-page-shell [sections]="sections">
|
||||||
|
<header class="page-head shell">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb"><a routerLink="/">~/</a><span>work</span></nav>
|
||||||
|
<span class="label">// selected work</span>
|
||||||
|
<h1>Production systems, anonymized.</h1>
|
||||||
|
<p class="lead">Six case studies from software that is running today: a ground-up fuel-management system for gas stations, ERP-grade operations platforms, an offline-first mobile app with realtime tracking, incremental modernization, and AI integration with real trust boundaries. Names and internals are withheld; the decisions are real.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section tight shell" aria-label="Case studies">
|
||||||
|
<div class="panel work-list">
|
||||||
|
@for (project of projects; track project.id; let first = $first) {
|
||||||
|
<a data-testid="project-row" class="project-row" [class.featured]="first" [routerLink]="['/work', project.slug]">
|
||||||
|
<span class="project-index">{{ project.index }}</span>
|
||||||
|
<span class="project-title"><h3>{{ project.title }}</h3><p>{{ project.tagline }}</p></span>
|
||||||
|
<span class="tag-row">@for (tech of project.stack.slice(0, 4); track tech) {<span class="tag">{{ tech }}</span>}</span>
|
||||||
|
<span class="project-arrow" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</app-page-shell>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class WorkPageComponent {
|
||||||
|
readonly sections = MAIN_NAVIGATION;
|
||||||
|
readonly projects = PORTFOLIO_PROJECTS;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
inject(SeoService).update({
|
||||||
|
title: 'Work — Amar Džanan',
|
||||||
|
description: 'Six anonymized case studies from production software: AI integration, a fuel-station management system, ERP operations platforms, an offline-first field sales app with realtime tracking, and scientific data.',
|
||||||
|
path: '/work'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { SectionNavItem } from '../models/section-nav.model';
|
import { SectionNavItem } from '../models/section-nav.model';
|
||||||
|
|
||||||
export const MAIN_NAVIGATION: SectionNavItem[] = [
|
export const MAIN_NAVIGATION: SectionNavItem[] = [
|
||||||
{ label: 'Work', path: '/', fragment: 'work' },
|
{ label: 'work', path: '/work' },
|
||||||
{ label: 'Profile', path: '/', fragment: 'profile' },
|
{ label: 'experience', path: '/experience' },
|
||||||
{ label: 'Experience', path: '/', fragment: 'experience' },
|
{ label: 'cv', path: '/cv' },
|
||||||
{ label: 'CV', path: '/cv' }
|
{ label: 'contact', path: '/contact' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The CV document itself, served from /public. */
|
||||||
|
export const CV_URL = '/cv.pdf';
|
||||||
|
|
||||||
|
/** Home-page index of the section pages. */
|
||||||
|
export const PAGE_INDEX: readonly { path: string; name: string; description: string }[] = [
|
||||||
|
{ path: '/work', name: 'work/', description: 'Six anonymized case studies from production systems.' },
|
||||||
|
{ path: '/experience', name: 'experience/', description: 'Eight years of roles, and the capabilities they built.' },
|
||||||
|
{ path: '/cv', name: 'cv.pdf', description: 'The full CV — read it here or download it.' },
|
||||||
|
{ path: '/contact', name: 'contact/', description: 'Email, LinkedIn, and where to find me.' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { ThemeService, Theme } from '../../../core/services/theme.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-theme-toggle',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
template: `
|
|
||||||
<div class="theme-toggle" aria-label="Color theme">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
[class.active]="themeService.theme() === 'light'"
|
|
||||||
(click)="setTheme('light')"
|
|
||||||
aria-label="Light theme"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
|
||||||
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
[class.active]="themeService.theme() === 'system'"
|
|
||||||
(click)="setTheme('system')"
|
|
||||||
aria-label="System theme"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
|
||||||
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm2 0v8h10V5H5z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
[class.active]="themeService.theme() === 'dark'"
|
|
||||||
(click)="setTheme('dark')"
|
|
||||||
aria-label="Dark theme"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
|
||||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
export class ThemeToggleComponent {
|
|
||||||
readonly themeService = inject(ThemeService);
|
|
||||||
|
|
||||||
setTheme(theme: Theme) {
|
|
||||||
this.themeService.setTheme(theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,22 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Amar Džanan — Senior Software Engineer & AI Integration</title>
|
<title>Amar Džanan — Senior Software Engineer & AI Integration</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="description" content="Senior full-stack engineer modernizing complex enterprise software and connecting it safely to AI.">
|
<meta name="description" content="Senior full-stack engineer building and modernizing ERP, mobile and expert systems — and connecting them safely to AI.">
|
||||||
<meta name="theme-color" content="#eee9df">
|
<meta name="theme-color" content="#0D1015">
|
||||||
<link rel="canonical" href="https://dzanan.net/">
|
<link rel="canonical" href="https://dzanan.net/">
|
||||||
<link rel="icon" type="image/x-icon" href="duck.png">
|
<link rel="icon" type="image/x-icon" href="duck.png">
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var theme = localStorage.getItem('theme') || 'system';
|
|
||||||
var dark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
|
||||||
document.documentElement.classList.toggle('dark', dark);
|
|
||||||
} catch (_) {}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
511
src/styles.scss
@@ -1,258 +1,299 @@
|
|||||||
@use 'tailwindcss';
|
@use 'tailwindcss';
|
||||||
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
|
||||||
--paper: #eee9df;
|
|
||||||
--paper-2: #e5ded2;
|
|
||||||
--ink: #20211f;
|
|
||||||
--muted: #66645f;
|
|
||||||
--line: #bdb5a9;
|
|
||||||
--accent: #a94728;
|
|
||||||
--accent-deep: #84371f;
|
|
||||||
--panel: rgba(249, 246, 239, 0.72);
|
|
||||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
||||||
--sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark {
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--paper: #181817;
|
--bg: #0D1015;
|
||||||
--paper-2: #22211f;
|
--panel: #12161D;
|
||||||
--ink: #ebe6dc;
|
--panel-2: #0F1318;
|
||||||
--muted: #a7a198;
|
--line: #232A33;
|
||||||
--line: #4a4640;
|
--line-strong: #2A323D;
|
||||||
--accent: #dc704c;
|
--text: #E6EAF0;
|
||||||
--accent-deep: #ef8a67;
|
--muted: #8B95A5;
|
||||||
--panel: rgba(32, 31, 29, 0.78);
|
--dim: #5C6672;
|
||||||
|
--accent: #8BE04A;
|
||||||
|
--accent-hover: #A9F26F;
|
||||||
|
--accent-glow: rgba(139, 224, 74, .22);
|
||||||
|
--code-key: #7AA2F7;
|
||||||
|
--code-str: #C3E88D;
|
||||||
|
--code-num: #F78C6C;
|
||||||
|
--mono: "JetBrains Mono Variable", "JetBrains Mono", Consolas, Menlo, monospace;
|
||||||
|
--sans: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
--radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html { scroll-behavior: smooth; scroll-padding-top: 6rem; }
|
html { scroll-behavior: smooth; scroll-padding-top: 6rem; }
|
||||||
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 17px; text-rendering: optimizeLegibility; }
|
body {
|
||||||
a { color: inherit; }
|
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, a { -webkit-tap-highlight-color: transparent; }
|
||||||
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
|
||||||
::selection { background: var(--accent); color: #fff8ef; }
|
::selection { background: var(--accent); color: var(--bg); }
|
||||||
|
h1, h2, h3, h4, p { margin: 0; }
|
||||||
|
h1, h2 { font-weight: 600; letter-spacing: -.03em; text-wrap: balance; }
|
||||||
|
h1 { font-size: clamp(2.4rem, 5vw, 3.9rem); line-height: 1.04; }
|
||||||
|
h2 { font-size: clamp(1.75rem, 3vw, 2.1rem); line-height: 1.1; letter-spacing: -.025em; }
|
||||||
|
h3 { font-size: 1.25rem; font-weight: 500; letter-spacing: -.01em; line-height: 1.25; }
|
||||||
|
h4 { font-size: 1rem; font-weight: 500; }
|
||||||
|
|
||||||
.site-frame { min-height: 100vh; background-color: var(--paper); background-image: linear-gradient(rgba(45,42,38,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(45,42,38,.035) 1px, transparent 1px); background-size: 48px 48px; }
|
/* ---- primitives ---- */
|
||||||
.dark .site-frame { background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.02) 1px, transparent 1px); }
|
.site-frame {
|
||||||
.shell { width: min(1180px, calc(100% - 40px)); margin-inline: auto; }
|
min-height: 100vh;
|
||||||
.mono, .eyebrow { font-family: var(--mono); }
|
background-color: var(--bg);
|
||||||
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: var(--ink); color: var(--paper); padding: .7rem 1rem; }
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, .028) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, .028) 1px, transparent 1px);
|
||||||
|
background-size: 64px 64px;
|
||||||
|
}
|
||||||
|
.shell { width: min(1160px, calc(100% - 40px)); margin-inline: auto; }
|
||||||
|
.mono { font-family: var(--mono); }
|
||||||
|
.label { font-family: var(--mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); }
|
||||||
|
.dim { color: var(--dim); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
|
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.tag { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 4px; border: 1px solid var(--line-strong); color: var(--muted); font: 400 12px/1 var(--mono); white-space: nowrap; }
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 20px; border-radius: 6px; font: 500 14px/1 var(--mono); white-space: nowrap; transition: background-color .15s ease, color .15s ease, box-shadow .15s ease, border-color .15s ease; }
|
||||||
|
.btn-accent { background: var(--accent); color: var(--bg); box-shadow: 0 0 0 1px rgba(139, 224, 74, .4), 0 0 32px var(--accent-glow); }
|
||||||
|
.btn-accent:hover { color: var(--bg); background: var(--accent-hover); box-shadow: 0 0 0 1px rgba(139, 224, 74, .6), 0 0 40px var(--accent-glow); }
|
||||||
|
.btn-line { border: 1px solid var(--line-strong); color: var(--text); background: rgba(255, 255, 255, .02); }
|
||||||
|
.btn-line:hover { color: var(--text); border-color: var(--accent); background: rgba(139, 224, 74, .06); }
|
||||||
|
.icon { width: 14px; height: 14px; flex: none; }
|
||||||
|
.status-dot { position: relative; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); }
|
||||||
|
.status-dot::after { content: ''; position: absolute; inset: -2px; border-radius: 50%; border: 2px solid var(--accent); opacity: 0; }
|
||||||
|
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: var(--accent); color: var(--bg); padding: .7rem 1rem; font-family: var(--mono); font-size: 13px; border-radius: 6px; }
|
||||||
.skip-link:focus { top: 1rem; }
|
.skip-link:focus { top: 1rem; }
|
||||||
|
|
||||||
.site-header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid #413e39; color: #eee8dc; background: rgba(24, 24, 23, .96); backdrop-filter: blur(14px); }
|
.section { padding-block: 96px; }
|
||||||
.header-inner { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 2rem; }
|
.section.tight { padding-top: 0; }
|
||||||
.wordmark { display: flex; align-items: center; gap: .85rem; text-decoration: none; }
|
.section-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 24px; }
|
||||||
.wordmark-mark { color: var(--accent); font: 700 1.25rem/1 var(--mono); letter-spacing: -.08em; }
|
.section-head .label { display: block; margin-bottom: 10px; }
|
||||||
.wordmark-copy { display: grid; font-weight: 650; font-size: .92rem; line-height: 1.15; letter-spacing: -.01em; }
|
.section-head .count { font: 400 12px/1 var(--mono); color: var(--dim); }
|
||||||
.wordmark-copy small { margin-top: .25rem; color: #aaa49a; font: 400 .7rem/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
|
.section-head.padded { padding-inline: 24px; }
|
||||||
.desktop-nav { display: flex; align-items: center; gap: 1.5rem; }
|
|
||||||
.desktop-nav nav { display: flex; gap: 1.35rem; }
|
|
||||||
.desktop-nav nav a, .mobile-nav nav a { color: #bbb4aa; font: 500 .8rem/1 var(--mono); text-decoration: none; text-transform: uppercase; letter-spacing: .045em; }
|
|
||||||
.desktop-nav nav a:hover, .mobile-nav nav a:hover { color: var(--accent); }
|
|
||||||
.theme-toggle { display: flex; gap: 2px; border-left: 1px solid #4d4943; padding-left: 1rem; }
|
|
||||||
.theme-toggle button { width: 29px; height: 29px; border: 0; border-radius: 2px; display: grid; place-items: center; color: #aaa49a; background: transparent; cursor: pointer; }
|
|
||||||
.theme-toggle button svg { width: 14px; }
|
|
||||||
.theme-toggle button:hover, .theme-toggle button.active { color: #181817; background: #eee8dc; }
|
|
||||||
.menu-button { display: none; align-items: center; gap: 1rem; border: 0; background: transparent; color: #eee8dc; font: 500 .8rem/1 var(--mono); text-transform: uppercase; }
|
|
||||||
.mobile-nav { display: none; border-top: 1px solid #413e39; padding-block: 1rem 1.4rem; }
|
|
||||||
.mobile-nav nav { display: grid; gap: 1rem; }
|
|
||||||
|
|
||||||
.hero { padding-block: clamp(5rem, 11vw, 9.5rem) 2rem; }
|
/* ---- header / footer ---- */
|
||||||
.hero-kicker { color: var(--muted); font-size: .78rem; line-height: 1.5; text-transform: uppercase; letter-spacing: .065em; }
|
.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); }
|
||||||
.hero-kicker span { display: inline-block; margin-right: 1rem; color: var(--accent); }
|
.header-inner { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||||
.hero-grid { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(260px, .65fr); align-items: end; gap: clamp(3rem, 8vw, 7rem); margin-top: 2.2rem; }
|
.wordmark { display: inline-flex; align-items: center; gap: 10px; font: 600 14px/1 var(--mono); }
|
||||||
.hero h1 { max-width: 850px; margin: 0; font-size: clamp(3.1rem, 7vw, 6.9rem); line-height: .94; letter-spacing: -.068em; font-weight: 600; text-wrap: balance; }
|
.wordmark:hover { color: var(--text); }
|
||||||
.hero h1::first-letter { color: var(--accent); }
|
.wordmark-tilde { color: var(--accent); font-weight: 400; }
|
||||||
.hero-aside { padding-bottom: .5rem; }
|
.desktop-nav { display: flex; align-items: center; gap: 28px; }
|
||||||
.hero-aside p { margin: 0; color: var(--muted); font-size: 1.08rem; line-height: 1.7; }
|
.desktop-nav nav { display: flex; gap: 28px; align-items: center; }
|
||||||
.action-row { display: flex; align-items: center; gap: 1.4rem; margin-top: 2rem; flex-wrap: wrap; }
|
.desktop-nav nav a, .mobile-nav nav a { color: var(--muted); font: 500 13px/1 var(--mono); letter-spacing: .02em; }
|
||||||
.button { display: inline-flex; align-items: center; justify-content: space-between; gap: 1.4rem; min-height: 50px; padding: 0 1.15rem; border: 1px solid currentColor; font: 600 .78rem/1 var(--mono); letter-spacing: .03em; text-decoration: none; text-transform: uppercase; }
|
.desktop-nav nav a:hover, .mobile-nav nav a:hover, .desktop-nav nav a.active, .mobile-nav nav a.active { color: var(--accent); }
|
||||||
.button-primary { background: var(--ink); border-color: var(--ink); color: var(--paper); }
|
.nav-cv { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.button-primary:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.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); }
|
||||||
.text-link { color: var(--ink); font: 600 .78rem/1.4 var(--mono); text-transform: uppercase; text-decoration-color: var(--accent); text-underline-offset: .35rem; }
|
.menu-button { display: none; align-items: center; gap: 12px; border: 0; background: transparent; color: var(--text); font: 500 13px/1 var(--mono); cursor: pointer; padding: 8px 0; }
|
||||||
.text-link:hover { color: var(--accent); }
|
.mobile-nav { display: none; border-top: 1px solid var(--line); padding-block: 16px 20px; }
|
||||||
.hero-foot { display: flex; justify-content: space-between; gap: 2rem; margin-top: clamp(5rem, 10vw, 9rem); padding-top: 1rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .72rem; line-height: 1.5; text-transform: uppercase; letter-spacing: .045em; }
|
.mobile-nav nav { display: grid; gap: 16px; }
|
||||||
|
.site-footer { border-top: 1px solid var(--line); padding-block: 28px; margin-top: 40px; }
|
||||||
|
.footer-inner { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--dim); font: 400 12px/1.6 var(--mono); }
|
||||||
|
.footer-inner a { color: var(--muted); }
|
||||||
|
.footer-inner a:hover { color: var(--accent); }
|
||||||
|
|
||||||
.section-block { padding-block: clamp(5rem, 9vw, 8rem); }
|
/* ---- hero ---- */
|
||||||
.section-heading { display: grid; grid-template-columns: 1.5fr .75fr; gap: clamp(3rem, 8vw, 8rem); align-items: end; margin-bottom: 3.5rem; }
|
.hero { display: grid; grid-template-columns: 1.05fr .95fr; gap: 64px; align-items: center; padding-block: 88px 96px; }
|
||||||
.section-heading.compact { grid-template-columns: 1fr auto; }
|
.hero-copy { display: flex; flex-direction: column; gap: 26px; }
|
||||||
.eyebrow { margin: 0 0 1.1rem; color: var(--accent); font-size: .75rem; line-height: 1.45; letter-spacing: .07em; text-transform: uppercase; }
|
.hero-copy .label { letter-spacing: .06em; }
|
||||||
.section-heading h2, .profile-band h2, .capabilities h2, .contact-block h2 { max-width: 760px; margin: 0; font-size: clamp(2.2rem, 4.4vw, 4.4rem); line-height: 1; letter-spacing: -.052em; font-weight: 580; text-wrap: balance; }
|
.hero-copy h1 span { color: var(--accent); }
|
||||||
.section-heading > p { color: var(--muted); margin: 0; font-size: 1.05rem; line-height: 1.7; }
|
.hero-lead { max-width: 560px; color: var(--muted); font-size: 18px; line-height: 1.6; }
|
||||||
.project-index { border-top: 1px solid var(--ink); }
|
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 4px; }
|
||||||
.project-row { display: grid; grid-template-columns: 58px minmax(0, 1fr) 235px 28px; gap: 1.25rem; align-items: start; padding: 2rem 0; border-bottom: 1px solid var(--line); text-decoration: none; transition: color .18s ease, padding .18s ease; }
|
|
||||||
.project-row:hover { color: var(--accent); padding-left: .75rem; }
|
|
||||||
.project-number, .project-meta { color: var(--muted); font-size: .72rem; line-height: 1.65; text-transform: uppercase; }
|
|
||||||
.project-main { display: grid; gap: .65rem; }
|
|
||||||
.project-main strong { font-size: clamp(1.35rem, 2.1vw, 2rem); letter-spacing: -.03em; font-weight: 590; }
|
|
||||||
.project-main small { max-width: 690px; color: var(--muted); font-size: .98rem; line-height: 1.58; }
|
|
||||||
.project-arrow { font-size: 1.2rem; color: var(--accent); text-align: right; }
|
|
||||||
|
|
||||||
.profile-band { padding-block: clamp(4rem, 7vw, 6.5rem); color: #eee8dc; background: #252522; }
|
.terminal { padding: 0; overflow: hidden; box-shadow: 0 30px 80px rgba(0, 0, 0, .5); font-family: var(--mono); }
|
||||||
.dark .profile-band { color: #eee8dc; background: #2a2926; }
|
.terminal-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2); }
|
||||||
.profile-grid { display: grid; grid-template-columns: 180px minmax(0, 1.25fr) minmax(270px, .65fr); gap: clamp(2rem, 5vw, 5rem); align-items: center; }
|
.terminal-dots { display: flex; gap: 6px; width: 42px; }
|
||||||
.profile-photo { width: 100%; aspect-ratio: 1; object-fit: cover; filter: grayscale(1) contrast(1.06); border-radius: 50%; }
|
.terminal-dots span { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); }
|
||||||
.profile-lead { max-width: 720px; margin: 1.6rem 0 0; color: #bcb6ac; font-size: 1.05rem; line-height: 1.65; }
|
.terminal-title { color: var(--dim); font-size: 12px; }
|
||||||
.profile-notes { border-left: 1px solid #595650; padding-left: 2rem; color: #c8c1b7; line-height: 1.65; font-size: 1rem; }
|
.terminal-spacer { width: 42px; }
|
||||||
.profile-notes a { display: inline-block; margin-top: 1rem; color: #eee8dc; font: 600 .76rem/1 var(--mono); text-transform: uppercase; text-underline-offset: .35rem; }
|
.terminal-body { padding: 22px 24px; font-size: 14px; line-height: 1.75; display: flex; flex-direction: column; overflow-x: auto; }
|
||||||
|
.terminal-body .k { color: var(--code-key); }
|
||||||
|
.terminal-body .s { color: var(--code-str); }
|
||||||
|
.terminal-body .n { color: var(--code-num); }
|
||||||
|
.terminal-body .c { color: var(--dim); }
|
||||||
|
.terminal-line { white-space: pre; }
|
||||||
|
.terminal-line.indent { padding-left: 2ch; }
|
||||||
|
.terminal-line.prompt { margin-top: 6px; }
|
||||||
|
.cursor { display: inline-block; width: 9px; height: 16px; background: var(--accent); vertical-align: -3px; margin-left: 2px; }
|
||||||
|
|
||||||
.experience-list { border-top: 1px solid var(--ink); }
|
/* ---- work list ---- */
|
||||||
.experience-row { display: grid; grid-template-columns: 180px 1fr 1.35fr; gap: 2rem; padding: 1.6rem 0; border-bottom: 1px solid var(--line); align-items: start; }
|
.work-list { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.experience-row time { color: var(--accent); font-size: .74rem; line-height: 1.45; }
|
.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; }
|
||||||
.experience-row h3 { margin: 0; font-size: 1.08rem; font-weight: 650; }
|
.project-row:first-child { border-top: 0; }
|
||||||
.experience-row div p, .experience-row > p { margin: .35rem 0 0; color: var(--muted); font-size: .94rem; line-height: 1.6; }
|
.project-row:hover { background: var(--panel-2); color: var(--text); }
|
||||||
.experience-row > p { margin-top: 0; }
|
.project-index { font: 400 13px/1 var(--mono); color: var(--dim); }
|
||||||
.capabilities { display: grid; grid-template-columns: .8fr 1.4fr; gap: clamp(3rem, 8vw, 8rem); border-top: 1px solid var(--line); }
|
.project-row.featured .project-index, .project-row.featured .project-arrow { color: var(--accent); }
|
||||||
.capability-grid { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--ink); }
|
.project-title { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.capability-grid article { padding: 1.5rem; border-right: 1px solid var(--line); }
|
.project-title p { color: var(--muted); font-size: 14.5px; }
|
||||||
.capability-grid article:last-child { border-right: 0; }
|
.project-arrow { color: var(--dim); text-align: right; font-size: 18px; transition: color .15s ease, transform .15s ease; }
|
||||||
.capability-grid span { color: var(--accent); font-size: .74rem; }
|
.project-row:hover .project-arrow { color: var(--accent); transform: translateX(4px); }
|
||||||
.capability-grid h3 { margin: 3rem 0 .75rem; font-size: 1.08rem; }
|
|
||||||
.capability-grid p { margin: 0; color: var(--muted); font-size: .94rem; line-height: 1.65; }
|
|
||||||
|
|
||||||
.contact-block { margin-bottom: 5rem; padding: clamp(3rem, 7vw, 6rem); color: #f0eadd; background: var(--accent-deep); }
|
/* ---- experience ---- */
|
||||||
.contact-block .eyebrow { color: #f4b19b; }
|
.experience { display: grid; grid-template-columns: 340px 1fr; gap: 64px; align-items: start; }
|
||||||
.contact-block h2 { max-width: 900px; }
|
.experience-intro { display: flex; flex-direction: column; gap: 18px; }
|
||||||
.contact-actions { display: flex; justify-content: space-between; align-items: end; gap: 2rem; margin-top: 3rem; }
|
.experience-intro p { color: var(--muted); font-size: 15px; line-height: 1.6; }
|
||||||
.button-light { color: #f0eadd; }
|
.experience-intro .text-link { align-self: flex-start; }
|
||||||
.button-light:hover { color: var(--accent-deep); background: #f0eadd; }
|
.portrait { width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block; filter: grayscale(1) contrast(1.05); border: 1px solid var(--line); }
|
||||||
.contact-actions > span { color: #e0aa98; font-size: .74rem; text-transform: uppercase; }
|
.text-link { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); font: 500 13px/1 var(--mono); }
|
||||||
.site-footer { border-top: 1px solid var(--line); }
|
.text-link:hover { color: var(--accent-hover); }
|
||||||
.footer-inner { display: flex; justify-content: space-between; gap: 1rem; padding-block: 1.7rem; color: var(--muted); font: 400 .72rem/1.6 var(--mono); text-transform: uppercase; }
|
.roles { display: flex; flex-direction: column; }
|
||||||
.footer-inner a { text-underline-offset: .25rem; }
|
.role { display: grid; grid-template-columns: 170px 1fr 1.2fr; gap: 28px; padding: 20px 0; border-top: 1px solid var(--line); align-items: baseline; }
|
||||||
|
.role:last-child { border-bottom: 1px solid var(--line); }
|
||||||
|
.role time { color: var(--dim); font: 400 13px/1.4 var(--mono); }
|
||||||
|
.role h4 { font-size: 16px; }
|
||||||
|
.role .company { display: block; color: var(--muted); font-size: 13.5px; margin-top: 2px; }
|
||||||
|
.role p { color: var(--muted); font-size: 14.5px; line-height: 1.5; }
|
||||||
|
|
||||||
.case-hero { padding-block: 3rem clamp(5rem, 10vw, 9rem); }
|
/* ---- capabilities ---- */
|
||||||
.breadcrumb { display: flex; gap: .7rem; color: var(--muted); font-size: .74rem; text-transform: uppercase; }
|
.capabilities { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||||
.breadcrumb a { text-underline-offset: .25rem; }
|
.capability { padding: 28px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
.case-heading { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(270px, .55fr); gap: clamp(3rem, 8vw, 7rem); align-items: end; margin-top: clamp(4rem, 9vw, 8rem); }
|
.capability .index { font: 400 12px/1 var(--mono); color: var(--accent); }
|
||||||
.case-heading h1 { max-width: 850px; margin: 0; font-size: clamp(3rem, 6.6vw, 6.7rem); font-weight: 590; line-height: .94; letter-spacing: -.065em; text-wrap: balance; }
|
.capability p { color: var(--muted); font-size: 14.5px; line-height: 1.55; }
|
||||||
.case-intro > p { margin: 0; color: var(--muted); font-size: 1.08rem; line-height: 1.72; }
|
|
||||||
.case-intro dl { margin: 2.2rem 0 0; border-top: 1px solid var(--line); }
|
|
||||||
.case-intro dl div { display: grid; grid-template-columns: 70px 1fr; gap: 1rem; padding: .85rem 0; border-bottom: 1px solid var(--line); }
|
|
||||||
.case-intro dt { color: var(--muted); font: 400 .71rem/1.5 var(--mono); text-transform: uppercase; }
|
|
||||||
.case-intro dd { margin: 0; font-size: .88rem; }
|
|
||||||
.case-section { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: clamp(2rem, 6vw, 6rem); padding-block: clamp(4rem, 8vw, 7rem); border-top: 1px solid var(--line); }
|
|
||||||
.case-index { margin: .45rem 0 0; color: var(--accent); font-size: .72rem; line-height: 1.45; text-transform: uppercase; }
|
|
||||||
.case-section h2 { margin: 0 0 2.2rem; font-size: clamp(2rem, 3.5vw, 3.5rem); line-height: 1; letter-spacing: -.045em; font-weight: 580; }
|
|
||||||
.large-copy { max-width: 900px; margin: 0; font-size: clamp(1.35rem, 2.25vw, 2rem); line-height: 1.48; letter-spacing: -.025em; }
|
|
||||||
.contribution-list { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--ink); }
|
|
||||||
.contribution-list li { display: grid; grid-template-columns: 52px 1fr; gap: 1rem; padding: 1.3rem 0; border-bottom: 1px solid var(--line); }
|
|
||||||
.contribution-list span, .decision-grid span { padding-top: .2rem; color: var(--accent); font-size: .72rem; }
|
|
||||||
.contribution-list p { max-width: 850px; margin: 0; font-size: 1rem; line-height: 1.62; }
|
|
||||||
.diagram-wrap { padding-block: clamp(4rem, 7vw, 6rem); background: #262624; color: #eee8dc; }
|
|
||||||
.system-diagram { margin: 0; }
|
|
||||||
.system-diagram figcaption { display: flex; align-items: baseline; justify-content: space-between; gap: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #55514b; }
|
|
||||||
.system-diagram figcaption span { color: #d87958; font-size: .72rem; text-transform: uppercase; }
|
|
||||||
.system-diagram figcaption strong { color: #bbb4aa; font-size: .8rem; font-weight: 400; }
|
|
||||||
.system-diagram svg { display: block; width: 100%; height: auto; margin-top: 1.7rem; }
|
|
||||||
.diagram-lines { fill: none; stroke: #777169; stroke-width: 1; }
|
|
||||||
.diagram-node rect { fill: #2d2d2a; stroke: #6b665f; stroke-width: 1; }
|
|
||||||
.diagram-node text { fill: #e8e1d5; font: 600 15px var(--mono); text-anchor: middle; letter-spacing: .07em; }
|
|
||||||
.diagram-node text.small { fill: #bdb5aa; font-size: 12px; font-weight: 400; }
|
|
||||||
.diagram-node.accent rect { fill: #803920; stroke: #d87958; }
|
|
||||||
.diagram-note text { fill: #df815f; font: 400 11px var(--mono); text-anchor: middle; letter-spacing: .08em; }
|
|
||||||
.decision-grid { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid var(--ink); }
|
|
||||||
.decision-grid article { min-height: 220px; padding: 1.4rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
|
||||||
.decision-grid article:nth-child(even) { border-right: 0; }
|
|
||||||
.decision-grid h3 { margin: 2.5rem 0 .7rem; font-size: 1.15rem; }
|
|
||||||
.decision-grid p { margin: 0; color: var(--muted); font-size: .96rem; line-height: 1.65; }
|
|
||||||
.outcome { border-bottom: 1px solid var(--line); }
|
|
||||||
.case-stack { display: grid; grid-template-columns: 180px 1fr; gap: clamp(2rem, 6vw, 6rem); padding-block: 2rem; }
|
|
||||||
.case-stack ul { display: flex; flex-wrap: wrap; gap: .55rem; list-style: none; padding: 0; margin: 0; }
|
|
||||||
.case-stack li { padding: .6rem .75rem; border: 1px solid var(--line); font: 500 .73rem/1 var(--mono); text-transform: uppercase; }
|
|
||||||
.case-pagination { display: grid; grid-template-columns: 1fr 1fr; margin-block: clamp(4rem, 8vw, 7rem); border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
|
|
||||||
.case-pagination a { display: grid; gap: 1rem; min-height: 150px; padding: 1.5rem 0; text-decoration: none; }
|
|
||||||
.case-pagination a + a { border-left: 1px solid var(--line); padding-left: 1.5rem; text-align: right; }
|
|
||||||
.case-pagination span { color: var(--accent); font-size: .72rem; text-transform: uppercase; }
|
|
||||||
.case-pagination strong { max-width: 400px; font-size: clamp(1rem, 2vw, 1.5rem); line-height: 1.2; letter-spacing: -.025em; }
|
|
||||||
.case-pagination a:last-child strong { justify-self: end; }
|
|
||||||
.case-pagination a:hover strong { color: var(--accent); }
|
|
||||||
.case-contact .text-link.light { align-self: center; color: #f0eadd; text-decoration-color: #f4b19b; }
|
|
||||||
|
|
||||||
.cv-hero { padding-block: 3rem clamp(5rem, 10vw, 9rem); }
|
/* ---- contact ---- */
|
||||||
.cv-heading { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(250px, .5fr); gap: clamp(3rem, 9vw, 8rem); align-items: end; margin-top: clamp(4rem, 9vw, 8rem); }
|
.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; }
|
||||||
.cv-heading h1 { max-width: 900px; margin: 0; font-size: clamp(3rem, 6.6vw, 6.7rem); line-height: .94; letter-spacing: -.065em; font-weight: 590; text-wrap: balance; }
|
.contact h2 { max-width: 760px; }
|
||||||
.cv-contact { display: grid; gap: .8rem; }
|
.contact-actions { display: flex; justify-content: space-between; align-items: center; gap: 24px; flex-wrap: wrap; margin-top: 8px; }
|
||||||
.cv-contact p { margin: 0 0 1rem; color: var(--muted); font-size: 1rem; line-height: 1.65; }
|
.contact-actions .meta { color: var(--dim); font: 400 12px/1 var(--mono); }
|
||||||
.cv-contact a { width: fit-content; font: 600 .76rem/1.4 var(--mono); text-transform: uppercase; text-underline-offset: .3rem; }
|
|
||||||
.cv-section { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: clamp(2rem, 6vw, 6rem); padding-block: clamp(4rem, 8vw, 7rem); border-top: 1px solid var(--line); }
|
|
||||||
.cv-section h2 { margin: 0 0 2.5rem; font-size: clamp(2rem, 3.5vw, 3.5rem); line-height: 1; letter-spacing: -.045em; font-weight: 580; }
|
|
||||||
.cv-roles { border-top: 1px solid var(--ink); }
|
|
||||||
.cv-roles article { display: grid; grid-template-columns: 1fr 1.2fr; gap: 2rem; padding: 1.6rem 0; border-bottom: 1px solid var(--line); }
|
|
||||||
.cv-role-head { display: grid; grid-template-columns: 145px 1fr; gap: 1.2rem; }
|
|
||||||
.cv-role-head time { color: var(--accent); font-size: .72rem; line-height: 1.45; }
|
|
||||||
.cv-role-head h3 { margin: 0; font-size: 1.05rem; }
|
|
||||||
.cv-role-head p, .cv-roles article > p { margin: .35rem 0 0; color: var(--muted); font-size: .94rem; line-height: 1.65; }
|
|
||||||
.cv-roles article > p { margin-top: 0; }
|
|
||||||
.cv-skill-grid { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid var(--ink); }
|
|
||||||
.cv-skill-grid article { min-height: 150px; padding: 1.4rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
|
||||||
.cv-skill-grid article:nth-child(even) { border-right: 0; }
|
|
||||||
.cv-skill-grid h3 { margin: 0 0 2rem; font-size: 1.02rem; }
|
|
||||||
.cv-skill-grid p { margin: 0; color: var(--muted); font-size: .93rem; line-height: 1.65; }
|
|
||||||
.course-list { border-top: 1px solid var(--ink); }
|
|
||||||
.course-list article { display: grid; grid-template-columns: 120px 1fr 160px; gap: 1.2rem; padding: 1rem 0; border-bottom: 1px solid var(--line); align-items: baseline; }
|
|
||||||
.course-list time { color: var(--accent); font-size: .72rem; }
|
|
||||||
.course-list h3 { margin: 0; font-size: .96rem; }
|
|
||||||
.course-list p { margin: 0; color: var(--muted); font-size: .86rem; text-align: right; }
|
|
||||||
.not-found { min-height: calc(100vh - 160px); display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding-block: 5rem; }
|
|
||||||
.not-found h1 { max-width: 850px; margin: 0; font-size: clamp(4rem, 12vw, 10rem); line-height: .88; letter-spacing: -.075em; font-weight: 590; }
|
|
||||||
.not-found > p:not(.eyebrow) { max-width: 500px; margin: 2rem 0; color: var(--muted); line-height: 1.65; }
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
/* ---- case study ---- */
|
||||||
|
.breadcrumb { display: flex; font: 400 13px/1 var(--mono); color: var(--text); }
|
||||||
|
.breadcrumb a { color: var(--dim); }
|
||||||
|
.breadcrumb a:hover { color: var(--accent); }
|
||||||
|
.case-hero { padding-block: 64px 48px; display: flex; flex-direction: column; gap: 22px; }
|
||||||
|
.case-hero h1 { max-width: 900px; }
|
||||||
|
.case-hero .lead { max-width: 760px; color: var(--muted); font-size: 18px; line-height: 1.6; }
|
||||||
|
.case-meta { display: grid; grid-template-columns: auto auto 1fr; gap: 32px; padding: 18px 24px; margin: 8px 0 0; align-items: start; }
|
||||||
|
.case-meta dt { font: 400 11px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--dim); margin-bottom: 8px; }
|
||||||
|
.case-meta dd { margin: 0; font-size: 14.5px; }
|
||||||
|
.case-section { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 40px; padding-block: 56px; border-top: 1px solid var(--line); }
|
||||||
|
.case-section .label { padding-top: 6px; }
|
||||||
|
.case-section h2 { margin-bottom: 20px; }
|
||||||
|
.large-copy { max-width: 720px; color: var(--muted); font-size: 17px; line-height: 1.65; }
|
||||||
|
.contribution-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||||
|
.contribution-list li { display: grid; grid-template-columns: 40px 1fr; gap: 16px; padding: 16px 0; border-top: 1px solid var(--line); }
|
||||||
|
.contribution-list li:last-child { border-bottom: 1px solid var(--line); }
|
||||||
|
.contribution-list li span { font: 400 13px/1.6 var(--mono); color: var(--accent); }
|
||||||
|
.contribution-list li p { color: var(--muted); font-size: 15px; line-height: 1.6; }
|
||||||
|
.decision-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.decision { padding: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.decision span { font: 400 12px/1 var(--mono); color: var(--accent); }
|
||||||
|
.decision p { color: var(--muted); font-size: 14.5px; line-height: 1.55; }
|
||||||
|
.diagram-wrap { padding-block: 8px 56px; }
|
||||||
|
.system-diagram { margin: 0; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.system-diagram figcaption { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.system-diagram figcaption .mono { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); }
|
||||||
|
.system-diagram figcaption strong { font-size: 15px; font-weight: 500; }
|
||||||
|
.system-diagram svg { width: 100%; height: auto; display: block; font-family: var(--mono); }
|
||||||
|
.diagram-lines path { fill: none; stroke: var(--line-strong); stroke-width: 1.5; }
|
||||||
|
.diagram-node rect { fill: var(--panel-2); stroke: var(--line-strong); stroke-width: 1.5; rx: 6; }
|
||||||
|
.diagram-node text { fill: var(--text); font-size: 14px; font-weight: 500; text-anchor: middle; letter-spacing: .04em; }
|
||||||
|
.diagram-node text.small { fill: var(--muted); font-size: 11px; font-weight: 400; }
|
||||||
|
.diagram-node.accent rect { stroke: var(--accent); fill: rgba(139, 224, 74, .06); }
|
||||||
|
.diagram-node.accent text { fill: var(--accent); }
|
||||||
|
.diagram-node.accent text.small { fill: var(--code-str); }
|
||||||
|
.diagram-note text { fill: var(--dim); font-size: 11px; letter-spacing: .1em; text-anchor: middle; }
|
||||||
|
.case-pagination { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding-block: 16px 64px; }
|
||||||
|
.case-pagination a { padding: 22px 24px; display: flex; flex-direction: column; gap: 10px; color: var(--text); transition: border-color .15s ease; }
|
||||||
|
.case-pagination a:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
.case-pagination a .mono { font-size: 12px; color: var(--dim); }
|
||||||
|
.case-pagination a:last-child { text-align: right; }
|
||||||
|
.case-pagination strong { font-weight: 500; font-size: 15px; }
|
||||||
|
.case-contact { margin-bottom: 64px; }
|
||||||
|
|
||||||
|
/* ---- sub-page head ---- */
|
||||||
|
.page-head { padding-block: 56px 40px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.page-head .breadcrumb { margin-bottom: 8px; }
|
||||||
|
.page-head h1 { max-width: 860px; }
|
||||||
|
.page-head .lead { max-width: 680px; color: var(--muted); font-size: 17px; line-height: 1.6; }
|
||||||
|
.page-head .actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---- home index ---- */
|
||||||
|
.index-list { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.index-row { display: grid; grid-template-columns: 180px minmax(0, 1fr) 40px; gap: 24px; padding: 22px 24px; border-top: 1px solid var(--line); align-items: center; color: var(--text); transition: background-color .15s ease; }
|
||||||
|
.index-row:first-child { border-top: 0; }
|
||||||
|
.index-row:hover { background: var(--panel-2); color: var(--text); }
|
||||||
|
.index-row .name { font: 500 14px/1 var(--mono); color: var(--accent); }
|
||||||
|
.index-row p { color: var(--muted); font-size: 14.5px; }
|
||||||
|
.index-row:hover .project-arrow { color: var(--accent); transform: translateX(4px); }
|
||||||
|
|
||||||
|
/* ---- cv ---- */
|
||||||
|
.cv-frame { overflow: hidden; }
|
||||||
|
.cv-frame object { display: block; width: 100%; height: min(1400px, calc(100vh - 120px)); border: 0; background: var(--panel-2); }
|
||||||
|
.cv-fallback { padding: 48px 24px; display: flex; flex-direction: column; align-items: center; gap: 18px; color: var(--muted); text-align: center; }
|
||||||
|
|
||||||
|
/* ---- contact page ---- */
|
||||||
|
.contact-details { margin: 8px 0 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 32px; padding-top: 24px; border-top: 1px solid var(--line); }
|
||||||
|
.contact-details dt { font: 400 11px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--dim); margin-bottom: 8px; }
|
||||||
|
.contact-details dd { margin: 0; font-size: 15px; color: var(--text); }
|
||||||
|
.contact-details dd a { color: var(--accent); font-family: var(--mono); font-size: 14px; }
|
||||||
|
|
||||||
|
/* ---- not found ---- */
|
||||||
|
.not-found { padding-block: 96px; display: flex; flex-direction: column; gap: 24px; max-width: 720px; }
|
||||||
|
.not-found .terminal-body { gap: 2px; }
|
||||||
|
.not-found .btn { align-self: flex-start; }
|
||||||
|
.not-found p.muted { font-size: 16px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ---- motion ---- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.hero-copy > * { animation: rise .48s ease both; }
|
||||||
|
.hero-copy > :nth-child(2) { animation-delay: .08s; }
|
||||||
|
.hero-copy > :nth-child(3) { animation-delay: .16s; }
|
||||||
|
.hero-copy > :nth-child(4) { animation-delay: .24s; }
|
||||||
|
.terminal { animation: rise .6s ease .2s both; }
|
||||||
|
.cursor { animation: blink 1s steps(1) infinite; }
|
||||||
|
.status-dot { animation: status-glow 2s ease-in-out infinite; }
|
||||||
|
.status-dot::after { animation: status-ping 2s ease-out infinite; }
|
||||||
|
}
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
@keyframes status-glow { 0%, 100% { box-shadow: 0 0 6px var(--accent); } 50% { box-shadow: 0 0 14px var(--accent), 0 0 24px var(--accent-glow); } }
|
||||||
|
@keyframes status-ping { 0% { transform: scale(.7); opacity: .9; } 80%, 100% { transform: scale(2.8); opacity: 0; } }
|
||||||
|
|
||||||
|
/* ---- responsive ---- */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.hero { grid-template-columns: 1fr; gap: 40px; padding-block: 56px 64px; }
|
||||||
|
.experience { grid-template-columns: 1fr; gap: 32px; }
|
||||||
|
.project-row { grid-template-columns: 48px minmax(0, 1fr) 40px; row-gap: 12px; }
|
||||||
|
.project-row .tag-row { grid-column: 2; }
|
||||||
|
.case-section { grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
.capabilities { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.section { padding-block: 64px; }
|
||||||
.desktop-nav { display: none; }
|
.desktop-nav { display: none; }
|
||||||
.menu-button { display: flex; }
|
.menu-button { display: inline-flex; }
|
||||||
.mobile-nav { display: flex; justify-content: space-between; }
|
.mobile-nav { display: block; }
|
||||||
.hero-grid, .section-heading, .profile-grid, .capabilities, .case-heading, .cv-heading { grid-template-columns: 1fr; }
|
.hero-copy h1 { font-size: clamp(2rem, 9vw, 2.6rem); }
|
||||||
.hero-aside { max-width: 580px; }
|
.project-row { grid-template-columns: minmax(0, 1fr) 24px; padding: 20px 18px; gap: 10px 12px; }
|
||||||
.profile-photo { width: 130px; }
|
.project-index { display: none; }
|
||||||
.profile-notes { max-width: 650px; }
|
.project-title { grid-column: 1; grid-row: 1; }
|
||||||
.project-row { grid-template-columns: 45px 1fr 25px; }
|
.project-arrow { grid-column: 2; grid-row: 1; }
|
||||||
.project-meta { display: none; }
|
.project-row .tag-row { grid-column: 1 / -1; grid-row: 2; }
|
||||||
.experience-row { grid-template-columns: 140px 1fr; }
|
.terminal-line { white-space: pre-wrap; }
|
||||||
.experience-row > p { grid-column: 2; }
|
.system-diagram { overflow-x: auto; }
|
||||||
.case-section { grid-template-columns: 120px 1fr; }
|
.system-diagram svg { min-width: 640px; }
|
||||||
.cv-section { grid-template-columns: 120px 1fr; }
|
.role { grid-template-columns: 1fr; gap: 6px; }
|
||||||
.cv-roles article { grid-template-columns: 1fr; }
|
.capabilities, .decision-grid, .case-pagination, .contact-details { grid-template-columns: 1fr; }
|
||||||
|
.index-row { grid-template-columns: minmax(0, 1fr) 24px; padding: 18px; gap: 6px 12px; }
|
||||||
|
.index-row .name { grid-column: 1; }
|
||||||
|
.index-row p { grid-column: 1; }
|
||||||
|
.index-row .project-arrow { grid-column: 2; grid-row: 1 / span 2; }
|
||||||
|
.cv-frame object { height: 70vh; }
|
||||||
|
.case-pagination a:last-child { text-align: left; }
|
||||||
|
.case-meta { grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
.section-head { flex-direction: column; align-items: flex-start; }
|
||||||
|
.section-head.padded { padding-inline: 0; }
|
||||||
|
.terminal-body { font-size: 12.5px; padding: 16px; }
|
||||||
}
|
}
|
||||||
|
@media (max-width: 360px) {
|
||||||
@media (max-width: 620px) {
|
.label-extra { display: none; }
|
||||||
.shell { width: min(100% - 28px, 1180px); }
|
|
||||||
.header-inner { min-height: 68px; }
|
|
||||||
.wordmark-copy small { display: none; }
|
|
||||||
.hero { padding-top: 4.5rem; }
|
|
||||||
.hero h1 { font-size: clamp(2.75rem, 14vw, 4.2rem); }
|
|
||||||
.hero-foot { align-items: flex-start; flex-direction: column; margin-top: 4rem; }
|
|
||||||
.section-heading { gap: 1.3rem; margin-bottom: 2.5rem; }
|
|
||||||
.section-heading.compact { grid-template-columns: 1fr; }
|
|
||||||
.project-row { grid-template-columns: 32px 1fr 22px; gap: .75rem; padding: 1.5rem 0; }
|
|
||||||
.project-main small { display: none; }
|
|
||||||
.profile-notes { border-left: 0; border-top: 1px solid #595650; padding: 1.5rem 0 0; }
|
|
||||||
.experience-row { grid-template-columns: 1fr; gap: .65rem; }
|
|
||||||
.experience-row > p { grid-column: 1; }
|
|
||||||
.capability-grid { grid-template-columns: 1fr; }
|
|
||||||
.capability-grid article { border-right: 0; border-bottom: 1px solid var(--line); padding-inline: 0; }
|
|
||||||
.capability-grid h3 { margin-top: 1.7rem; }
|
|
||||||
.contact-block { width: 100%; margin-bottom: 0; }
|
|
||||||
.contact-actions, .footer-inner { align-items: flex-start; flex-direction: column; }
|
|
||||||
.case-section, .case-stack { grid-template-columns: 1fr; gap: 1.5rem; }
|
|
||||||
.cv-section { grid-template-columns: 1fr; gap: 1.5rem; }
|
|
||||||
.decision-grid { grid-template-columns: 1fr; }
|
|
||||||
.decision-grid article { min-height: auto; padding-inline: 0; border-right: 0; }
|
|
||||||
.case-pagination { grid-template-columns: 1fr; }
|
|
||||||
.case-pagination a { min-height: 120px; }
|
|
||||||
.case-pagination a + a { border-top: 1px solid var(--line); border-left: 0; padding-left: 0; }
|
|
||||||
.cv-role-head { grid-template-columns: 1fr; gap: .65rem; }
|
|
||||||
.cv-skill-grid { grid-template-columns: 1fr; }
|
|
||||||
.cv-skill-grid article { min-height: auto; padding-inline: 0; border-right: 0; }
|
|
||||||
.course-list article { grid-template-columns: 1fr; gap: .4rem; }
|
|
||||||
.course-list p { text-align: left; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
html { scroll-behavior: auto; }
|
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,13 @@
|
|||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.spec.ts"
|
||||||
]
|
],
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"extendedDiagnostics": {
|
||||||
|
"checks": {
|
||||||
|
"nullishCoalescingNotNullable": "suppress",
|
||||||
|
"optionalChainNotNullable": "suppress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,13 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
]
|
],
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"extendedDiagnostics": {
|
||||||
|
"checks": {
|
||||||
|
"nullishCoalescingNotNullable": "suppress",
|
||||||
|
"optionalChainNotNullable": "suppress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||