feature/Update #38

Merged
lobo merged 3 commits from feature/Update into main 2026-08-26 11:44:45 +02:00
14 changed files with 1502 additions and 1162 deletions
Showing only changes of commit 888fb6289d - Show all commits

1808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@
"@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",
@@ -54,6 +55,10 @@
},
"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"
}
}
}

View File

@@ -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',

View File

@@ -7,5 +7,8 @@
<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>

View File

@@ -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'
})

View File

@@ -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>
}
</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>
<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>
</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>
<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>
<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 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>
@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>
</div>
} @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>
<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>
@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(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
{
@if (!$last) {
<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>

View File

@@ -0,0 +1,174 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
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);
});
});
});

View File

@@ -1,4 +1,4 @@
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';
@@ -11,6 +11,18 @@ 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',
@@ -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,59 +150,114 @@ export class AboutComponent {
}
];
educationKeys = [
readonly recentEducationKeys = [
'ABOUT.EDUCATION.E6',
'ABOUT.EDUCATION.E5',
'ABOUT.EDUCATION.E4'
];
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[] = [
{
key: 'ABOUT.EDUCATION.E6'
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'
]
},
{
key: 'ABOUT.EDUCATION.E5'
titleKey: 'ABOUT.SECTION.ARCH_CLOUD',
skillKeys: [
'ABOUT.SKILLS.ARCH_MICROSERVICES',
'ABOUT.SKILLS.ARCH_CLOUD',
'ABOUT.TOOLS.DOCKER',
'ABOUT.TOOLS.K8S',
'ABOUT.TOOLS.POSTGRES',
'ABOUT.TOOLS.MONGO',
'ABOUT.TOOLS.GITLAB',
'ABOUT.TOOLS.JENKINS'
]
},
{
key: 'ABOUT.EDUCATION.E4'
titleKey: 'ABOUT.SECTION.SIM_ALGO',
skillKeys: [
'ABOUT.SKILLS.ENG_ALGO',
'ABOUT.SKILLS.ENG_SIM',
'ABOUT.SKILLS.ENG_GPU',
'ABOUT.SKILLS.ENG_PERF',
'ABOUT.SKILLS.UNITY'
]
},
{
key: 'ABOUT.EDUCATION.E3'
},
{
key: 'ABOUT.EDUCATION.E2'
},
{
key: 'ABOUT.EDUCATION.E1'
},
{
key: 'ABOUT.EDUCATION.E0'
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'
]
}
]
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'
];
skillsArchitecture = [
'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'
];
readonly visibleExperience = computed(() => {
if (this.showEarlierExperience()) {
return [...this.recentExperience, ...this.earlierExperience];
}
return this.recentExperience;
});
skillsEngineering = [
'ABOUT.SKILLS.ENG_ALGO',
'ABOUT.SKILLS.ENG_SIM',
'ABOUT.SKILLS.ENG_GPU',
'ABOUT.SKILLS.UNITY',
'ABOUT.SKILLS.ENG_PERF'
];
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;

View File

@@ -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>

View File

@@ -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',

View File

