Compare commits
4 Commits
3f1093e3c8
...
feature/Up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16142178f4 | ||
|
|
db2bc7ab97 | ||
|
|
888fb6289d | ||
|
|
f543a5e2f3 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -34,7 +34,7 @@ Dockerfile text eol=lf
|
||||
*.adoc text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.svg text eol=lf # svg ist Text
|
||||
*.svg text eol=lf
|
||||
|
||||
# ---- binary files never convert ----
|
||||
*.jar binary
|
||||
|
||||
5
.postcssrc.json
Normal file
5
.postcssrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ This is the frontend of the Playground project, built with Angular 21 and Angula
|
||||
* **Internationalization:** ngx-translate
|
||||
* **Server:** NGINX (for serving the SPA)
|
||||
* **Containerization:** Docker
|
||||
* **CI/CD:** GitHub Actions
|
||||
* **CI/CD:** Gitea Actions (self-hosted, `git.andreas-dahm.eu`)
|
||||
|
||||
## Building and Running
|
||||
|
||||
@@ -77,7 +77,7 @@ ng build
|
||||
|
||||
* **Language:** TypeScript
|
||||
* **Framework:** Angular
|
||||
* **Styling:** Tailwind CSS (v3) is the primary styling approach. Use utility classes in templates. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@layer components`.
|
||||
* **Styling:** Tailwind CSS (v4, CSS-first config) is the primary styling approach. Use utility classes in templates. Theme tokens and custom variants are declared in `src/tailwind.css` via `@theme` and `@custom-variant` — there is no `tailwind.config.js`. Preflight stays disabled by importing `tailwindcss/theme.css` and `tailwindcss/utilities.css` individually, so Angular Material's reset stays in charge. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@utility`.
|
||||
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
|
||||
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.
|
||||
|
||||
|
||||
121
README.md
121
README.md
@@ -1,9 +1,8 @@
|
||||
## 📗 `playground-frontend/README.md`
|
||||
|
||||
# 🎨 Playground Frontend
|
||||
|
||||
This is the **frontend** of the Playground project.
|
||||
Built with **Angular 21** and **Angular Material**, including a simple light/dark theme toggle and multi-language support via **ngx-translate**.
|
||||
Built with **Angular 21** and **Angular Material**, including a light/dark theme toggle and
|
||||
multi-language support (EN/DE) via **ngx-translate**.
|
||||
|
||||
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
||||
deployed at:
|
||||
@@ -12,84 +11,136 @@ deployed at:
|
||||
---
|
||||
|
||||
## 🧩 Tech Stack
|
||||
|
||||
| Component | Purpose |
|
||||
|------------|----------|
|
||||
| Angular 21 | Frontend framework |
|
||||
| Angular Material | UI components & theming |
|
||||
| ngx-translate | i18n / instant translation |
|
||||
| --------- | ------- |
|
||||
| Angular 21 | Frontend framework (standalone components, signals, zoneless change detection) |
|
||||
| Angular Material 21 | UI components & theming |
|
||||
| Tailwind CSS 4 | Utility-first styling (CSS-first config, no `tailwind.config.js`) |
|
||||
| ngx-translate 18 | i18n / instant translation |
|
||||
| Babylon.js 9 | WebGL/WebGPU rendering for the algorithm demos |
|
||||
| Swiper 14 | Image carousels in the project dialogs |
|
||||
| Angular Service Worker | PWA / offline caching |
|
||||
| NGINX | Serves the compiled SPA |
|
||||
| Docker + GitHub Actions | Automated build & image publishing |
|
||||
| Docker + Gitea Actions | Automated build, test & image publishing |
|
||||
|
||||
Requires **Node.js 22**.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
playground-frontend/
|
||||
├─ src/
|
||||
│ ├─ app/
|
||||
│ │ ├─ app.component.ts
|
||||
│ │ ├─ theme.service.ts
|
||||
│ │ └─ ...
|
||||
│ ├─ assets/i18n/
|
||||
│ │ ├─ en.json
|
||||
│ │ └─ de.json
|
||||
│ │ ├─ layout/ # app shell, topbar, dialogs
|
||||
│ │ ├─ pages/ # about, projects, algorithms, imprint, stopwatch, not-found
|
||||
│ │ ├─ service/ # theme, language, SEO, GPU capability
|
||||
│ │ ├─ shared/ # reusable components & helpers
|
||||
│ │ ├─ constants/
|
||||
│ │ ├─ app.config.ts
|
||||
│ │ └─ app.routes.ts
|
||||
│ ├─ assets/
|
||||
│ │ ├─ i18n/ # en.json / de.json (keys must stay in sync)
|
||||
│ │ ├─ logos/, flags/, icons/
|
||||
│ │ └─ projects/ # project screenshots
|
||||
│ ├─ tailwind.css # Tailwind entry + @theme tokens
|
||||
│ ├─ styles.scss # Material theme setup & component overrides
|
||||
│ └─ index.html
|
||||
├─ scripts/check-i18n.mjs # fails the build on i18n key drift
|
||||
├─ Dockerfile
|
||||
├─ nginx.conf
|
||||
└─ .github/workflows/docker.yml
|
||||
├─ ngsw-config.json # service worker caching rules
|
||||
├─ lighthouserc.json
|
||||
└─ .gitea/workflows/build-Frontend-a.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Local Development
|
||||
|
||||
# 1. Install dependencies
|
||||
**1. Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
# 2. Start development server
|
||||
**2. Start development server**
|
||||
```bash
|
||||
ng serve --open
|
||||
```
|
||||
|
||||
App runs at http://localhost:4200
|
||||
|
||||
|
||||
# 3. 🐳 Docker Build (local)
|
||||
**3. Docker build (local)**
|
||||
```bash
|
||||
docker build -t playground-frontend:local .
|
||||
docker run -p 8080:80 playground-frontend:local
|
||||
```
|
||||
|
||||
Then open http://localhost:8080
|
||||
|
||||
## ⚙️ GitHub Actions (CI/CD)
|
||||
---
|
||||
|
||||
On every push to main, GitHub Actions will:
|
||||
## 🧪 Scripts
|
||||
|
||||
Build the Angular project
|
||||
| Command | Purpose |
|
||||
| ------- | ------- |
|
||||
| `npm start` | Dev server |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run watch` | Rebuild on change (development configuration) |
|
||||
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
|
||||
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
|
||||
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
|
||||
|
||||
1. Create a Docker image
|
||||
2. Push it to Docker Hub (docker.io/andreasdahm/playground-frontend:main)
|
||||
3. Workflow file: .github/workflows/docker.yml
|
||||
---
|
||||
|
||||
Required repository secrets:
|
||||
## ⚙️ Gitea Actions (CI/CD)
|
||||
|
||||
Name Description
|
||||
DOCKERHUB_USERNAME Your Docker Hub username
|
||||
DOCKERHUB_TOKEN Personal access token for Docker Hub
|
||||
CI/CD runs on a **self-hosted Gitea instance** at `git.andreas-dahm.eu`.
|
||||
Workflow file: `.gitea/workflows/build-Frontend-a.yml`
|
||||
|
||||
Triggered on every push to `main` and on pull requests targeting `main`.
|
||||
|
||||
**Job 1 — `quality-check`** (runs on both pushes and PRs):
|
||||
|
||||
1. Lint & type check — `npm run lint`
|
||||
2. i18n key sync — `npm run i18n:check`
|
||||
3. Unit tests — `npx ng test --watch=false`
|
||||
4. Production build — `npx ng build --configuration production`
|
||||
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
||||
|
||||
**Job 2 — `docker`** (only on a push to `main`, and only if `quality-check` passed):
|
||||
|
||||
Builds the image and pushes it to the Gitea container registry at `git.andreas-dahm.eu`
|
||||
with these tags:
|
||||
|
||||
```
|
||||
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>
|
||||
git.andreas-dahm.eu/<owner>/playground:frontend-a-<branch>-<short-sha>
|
||||
git.andreas-dahm.eu/<owner>/playground:frontend-a-main
|
||||
```
|
||||
|
||||
**Required repository secret:**
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ----------- |
|
||||
| `TOKEN_GITEA` | Gitea access token used to authenticate against the container registry |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Deployment
|
||||
|
||||
The built image is deployed via
|
||||
https://github.com/lobothedark/playground-deploy
|
||||
|
||||
on the Hostinger KVM server using Traefik.
|
||||
The built image is deployed via the `playground-deploy` repository on the Hostinger KVM
|
||||
server using **Traefik**:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
✅ Live site at https://app.andreas-dahm.eu
|
||||
|
||||
---
|
||||
|
||||
## 🪄 Maintainer
|
||||
|
||||
Andreas Dahm
|
||||
|
||||
6170
package-lock.json
generated
6170
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@@ -13,45 +13,52 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular-slider/ngx-slider": "^21.0.0",
|
||||
"@angular/animations": "~21.2.9",
|
||||
"@angular/animations": "~21.2.21",
|
||||
"@angular/cdk": "~21.2.6",
|
||||
"@angular/common": "~21.2.9",
|
||||
"@angular/compiler": "~21.2.9",
|
||||
"@angular/core": "~21.2.9",
|
||||
"@angular/forms": "~21.2.9",
|
||||
"@angular/common": "~21.2.21",
|
||||
"@angular/compiler": "~21.2.21",
|
||||
"@angular/core": "~21.2.21",
|
||||
"@angular/forms": "~21.2.21",
|
||||
"@angular/material": "~21.2.6",
|
||||
"@angular/platform-browser": "~21.2.9",
|
||||
"@angular/router": "~21.2.9",
|
||||
"@angular/service-worker": "21.2.9",
|
||||
"@babylonjs/core": "^8.54.1",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@ngx-translate/http-loader": "^17.0.0",
|
||||
"@angular/platform-browser": "~21.2.21",
|
||||
"@angular/router": "~21.2.21",
|
||||
"@angular/service-worker": "~21.2.21",
|
||||
"@babylonjs/core": "^9.22.0",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@ngx-translate/http-loader": "^18.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"swiper": "~12.1.0",
|
||||
"swiper": "^14.1.0",
|
||||
"tslib": "~2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "~21.2.7",
|
||||
"@angular/build": "~21.2.21",
|
||||
"@angular/cli": "~21.2.7",
|
||||
"@angular/compiler-cli": "~21.2.9",
|
||||
"@angular/compiler-cli": "~21.2.21",
|
||||
"@eslint/js": "~10.0.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@puppeteer/browsers": "3.2.1",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/jasmine": "~6.0.0",
|
||||
"@webgpu/types": "^0.1.72",
|
||||
"angular-eslint": "21.3.1",
|
||||
"eslint": "^10.0.3",
|
||||
"jasmine-core": "~6.1.0",
|
||||
"jasmine-core": "^7.0.2",
|
||||
"karma": "^6.4.4",
|
||||
"karma-chrome-launcher": "^3.2.0",
|
||||
"karma-coverage": "^2.2.1",
|
||||
"karma-jasmine": "^5.1.0",
|
||||
"karma-jasmine-html-reporter": "^2.2.0",
|
||||
"puppeteer": "^25.1.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.58.2"
|
||||
"typescript-eslint": "^8.67.0"
|
||||
},
|
||||
"overrides": {
|
||||
"tmp": "^0.2.3",
|
||||
"ip-address": "^10.1.1"
|
||||
"ip-address": "^10.1.1",
|
||||
"@lhci/cli": {
|
||||
"lighthouse": "^13.4.1",
|
||||
"uuid": "^11.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export class AssetsConstants {
|
||||
|
||||
static readonly ME = '/assets/me.webp';
|
||||
static readonly LOGO = '/assets/favicon.ico';
|
||||
static readonly LOGO = '/assets/logos/logo-wolf.png';
|
||||
static readonly FLAG_DE = '/assets/flags/de.svg';
|
||||
static readonly FLAG_EN = '/assets/flags/gb.svg';
|
||||
|
||||
@@ -41,6 +41,10 @@ export class AssetsConstants {
|
||||
'/assets/projects/diploma/6.jpg'
|
||||
];
|
||||
|
||||
static readonly CLAUDE_BOX_IMAGES = [
|
||||
'/assets/projects/claude-in-a-box/1.svg'
|
||||
];
|
||||
|
||||
static readonly TRIBBLE_IMAGES = [
|
||||
'/assets/projects/tribble-the-homeserver/1.png',
|
||||
'/assets/projects/tribble-the-homeserver/2.png',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<app-particles-background></app-particles-background>
|
||||
<app-topbar />
|
||||
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-[220ms]">
|
||||
<main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-220">
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-black/[.08] p-fluid-md text-center opacity-80 bg-app-bg">
|
||||
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
|
||||
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
|
||||
<br />
|
||||
<small>{{ `APP.AI_NOTICE` | translate }}</small>
|
||||
<small>
|
||||
{{ 'APP.AI_NOTICE' | translate }}
|
||||
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
|
||||
</small>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import {TopbarComponent} from '../topbar/topbar.component';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
|
||||
@@ -8,7 +8,7 @@ import {SeoService} from '../../service/seo.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
||||
imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<mat-toolbar class="!flex !items-center !p-[clamp(0.5rem,1vw,1rem)] !backdrop-blur-[8px] !backdrop-saturate-[1.1] !bg-white/80 dark:!bg-[#313131]/80 !border-b !border-black/[.08]" color="primary" (keydown)="onKeydown($event)">
|
||||
<mat-toolbar class="flex! items-center! p-[clamp(0.5rem,1vw,1rem)]! backdrop-blur-sm! backdrop-saturate-[1.1]! bg-white/80! dark:bg-[#313131]/80! border-b! border-black/8!" color="primary" (keydown)="onKeydown($event)">
|
||||
<a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
|
||||
<img class="w-[clamp(36px,10vw,48px)] h-[clamp(36px,10vw,48px)] rounded-full" src="{{AssetsConstants.LOGO}}" alt="" aria-hidden="true" draggable="false"
|
||||
<img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" src="{{AssetsConstants.LOGO}}" width="117" height="128" alt="" aria-hidden="true" draggable="false"
|
||||
oncontextmenu="return false;">
|
||||
<span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
|
||||
</a>
|
||||
|
||||
<nav class="absolute left-1/2 -translate-x-1/2 flex gap-[clamp(0.25rem,1vw,0.5rem)] justify-center mobile:hidden">
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | translate }}</a>
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.STOPWATCH.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.STOPWATCH' | translate }}</a>
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ThemeService } from '../../service/theme.service';
|
||||
import { LanguageService } from '../../service/language.service';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
@@ -17,7 +17,7 @@ import {RouterConstants} from '../../constants/RouterConstants';
|
||||
imports: [
|
||||
RouterLink, RouterLinkActive,
|
||||
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
|
||||
TranslateModule, MatDivider
|
||||
TranslatePipe, MatDivider
|
||||
],
|
||||
templateUrl: './topbar.component.html',
|
||||
styleUrl: './topbar.component.scss'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||
<mat-card>
|
||||
<mat-card class="card-gradient-bar">
|
||||
<div class="flex flex-wrap gap-fluid-lg p-fluid-md items-start">
|
||||
<div class="flex-[1_1_min(100%,425px)] max-w-full flex justify-center">
|
||||
<img class="block w-full h-auto max-w-[425px] rounded-xl shadow-[0_6px_24px_rgba(0,0,0,0.25)] object-cover"
|
||||
@@ -13,7 +13,7 @@
|
||||
{{ 'ABOUT.LEAD' | translate }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
<div class="flex flex-col gap-1 mb-4">
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">work</mat-icon>
|
||||
<span>{{ 'ABOUT.ROLE' | translate }}</span>
|
||||
@@ -24,18 +24,26 @@
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">email</mat-icon>
|
||||
<a href="" (click)="SharedFunctions.openMail($event)">
|
||||
<button type="button" class="link-button" data-testid="contact-mail"
|
||||
(click)="SharedFunctions.openMail($event)">
|
||||
{{ 'ABOUT.CONTACT_ME' | translate }}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon>data_object</mat-icon>
|
||||
<mat-icon aria-hidden="true">data_object</mat-icon>
|
||||
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
|
||||
<span>·</span>
|
||||
<mat-icon svgIcon="linkedin"></mat-icon>
|
||||
<mat-icon svgIcon="linkedin" aria-hidden="true"></mat-icon>
|
||||
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-fluid-sm">
|
||||
<a mat-flat-button [routerLink]="['/projects']">
|
||||
<mat-icon aria-hidden="true">grid_view</mat-icon>
|
||||
{{ 'ABOUT.VIEW_PROJECTS' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
@@ -43,72 +51,62 @@
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,250px),1fr))] gap-fluid-sm ml-1 mb-2">
|
||||
@for (group of skillGroups; track group.titleKey) {
|
||||
<div>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.BACKEND_ARCH' | translate }}</h3>
|
||||
<mat-chip-set aria-label="Backend and Architecture">
|
||||
@for (s of skillsArchitecture; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ group.titleKey | translate }}</h3>
|
||||
<mat-chip-set [attr.aria-label]="group.titleKey | translate">
|
||||
@for (skillKey of group.skillKeys; track skillKey) {
|
||||
<mat-chip>{{ skillKey | translate }}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.INFRA_CLOUD' | translate }}</h3>
|
||||
<mat-chip-set aria-label="Infrastructure and Cloud">
|
||||
@for (s of skillsCore; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.SIM_ALGO' | translate }}</h3>
|
||||
<mat-chip-set aria-label="Simulation and Algorithms">
|
||||
@for (s of skillsEngineering; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
||||
<div class="ml-1 grid gap-fluid-sm">
|
||||
@for (entry of xpKeys; track entry.key) {
|
||||
<div>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-3">
|
||||
<div class="row-span-2 col-start-1 flex items-center">
|
||||
<img src="{{entry.logo}}" alt="" class="w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" aria-hidden="true" />
|
||||
|
||||
<div class="relative ml-2">
|
||||
<span class="absolute left-5 top-2 bottom-2 w-px bg-current opacity-15" aria-hidden="true"></span>
|
||||
@for (entry of visibleExperience(); track entry.key; let i = $index) {
|
||||
<div class="relative pl-14 pb-fluid-sm">
|
||||
@if (isFirstRoleAtCompany(i)) {
|
||||
<img src="{{entry.logo}}" alt="" aria-hidden="true"
|
||||
class="absolute left-0 top-0 w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" />
|
||||
<div class="font-semibold opacity-90 text-[clamp(0.9rem,2.2vw,1.05rem)] leading-10" data-testid="company">
|
||||
{{ (entry.key + '.COMPANY') | translate }}
|
||||
</div>
|
||||
<div class="row-start-1 col-start-2 flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||
} @else {
|
||||
<span class="absolute left-5 top-[0.6rem] -translate-x-1/2 w-2 h-2 rounded-full bg-current opacity-30"
|
||||
aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="role">{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row-start-2 col-start-2 mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||
{{ (entry.key + '.COMPANY') | translate }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@if (!entry.no_highlights) {
|
||||
<div class="mt-[0.4rem] ml-[clamp(0.25rem,1vw,0.75rem)] pl-[clamp(0.8rem,2vw,1.2rem)]">
|
||||
<ul>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
||||
<ul class="mt-[0.4rem]">
|
||||
@for (highlight of highlightKeys(entry); track highlight) {
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ highlight | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (isLastRoleAtCompany(i) && !$last) {
|
||||
<mat-divider class="mt-fluid-sm!"></mat-divider>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key)
|
||||
{
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<button mat-button type="button" class="mt-1 ml-2" data-testid="toggle-experience" (click)="toggleEarlierExperience()"
|
||||
[attr.aria-expanded]="showEarlierExperience()">
|
||||
<mat-icon aria-hidden="true">{{ showEarlierExperience() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ (showEarlierExperience() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_XP') | translate }}
|
||||
</button>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
|
||||
|
||||
@@ -124,7 +122,7 @@
|
||||
@if (entry.externalLink) {
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
||||
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">open_in_new</mat-icon>
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">open_in_new</mat-icon>
|
||||
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -132,7 +130,7 @@
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||
rel="noopener noreferrer">
|
||||
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">link</mat-icon>
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">link</mat-icon>
|
||||
{{ (entry.key + '.LINK_INTERNAL') | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -145,8 +143,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(entry.key !== projectKeys.at(projectKeys.length-1)?.key)
|
||||
{
|
||||
@if (!$last) {
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
@@ -158,21 +155,25 @@
|
||||
|
||||
<div class="ml-1 grid gap-fluid-sm">
|
||||
<div>
|
||||
@for (entry of educationKeys; track entry.key) {
|
||||
@for (key of visibleEducationKeys(); track key) {
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.WHERE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.WHEN') | translate }}</span>
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="education-where">{{ (key + '.WHERE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (key + '.WHEN') | translate }}</span>
|
||||
</div>
|
||||
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||
{{ (entry.key + '.WHAT') | translate }}
|
||||
{{ (key + '.WHAT') | translate }}
|
||||
</div>
|
||||
@if (!$last) {
|
||||
<mat-divider class="my-2!"></mat-divider>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
|
||||
{
|
||||
<mat-divider class="!my-2"></mat-divider>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button mat-button type="button" class="mt-1" data-testid="toggle-education" (click)="toggleEarlierEducation()"
|
||||
[attr.aria-expanded]="showEarlierEducation()">
|
||||
<mat-icon aria-hidden="true">{{ showEarlierEducation() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ (showEarlierEducation() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_EDU') | translate }}
|
||||
</button>
|
||||
</mat-card>
|
||||
</section>
|
||||
|
||||
214
src/app/pages/about/about.component.spec.ts
Normal file
214
src/app/pages/about/about.component.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component, provideZonelessChangeDetection } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
import { AboutComponent } from './about.component';
|
||||
|
||||
/**
|
||||
* No translations are loaded, so the translate pipe echoes the key back. That is
|
||||
* exactly what these tests want: they assert which key is rendered where, which
|
||||
* is what the mismatched skill headings got wrong. Expected values are written
|
||||
* out literally rather than read off the component, so a change to the component
|
||||
* cannot silently move the assertion with it.
|
||||
*/
|
||||
describe('AboutComponent', () => {
|
||||
let fixture: ComponentFixture<AboutComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AboutComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter([]),
|
||||
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AboutComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
function textOf(selector: string): string[] {
|
||||
return fixture.debugElement
|
||||
.queryAll(By.css(selector))
|
||||
.map(element => element.nativeElement.textContent.trim());
|
||||
}
|
||||
|
||||
function click(testId: string): void {
|
||||
fixture.debugElement
|
||||
.query(By.css(`[data-testid="${testId}"]`))
|
||||
.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function skillGroupTexts(): { heading: string; chips: string[] }[] {
|
||||
return fixture.debugElement.queryAll(By.css('mat-chip-set')).map(chipSet => {
|
||||
const group = chipSet.parent!;
|
||||
const heading = group.query(By.css('h3')).nativeElement.textContent.trim();
|
||||
const chips = group
|
||||
.queryAll(By.css('mat-chip'))
|
||||
.map(chip => chip.nativeElement.textContent.trim());
|
||||
return { heading, chips };
|
||||
});
|
||||
}
|
||||
|
||||
describe('skills', () => {
|
||||
it('renders the skill groups in the documented order', () => {
|
||||
expect(skillGroupTexts().map(group => group.heading)).toEqual([
|
||||
'ABOUT.SECTION.LANGUAGES',
|
||||
'ABOUT.SECTION.ARCH_CLOUD',
|
||||
'ABOUT.SECTION.SIM_ALGO',
|
||||
'ABOUT.SECTION.AI_ENG',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists languages under the languages heading, not under architecture', () => {
|
||||
const languages = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.LANGUAGES');
|
||||
|
||||
expect(languages).toBeDefined();
|
||||
expect(languages!.chips).toContain('ABOUT.SKILLS.JAVA');
|
||||
expect(languages!.chips).not.toContain('ABOUT.TOOLS.DOCKER');
|
||||
});
|
||||
|
||||
it('lists infrastructure tools under the architecture heading', () => {
|
||||
const architecture = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.ARCH_CLOUD');
|
||||
|
||||
expect(architecture).toBeDefined();
|
||||
expect(architecture!.chips).toContain('ABOUT.TOOLS.DOCKER');
|
||||
expect(architecture!.chips).not.toContain('ABOUT.SKILLS.JAVA');
|
||||
});
|
||||
|
||||
it('advertises AI-assisted engineering as its own group', () => {
|
||||
const ai = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.AI_ENG');
|
||||
|
||||
expect(ai).toBeDefined();
|
||||
expect(ai!.chips).toContain('ABOUT.SKILLS.AI_HARNESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('experience', () => {
|
||||
it('hides the pre-2010 positions until they are requested', () => {
|
||||
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY9.ROLE');
|
||||
expect(textOf('[data-testid="role"]')).not.toContain('ABOUT.XP.COMPANY0.ROLE');
|
||||
});
|
||||
|
||||
it('reveals the earlier positions when the toggle button is clicked', () => {
|
||||
click('toggle-experience');
|
||||
|
||||
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY0.ROLE');
|
||||
});
|
||||
|
||||
it('reports the expanded state of the experience toggle to assistive technology', () => {
|
||||
const toggle = fixture.debugElement.query(By.css('[data-testid="toggle-experience"]')).nativeElement;
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
click('toggle-experience');
|
||||
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('draws the employer header once per run of consecutive roles', () => {
|
||||
const companies = textOf('[data-testid="company"]');
|
||||
|
||||
// COMPANY5/4/3 are three consecutive Assyst roles and share one header.
|
||||
expect(companies).toContain('ABOUT.XP.COMPANY5.COMPANY');
|
||||
expect(companies).not.toContain('ABOUT.XP.COMPANY4.COMPANY');
|
||||
expect(companies).not.toContain('ABOUT.XP.COMPANY3.COMPANY');
|
||||
});
|
||||
|
||||
it('treats the two ColorDigital spells as separate blocks so the timeline stays chronological', () => {
|
||||
const companies = textOf('[data-testid="company"]');
|
||||
|
||||
expect(companies).toContain('ABOUT.XP.COMPANY9.COMPANY');
|
||||
expect(companies).toContain('ABOUT.XP.COMPANY7.COMPANY');
|
||||
});
|
||||
|
||||
it('keeps the employer header at the seam when the earlier roles are appended', () => {
|
||||
click('toggle-experience');
|
||||
|
||||
// COMPANY3 (Assyst) and COMPANY2 (TH Bingen) meet at the seam, so the
|
||||
// first earlier role must still get its own header.
|
||||
expect(textOf('[data-testid="company"]')).toContain('ABOUT.XP.COMPANY2.COMPANY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('education', () => {
|
||||
it('shows only the post-school entries until the toggle button is clicked', () => {
|
||||
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
|
||||
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE');
|
||||
|
||||
click('toggle-education');
|
||||
|
||||
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('call to action', () => {
|
||||
it('offers no download link, so nothing can point at a missing file', () => {
|
||||
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes the mail contact as a button rather than an empty link', () => {
|
||||
const emptyLinks = fixture.debugElement
|
||||
.queryAll(By.css('a'))
|
||||
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
|
||||
|
||||
expect(emptyLinks.length).toBe(0);
|
||||
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the mail button in the same font as the links it sits next to', () => {
|
||||
const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement;
|
||||
const siblingLink = fixture.debugElement
|
||||
.queryAll(By.css('a'))
|
||||
.find(anchor => anchor.nativeElement.textContent.trim() === 'Codeberg')!.nativeElement;
|
||||
|
||||
const buttonStyle = getComputedStyle(mailButton);
|
||||
const linkStyle = getComputedStyle(siblingLink);
|
||||
|
||||
expect(buttonStyle.fontSize).toBe(linkStyle.fontSize);
|
||||
expect(buttonStyle.fontFamily).toBe(linkStyle.fontFamily);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Material typography (`brand-family` in styles.scss) and the Tailwind body
|
||||
* stack are declared in two separate places and are easy to change apart. When
|
||||
* they disagree, only visitors who happen to have the extra font installed
|
||||
* locally see it, so it will not show up in review.
|
||||
*/
|
||||
describe('global font stack', () => {
|
||||
@Component({
|
||||
imports: [MatCardModule],
|
||||
template: `<mat-card><mat-card-title>probe</mat-card-title></mat-card>`,
|
||||
})
|
||||
class FontProbeComponent {}
|
||||
|
||||
function normalizeStack(value: string): string {
|
||||
return value
|
||||
.replace(/["']/g, '')
|
||||
.split(',')
|
||||
.map(family => family.trim())
|
||||
.filter(family => family.length > 0)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
it('resolves Material card titles to the same font stack as body copy', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FontProbeComponent],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
|
||||
const probe = TestBed.createComponent(FontProbeComponent);
|
||||
probe.detectChanges();
|
||||
|
||||
const title = probe.nativeElement.querySelector('.mat-mdc-card-title') as HTMLElement;
|
||||
const titleStack = normalizeStack(getComputedStyle(title).fontFamily);
|
||||
const bodyStack = normalizeStack(getComputedStyle(document.body).fontFamily);
|
||||
|
||||
expect(titleStack).toBe(bodyStack);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,28 @@
|
||||
import { Component} from '@angular/core';
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {UrlConstants} from '../../constants/UrlConstants';
|
||||
import {AssetsConstants} from '../../constants/AssetsConstants';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
|
||||
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
|
||||
interface ExperienceEntry {
|
||||
key: string;
|
||||
company: string;
|
||||
logo: string;
|
||||
highlightCount: number;
|
||||
}
|
||||
|
||||
interface SkillGroup {
|
||||
titleKey: string;
|
||||
skillKeys: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
@@ -21,7 +33,7 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
RouterLink
|
||||
],
|
||||
templateUrl: './about.component.html',
|
||||
@@ -29,51 +41,83 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
})
|
||||
export class AboutComponent {
|
||||
|
||||
xpKeys = [
|
||||
/** Roles older than the current decade are hidden until the reader asks for them. */
|
||||
readonly showEarlierExperience = signal(false);
|
||||
readonly showEarlierEducation = signal(false);
|
||||
|
||||
readonly recentExperience: ExperienceEntry[] = [
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY9',
|
||||
company: 'colordigital',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||
no_highlights: false
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY8',
|
||||
logo: AssetsConstants.TERAPORT_LOGO
|
||||
company: 'teraport',
|
||||
logo: AssetsConstants.TERAPORT_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY7',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO
|
||||
company: 'colordigital-earlier',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY6',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO
|
||||
company: 'colordigital-earlier',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY5',
|
||||
logo: AssetsConstants.ASSYST_LOGO
|
||||
company: 'assyst',
|
||||
logo: AssetsConstants.ASSYST_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY4',
|
||||
logo: AssetsConstants.ASSYST_LOGO
|
||||
company: 'assyst',
|
||||
logo: AssetsConstants.ASSYST_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY3',
|
||||
logo: AssetsConstants.ASSYST_LOGO
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY2',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY1',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY0',
|
||||
logo: AssetsConstants.CHAMAELEON_LOGO
|
||||
company: 'assyst',
|
||||
logo: AssetsConstants.ASSYST_LOGO,
|
||||
highlightCount: 3
|
||||
}
|
||||
];
|
||||
|
||||
projectKeys = [
|
||||
readonly earlierExperience: ExperienceEntry[] = [
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY2',
|
||||
company: 'th-bingen',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY1',
|
||||
company: 'th-bingen',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY0',
|
||||
company: 'chamaeleon',
|
||||
logo: AssetsConstants.CHAMAELEON_LOGO,
|
||||
highlightCount: 3
|
||||
}
|
||||
];
|
||||
|
||||
readonly projectKeys = [
|
||||
{
|
||||
key: 'ABOUT.PROJECT.CLAUDE_BOX',
|
||||
externalLink: '',
|
||||
internalLink: 'projects',
|
||||
identifier: 'claude-in-a-box',
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.PROJECT.P2',
|
||||
externalLink: 'https://andreas-dahm.eu/',
|
||||
@@ -106,60 +150,115 @@ export class AboutComponent {
|
||||
}
|
||||
];
|
||||
|
||||
educationKeys = [
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E6'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E5'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E4'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E3'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E2'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E1'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E0'
|
||||
}
|
||||
]
|
||||
|
||||
skillsCore = [
|
||||
'ABOUT.SKILLS.JAVA',
|
||||
'ABOUT.SKILLS.SPRING',
|
||||
'ABOUT.SKILLS.ANGULAR',
|
||||
'ABOUT.SKILLS.VUE',
|
||||
'ABOUT.SKILLS.REACT',
|
||||
'ABOUT.SKILLS.TYPESCRIPT',
|
||||
'ABOUT.SKILLS.CSHARP',
|
||||
'ABOUT.SKILLS.PYTHON'
|
||||
readonly recentEducationKeys = [
|
||||
'ABOUT.EDUCATION.E6',
|
||||
'ABOUT.EDUCATION.E5',
|
||||
'ABOUT.EDUCATION.E4'
|
||||
];
|
||||
|
||||
skillsArchitecture = [
|
||||
readonly earlierEducationKeys = [
|
||||
'ABOUT.EDUCATION.E3',
|
||||
'ABOUT.EDUCATION.E2',
|
||||
'ABOUT.EDUCATION.E1',
|
||||
'ABOUT.EDUCATION.E0'
|
||||
];
|
||||
|
||||
/**
|
||||
* Heading and chips travel together so the two can no longer drift apart —
|
||||
* they used to be declared in separate places and had ended up mismatched.
|
||||
*/
|
||||
readonly skillGroups: SkillGroup[] = [
|
||||
{
|
||||
titleKey: 'ABOUT.SECTION.LANGUAGES',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.JAVA',
|
||||
'ABOUT.SKILLS.SPRING',
|
||||
'ABOUT.SKILLS.TYPESCRIPT',
|
||||
'ABOUT.SKILLS.ANGULAR',
|
||||
'ABOUT.SKILLS.CSHARP',
|
||||
'ABOUT.SKILLS.PYTHON',
|
||||
'ABOUT.SKILLS.VUE',
|
||||
'ABOUT.SKILLS.REACT'
|
||||
]
|
||||
},
|
||||
{
|
||||
titleKey: 'ABOUT.SECTION.ARCH_CLOUD',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.ARCH_MICROSERVICES',
|
||||
'ABOUT.SKILLS.ARCH_CLOUD',
|
||||
'ABOUT.TOOLS.DOCKER',
|
||||
'ABOUT.TOOLS.K8S',
|
||||
'ABOUT.TOOLS.JENKINS',
|
||||
'ABOUT.TOOLS.POSTGRES',
|
||||
'ABOUT.TOOLS.MONGO',
|
||||
'ABOUT.TOOLS.GITLAB'
|
||||
];
|
||||
|
||||
skillsEngineering = [
|
||||
'ABOUT.TOOLS.GITLAB',
|
||||
'ABOUT.TOOLS.JENKINS'
|
||||
]
|
||||
},
|
||||
{
|
||||
titleKey: 'ABOUT.SECTION.SIM_ALGO',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.ENG_ALGO',
|
||||
'ABOUT.SKILLS.ENG_SIM',
|
||||
'ABOUT.SKILLS.ENG_GPU',
|
||||
'ABOUT.SKILLS.UNITY',
|
||||
'ABOUT.SKILLS.ENG_PERF'
|
||||
'ABOUT.SKILLS.ENG_PERF',
|
||||
'ABOUT.SKILLS.UNITY'
|
||||
]
|
||||
},
|
||||
{
|
||||
titleKey: 'ABOUT.SECTION.AI_ENG',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.AI_AGENTS',
|
||||
'ABOUT.SKILLS.AI_MCP',
|
||||
'ABOUT.SKILLS.AI_CONTEXT',
|
||||
'ABOUT.SKILLS.AI_HARNESS',
|
||||
'ABOUT.SKILLS.AI_GATES'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
readonly visibleExperience = computed(() => {
|
||||
if (this.showEarlierExperience()) {
|
||||
return [...this.recentExperience, ...this.earlierExperience];
|
||||
}
|
||||
return this.recentExperience;
|
||||
});
|
||||
|
||||
readonly visibleEducationKeys = computed(() => {
|
||||
if (this.showEarlierEducation()) {
|
||||
return [...this.recentEducationKeys, ...this.earlierEducationKeys];
|
||||
}
|
||||
return this.recentEducationKeys;
|
||||
});
|
||||
|
||||
toggleEarlierExperience(): void {
|
||||
this.showEarlierExperience.update(shown => !shown);
|
||||
}
|
||||
|
||||
toggleEarlierEducation(): void {
|
||||
this.showEarlierEducation.update(shown => !shown);
|
||||
}
|
||||
|
||||
/** The employer header is drawn once per run of consecutive roles at the same company. */
|
||||
isFirstRoleAtCompany(index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
const entries = this.visibleExperience();
|
||||
return entries[index - 1].company !== entries[index].company;
|
||||
}
|
||||
|
||||
isLastRoleAtCompany(index: number): boolean {
|
||||
const entries = this.visibleExperience();
|
||||
if (index === entries.length - 1) {
|
||||
return true;
|
||||
}
|
||||
return entries[index + 1].company !== entries[index].company;
|
||||
}
|
||||
|
||||
highlightKeys(entry: ExperienceEntry): string[] {
|
||||
return Array.from({ length: entry.highlightCount }, (_, i) => `${entry.key}.HIGHLIGHTS.P${i + 1}`);
|
||||
}
|
||||
|
||||
protected readonly UrlConstants = UrlConstants;
|
||||
protected readonly AssetsConstants = AssetsConstants;
|
||||
protected readonly SharedFunctions = SharedFunctions;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
@for (category of categories; track category.id) {
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col cursor-pointer hover:-translate-y-1 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)]" [routerLink]="[category.routerLink]">
|
||||
<mat-card-content>
|
||||
<div class="flex items-center text-[var(--mat-sys-primary)] mb-4">
|
||||
<mat-icon class="!text-[26px] !w-[26px] !h-[26px]">{{ category.icon }}</mat-icon>
|
||||
<div class="flex items-center text-(--mat-sys-primary) mb-4">
|
||||
<mat-icon class="text-[26px]! w-[26px]! h-[26px]!">{{ category.icon }}</mat-icon>
|
||||
</div>
|
||||
<h3 class="text-[1.05rem] font-semibold mb-2 m-0">{{ category.title | translate }}</h3>
|
||||
<p class="m-0 opacity-75 text-sm leading-relaxed">{{ category.description | translate }}</p>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded border-l-[5px] font-medium min-w-[300px] items-center"
|
||||
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
|
||||
[ngClass]="{
|
||||
'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
|
||||
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCardModule} from '@angular/material/card';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {TranslateModule, TranslateService} from '@ngx-translate/core';
|
||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
|
||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
|
||||
@@ -25,7 +25,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
Information
|
||||
],
|
||||
templateUrl: './four-color.component.html',
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<mat-button-toggle-group class="rounded overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
||||
<mat-button-toggle-group class="rounded-sm overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
||||
<mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle>
|
||||
<mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
|
||||
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
|
||||
import {TranslateModule, TranslateService} from '@ngx-translate/core';
|
||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||
|
||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MAX_RANDOM_WALLS_FACTORS, MIN_GRID_SIZE, Node} from './pathfinding.models';
|
||||
import {PathfindingService} from './service/pathfinding.service';
|
||||
@@ -35,7 +35,7 @@ enum NodeType {
|
||||
MatButtonToggleModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
MatCard,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
|
||||
@for (item of sortArray; track $index) {
|
||||
<div
|
||||
class="grow w-2.5 min-w-px transition-all duration-[50ms]"
|
||||
class="grow w-2.5 min-w-px transition-all duration-50"
|
||||
[style.height.px]="item.value * 3"
|
||||
[ngClass]="{
|
||||
'bg-[#424242]': item.state === 'unsorted',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {MatFormFieldModule} from "@angular/material/form-field";
|
||||
import {MatSelectModule} from "@angular/material/select";
|
||||
import {MatButtonModule} from "@angular/material/button";
|
||||
import {MatIconModule} from "@angular/material/icon";
|
||||
import {TranslateModule} from "@ngx-translate/core";
|
||||
import {TranslatePipe} from "@ngx-translate/core";
|
||||
import { SortingService } from './service/sorting.service';
|
||||
import { SortingAudioService } from './service/sorting-audio.service';
|
||||
import {SortData, SortSnapshot} from './sorting.models';
|
||||
@@ -16,7 +16,7 @@ import {AlgorithmInformation} from '../information/information.models';
|
||||
import {Information} from '../information/information';
|
||||
@Component({
|
||||
selector: 'app-sorting',
|
||||
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslateModule, FormsModule, MatInput, Information],
|
||||
imports: [CommonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatButtonModule, MatIconModule, TranslatePipe, FormsModule, MatInput, Information],
|
||||
templateUrl: './sorting.component.html',
|
||||
styleUrl: './sorting.component.scss'
|
||||
})
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
<p>
|
||||
Andreas Dahm<br />
|
||||
Bgm.-Germeier-Str. 2<br />
|
||||
85586 Poing<br />
|
||||
Hauptstraße 37A<br />
|
||||
85614 Kirchseeon<br />
|
||||
{{ 'IMPRINT.COUNTRY' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
|
||||
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
|
||||
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="!text-[24px] !w-6 !h-6">settings_suggest</mat-icon>
|
||||
<mat-icon class="text-[24px]! w-6! h-6!">settings_suggest</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
||||
</div>
|
||||
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||
@@ -25,9 +25,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
|
||||
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="!text-[24px] !w-6 !h-6">psychology</mat-icon>
|
||||
<mat-icon class="text-[24px]! w-6! h-6!">psychology</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
|
||||
</div>
|
||||
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||
@@ -46,7 +46,7 @@
|
||||
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
|
||||
@for (img of project.images; track img) {
|
||||
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
|
||||
<img class="w-full h-auto !max-h-[clamp(300px,60vh,512px)] object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
|
||||
<img class="w-full h-auto max-h-[clamp(300px,60vh,512px)]! object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
|
||||
@if (img.source) {
|
||||
<div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
|
||||
{{ img.source }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
|
||||
@if (featuredProject(); as project) {
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card-header class="pb-4">
|
||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
|
||||
@for (project of otherProjects(); track project) {
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:translate-y-[-5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
||||
<mat-card-header class="pb-4">
|
||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
@@ -53,6 +53,33 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
private queryParamSub: Subscription | undefined;
|
||||
|
||||
allProjects: Projects[] = [
|
||||
{
|
||||
identifier: "claude-in-a-box",
|
||||
title: 'PROJECTS.CLAUDE_BOX.TITLE',
|
||||
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
|
||||
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
|
||||
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })),
|
||||
icon: 'smart_toy',
|
||||
assets: '',
|
||||
links: [],
|
||||
bulletPoints: [
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_1',
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_2',
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_3',
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_4',
|
||||
],
|
||||
challenges: [
|
||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_1',
|
||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_2',
|
||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_3',
|
||||
],
|
||||
learnings: [
|
||||
'PROJECTS.CLAUDE_BOX.LEARNING_1',
|
||||
'PROJECTS.CLAUDE_BOX.LEARNING_2',
|
||||
],
|
||||
isFeatured: false,
|
||||
technologies: ['Claude Code', 'MCP', 'Docker', 'Bash', 'Node.js']
|
||||
},
|
||||
{
|
||||
identifier: "playground",
|
||||
title: 'PROJECTS.PLAYGROUND.TITLE',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div
|
||||
class="font-mono tabular-nums tracking-wider text-[clamp(2.5rem,8vw,5rem)] font-semibold leading-none rounded-2xl px-6 py-3 ring-2 ring-transparent transition-all duration-300 ease-out"
|
||||
[class]="flashing()
|
||||
? 'scale-110 !text-[var(--mat-sys-primary)] !ring-[var(--mat-sys-primary)] shadow-[0_0_40px_var(--mat-sys-primary)]'
|
||||
? 'scale-110 text-(--mat-sys-primary)! ring-(--mat-sys-primary)! shadow-[0_0_40px_var(--mat-sys-primary)]'
|
||||
: ''"
|
||||
>
|
||||
{{ display() }}
|
||||
@@ -37,6 +37,8 @@
|
||||
{{ 'STOPWATCH.INTERVAL_ENABLED' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
<p class="m-0 font-medium text-sm">{{ 'STOPWATCH.FIRST_INTERVAL' | translate }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3 items-center justify-center">
|
||||
<mat-form-field appearance="outline" class="w-[160px]">
|
||||
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
|
||||
@@ -46,8 +48,8 @@
|
||||
min="0"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="intervalMinutes()"
|
||||
(ngModelChange)="intervalMinutes.set($event)"
|
||||
[ngModel]="firstIntervalMinutes()"
|
||||
(ngModelChange)="updateIntervalValue(firstIntervalMinutes, $event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
@@ -60,15 +62,60 @@
|
||||
max="59"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="intervalSeconds()"
|
||||
(ngModelChange)="intervalSeconds.set($event)"
|
||||
[ngModel]="firstIntervalSeconds()"
|
||||
(ngModelChange)="updateIntervalValue(firstIntervalSeconds, $event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-checkbox
|
||||
[checked]="secondIntervalEnabled()"
|
||||
[disabled]="!intervalEnabled()"
|
||||
(change)="secondIntervalEnabled.set($event.checked)"
|
||||
>
|
||||
{{ 'STOPWATCH.SECOND_INTERVAL_ENABLED' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
@if (secondIntervalEnabled()) {
|
||||
<div class="flex flex-wrap gap-3 items-center justify-center">
|
||||
<mat-form-field appearance="outline" class="w-[160px]">
|
||||
<mat-label>{{ 'STOPWATCH.INTERVAL_MINUTES' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="secondIntervalMinutes()"
|
||||
(ngModelChange)="updateIntervalValue(secondIntervalMinutes, $event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-[160px]">
|
||||
<mat-label>{{ 'STOPWATCH.INTERVAL_SECONDS' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
step="1"
|
||||
[disabled]="!intervalEnabled()"
|
||||
[ngModel]="secondIntervalSeconds()"
|
||||
(ngModelChange)="updateIntervalValue(secondIntervalSeconds, $event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="m-0 opacity-70 text-sm text-center">
|
||||
{{ 'STOPWATCH.INTERVAL_HINT' | translate }}
|
||||
</p>
|
||||
|
||||
@if (intervalEnabled() && schedulePreview()) {
|
||||
<p class="m-0 opacity-70 text-sm text-center">
|
||||
{{ 'STOPWATCH.INTERVAL_SCHEDULE' | translate }} {{ schedulePreview() }} …
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
||||
39
src/app/pages/stopwatch/stopwatch.component.spec.ts
Normal file
39
src/app/pages/stopwatch/stopwatch.component.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {collectBeepTimes, countBeeps, slotOfBeep} from './stopwatch.component';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
describe('stopwatch interval scheduling', () => {
|
||||
const firstMs = 1 * MINUTE;
|
||||
const secondMs = 2 * MINUTE;
|
||||
|
||||
it('counts no beeps without a configured first interval', () => {
|
||||
expect(countBeeps(10 * MINUTE, 0, secondMs)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to a single repeating interval when the second one is off', () => {
|
||||
expect(countBeeps(0.9 * MINUTE, firstMs, 0)).toBe(0);
|
||||
expect(countBeeps(1 * MINUTE, firstMs, 0)).toBe(1);
|
||||
expect(countBeeps(3 * MINUTE, firstMs, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it('alternates both intervals so beeps fall on 1, 3, 4, 6, 7 minutes', () => {
|
||||
const expectedBeepCountPerMinute = [0, 1, 1, 2, 3, 3, 4, 5, 5, 6];
|
||||
|
||||
expectedBeepCountPerMinute.forEach((expected, minute) => {
|
||||
expect(countBeeps(minute * MINUTE, firstMs, secondMs)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the alternating beep times', () => {
|
||||
const times = collectBeepTimes(firstMs, secondMs, 5);
|
||||
|
||||
expect(times).toEqual([1 * MINUTE, 3 * MINUTE, 4 * MINUTE, 6 * MINUTE, 7 * MINUTE]);
|
||||
});
|
||||
|
||||
it('assigns odd beeps to the first interval and even ones to the second', () => {
|
||||
expect(slotOfBeep(1, secondMs)).toBe('first');
|
||||
expect(slotOfBeep(2, secondMs)).toBe('second');
|
||||
expect(slotOfBeep(3, secondMs)).toBe('first');
|
||||
expect(slotOfBeep(2, 0)).toBe('first');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, computed, effect, inject, OnDestroy, signal} from '@angular/core';
|
||||
import {Component, computed, effect, inject, OnDestroy, signal, WritableSignal} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCardModule} from '@angular/material/card';
|
||||
@@ -7,10 +7,16 @@ import {MatIconModule} from '@angular/material/icon';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
|
||||
type StopwatchState = 'idle' | 'running' | 'paused';
|
||||
|
||||
/** Which of the two configured intervals a beep belongs to. */
|
||||
type IntervalSlot = 'first' | 'second';
|
||||
|
||||
const FIRST_INTERVAL_FREQUENCY_HZ = 880;
|
||||
const SECOND_INTERVAL_FREQUENCY_HZ = 587;
|
||||
|
||||
@Component({
|
||||
selector: 'app-stopwatch',
|
||||
standalone: true,
|
||||
@@ -22,7 +28,7 @@ type StopwatchState = 'idle' | 'running' | 'paused';
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
TranslateModule
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './stopwatch.component.html'
|
||||
})
|
||||
@@ -31,13 +37,34 @@ export class StopwatchComponent implements OnDestroy {
|
||||
readonly elapsedMs = signal(0);
|
||||
|
||||
readonly intervalEnabled = signal(false);
|
||||
readonly intervalMinutes = signal(2);
|
||||
readonly intervalSeconds = signal(0);
|
||||
readonly firstIntervalMinutes = signal(1);
|
||||
readonly firstIntervalSeconds = signal(0);
|
||||
|
||||
readonly secondIntervalEnabled = signal(false);
|
||||
readonly secondIntervalMinutes = signal(2);
|
||||
readonly secondIntervalSeconds = signal(0);
|
||||
|
||||
readonly flashing = signal(false);
|
||||
|
||||
readonly display = computed(() => formatElapsed(this.elapsedMs()));
|
||||
|
||||
readonly firstIntervalMs = computed(
|
||||
() => toMilliseconds(this.firstIntervalMinutes(), this.firstIntervalSeconds())
|
||||
);
|
||||
|
||||
readonly secondIntervalMs = computed(() => {
|
||||
if (!this.secondIntervalEnabled()) {
|
||||
return 0;
|
||||
}
|
||||
return toMilliseconds(this.secondIntervalMinutes(), this.secondIntervalSeconds());
|
||||
});
|
||||
|
||||
/** Absolute times of the upcoming beeps, used as a preview of the alternating schedule. */
|
||||
readonly schedulePreview = computed(() => {
|
||||
const beepTimes = collectBeepTimes(this.firstIntervalMs(), this.secondIntervalMs(), 4);
|
||||
return beepTimes.map(formatElapsedShort).join(' · ');
|
||||
});
|
||||
|
||||
private intervalId: number | null = null;
|
||||
private startTimestamp = 0;
|
||||
private accumulatedMs = 0;
|
||||
@@ -88,6 +115,11 @@ export class StopwatchComponent implements OnDestroy {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** Number inputs can emit empty strings or negative values, so sanitize before storing. */
|
||||
updateIntervalValue(target: WritableSignal<number>, value: unknown): void {
|
||||
target.set(toNonNegativeInteger(value));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelTick();
|
||||
if (this.flashTimeoutId !== null) {
|
||||
@@ -140,17 +172,19 @@ export class StopwatchComponent implements OnDestroy {
|
||||
if (!this.intervalEnabled()) {
|
||||
return;
|
||||
}
|
||||
const intervalMs = this.intervalMinutes() * 60_000 + this.intervalSeconds() * 1_000;
|
||||
if (intervalMs <= 0) {
|
||||
const firstMs = this.firstIntervalMs();
|
||||
const secondMs = this.secondIntervalMs();
|
||||
|
||||
const previousBeeps = countBeeps(prevMs, firstMs, secondMs);
|
||||
const currentBeeps = countBeeps(currMs, firstMs, secondMs);
|
||||
if (currentBeeps <= previousBeeps) {
|
||||
return;
|
||||
}
|
||||
const prevTick = Math.floor(prevMs / intervalMs);
|
||||
const currTick = Math.floor(currMs / intervalMs);
|
||||
if (currTick > prevTick) {
|
||||
this.beep();
|
||||
|
||||
const slot = slotOfBeep(currentBeeps, secondMs);
|
||||
this.beep(slot === 'first' ? FIRST_INTERVAL_FREQUENCY_HZ : SECOND_INTERVAL_FREQUENCY_HZ);
|
||||
this.triggerFlash();
|
||||
}
|
||||
}
|
||||
|
||||
private triggerFlash(): void {
|
||||
if (this.flashTimeoutId !== null) {
|
||||
@@ -180,7 +214,7 @@ export class StopwatchComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private beep(): void {
|
||||
private beep(frequencyHz: number): void {
|
||||
if (!this.audioContext) {
|
||||
return;
|
||||
}
|
||||
@@ -189,7 +223,7 @@ export class StopwatchComponent implements OnDestroy {
|
||||
const gain = ctx.createGain();
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = 880;
|
||||
oscillator.frequency.value = frequencyHz;
|
||||
|
||||
const now = ctx.currentTime;
|
||||
const duration = 0.18;
|
||||
@@ -204,6 +238,62 @@ export class StopwatchComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
function toMilliseconds(minutes: number, seconds: number): number {
|
||||
return minutes * 60_000 + seconds * 1_000;
|
||||
}
|
||||
|
||||
function toNonNegativeInteger(value: unknown): number {
|
||||
const parsed = Math.floor(Number(value));
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two intervals alternate: the first beep lands after `firstMs`, the next one
|
||||
* `secondMs` later, then `firstMs` again. With first = 1min and second = 2min the
|
||||
* beeps therefore fall on 1, 3, 4, 6, 7 ... minutes.
|
||||
*/
|
||||
export function countBeeps(elapsedMs: number, firstMs: number, secondMs: number): number {
|
||||
if (firstMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (secondMs <= 0) {
|
||||
return Math.floor(elapsedMs / firstMs);
|
||||
}
|
||||
|
||||
const cycleMs = firstMs + secondMs;
|
||||
const completedCycles = Math.floor(elapsedMs / cycleMs);
|
||||
const remainderMs = elapsedMs % cycleMs;
|
||||
const firstBeepOfCycleReached = remainderMs >= firstMs ? 1 : 0;
|
||||
|
||||
return completedCycles * 2 + firstBeepOfCycleReached;
|
||||
}
|
||||
|
||||
/** Beeps alternate, so odd beep numbers end the first interval and even ones the second. */
|
||||
export function slotOfBeep(beepNumber: number, secondMs: number): IntervalSlot {
|
||||
if (secondMs <= 0) {
|
||||
return 'first';
|
||||
}
|
||||
return beepNumber % 2 === 1 ? 'first' : 'second';
|
||||
}
|
||||
|
||||
export function collectBeepTimes(firstMs: number, secondMs: number, count: number): number[] {
|
||||
if (firstMs <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const times: number[] = [];
|
||||
let current = 0;
|
||||
for (let beepNumber = 1; beepNumber <= count; beepNumber++) {
|
||||
const isFirstSlot = slotOfBeep(beepNumber, secondMs) === 'first';
|
||||
current += isFirstSlot ? firstMs : secondMs;
|
||||
times.push(current);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
function formatElapsed(totalMs: number): string {
|
||||
const ms = Math.floor(totalMs % 1000);
|
||||
const totalSeconds = Math.floor(totalMs / 1000);
|
||||
|
||||
@@ -14,12 +14,15 @@ describe('LanguageService', () => {
|
||||
return TestBed.inject(LanguageService);
|
||||
}
|
||||
|
||||
const documentLanguage = document.documentElement.lang;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.lang = documentLanguage;
|
||||
});
|
||||
|
||||
it('uses the stored language as the initial value', () => {
|
||||
@@ -40,4 +43,15 @@ describe('LanguageService', () => {
|
||||
expect(translateSpy.use).toHaveBeenCalledWith('de');
|
||||
expect(localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY)).toBe('de');
|
||||
});
|
||||
|
||||
it('marks the document with the active language so screen readers switch voice', () => {
|
||||
localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, 'en');
|
||||
const service = createService();
|
||||
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
|
||||
service.use('de');
|
||||
|
||||
expect(document.documentElement.lang).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {LocalStoreConstants} from '../constants/LocalStoreConstants';
|
||||
|
||||
@@ -7,20 +8,32 @@ type Lang = 'de' | 'en';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LanguageService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
readonly lang = signal<Lang>(this.getInitial());
|
||||
|
||||
constructor() {
|
||||
// translate service lang and fallback are already configured via provideTranslateService in app.config
|
||||
// just ensure the stored preference is active on startup
|
||||
this.translate.use(this.lang());
|
||||
this.markDocumentLanguage(this.lang());
|
||||
}
|
||||
|
||||
use(l: Lang) {
|
||||
this.lang.set(l);
|
||||
this.translate.use(l);
|
||||
this.markDocumentLanguage(l);
|
||||
try { localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, l); } catch (e) { void e; }
|
||||
}
|
||||
|
||||
/**
|
||||
* index.html ships a static lang="en". Without this the document keeps
|
||||
* claiming English after a switch, so a screen reader reads the German
|
||||
* content with an English voice.
|
||||
*/
|
||||
private markDocumentLanguage(l: Lang): void {
|
||||
this.document.documentElement.lang = l;
|
||||
}
|
||||
|
||||
private getInitial(): Lang {
|
||||
try {
|
||||
const stored = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) as Lang | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||
<canvas #gridCanvas
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
|
||||
</canvas>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||
<canvas #renderCanvas
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
|
||||
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-hidden">
|
||||
</canvas>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"APP": {
|
||||
"TITLE": "Playground",
|
||||
"COPYRIGHT": "Bilder und Sourcecode sind urheberrechtlich geschützt, keine Nutzung ohne Zustimmung!",
|
||||
"AI_NOTICE": "Diese Seite wurde mit der Hilfe von KI erzeugt."
|
||||
"AI_NOTICE": "Entstanden mit einem KI-gestützten Engineering-Workflow.",
|
||||
"AI_NOTICE_LINK": "Wie ich mit KI arbeite"
|
||||
},
|
||||
"TOPBAR": {
|
||||
"ABOUT": "Über mich",
|
||||
@@ -14,25 +15,37 @@
|
||||
"LANGUAGE": "Sprache",
|
||||
"APPEARANCE": "Darstellung"
|
||||
},
|
||||
"THEME": { "LIGHT": "Hell", "DARK": "Dunkel" },
|
||||
"LANG": { "LABEL": "Sprache", "EN": "Englisch", "DE": "Deutsch" },
|
||||
"THEME": {
|
||||
"LIGHT": "Hell",
|
||||
"DARK": "Dunkel"
|
||||
},
|
||||
"LANG": {
|
||||
"LABEL": "Sprache",
|
||||
"EN": "Englisch",
|
||||
"DE": "Deutsch"
|
||||
},
|
||||
"ABOUT": {
|
||||
"ALT": { "PROFILE": "Profilfoto von Andreas Dahm" },
|
||||
"ALT": {
|
||||
"PROFILE": "Profilfoto von Andreas Dahm"
|
||||
},
|
||||
"HELLO": "Hallo, ich bin Andreas.",
|
||||
"LEAD": "Bereits in meiner Ausbildung zum Fachinformatiker habe ich gemerkt, wie sehr mich Softwareentwicklung interessiert. Nach meinem Studium der Angewandten Informatik an der FH Bingen arbeite ich seit 2010 durchgehend als Softwareentwickler. In dieser Zeit konnte ich in verschiedenen Bereichen Erfahrungen sammeln, darunter 3D-Simulation und moderne Web-Anwendungen. Auch privat beschäftige ich mich gern mit Softwareentwicklung, besonders mit Spieleentwicklung und algorithmischen Themen. Schön, dass du hier bist!",
|
||||
"LEAD": "Ich arbeite seit 2010 als Softwareentwickler, die meiste Zeit davon in zwei recht unterschiedlichen Bereichen. Der eine ist Echtzeit-3D-Simulation: Stoffphysik, Kollisionserkennung, GPU-Shader. Der andere ist Cloud-Architektur für größere Unternehmensplattformen. Ein paar Jahre habe ich ein kleines Backend-Team geleitet, den Umbau eines großen Legacy-Monolithen Richtung Microservices geplant und zusammen mit meinem Bruder ein Spiel auf Steam veröffentlicht. Zuletzt stecke ich viel Zeit in Tooling rund um KI-gestützte Entwicklung, vor allem in die Frage, wie das Ergebnis überprüfbar bleibt. Privat baue ich Spiele und probiere Sachen mit Algorithmen aus. Das meiste davon landet irgendwann auf dieser Seite.",
|
||||
"ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt",
|
||||
"LOCATION": "München · Remote",
|
||||
"DOWNLOAD_CV": "Lebenslauf herunterladen",
|
||||
"VIEW_PROJECTS": "Projekte ansehen",
|
||||
"CONTACT_ME": "Kontaktiere mich",
|
||||
"SECTION": {
|
||||
"SKILLS": "Fähigkeiten & Stack",
|
||||
"BACKEND_ARCH": "Backend & Architektur",
|
||||
"INFRA_CLOUD": "Infrastruktur & Cloud",
|
||||
"LANGUAGES": "Sprachen & Frameworks",
|
||||
"ARCH_CLOUD": "Architektur & Cloud",
|
||||
"SIM_ALGO": "Simulation & Algorithmen",
|
||||
"AI_ENG": "KI-gestütztes Engineering",
|
||||
"EXPERIENCE": "Erfahrung",
|
||||
"PROJECTS": "Projekte",
|
||||
"EDUCATION": "Ausbildung"
|
||||
"EDUCATION": "Ausbildung",
|
||||
"SHOW_EARLIER_XP": "Frühere Stationen anzeigen",
|
||||
"SHOW_EARLIER_EDU": "Frühere Ausbildung anzeigen",
|
||||
"SHOW_LESS": "Weniger anzeigen"
|
||||
},
|
||||
"SKILLS": {
|
||||
"JAVA": "Java",
|
||||
@@ -51,7 +64,12 @@
|
||||
"ENG_SIM": "3D Simulation",
|
||||
"ENG_GPU": "WebGPU / OpenGL / GLSL",
|
||||
"ENG_PERF": "Performance Optimization",
|
||||
"ENG_3D": "3D-Scanner Tech"
|
||||
"ENG_3D": "3D-Scanner Tech",
|
||||
"AI_AGENTS": "Claude Code / Agent SDK",
|
||||
"AI_MCP": "MCP-Server",
|
||||
"AI_CONTEXT": "Context- & Prompt-Engineering",
|
||||
"AI_HARNESS": "Agent-Harness-Design",
|
||||
"AI_GATES": "Automatisierte Review-Gates"
|
||||
},
|
||||
"TOOLS": {
|
||||
"GIT": "Git",
|
||||
@@ -161,12 +179,22 @@
|
||||
"TIME": "Jul. 2002 – Jun. 2005",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Entwicklung in PERL, PHP und ASP.",
|
||||
"P2": "Portierung, Wartung und Reengineering von bestender Software.",
|
||||
"P2": "Portierung, Wartung und Reengineering von bestehender Software.",
|
||||
"P3": "Regelmäßige Durchführung von Systemtests und Qualitätskontrollen, sowie deren Dokumentation."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PROJECT": {
|
||||
"CLAUDE_BOX": {
|
||||
"TITLE": "Claude-in-a-Box",
|
||||
"DESCRIPTION": "Eine Entwicklungsumgebung im Container, in der ein KI-Coding-Agent nach einem festen Regelsatz arbeitet und nicht nach dem, was ich gerade eintippe. Er zieht sich die Regeln für den Stack, den er im Repository vorfindet, und ein Git-Hook stoppt den Commit, wenn Build oder Tests rot sind.",
|
||||
"LINK_INTERNAL": "Projektdetails",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Der Agent lädt die Regeln für den Stack, den er im Repo vorfindet. Ein Java-Service bekommt also andere als ein Frontend.",
|
||||
"P2": "Ein Commit-Hook führt Build und Tests aus und blockiert den Commit, wenn sie fehlschlagen.",
|
||||
"P3": "MCP-Server für Codesuche und lesenden Jira-Zugriff, dazu eigene Review-Agenten, die den Diff durchgehen."
|
||||
}
|
||||
},
|
||||
"P2": {
|
||||
"TITLE": "Playground",
|
||||
"DESCRIPTION": "Dieses Projekt ist, wie der Name schon sagt, ein persönlicher Playground, der im Laufe der Zeit wachsen soll. Es präsentiert verschiedene private Projekte, an denen ich arbeite, und dokumentiert deren Fortschritt, zentrale Ideen und wichtige Meilensteine.",
|
||||
@@ -333,6 +361,20 @@
|
||||
"CHALLENGE_2": "Mathematische Stabilisierung der Integrationsverfahren bei hohen Krafteinwirkungen.",
|
||||
"LEARNING_1": "Transfer von theoretischen Forschungsarbeiten in produktiven, performanten Code.",
|
||||
"LEARNING_2": "Präzises Arbeiten und Dokumentation nach wissenschaftlichen Standards."
|
||||
},
|
||||
"CLAUDE_BOX": {
|
||||
"TITLE": "Claude-in-a-Box",
|
||||
"SHORT_DESCRIPTION": "Ein Container-Setup, um mit einem KI-Coding-Agenten nach einem festen Regelsatz zu arbeiten.",
|
||||
"INTRODUCTION": "Ein KI-Coding-Agent produziert den ganzen Tag Code, ohne sich zu beschweren. Das Problem ist, dass das Ergebnis meistens vernünftig aussieht, auch wenn es falsch ist, und die ganzen Kosten damit im Review landen. Also habe ich ein Docker-Setup gebaut, das den Agenten denselben Regeln unterwirft wie alle anderen, die am Repository arbeiten. Er liest die Architekturregeln des Projekts, in dem er gerade steckt, und der Build muss durchlaufen, bevor ein Commit möglich ist. Auf den Hook, der genau das durchsetzt, komme ich immer wieder zurück, weil er das Einzige ist, woran der Agent nicht vorbeikommt.",
|
||||
"BULLET_1": "Container mit festen Toolchains: JDKs und Maven über SDKMAN, Node und ein Home-Volume, das einen Rebuild übersteht.",
|
||||
"BULLET_2": "Die Regeln werden per Erkennung geladen. Ein Repo mit pom.xml bekommt die Java- und Spring-Regeln, eines mit package.json die TypeScript-Regeln, und Repos mit eigenen Architekturnotizen bekommen diese zusätzlich.",
|
||||
"BULLET_3": "Ein Commit-Hook ermittelt den Stack, führt den passenden Build und die Tests aus und verweigert den Commit, wenn sie fehlschlagen.",
|
||||
"BULLET_4": "MCP-Server für Code-Graph-Abfragen und lesende Jira-Zugriffe, dazu Review-Agenten, die einen fertigen Diff prüfen, bevor er als erledigt gilt.",
|
||||
"CHALLENGE_1": "Die Regeln kurz genug zu halten, dass sie noch etwas bringen. Lange allgemeine Richtlinien werden einfach ignoriert, also sind sie in Dateien pro Stack und pro Repo aufgeteilt, die nur bei Bedarf geladen werden.",
|
||||
"CHALLENGE_2": "Das Quality Gate so zu bauen, dass der Agent sich nicht daran vorbeireden kann. Die Prüfung läuft als Git-Hook außerhalb des Agenten, ein fehlgeschlagener Build blockiert den Commit also unabhängig davon, was der Agent über seine eigene Arbeit denkt.",
|
||||
"CHALLENGE_3": "Die Toolchains in einem Container ohne Root zum Laufen zu bringen. Builds, Browser und Sprach-Runtimes mussten alle in Volumes landen, die dem Entwickler-User gehören.",
|
||||
"LEARNING_1": "Die Formulierung der Prompts war deutlich weniger wichtig, als ich dachte. Fast der ganze Unterschied kam vom Drumherum.",
|
||||
"LEARNING_2": "Den Output des Agenten genauso zu reviewen und durch dieselben Gates zu schicken wie den Pull Request eines neuen Teammitglieds hat sich als die nützlichste Regel überhaupt herausgestellt."
|
||||
}
|
||||
},
|
||||
"IMPRINT": {
|
||||
@@ -578,7 +620,8 @@
|
||||
"CONFLICTS": "Achtung: Benachbarte Regionen haben die gleiche Farbe!",
|
||||
"INVALID": "Karte ist vollständig, enthält aber Fehler."
|
||||
},
|
||||
"EXPLANATION": { "TITLE": "Der Vier-Farben-Satz",
|
||||
"EXPLANATION": {
|
||||
"TITLE": "Der Vier-Farben-Satz",
|
||||
"EXPLANATION": "Der Vier-Farben-Satz besagt, dass jede Landkarte in der Ebene mit maximal vier Farben so eingefärbt werden kann, dass keine zwei aneinandergrenzenden Gebiete dieselbe Farbe besitzen.",
|
||||
"DISCLAIMER": "Dieser Algorithmus verwendet Backtracking, um eine gültige Färbung für die generierten Regionen zu finden.",
|
||||
"DISCLAIMER_1": "Kartengenerierung: Regionen werden mittels eines zufälligen Seed-Wachstumsalgorithmus (Voronoi-ähnlich) auf einem Gitter erzeugt.",
|
||||
@@ -598,9 +641,12 @@
|
||||
"STOP": "Stopp",
|
||||
"RESET": "Zurücksetzen",
|
||||
"INTERVAL_ENABLED": "Signalton bei Intervall",
|
||||
"FIRST_INTERVAL": "Erstes Intervall",
|
||||
"INTERVAL_MINUTES": "Minuten",
|
||||
"INTERVAL_SECONDS": "Sekunden",
|
||||
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald die verstrichene Zeit ein Vielfaches des Intervalls überschreitet."
|
||||
"SECOND_INTERVAL_ENABLED": "Zweites, abwechselndes Intervall hinzufügen",
|
||||
"INTERVAL_HINT": "Spielt einen kurzen Ton ab, sobald ein Intervall abgelaufen ist. Mit aktiviertem zweiten Intervall wechseln sich beide ab, das zweite startet direkt nach dem ersten.",
|
||||
"INTERVAL_SCHEDULE": "Nächste Signaltöne bei:"
|
||||
},
|
||||
"NOT_FOUND": {
|
||||
"TITLE": "Seite nicht gefunden",
|
||||
@@ -614,7 +660,7 @@
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "Über mich – Andreas Dahm",
|
||||
"DESCRIPTION": "Andreas Dahm, Senior Software Developer und Architekt aus München: Erfahrung, Skills und Projekte in Full-Stack-Entwicklung, 3D-Simulation und Algorithmen."
|
||||
"DESCRIPTION": "Andreas Dahm, Senior Softwareentwickler und Architekt aus München: Erfahrung, Fähigkeiten und Projekte in Full-Stack-Entwicklung, 3D-Simulation, Algorithmen und KI-gestütztem Engineering."
|
||||
},
|
||||
"PROJECTS": {
|
||||
"TITLE": "Projekte – Andreas Dahm",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"APP": {
|
||||
"TITLE": "Playground",
|
||||
"COPYRIGHT": "Images and code protected by copyright, no use without permission!",
|
||||
"AI_NOTICE": "This page is created with the help of AI."
|
||||
"AI_NOTICE": "Built with an AI-assisted engineering workflow.",
|
||||
"AI_NOTICE_LINK": "How I build with AI"
|
||||
},
|
||||
"TOPBAR": {
|
||||
"ABOUT": "About me",
|
||||
@@ -14,25 +15,37 @@
|
||||
"LANGUAGE": "Language",
|
||||
"APPEARANCE": "Appearance"
|
||||
},
|
||||
"THEME": { "LIGHT": "Light", "DARK": "Dark" },
|
||||
"LANG": { "LABEL": "Language", "EN": "English", "DE": "Deutsch" },
|
||||
"THEME": {
|
||||
"LIGHT": "Light",
|
||||
"DARK": "Dark"
|
||||
},
|
||||
"LANG": {
|
||||
"LABEL": "Language",
|
||||
"EN": "English",
|
||||
"DE": "Deutsch"
|
||||
},
|
||||
"ABOUT": {
|
||||
"ALT": { "PROFILE": "Profile photo of Andreas Dahm" },
|
||||
"ALT": {
|
||||
"PROFILE": "Profile photo of Andreas Dahm"
|
||||
},
|
||||
"HELLO": "Hello, I’m Andreas.",
|
||||
"LEAD": "I first discovered my interest in software development during my training as an application developer. After completing my degree in Applied Computer Science at FH Bingen, I have been working continuously as a software developer since 2010. During this time, I have gained experience in various areas, including 3D simulation and modern web applications. I also enjoy working on personal software projects, particularly in game development and algorithmic topics. Thank you for stopping by!",
|
||||
"LEAD": "I have been working as a software developer since 2010. Most of that time went into two fairly different corners. One is real-time 3D simulation: cloth physics, collision detection, GPU shaders. The other is cloud architecture for larger enterprise platforms. I led a small backend team for a few years, planned the move of a big legacy monolith towards microservices, and released a game on Steam together with my brother. Lately a lot of my time goes into tooling around AI-assisted development, mostly into making the output reviewable instead of just fast. In my free time I build games and mess around with algorithms. Most of that ends up somewhere on this site.",
|
||||
"ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect",
|
||||
"LOCATION": "Munich · Remote",
|
||||
"DOWNLOAD_CV": "Download CV",
|
||||
"VIEW_PROJECTS": "View projects",
|
||||
"CONTACT_ME": "Contact me",
|
||||
"SECTION": {
|
||||
"SKILLS": "Skills & Stack",
|
||||
"BACKEND_ARCH": "Backend & Architecture",
|
||||
"INFRA_CLOUD": "Infrastructure & Cloud",
|
||||
"LANGUAGES": "Languages & Frameworks",
|
||||
"ARCH_CLOUD": "Architecture & Cloud",
|
||||
"SIM_ALGO": "Simulation & Algorithms",
|
||||
"AI_ENG": "AI-Assisted Engineering",
|
||||
"EXPERIENCE": "Experience",
|
||||
"PROJECTS": "Projects",
|
||||
"EDUCATION": "Education"
|
||||
"EDUCATION": "Education",
|
||||
"SHOW_EARLIER_XP": "Show earlier positions",
|
||||
"SHOW_EARLIER_EDU": "Show earlier education",
|
||||
"SHOW_LESS": "Show less"
|
||||
},
|
||||
"SKILLS": {
|
||||
"JAVA": "Java",
|
||||
@@ -51,7 +64,12 @@
|
||||
"ENG_SIM": "3D Simulation",
|
||||
"ENG_GPU": "WebGPU / OpenGL / GLSL",
|
||||
"ENG_PERF": "Performance Optimization",
|
||||
"ENG_3D": "3D-Scanner Tech"
|
||||
"ENG_3D": "3D-Scanner Tech",
|
||||
"AI_AGENTS": "Claude Code / Agent SDK",
|
||||
"AI_MCP": "MCP servers",
|
||||
"AI_CONTEXT": "Context & prompt engineering",
|
||||
"AI_HARNESS": "Agent harness design",
|
||||
"AI_GATES": "Automated review gates"
|
||||
},
|
||||
"TOOLS": {
|
||||
"GIT": "Git",
|
||||
@@ -147,7 +165,7 @@
|
||||
},
|
||||
"COMPANY1": {
|
||||
"COMPANY": "TH Bingen (University of Applied Sciences)",
|
||||
"ROLE": "Research Asssitent",
|
||||
"ROLE": "Research Assistant",
|
||||
"TIME": "Oct. 2007 – Apr. 2008",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Evaluation of various Web 2.0 technologies.",
|
||||
@@ -161,12 +179,22 @@
|
||||
"TIME": "Jul. 2002 – Jun. 2005",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Development in PERL, PHP and ASP.",
|
||||
"P2": "Portierung, Wartung und Reengineering von bestender Software.",
|
||||
"P2": "Porting, maintenance and reengineering of existing software.",
|
||||
"P3": "Regular performance of system tests and quality controls, as well as their documentation."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PROJECT": {
|
||||
"CLAUDE_BOX": {
|
||||
"TITLE": "Claude-in-a-Box",
|
||||
"DESCRIPTION": "A development setup in a container where an AI coding agent works under a fixed set of rules rather than whatever I happen to type that day. It picks up the rules for whatever stack it finds in the repository, and a git hook stops the commit if the build or the tests are red.",
|
||||
"LINK_INTERNAL": "Project details",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "The agent loads the rules for the stack it finds in the repo, so a Java service and a frontend get different ones.",
|
||||
"P2": "A commit hook runs the build and the tests and blocks the commit when they fail.",
|
||||
"P3": "MCP servers for code search and read-only Jira access, plus separate review agents that go over the diff."
|
||||
}
|
||||
},
|
||||
"P2": {
|
||||
"TITLE": "Playground",
|
||||
"DESCRIPTION": "This project is, as the name suggests, a personal playground that will grow over time. It showcases various private projects I’m working on and documents their progress, key ideas, and milestones.",
|
||||
@@ -333,6 +361,20 @@
|
||||
"CHALLENGE_2": "Mathematical stabilization of integration methods under high force impacts.",
|
||||
"LEARNING_1": "Transferring theoretical research into productive, high-performance code.",
|
||||
"LEARNING_2": "Precise working and documentation according to scientific standards."
|
||||
},
|
||||
"CLAUDE_BOX": {
|
||||
"TITLE": "Claude-in-a-Box",
|
||||
"SHORT_DESCRIPTION": "A container setup for working with an AI coding agent under a fixed set of rules.",
|
||||
"INTRODUCTION": "An AI coding agent will produce code all day without complaining. The trouble is that what comes out usually looks reasonable even when it is wrong, so all the cost lands in review. I built a Docker setup that puts the agent under the same constraints as anyone else working on the repo. It reads the architecture rules of whatever project it is in, and the build has to pass before a commit goes through. The hook that enforces that last part is the piece I keep coming back to, because it is the one thing the agent has no way around.",
|
||||
"BULLET_1": "Container with fixed toolchains: JDKs and Maven through SDKMAN, Node, and a home volume that survives a rebuild.",
|
||||
"BULLET_2": "Rules are loaded by detection. A repo with a pom.xml gets the Java and Spring rules, one with a package.json gets the TypeScript rules, and repos with their own architecture notes get those on top.",
|
||||
"BULLET_3": "A commit hook works out the stack, runs the matching build and tests, and refuses the commit if they fail.",
|
||||
"BULLET_4": "MCP servers for code graph queries and read-only Jira lookups, plus review agents that check a finished diff before it counts as done.",
|
||||
"CHALLENGE_1": "Keeping the rules short enough to stay useful. Long general guidelines just get ignored, so they are split into per-stack and per-repo files that only load when they apply.",
|
||||
"CHALLENGE_2": "Making the quality gate something the agent cannot talk its way past. The check runs as a git hook outside the agent, so a failing build blocks the commit no matter what the agent believes about its own work.",
|
||||
"CHALLENGE_3": "Getting the toolchains installed in a container that has no root. Builds, browsers and language runtimes all had to go into volumes owned by the development user.",
|
||||
"LEARNING_1": "Prompt wording mattered far less than I expected. Almost all of the difference came from the scaffolding.",
|
||||
"LEARNING_2": "Reviewing and gating the agent's output the same way you would a new colleague's pull request turned out to be the most useful rule of the lot."
|
||||
}
|
||||
},
|
||||
"IMPRINT": {
|
||||
@@ -578,7 +620,8 @@
|
||||
"CONFLICTS": "Warning: Adjacent regions have the same color!",
|
||||
"INVALID": "Map is fully colored, but contains conflicts."
|
||||
},
|
||||
"EXPLANATION": { "TITLE": "Four Color Theorem",
|
||||
"EXPLANATION": {
|
||||
"TITLE": "Four Color Theorem",
|
||||
"EXPLANATION": "The four color theorem states that any map in a plane can be colored using at most four colors in such a way that regions sharing a common boundary (other than a single point) do not share the same color.",
|
||||
"DISCLAIMER": "This algorithm uses backtracking to find a valid coloring for the generated regions.",
|
||||
"DISCLAIMER_1": "Map Generation: Regions are generated using a random seed growth algorithm (Voronoi-like) on a grid.",
|
||||
@@ -598,9 +641,12 @@
|
||||
"STOP": "Stop",
|
||||
"RESET": "Reset",
|
||||
"INTERVAL_ENABLED": "Beep at interval",
|
||||
"FIRST_INTERVAL": "First interval",
|
||||
"INTERVAL_MINUTES": "Minutes",
|
||||
"INTERVAL_SECONDS": "Seconds",
|
||||
"INTERVAL_HINT": "Plays a short tone whenever the elapsed time crosses a multiple of the interval."
|
||||
"SECOND_INTERVAL_ENABLED": "Add a second, alternating interval",
|
||||
"INTERVAL_HINT": "Plays a short tone every time an interval elapses. With a second interval enabled both alternate, so the second one starts right after the first.",
|
||||
"INTERVAL_SCHEDULE": "Next beeps at:"
|
||||
},
|
||||
"NOT_FOUND": {
|
||||
"TITLE": "Page not found",
|
||||
@@ -614,7 +660,7 @@
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "About me – Andreas Dahm",
|
||||
"DESCRIPTION": "Andreas Dahm, senior software developer and architect from Munich: experience, skills and projects in full-stack development, 3D simulation and algorithms."
|
||||
"DESCRIPTION": "Andreas Dahm, senior software developer and architect from Munich: experience, skills and projects in full-stack development, 3D simulation, algorithms and AI-assisted engineering."
|
||||
},
|
||||
"PROJECTS": {
|
||||
"TITLE": "Projects – Andreas Dahm",
|
||||
|
||||
BIN
src/assets/logos/logo-wolf.png
Normal file
BIN
src/assets/logos/logo-wolf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
109
src/assets/projects/claude-in-a-box/1.svg
Normal file
109
src/assets/projects/claude-in-a-box/1.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Claude-in-a-Box</title>
|
||||
<desc id="desc">A containerised agent: repository detection routes stack rules into the box, and a commit gate blocks anything with a failing build.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1e2530"/>
|
||||
<stop offset="100%" stop-color="#15181d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#26c6da"/>
|
||||
<stop offset="100%" stop-color="#ffa726"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5563"/>
|
||||
</marker>
|
||||
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#26c6da"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<style>
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
|
||||
.sans { font-family: Roboto, Arial, sans-serif; }
|
||||
.lbl { fill: #8b97a6; font-size: 17px; letter-spacing: .04em; }
|
||||
.chip { fill: #cdd6e0; font-size: 19px; }
|
||||
.cap { fill: #6c7887; font-size: 15px; letter-spacing: .18em; }
|
||||
</style>
|
||||
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<rect x="0" y="0" width="1200" height="4" fill="url(#accent)"/>
|
||||
|
||||
<text x="64" y="72" class="mono" fill="#26c6da" font-size="26" font-weight="600">claude-in-a-box</text>
|
||||
<text x="64" y="102" class="sans lbl">the harness, not the prompt</text>
|
||||
|
||||
<!-- ── Left: repository detection ─────────────────────────── -->
|
||||
<text x="64" y="176" class="sans cap">detect</text>
|
||||
|
||||
<g>
|
||||
<rect x="64" y="196" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||
<text x="86" y="223" class="mono chip">pom.xml</text>
|
||||
<text x="86" y="245" class="sans lbl" font-size="15">→ java / spring rules</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="64" y="278" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||
<text x="86" y="305" class="mono chip">package.json</text>
|
||||
<text x="86" y="327" class="sans lbl" font-size="15">→ typescript rules</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="64" y="360" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||
<text x="86" y="387" class="mono chip">pyproject.toml</text>
|
||||
<text x="86" y="409" class="sans lbl" font-size="15">→ python rules</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="64" y="442" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
|
||||
<text x="86" y="469" class="mono chip"><repo>.md</text>
|
||||
<text x="86" y="491" class="sans lbl" font-size="15">→ architecture profile</text>
|
||||
</g>
|
||||
|
||||
<path d="M 300 227 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<path d="M 300 309 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<path d="M 300 391 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<path d="M 300 473 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- ── Centre: the container ──────────────────────────────── -->
|
||||
<rect x="376" y="150" width="330" height="380" rx="18" fill="#1a222c" stroke="#26c6da" stroke-width="2"/>
|
||||
<text x="400" y="184" class="sans cap" fill="#26c6da">container</text>
|
||||
|
||||
<rect x="404" y="212" width="274" height="96" rx="12" fill="#243040" stroke="#33475c"/>
|
||||
<circle cx="440" cy="248" r="14" fill="none" stroke="#26c6da" stroke-width="2"/>
|
||||
<circle cx="440" cy="248" r="5" fill="#26c6da"/>
|
||||
<text x="470" y="246" class="sans" fill="#e3e9f0" font-size="21" font-weight="600">agent</text>
|
||||
<text x="470" y="270" class="sans lbl" font-size="15">scoped context only</text>
|
||||
<text x="424" y="296" class="mono" fill="#6c7887" font-size="14">/workspace · no root</text>
|
||||
|
||||
<text x="404" y="348" class="sans cap">pinned toolchain</text>
|
||||
<g class="mono" font-size="16" fill="#9aa7b5">
|
||||
<text x="404" y="380">sdkman · jdk · maven</text>
|
||||
<text x="404" y="408">node · npm</text>
|
||||
<text x="404" y="436">mcp: codegraph, jira (ro)</text>
|
||||
</g>
|
||||
<rect x="404" y="462" width="274" height="44" rx="10" fill="#2a2118" stroke="#5c4326"/>
|
||||
<text x="424" y="490" class="mono" fill="#ffa726" font-size="16">review subagents</text>
|
||||
|
||||
<path d="M 714 340 H 776" stroke="#26c6da" stroke-width="2" marker-end="url(#arrowCyan)"/>
|
||||
|
||||
<!-- ── Right: the gate ────────────────────────────────────── -->
|
||||
<text x="792" y="176" class="sans cap">gate</text>
|
||||
|
||||
<rect x="792" y="196" width="344" height="288" rx="14" fill="#1c242e" stroke="#2f3a47"/>
|
||||
|
||||
<g class="mono" font-size="17">
|
||||
<circle cx="822" cy="243" r="7" fill="#4caf50"/>
|
||||
<text x="844" y="249" fill="#cdd6e0">lint</text>
|
||||
<circle cx="822" cy="291" r="7" fill="#4caf50"/>
|
||||
<text x="844" y="297" fill="#cdd6e0">build</text>
|
||||
<circle cx="822" cy="339" r="7" fill="#ef5350"/>
|
||||
<text x="844" y="345" fill="#cdd6e0">tests</text>
|
||||
</g>
|
||||
|
||||
<rect x="812" y="378" width="304" height="76" rx="10" fill="#2b1b1c" stroke="#7d3a3c"/>
|
||||
<text x="836" y="408" class="mono" fill="#ef8b8d" font-size="17">git commit — blocked</text>
|
||||
<text x="836" y="434" class="sans lbl" font-size="15">hook runs outside the agent</text>
|
||||
|
||||
<text x="792" y="524" class="sans lbl" font-size="16">a failing build cannot be argued with</text>
|
||||
|
||||
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
|
||||
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">docker · claude code · mcp · hooks</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -27,9 +27,18 @@
|
||||
<meta name="twitter:image" content="https://andreas-dahm.eu/assets/me.webp">
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
|
||||
<!--
|
||||
The LCP image lives in a lazily loaded route chunk, so the browser cannot
|
||||
discover it until Angular has booted. Preloading it here is what makes it
|
||||
fetch in parallel with the bundle instead of after it.
|
||||
-->
|
||||
<link rel="preload" as="image" href="assets/me.webp" fetchpriority="high">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root class="flex flex-col min-h-screen"></app-root>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// ---- Themes ----
|
||||
$light-theme: mat.define-theme((color: (theme-type: light, primary: mat.$cyan-palette, tertiary: mat.$orange-palette ),
|
||||
typography: (brand-family: 'Inter, Roboto, Arial, sans-serif', bold-weight: 600),
|
||||
typography: (brand-family: 'Roboto, Arial, sans-serif', bold-weight: 600),
|
||||
density: (scale: 0),
|
||||
));
|
||||
|
||||
@@ -31,8 +31,13 @@ $dark-theme: mat.define-theme((color: (theme-type: dark, primary: mat.$cyan-pale
|
||||
--card-border-inset: rgba(255, 255, 255, 0.35);
|
||||
--card-border-inset-dark: rgba(0, 0, 0, 0.35);
|
||||
|
||||
--link-color: #38a7ff;
|
||||
--link-color-hover: #66bfff;
|
||||
/*
|
||||
Chosen to clear WCAG AA *after* compositing: several link containers carry
|
||||
opacity-85/opacity-80, which blends the text toward the surface. Worst case
|
||||
(footer, 80% over #f1f4f3) is 4.73:1; a plain link on a card is 8.03:1.
|
||||
*/
|
||||
--link-color: #0f4f8a;
|
||||
--link-color-hover: #0a3a66;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
||||
104
src/tailwind.css
104
src/tailwind.css
@@ -1,6 +1,77 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/*
|
||||
Tailwind v4 dropped `corePlugins`, so preflight can no longer be switched off
|
||||
from the config. Importing the layers individually and leaving out
|
||||
`preflight.css` keeps Angular Material's own reset in charge.
|
||||
*/
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@import 'tailwindcss/theme.css' layer(theme);
|
||||
@import 'tailwindcss/utilities.css' layer(utilities);
|
||||
|
||||
/*
|
||||
The theme is driven by the CSS custom properties that styles.scss sets per theme.
|
||||
`inline` is required: the theme service toggles `.dark` on <body>, while a plain
|
||||
`@theme` would resolve these references once at `:root` (<html>) and freeze the
|
||||
light values. Inlining makes the utilities emit `var(--app-fg)` directly, so they
|
||||
resolve on the element and follow the `.dark` override again.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-app-bg: var(--app-bg);
|
||||
--color-app-fg: var(--app-fg);
|
||||
--color-card-bg: var(--card-bg);
|
||||
--color-link: var(--link-color);
|
||||
--color-link-hover: var(--link-color-hover);
|
||||
--color-logo-bg: var(--app-logo-bg);
|
||||
|
||||
--container-app: var(--app-maxWidth);
|
||||
--radius-card: var(--card-radius);
|
||||
|
||||
--font-sans: Roboto, Arial, sans-serif;
|
||||
|
||||
--spacing-fluid-sm: clamp(0.5rem, 2vw, 1rem);
|
||||
--spacing-fluid-md: clamp(1rem, 3vw, 1.5rem);
|
||||
--spacing-fluid-lg: clamp(1rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
/* The theme service toggles a `dark` class instead of relying on the media query. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@custom-variant mobile (@media (width <= 760px));
|
||||
@custom-variant sm-dialog (@media (width <= 600px));
|
||||
@custom-variant tablet (@media (width <= 900px));
|
||||
|
||||
@utility legend-swatch {
|
||||
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
|
||||
}
|
||||
|
||||
/*
|
||||
Preflight is off, so a bare <button> keeps the UA font (13.33px Arial) instead of
|
||||
the page font. Anything that should read as a link but has to stay a button for
|
||||
accessibility uses this.
|
||||
*/
|
||||
@utility link-button {
|
||||
font: inherit;
|
||||
@apply text-link font-medium bg-transparent p-0 cursor-pointer hover:text-link-hover hover:underline;
|
||||
}
|
||||
|
||||
@utility card-gradient-bar {
|
||||
@apply relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--mat-sys-primary),
|
||||
var(--mat-sys-tertiary)
|
||||
);
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
@@ -17,7 +88,7 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, Roboto, Arial, sans-serif;
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
background-color: var(--app-bg);
|
||||
color: var(--app-fg);
|
||||
transition: background-color 220ms ease, color 220ms ease;
|
||||
@@ -27,10 +98,6 @@
|
||||
@apply text-link no-underline font-medium hover:text-link-hover hover:underline;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background: radial-gradient(ellipse at 50% 0%, #1e2530 0%, #1a1a1a 65%);
|
||||
}
|
||||
@@ -44,24 +111,3 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.legend-swatch {
|
||||
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
|
||||
}
|
||||
|
||||
.card-gradient-bar {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.card-gradient-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--mat-sys-primary), var(--mat-sys-tertiary));
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{html,ts}'],
|
||||
darkMode: 'class',
|
||||
corePlugins: { preflight: false },
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'app-bg': 'var(--app-bg)',
|
||||
'app-fg': 'var(--app-fg)',
|
||||
'card-bg': 'var(--card-bg)',
|
||||
'link': 'var(--link-color)',
|
||||
'link-hover': 'var(--link-color-hover)',
|
||||
'logo-bg': 'var(--app-logo-bg)',
|
||||
},
|
||||
maxWidth: {
|
||||
'app': 'var(--app-maxWidth)',
|
||||
},
|
||||
borderRadius: {
|
||||
'card': 'var(--card-radius)',
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': ['Inter', 'Roboto', 'Arial', 'sans-serif'],
|
||||
},
|
||||
spacing: {
|
||||
'fluid-sm': 'clamp(0.5rem, 2vw, 1rem)',
|
||||
'fluid-md': 'clamp(1rem, 3vw, 1.5rem)',
|
||||
'fluid-lg': 'clamp(1rem, 4vw, 2rem)',
|
||||
},
|
||||
screens: {
|
||||
'mobile': { 'max': '760px' },
|
||||
'sm-dialog': { 'max': '600px' },
|
||||
'tablet': { 'max': '900px' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,9 @@
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
"types": [
|
||||
"@webgpu/types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
"jasmine",
|
||||
"@webgpu/types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user