@@ -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": "Senior Softwareentwickler und Architekt mit über 15 Jahren Erfahrung. Meine Arbeit bewegt sich zwischen zwei Bereichen, die selten zusammentreffen: Echtzeit-3D-Simulation — Stoffphysik, Kollisionserkennung, GPU-Shader — und verteilte Cloud-Architektur für Unternehmensplattformen. Unterwegs habe ich ein Entwicklungsteam geleitet, die Migration eines großen Legacy-Monolithen zu Microservices geplant und ein Spiel auf Steam veröffentlicht. In letzter Zeit beschäftigt mich vor allem die Engineering-Seite der KI-gestützten Entwicklung: die Harnesses, Leitplanken und Quality Gates, die generierten Code überprüfbar machen. Privat entwickle ich Spiele und beschäftige mich mit algorithmischen Themen — vieles davon findet sich irgendwo auf dieser Seite wieder.",
"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 containerisierte Entwicklungsumgebung, die einen KI-Coding-Agenten zu einem überprüfbaren Teil der Toolchain macht. Statt spontaner Prompts führt die Box den Agenten über Regeln pro Stack, Architekturprofile pro Repository und Commit-Hooks, die keinen roten Build durchlassen.",
"LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": {
"P1": "Deterministisches Context-Routing: Der Agent lädt die Regeln des Stacks, den er im Repository tatsächlich vorfindet.",
"P2": "Commit-Gates, die bei fehlgeschlagenem Build oder Test blockieren — generierter Code kann die Qualitätsschwelle nicht umgehen.",
"P3": "MCP-Integrationen für Code-Intelligence und lesenden Ticket-Zugriff, dazu Subagenten, die den Diff prüfen."
}
},
"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,10 +361,24 @@
"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": "Eine containerisierte, regelgetriebene Umgebung für die Arbeit mit einem KI-Coding-Agenten.",
"INTRODUCTION": "Mit einem KI-Coding-Agenten zu arbeiten ist leicht angefangen und schwer ehrlich gehalten. Das Ergebnis sieht plausibel aus, also sind die teuren Fehler genau die, die durch das Review kommen. Claude-in-a-Box ist meine Antwort darauf: eine Docker-basierte Entwicklungsumgebung, in der der Agent unter denselben Bedingungen arbeitet wie eine neue Kollegin oder ein neuer Kollege — nach den Architekturregeln des Repositories, in dem er sich befindet, und mit einem Build, der grün bleiben muss, bevor irgendetwas committet werden kann. Das interessante Engineering steckt nicht in den Prompts, sondern im Harness darum herum.",
"BULLET_1": "Containerisierter Workspace mit fixierten Toolchains — über SDKMAN verwaltete JDKs und Maven, Node und ein persistentes Home-Volume, das einen Container-Rebuild überlebt.",
"BULLET_2": "Context-Routing per Erkennung: Ein Repository mit pom.xml lädt die Java/Spring-Regeln, eines mit package.json die TypeScript-Regeln, und ein Repository mit eigenem Architekturprofil lädt zusätzlich dieses.",
"BULLET_3": "Ein Commit-Hook, der den Stack selbst erkennt, das passende Build- und Test-Gate ausführt und den Commit bei Fehlschlag blockiert.",
"BULLET_4": "MCP-Server für Code-Graph-Abfragen und lesenden Ticket-Zugriff, dazu eigens gebaute Review-Subagenten, die einen fertigen Diff prüfen, bevor er als erledigt gemeldet wird.",
"CHALLENGE_1": "Das Regelwerk klein genug für den Context zu halten und trotzdem verbindlich zu machen — allgemeine Ratschläge werden ignoriert, also mussten die Regeln in Stack-Skills und Repository-Profile aufgeteilt werden, die nur bei Bedarf laden.",
"CHALLENGE_2": "Das Quality Gate unumgehbar machen. Die Prüfung läuft als Hook außerhalb des Agenten, sodass ein fehlgeschlagener Build den Commit blockiert — unabhängig davon, was der Agent über seine eigene Arbeit glaubt.",
"CHALLENGE_3": "Toolchain-Isolation in einem Container ohne Root: Builds, Browser und Sprach-Runtimes mussten alle in Volumes installiert werden, die dem Entwickler-User gehören.",
"LEARNING_1": "Die Qualität KI-gestützter Arbeit hängt vor allem am Harness, nicht am Prompt. Deterministische Gates schlagen sorgfältige Formulierungen jedes Mal.",
"LEARNING_2": "Den Agenten als nicht vertrauenswürdigen Mitwirkenden zu behandeln — reviewed, gegated und nur mit dem nötigen Context versorgt — ist dieselbe Disziplin, die auch menschliches Code-Review funktionieren lässt."
}
},
"IMPRINT": {
"TITLE" : "Impressum",
"TITLE": "Impressum",
"PARAGRAPH": "Angaben gemäß § 5 DDG",
"COUNTRY": "Deutschland",
"CONTACT": "Kontakt"
@@ -379,11 +421,11 @@
"ARRAY_SIZE": "Anzahl der Balken",
"EXPLANATION": {
"TITLE": "Algorithmen",
"BUBBLE_SORT_EXPLANATION":"vergleicht wiederholt benachbarte Elemente und tauscht sie, wenn sie in der falschen Reihenfolge stehen. Das größte Element \"blubbert\" dabei wie eine Luftblase ans Ende der Liste. Vorteil: Extrem einfach zu verstehen und zu implementieren; erkennt bereits sortierte Listen sehr schnell. Nachteil: Sehr ineffizient bei großen Listen (Laufzeit O(n²)). In der Praxis kaum genutzt.",
"BUBBLE_SORT_EXPLANATION": "vergleicht wiederholt benachbarte Elemente und tauscht sie, wenn sie in der falschen Reihenfolge stehen. Das größte Element \"blubbert\" dabei wie eine Luftblase ans Ende der Liste. Vorteil: Extrem einfach zu verstehen und zu implementieren; erkennt bereits sortierte Listen sehr schnell. Nachteil: Sehr ineffizient bei großen Listen (Laufzeit O(n²)). In der Praxis kaum genutzt.",
"QUICK_SORT_EXPLANATION": "folgt dem \"Teile und Herrsche\"-Prinzip. Ein \"Pivot\"-Element wird gewählt, und das Array wird in zwei Hälften geteilt: Elemente kleiner als das Pivot und Elemente größer als das Pivot. Vorteil: Im Durchschnitt einer der schnellsten Sortieralgorithmen (O(n log n)); benötigt keinen zusätzlichen Speicher (In-Place). Nachteil: Im schlechtesten Fall (Worst Case) langsam (O(n²)), wenn das Pivot ungünstig gewählt wird. Ist nicht stabil (ändert Reihenfolge gleicher Elemente).",
"HEAP_SORT_EXPLANATION": "organisiert die Daten zunächst in einer speziellen Baumstruktur (Binary Heap). Das größte Element (die Wurzel) wird entnommen und ans Ende sortiert, dann wird der Baum repariert. Vorteil: Garantiert eine schnelle Laufzeit von O(n log n), selbst im schlechtesten Fall. Benötigt fast keinen zusätzlichen Speicher. Nachteil: In der Praxis oft etwas langsamer als Quick Sort, da die Sprünge im Speicher (Heap-Struktur) den CPU-Cache schlechter nutzen.",
"TIMSORT_EXPLANATION": "ist ein hybrider Sortieralgorithmus, der aus Merge Sort und Insertion Sort kombiniert ist. Er unterteilt das Array in kleine 'Runs' und sortiert jeden davon mit Insertion Sort, um sie anschließend schrittweise mit Merge Sort zusammenzuführen. Vorteil: Extrem effizient bei realen Daten, die oft teilweise sortiert sind O(n log n) im schlechtesten und O(n) im besten Fall. Er ist der Standardalgorithmus in Python und Java. Nachteil: Komplexer zu implementieren als ein reiner Algorithmus und benötigt zusätzlichen Speicher für den Merge-Schritt.",
"COCKTAIL_SORT_EXPLANATION" : "(auch Shaker Sort) ist eine Erweiterung des Bubble Sort. Statt nur von links nach rechts zu gehen, wechselt er bei jedem Durchlauf die Richtung und schiebt abwechselnd das größte Element nach rechts und das kleinste nach links. Vorteil: Schneller als Bubble Sort, da kleine Elemente am Ende schneller nach vorne wandern (\"Schildkröten-Problem\" gelöst). Nachteil: Bleibt in der Laufzeitklasse O(n²), also für große Datenmengen ineffizient.",
"COCKTAIL_SORT_EXPLANATION": "(auch Shaker Sort) ist eine Erweiterung des Bubble Sort. Statt nur von links nach rechts zu gehen, wechselt er bei jedem Durchlauf die Richtung und schiebt abwechselnd das größte Element nach rechts und das kleinste nach links. Vorteil: Schneller als Bubble Sort, da kleine Elemente am Ende schneller nach vorne wandern (\"Schildkröten-Problem\" gelöst). Nachteil: Bleibt in der Laufzeitklasse O(n²), also für große Datenmengen ineffizient.",
"DISCLAIMER": "Die Wahl des \"besten\" Sortieralgorithmus hängt stark von den Daten und den Rahmenbedingungen ab. In der Informatik betrachtet man oft drei Szenarien:",
"DISCLAIMER_1": "Best Case: Die Daten sind schon fast sortiert (hier glänzt z.B. Bubble Sort).",
"DISCLAIMER_2": "Average Case: Der statistische Normalfall.",
@@ -405,7 +447,7 @@
"SPEED": "Zeit pro Generation",
"EXPLANATION": {
"TITLE": "Erklärung",
"EXPLANATION" : "Das Spiel läuft schrittweise ab. Zunächst wird eine Anfangsgeneration von lebenden Zellen auf dem Spielfeld definiert. Aus der vorliegenden Generation (dem Gesamtbild des Spielfeldes) wird die Folgegeneration ermittelt. Der Zustand jeder einzelnen Zelle in der Folgegeneration ergibt sich dabei nach einfachen Regeln aus ihrem aktuellen Zustand sowie den aktuellen Zuständen ihrer acht Nachbarzellen (Moore-Nachbarschaft).",
"EXPLANATION": "Das Spiel läuft schrittweise ab. Zunächst wird eine Anfangsgeneration von lebenden Zellen auf dem Spielfeld definiert. Aus der vorliegenden Generation (dem Gesamtbild des Spielfeldes) wird die Folgegeneration ermittelt. Der Zustand jeder einzelnen Zelle in der Folgegeneration ergibt sich dabei nach einfachen Regeln aus ihrem aktuellen Zustand sowie den aktuellen Zuständen ihrer acht Nachbarzellen (Moore-Nachbarschaft).",
"DISCLAIMER": "Nach Conways ursprünglicher Regel lebt eine Zelle in der nächsten Runde, wenn zuvor in ihrer 3x3-Umgebung insgesamt genau drei Zellen leben, wobei sie selbst nur bei Bedarf mitgezählt wird, das heißt:",
"DISCLAIMER_1": "Eine lebende Zelle lebt auch in der Folgegeneration, wenn sie entweder zwei oder drei lebende Nachbarn hat.",
"DISCLAIMER_2": "Eine tote Zelle „wird geboren“ (lebt in der Folgegeneration), wenn sie genau drei lebende Nachbarn hat.",
@@ -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.",
@@ -617,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",

View File

@@ -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, Im 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": "Senior software developer and architect, 15+ years in the field. My work sits across two areas that rarely meet: real-time 3D simulation — cloth physics, collision detection, GPU shaders — and distributed cloud architecture for enterprise platforms. Along the way I have led a development team, planned the migration of a large legacy monolith to microservices, and shipped a game on Steam. More recently I have been working on the engineering side of AI-assisted development: the harnesses, guardrails and quality gates that make generated code reviewable. Outside work I build games and dig into algorithmic problems — most of which end 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 containerised development environment that turns an AI coding agent into a reviewable part of the toolchain. Instead of prompting ad hoc, the box routes the agent through per-stack rules, per-repository architecture profiles and commit hooks that refuse to let a red build through.",
"LINK_INTERNAL": "Project details",
"HIGHLIGHTS": {
"P1": "Deterministic context routing: the agent loads the rules for the stack it actually detects in the repository.",
"P2": "Commit gates that block on a failing build or test run, so generated code cannot bypass the quality bar.",
"P3": "MCP integrations for code intelligence and read-only ticket access, plus subagents that review 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 Im working on and documents their progress, key ideas, and milestones.",
@@ -333,10 +361,24 @@
"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 containerised, rule-driven environment for working with an AI coding agent.",
"INTRODUCTION": "Working with an AI coding agent is easy to start and hard to keep honest. The output looks plausible, so the expensive failures are the ones that pass review. Claude-in-a-Box is my answer to that: a Docker-based development environment where the agent operates under the same constraints a new colleague would — the architecture rules of the repository it is in, and a build that has to stay green before anything can be committed. The interesting engineering is not in the prompts; it is in the harness around them.",
"BULLET_1": "Containerised workspace with pinned toolchains — SDKMAN-managed JDKs and Maven, Node, and a persistent home volume that survives a container rebuild.",
"BULLET_2": "Context routing by detection: a repository with a pom.xml loads the Java/Spring rules, one with a package.json loads the TypeScript rules, and a repository with its own architecture profile loads that too.",
"BULLET_3": "A commit hook that detects the stack itself, runs the matching build and test gate, and blocks the commit when it fails.",
"BULLET_4": "MCP servers for code-graph queries and read-only ticket lookup, plus purpose-built review subagents that grill a finished diff before it is reported as done.",
"CHALLENGE_1": "Keeping the rule set small enough to stay in context but specific enough to be binding — general advice gets ignored, so the rules had to be split into stack skills and repository profiles that load only when relevant.",
"CHALLENGE_2": "Making the quality gate impossible to talk around. The check runs as a hook outside the agent, so a failing build blocks the commit regardless of what the agent believes about its own work.",
"CHALLENGE_3": "Toolchain isolation in a container that has no root: builds, browsers and language runtimes all had to be installed into volumes owned by the development user.",
"LEARNING_1": "The quality of AI-assisted work is mostly a function of the harness, not the prompt. Deterministic gates beat careful wording every time.",
"LEARNING_2": "Treating the agent as an untrusted contributor — reviewed, gated, and given only the context it needs — turns out to be the same discipline that makes human code review work."
}
},
"IMPRINT": {
"TITLE" : "Imprint",
"TITLE": "Imprint",
"PARAGRAPH": "Information pursuant to Section 5 DDG",
"COUNTRY": "Germany",
"CONTACT": "Contact"
@@ -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.",
@@ -617,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",

View 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: Inter, 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">&lt;repo&gt;.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

View File

@@ -44,6 +44,16 @@
@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;