Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0857ce482b | ||
|
|
1cff9e6eb2 | ||
|
|
82228e9141 | ||
|
|
910822028b |
No files matched your search
@@ -42,6 +42,7 @@ export class AssetsConstants {
|
|||||||
];
|
];
|
||||||
|
|
||||||
static readonly CLAUDE_BOX_DIAGRAM = '/assets/projects/claude-in-a-box/1.svg';
|
static readonly CLAUDE_BOX_DIAGRAM = '/assets/projects/claude-in-a-box/1.svg';
|
||||||
|
static readonly PLAYGROUND_DIAGRAM = '/assets/projects/playground/architecture.svg';
|
||||||
static readonly TBP_DIAGRAM = '/assets/projects/to-be-played/architecture.svg';
|
static readonly TBP_DIAGRAM = '/assets/projects/to-be-played/architecture.svg';
|
||||||
static readonly TRIBBLE_DIAGRAM = '/assets/projects/tribble-the-homeserver/architecture.svg';
|
static readonly TRIBBLE_DIAGRAM = '/assets/projects/tribble-the-homeserver/architecture.svg';
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<mat-icon>zoom_out</mat-icon>
|
<mat-icon>zoom_out</mat-icon>
|
||||||
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
|
@if (sliderReady()) {
|
||||||
|
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
|
||||||
|
} @else {
|
||||||
|
<div class="grow h-[60px]"></div>
|
||||||
|
}
|
||||||
<mat-icon>zoom_in</mat-icon>
|
<mat-icon>zoom_in</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, OnInit, signal} from '@angular/core';
|
import {afterNextRender, Component, OnInit, signal} from '@angular/core';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
@@ -39,6 +39,13 @@ import {clampZoom, initialView, iterationsForZoom, zoomAroundPoint} from './frac
|
|||||||
styleUrl: './fractal.component.scss',
|
styleUrl: './fractal.component.scss',
|
||||||
})
|
})
|
||||||
export class FractalComponent implements OnInit {
|
export class FractalComponent implements OnInit {
|
||||||
|
// ngx-slider throws on the server (no layout APIs), so render it in the browser only
|
||||||
|
readonly sliderReady = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
afterNextRender(() => this.sliderReady.set(true));
|
||||||
|
}
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'FRACTAL.EXPLANATION.TITLE',
|
title: 'FRACTAL.EXPLANATION.TITLE',
|
||||||
entries: [
|
entries: [
|
||||||
|
|||||||
@@ -5,39 +5,43 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<app-information [algorithmInformation]="algoInformation"/>
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
<div class="flex flex-col mb-4">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
|
@if (sliderReady()) {
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<ngx-slider class="grow" [(value)]="trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
||||||
</div>
|
<ngx-slider class="grow" [(value)]="trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
||||||
<div class="flex items-center gap-4 mr-4">
|
</div>
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<ngx-slider class="grow" [(value)]="g" [options]="gravityOptions" ></ngx-slider>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
||||||
</div>
|
<ngx-slider class="grow" [(value)]="g" [options]="gravityOptions" ></ngx-slider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
||||||
<ngx-slider class="grow" [(value)]="l1" [options]="lengthOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="l1" [options]="lengthOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
||||||
<ngx-slider class="grow" [(value)]="l2" [options]="lengthOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="l2" [options]="lengthOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
||||||
<ngx-slider class="grow" [(value)]="m1" [options]="massOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="m1" [options]="massOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 mr-4">
|
<div class="flex items-center gap-4 mr-4">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
||||||
<ngx-slider class="grow" [(value)]="m2" [options]="massOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="m2" [options]="massOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mr-4 col-span-full">
|
<div class="flex items-center gap-4 mr-4 col-span-full">
|
||||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
|
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
|
||||||
<ngx-slider class="grow" [(value)]="damping" [options]="dampingOptions" ></ngx-slider>
|
<ngx-slider class="grow" [(value)]="damping" [options]="dampingOptions" ></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="mb-6 h-[200px]"></div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 mb-4">
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
<button mat-raised-button color="primary" (click)="pushPendulum(true)">
|
<button mat-raised-button color="primary" (click)="pushPendulum(true)">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, signal} from '@angular/core';
|
import {afterNextRender, Component, signal} from '@angular/core';
|
||||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
|
||||||
@@ -33,6 +33,13 @@ import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
|
|||||||
})
|
})
|
||||||
class PendulumComponent {
|
class PendulumComponent {
|
||||||
|
|
||||||
|
// ngx-slider throws on the server (no layout APIs), so render it in the browser only
|
||||||
|
readonly sliderReady = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
afterNextRender(() => this.sliderReady.set(true));
|
||||||
|
}
|
||||||
|
|
||||||
algoInformation: AlgorithmInformation = {
|
algoInformation: AlgorithmInformation = {
|
||||||
title: 'PENDULUM.EXPLANATION.TITLE',
|
title: 'PENDULUM.EXPLANATION.TITLE',
|
||||||
entries: [
|
entries: [
|
||||||
|
|||||||
@@ -20,6 +20,20 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (project.decisions?.length) {
|
||||||
|
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10 mb-4">
|
||||||
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
|
<mat-icon class="text-[24px]! w-6! h-6!">alt_route</mat-icon>
|
||||||
|
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.DECISIONS' | translate }}</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||||
|
@for(decision of project.decisions; track decision) {
|
||||||
|
<li class="mb-1">{{ decision | translate }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
|
<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/3 border border-black/5 dark:bg-white/5 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">
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
@@ -45,6 +59,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (project.result) {
|
||||||
|
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10 mb-4">
|
||||||
|
<div class="flex items-center gap-3 mb-3 text-link">
|
||||||
|
<mat-icon class="text-[24px]! w-6! h-6!">flag</mat-icon>
|
||||||
|
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.RESULT' | translate }}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 text-[0.95rem] leading-relaxed opacity-85">{{ project.result | translate }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (project.images.length > 0)
|
@if (project.images.length > 0)
|
||||||
|
|||||||
@@ -69,4 +69,26 @@ describe('ProjectDialogComponent', () => {
|
|||||||
|
|
||||||
expect(fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]')).toBeNull();
|
expect(fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders decisions and result when the project has them', async () => {
|
||||||
|
const fixture = await createDialog({
|
||||||
|
title: 'T', technologies: [], images: [], decisions: ['D1', 'D2'], result: 'R',
|
||||||
|
});
|
||||||
|
|
||||||
|
const text: string = fixture.nativeElement.textContent;
|
||||||
|
for (const expected of ['PROJECTS.SECTION.DECISIONS', 'D1', 'D2', 'PROJECTS.SECTION.RESULT', 'R']) {
|
||||||
|
expect(text).toContain(expected);
|
||||||
|
}
|
||||||
|
const decisions = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('h3'))
|
||||||
|
.find(h => h.textContent?.includes('PROJECTS.SECTION.DECISIONS'));
|
||||||
|
expect(decisions?.closest('div.p-5')?.querySelectorAll('li').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no decisions or result blocks without them', async () => {
|
||||||
|
const fixture = await createDialog({ title: 'T', technologies: [], images: [] });
|
||||||
|
|
||||||
|
const text: string = fixture.nativeElement.textContent;
|
||||||
|
expect(text).not.toContain('PROJECTS.SECTION.DECISIONS');
|
||||||
|
expect(text).not.toContain('PROJECTS.SECTION.RESULT');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -52,6 +52,13 @@ describe('ProjectsComponent', () => {
|
|||||||
...project.bulletPoints,
|
...project.bulletPoints,
|
||||||
...project.challenges,
|
...project.challenges,
|
||||||
...project.learnings,
|
...project.learnings,
|
||||||
|
...(project.decisions ?? []),
|
||||||
|
...(project.result ? [project.result] : []),
|
||||||
|
]).concat([
|
||||||
|
'PROJECTS.SECTION.TECHNICAL',
|
||||||
|
'PROJECTS.SECTION.LEARNINGS',
|
||||||
|
'PROJECTS.SECTION.DECISIONS',
|
||||||
|
'PROJECTS.SECTION.RESULT',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const missing = Object.entries({ en, de }).flatMap(([language, translations]) =>
|
const missing = Object.entries({ en, de }).flatMap(([language, translations]) =>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface Projects {
|
|||||||
bulletPoints: string[],
|
bulletPoints: string[],
|
||||||
challenges: string[],
|
challenges: string[],
|
||||||
learnings: string[],
|
learnings: string[],
|
||||||
|
decisions?: string[],
|
||||||
|
result?: string,
|
||||||
isFeatured: boolean,
|
isFeatured: boolean,
|
||||||
technologies: string[]
|
technologies: string[]
|
||||||
}
|
}
|
||||||
@@ -88,6 +90,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
'PROJECTS.TBP.LEARNING_1',
|
'PROJECTS.TBP.LEARNING_1',
|
||||||
'PROJECTS.TBP.LEARNING_2',
|
'PROJECTS.TBP.LEARNING_2',
|
||||||
],
|
],
|
||||||
|
decisions: [
|
||||||
|
'PROJECTS.TBP.DECISION_1',
|
||||||
|
'PROJECTS.TBP.DECISION_2',
|
||||||
|
'PROJECTS.TBP.DECISION_3',
|
||||||
|
],
|
||||||
|
result: 'PROJECTS.TBP.RESULT',
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker']
|
technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker']
|
||||||
},
|
},
|
||||||
@@ -106,18 +114,28 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
'PROJECTS.CLAUDE_BOX.BULLET_2',
|
'PROJECTS.CLAUDE_BOX.BULLET_2',
|
||||||
'PROJECTS.CLAUDE_BOX.BULLET_3',
|
'PROJECTS.CLAUDE_BOX.BULLET_3',
|
||||||
'PROJECTS.CLAUDE_BOX.BULLET_4',
|
'PROJECTS.CLAUDE_BOX.BULLET_4',
|
||||||
|
'PROJECTS.CLAUDE_BOX.BULLET_5',
|
||||||
|
'PROJECTS.CLAUDE_BOX.BULLET_6',
|
||||||
],
|
],
|
||||||
challenges: [
|
challenges: [
|
||||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_1',
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_1',
|
||||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_2',
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_2',
|
||||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_3',
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_3',
|
||||||
|
'PROJECTS.CLAUDE_BOX.CHALLENGE_4',
|
||||||
],
|
],
|
||||||
learnings: [
|
learnings: [
|
||||||
'PROJECTS.CLAUDE_BOX.LEARNING_1',
|
'PROJECTS.CLAUDE_BOX.LEARNING_1',
|
||||||
'PROJECTS.CLAUDE_BOX.LEARNING_2',
|
'PROJECTS.CLAUDE_BOX.LEARNING_2',
|
||||||
|
'PROJECTS.CLAUDE_BOX.LEARNING_3',
|
||||||
],
|
],
|
||||||
|
decisions: [
|
||||||
|
'PROJECTS.CLAUDE_BOX.DECISION_1',
|
||||||
|
'PROJECTS.CLAUDE_BOX.DECISION_2',
|
||||||
|
'PROJECTS.CLAUDE_BOX.DECISION_3',
|
||||||
|
],
|
||||||
|
result: 'PROJECTS.CLAUDE_BOX.RESULT',
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
technologies: ['Claude Code', 'MCP', 'Docker', 'Bash', 'Node.js']
|
technologies: ['Claude Code', 'MCP', 'Docker', 'Bash', 'Node.js', 'Playwright']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identifier: "playground",
|
identifier: "playground",
|
||||||
@@ -125,6 +143,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
shortDescription: 'PROJECTS.PLAYGROUND.SHORT_DESCRIPTION',
|
shortDescription: 'PROJECTS.PLAYGROUND.SHORT_DESCRIPTION',
|
||||||
introduction: 'PROJECTS.PLAYGROUND.INTRODUCTION',
|
introduction: 'PROJECTS.PLAYGROUND.INTRODUCTION',
|
||||||
images: AssetsConstants.PLAYGROUND_IMAGES.map(url => ({ url, source: '' })),
|
images: AssetsConstants.PLAYGROUND_IMAGES.map(url => ({ url, source: '' })),
|
||||||
|
diagram: AssetsConstants.PLAYGROUND_DIAGRAM,
|
||||||
icon: 'web',
|
icon: 'web',
|
||||||
assets: '',
|
assets: '',
|
||||||
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://andreas-dahm.eu' }],
|
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://andreas-dahm.eu' }],
|
||||||
@@ -134,17 +153,26 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
'PROJECTS.PLAYGROUND.BULLET_3',
|
'PROJECTS.PLAYGROUND.BULLET_3',
|
||||||
'PROJECTS.PLAYGROUND.BULLET_4',
|
'PROJECTS.PLAYGROUND.BULLET_4',
|
||||||
'PROJECTS.PLAYGROUND.BULLET_5',
|
'PROJECTS.PLAYGROUND.BULLET_5',
|
||||||
|
'PROJECTS.PLAYGROUND.BULLET_6',
|
||||||
],
|
],
|
||||||
challenges: [
|
challenges: [
|
||||||
'PROJECTS.PLAYGROUND.CHALLENGE_1',
|
'PROJECTS.PLAYGROUND.CHALLENGE_1',
|
||||||
'PROJECTS.PLAYGROUND.CHALLENGE_2',
|
'PROJECTS.PLAYGROUND.CHALLENGE_2',
|
||||||
|
'PROJECTS.PLAYGROUND.CHALLENGE_3',
|
||||||
],
|
],
|
||||||
learnings: [
|
learnings: [
|
||||||
'PROJECTS.PLAYGROUND.LEARNING_1',
|
'PROJECTS.PLAYGROUND.LEARNING_1',
|
||||||
'PROJECTS.PLAYGROUND.LEARNING_2',
|
'PROJECTS.PLAYGROUND.LEARNING_2',
|
||||||
|
'PROJECTS.PLAYGROUND.LEARNING_3',
|
||||||
],
|
],
|
||||||
|
decisions: [
|
||||||
|
'PROJECTS.PLAYGROUND.DECISION_1',
|
||||||
|
'PROJECTS.PLAYGROUND.DECISION_2',
|
||||||
|
'PROJECTS.PLAYGROUND.DECISION_3',
|
||||||
|
],
|
||||||
|
result: 'PROJECTS.PLAYGROUND.RESULT',
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
technologies: ['Angular', 'TypeScript', 'Tailwind CSS', 'WebGPU', 'GLSL', 'Babylon.js', 'Gitea Actions', 'Docker']
|
technologies: ['Angular', 'TypeScript', 'Tailwind CSS', 'WebGPU', 'GLSL', 'Babylon.js', 'Gitea Actions', 'Docker', 'Traefik', 'nginx', 'Tailscale']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identifier: "elmucho",
|
identifier: "elmucho",
|
||||||
@@ -248,7 +276,6 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
links: [
|
links: [
|
||||||
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
|
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
|
||||||
{ name: 'Docker', url: 'https://www.docker.com/' },
|
{ name: 'Docker', url: 'https://www.docker.com/' },
|
||||||
{ name: 'Portainer', url: 'https://www.portainer.io/' },
|
|
||||||
{ name: 'Traefik', url: 'https://traefik.io/' },
|
{ name: 'Traefik', url: 'https://traefik.io/' },
|
||||||
{ name: 'Gitea', url: 'https://gitea.io/' },
|
{ name: 'Gitea', url: 'https://gitea.io/' },
|
||||||
{ name: 'Jellyfin', url: 'https://jellyfin.org/' },
|
{ name: 'Jellyfin', url: 'https://jellyfin.org/' },
|
||||||
@@ -262,7 +289,6 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
'PROJECTS.TRIBBLE.BULLET_2',
|
'PROJECTS.TRIBBLE.BULLET_2',
|
||||||
'PROJECTS.TRIBBLE.BULLET_3',
|
'PROJECTS.TRIBBLE.BULLET_3',
|
||||||
'PROJECTS.TRIBBLE.BULLET_4',
|
'PROJECTS.TRIBBLE.BULLET_4',
|
||||||
'PROJECTS.TRIBBLE.BULLET_5',
|
|
||||||
],
|
],
|
||||||
challenges: [
|
challenges: [
|
||||||
'PROJECTS.TRIBBLE.CHALLENGE_1',
|
'PROJECTS.TRIBBLE.CHALLENGE_1',
|
||||||
@@ -272,8 +298,14 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
|||||||
'PROJECTS.TRIBBLE.LEARNING_1',
|
'PROJECTS.TRIBBLE.LEARNING_1',
|
||||||
'PROJECTS.TRIBBLE.LEARNING_2',
|
'PROJECTS.TRIBBLE.LEARNING_2',
|
||||||
],
|
],
|
||||||
|
decisions: [
|
||||||
|
'PROJECTS.TRIBBLE.DECISION_1',
|
||||||
|
'PROJECTS.TRIBBLE.DECISION_2',
|
||||||
|
'PROJECTS.TRIBBLE.DECISION_3',
|
||||||
|
],
|
||||||
|
result: 'PROJECTS.TRIBBLE.RESULT',
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
technologies: ['Ubuntu Server', 'Docker', 'Portainer', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale', 'Watchtower']
|
technologies: ['Ubuntu Server', 'Docker', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale', 'Watchtower']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+49
-25
@@ -197,12 +197,12 @@
|
|||||||
},
|
},
|
||||||
"CLAUDE_BOX": {
|
"CLAUDE_BOX": {
|
||||||
"TITLE": "Claude-in-a-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.",
|
"DESCRIPTION": "Eine Entwicklungsumgebung im Container, in der KI-Agenten Code nach festen Regeln schreiben. Eine Pipeline aus kleinen Agenten klärt, plant, implementiert, vereinfacht und reviewt, und ich sage Ja zum Plan und zu jedem Commit. Hooks außerhalb der Agenten blockieren rote Builds und Zugriffe auf Secrets.",
|
||||||
"LINK_INTERNAL": "Projektdetails",
|
"LINK_INTERNAL": "Projektdetails",
|
||||||
"HIGHLIGHTS": {
|
"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.",
|
"P1": "Ein Agent pro Aufgabe, jeder mit frischem Kontext und passendem Modell. Die Übergabe läuft über Dateien, nicht über den Chat.",
|
||||||
"P2": "Ein Commit-Hook führt Build und Tests aus und blockiert den Commit, wenn sie fehlschlagen.",
|
"P2": "Ein Commit-Hook führt Build, Tests und einen Secret-Scan aus und blockiert den Commit, wenn etwas rot ist.",
|
||||||
"P3": "MCP-Server für Codesuche und lesenden Jira-Zugriff, dazu eigene Review-Agenten, die den Diff durchgehen."
|
"P3": "Eine Eval-Suite misst jede Änderung an Regeln oder Agenten an festen Aufgaben mit versteckten Prüfungen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"P2": {
|
"P2": {
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
},
|
},
|
||||||
"TRIBBLE": {
|
"TRIBBLE": {
|
||||||
"TITLE": "Homeserver 'Tribble'",
|
"TITLE": "Homeserver 'Tribble'",
|
||||||
"DESCRIPTION": "Ein Mini-PC zu Hause betreibt Gitea mit Actions-Runner und Registry, dazu Jellyfin, Paperless-ngx und AdGuard Home. Ein VPS liefert die öffentlichen Seiten aus und holt neue Images mit Watchtower über Tailscale. Läuft seit Mai 2024, Backups auf Google Drive.",
|
"DESCRIPTION": "Ein Mini-PC zu Hause betreibt Gitea mit Actions-Runner und Registry, dazu Jellyfin, Paperless-ngx und AdGuard Home. Ein VPS liefert die öffentlichen Seiten aus und holt neue Images mit Watchtower über Tailscale. Läuft seit Mai 2024.",
|
||||||
"LINK_INTERNAL": "Projektdetails",
|
"LINK_INTERNAL": "Projektdetails",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Gitea-CI baut und testet diese Seite und To Be Played zu Hause; Watchtower spielt die Images auf dem VPS ein.",
|
"P1": "Gitea-CI baut und testet diese Seite und To Be Played zu Hause; Watchtower spielt die Images auf dem VPS ein.",
|
||||||
@@ -305,21 +305,30 @@
|
|||||||
"CLOSE": "Schließen",
|
"CLOSE": "Schließen",
|
||||||
"SECTION": {
|
"SECTION": {
|
||||||
"TECHNICAL": "Technische Herausforderungen",
|
"TECHNICAL": "Technische Herausforderungen",
|
||||||
"LEARNINGS": "Learnings & Soft Skills"
|
"LEARNINGS": "Learnings & Soft Skills",
|
||||||
|
"DECISIONS": "Entscheidungen",
|
||||||
|
"RESULT": "Ergebnis"
|
||||||
},
|
},
|
||||||
"PLAYGROUND": {
|
"PLAYGROUND": {
|
||||||
"TITLE": "Playground",
|
"TITLE": "Playground",
|
||||||
"SHORT_DESCRIPTION": "Diese Seite: ein Portfolio mit Algorithmus-Visualisierungen, die im Browser laufen, einige davon auf der GPU.",
|
"SHORT_DESCRIPTION": "Diese Seite: ein Portfolio mit Algorithmus-Visualisierungen, die im Browser laufen, einige davon auf der GPU.",
|
||||||
"INTRODUCTION": "Auf dieser Seite probiere ich Dinge aus. Ich wollte sehen, wie weit ein normaler Browser mit den Algorithmen kommt, die ich mag. Deshalb läuft jede Visualisierung im Client: Pfadfindung, Labyrinth-Erzeugung, Sortieren, Conways Game of Life, Fraktale in GLSL-Shadern und zwei Physiksimulationen, die als Compute Shader auf WebGPU laufen. Der Rest ist eine normale Angular-App mit deutschen und englischen Texten, einem Dark Mode und einer CI-Pipeline, die sie baut, testet und als Docker-Image in meine eigene Registry schiebt.",
|
"INTRODUCTION": "Auf dieser Seite probiere ich Dinge aus. Ich wollte sehen, wie weit ein normaler Browser mit den Algorithmen kommt, die ich mag. Deshalb läuft jede Visualisierung im Client: Pfadfindung, Labyrinth-Erzeugung, Sortieren, Conways Game of Life, Fraktale in GLSL-Shadern und zwei Physiksimulationen, die als Compute Shader auf WebGPU laufen. Der Rest ist eine normale Angular-App mit deutschen und englischen Texten, einem Dark Mode und einer Pipeline, die jeden Push auf main bis auf den Live-Server bringt.",
|
||||||
"BULLET_1": "Pfadfindung mit Dijkstra und A*, Labyrinth-Erzeugung mit Prim und Kruskal, fünf Sortieralgorithmen mit Ton, Conways Game of Life und eine Vier-Farben-Karte.",
|
"BULLET_1": "Pfadfindung mit Dijkstra und A*, Labyrinth-Erzeugung mit Prim und Kruskal, fünf Sortieralgorithmen mit Ton, Conways Game of Life und eine Vier-Farben-Karte.",
|
||||||
"BULLET_2": "2D-Fraktale (Mandelbrot, Julia, Burning Ship, Newton) in einem GLSL-Shader und 3D-Fraktale (Mandelbulb, Mandelbox, Julia-Bulb) per Raymarching, mit Normalen aus dem Distanzfeld.",
|
"BULLET_2": "2D-Fraktale (Mandelbrot, Julia, Burning Ship, Newton) in einem GLSL-Shader und 3D-Fraktale (Mandelbulb, Mandelbox, Julia-Bulb) per Raymarching, mit Normalen aus dem Distanzfeld.",
|
||||||
"BULLET_3": "Ein Doppelpendel und eine Stoffsimulation laufen als Compute Shader auf WebGPU. Ohne WebGPU weichen sie auf die CPU oder WebGL aus und melden das in einer Snackbar.",
|
"BULLET_3": "Ein Doppelpendel und eine Stoffsimulation laufen als Compute Shader auf WebGPU. Ohne WebGPU weichen sie auf die CPU oder WebGL aus und melden das in einer Snackbar.",
|
||||||
"BULLET_4": "Angular mit Material und Tailwind, Signals für den Zustand, Deutsch und Englisch und ein Service Worker (PWA).",
|
"BULLET_4": "Angular mit Material und Tailwind, Signals für den Zustand, Deutsch und Englisch. Jede Route wird beim Build in eine eigene HTML-Datei mit eigenem Titel und eigenen Meta-Tags vorgerendert, damit ein geteilter Link eine richtige Vorschau bekommt. Im Browser übernehmen dann Angular und ein Service Worker (PWA) die statische Seite.",
|
||||||
"BULLET_5": "CI auf Gitea Actions: Lint, i18n-Prüfung, Tests, Produktions-Build und ein Lighthouse-Bericht. Auf main baut sie ein Docker-Image und schiebt es in meine eigene Registry.",
|
"BULLET_5": "Privacy by Design: Die Schriften kommen von der Seite selbst, es gibt keine Cookies, kein Tracking und kein Access-Log auf dem Server. Nur Sprache und Theme liegen im Local Storage des Browsers.",
|
||||||
|
"BULLET_6": "Betrieb: Die CI auf Gitea Actions auf meinem Homeserver Tribble führt Lint, i18n-Prüfung, Tests, den Produktions-Build, eine Prerender-Prüfung und Lighthouse aus. Ein Push auf main baut ein Docker-Image; auf dem VPS holt Watchtower es ab, nginx liefert die statischen Dateien aus und Traefik übernimmt HTTPS. Gitea selbst ist über Traefik und Tailscale erreichbar.",
|
||||||
"CHALLENGE_1": "Der Stoff-Solver auf der GPU. XPBD-Constraints, die sich einen Partikel teilen, lassen sich nicht parallel lösen, ohne eine Race Condition auszulösen. Ich gruppiere die Constraints per Graphfärbung in acht Phasen. So berühren innerhalb einer Phase nie zwei Constraints denselben Partikel, und jede Phase läuft voll parallel.",
|
"CHALLENGE_1": "Der Stoff-Solver auf der GPU. XPBD-Constraints, die sich einen Partikel teilen, lassen sich nicht parallel lösen, ohne eine Race Condition auszulösen. Ich gruppiere die Constraints per Graphfärbung in acht Phasen. So berühren innerhalb einer Phase nie zwei Constraints denselben Partikel, und jede Phase läuft voll parallel.",
|
||||||
"CHALLENGE_2": "Nicht jeder Browser hat WebGPU. Beide Simulationen haben einen CPU- oder WebGL-Pfad und eine Snackbar, die sagt, dass der Fallback aktiv ist. So funktioniert die Seite überall, und niemand fragt sich, warum es langsamer ist.",
|
"CHALLENGE_2": "Nicht jeder Browser hat WebGPU. Beide Simulationen haben einen CPU- oder WebGL-Pfad und eine Snackbar, die sagt, dass der Fallback aktiv ist. So funktioniert die Seite überall, und niemand fragt sich, warum es langsamer ist.",
|
||||||
|
"CHALLENGE_3": "Eine bestehende SPA vorrendern. Beim Build wird jede Seite in Node gerendert, und dort gibt es kein WebGPU, kein Canvas für Babylon.js und keinen localStorage. Browser-Code musste Komponente für Komponente abgesichert werden, bis der Build durchlief. Die Client-Shell index.csr.html bleibt, weil Service Worker und 404-Seite sie weiter brauchen.",
|
||||||
"LEARNING_1": "Compute Shader verändern, wie man über einen Algorithmus denkt. Das Datenlayout und die Reihenfolge der Schreibzugriffe zählen mehr als die Mathematik.",
|
"LEARNING_1": "Compute Shader verändern, wie man über einen Algorithmus denkt. Das Datenlayout und die Reihenfolge der Schreibzugriffe zählen mehr als die Mathematik.",
|
||||||
"LEARNING_2": "Eine kleine CI mit einem Abgleich der Übersetzungsschlüssel und einem Lighthouse-Lauf fängt die langweiligen Fehler ab (eine fehlende Übersetzung, eine langsame Seite), bevor ich sie auf der Live-Seite sehe."
|
"LEARNING_2": "Eine kleine CI mit einem Abgleich der Übersetzungsschlüssel und einem Lighthouse-Lauf fängt die langweiligen Fehler ab (eine fehlende Übersetzung, eine langsame Seite), bevor ich sie auf der Live-Seite sehe.",
|
||||||
|
"LEARNING_3": "Eine Datenschutzseite ist eine Vorgabe für den Server. Sobald dort steht, dass es kein Access-Log und keine Verbindung zu Google gibt, darf nginx kein Log schreiben, und die Schriften müssen mit der Seite ausgeliefert werden. Es zuerst aufzuschreiben hat mir diese Entscheidungen abgenommen.",
|
||||||
|
"DECISION_1": "Vorrendern beim Build statt Client-Rendering oder einem SSR-Server. Jede Route wird beim Build zu einer eigenen HTML-Datei, die nginx als statische Datei ausliefert; auf dem VPS läuft kein Node-Prozess. Der Grund war die Link-Vorschau: Ein geteilter Link braucht Titel und Meta-Tags im HTML, nicht erst, nachdem die App geladen hat. Ein Node-Server wäre für eine Seite, deren Inhalt ohnehin im Browser läuft, zu aufwendig gewesen.",
|
||||||
|
"DECISION_2": "Vitest mit jsdom statt Karma mit Chrome. Karma brauchte einen Headless-Chrome im CI-Container; Vitest läuft ohne Browser. Der Nachteil: jsdom wendet kein echtes CSS an, zwei Tests auf die geladenen Schriften sind dabei gestrichen worden.",
|
||||||
|
"DECISION_3": "Tailwind ohne Preflight statt SCSS-first oder Tailwind komplett. Die Styles sind aus einer großen styles.scss in Utility-Klassen im Template umgezogen, damit ein Style neben dem Markup steht, das er betrifft; SCSS bleibt nur für das Material-Theme und seine Overrides. Tailwinds eigener Reset (Preflight) bleibt draußen, damit der Reset von Angular Material Vorrang behält.",
|
||||||
|
"RESULT": "Die Seite läuft so, wie die Datenschutzseite es verspricht: ohne Cookies, ohne Tracking, ohne Google-Fonts-Request, und der Build schlägt fehl, sobald einer auftaucht. Ein Push auf main ist nach wenigen Minuten live, ohne manuellen Schritt. Geteilte Links zeigen jetzt Titel und Vorschau, und die CI fängt fehlende Übersetzungen und einen fehlenden Titel oder Canonical-Tag ab, bevor ich sie auf der Live-Seite sehe."
|
||||||
},
|
},
|
||||||
"TRIBBLE": {
|
"TRIBBLE": {
|
||||||
"TITLE": "Self-Hosted Infrastructure",
|
"TITLE": "Self-Hosted Infrastructure",
|
||||||
@@ -327,13 +336,16 @@
|
|||||||
"INTRODUCTION": "Zwei Maschinen. Zu Hause läuft auf einem Mini-PC namens „Tribble“ Gitea mit Actions-Runner und Container-Registry, dazu die privaten Dienste. Ein Hostinger-VPS liefert die öffentlichen Seiten aus, auch diese hier. Ein Git-Push baut das Image zu Hause, der VPS holt es mit Watchtower über Tailscale, und Traefik übernimmt TLS und Routing. Das Ganze läuft seit Mai 2024.",
|
"INTRODUCTION": "Zwei Maschinen. Zu Hause läuft auf einem Mini-PC namens „Tribble“ Gitea mit Actions-Runner und Container-Registry, dazu die privaten Dienste. Ein Hostinger-VPS liefert die öffentlichen Seiten aus, auch diese hier. Ein Git-Push baut das Image zu Hause, der VPS holt es mit Watchtower über Tailscale, und Traefik übernimmt TLS und Routing. Das Ganze läuft seit Mai 2024.",
|
||||||
"BULLET_1": "Hardware: ein KODLIX GD50 Mini-PC mit einem 8-Kern-Intel-Core-i5, 32 GB RAM und einer 512-GB-SSD.",
|
"BULLET_1": "Hardware: ein KODLIX GD50 Mini-PC mit einem 8-Kern-Intel-Core-i5, 32 GB RAM und einer 512-GB-SSD.",
|
||||||
"BULLET_2": "Gitea für Git, den Actions-Runner und die Container-Registry. Diese Seite und To Be Played werden dort gebaut und getestet.",
|
"BULLET_2": "Gitea für Git, den Actions-Runner und die Container-Registry. Diese Seite und To Be Played werden dort gebaut und getestet.",
|
||||||
"BULLET_3": "Private Dienste: Jellyfin für Medien, Paperless-ngx für Dokumente, AdGuard Home als DNS-Server und Werbeblocker, Portainer, um die Container im Blick zu haben, und Traefik davor.",
|
"BULLET_3": "Private Dienste: Jellyfin für Medien, Paperless-ngx für Dokumente, AdGuard Home als DNS-Server und Werbeblocker, Traefik davor.",
|
||||||
"BULLET_4": "Tailscale verbindet die beiden Maschinen. Darüber holt der VPS neue Images aus der Registry zu Hause, und Watchtower tauscht den laufenden Container aus.",
|
"BULLET_4": "Tailscale verbindet die beiden Maschinen. Darüber holt der VPS neue Images aus der Registry zu Hause, und Watchtower tauscht den laufenden Container aus.",
|
||||||
"BULLET_5": "Die Backups gehen auf Google Drive.",
|
|
||||||
"CHALLENGE_1": "DNS. Namen müssen von zu Hause aus, über Tailscale und aus dem öffentlichen Internet auf die richtige Adresse zeigen, und AdGuard Home sitzt mittendrin. Das richtig hinzubekommen hat länger gedauert als jeder einzelne Dienst.",
|
"CHALLENGE_1": "DNS. Namen müssen von zu Hause aus, über Tailscale und aus dem öffentlichen Internet auf die richtige Adresse zeigen, und AdGuard Home sitzt mittendrin. Das richtig hinzubekommen hat länger gedauert als jeder einzelne Dienst.",
|
||||||
"CHALLENGE_2": "Alles gleichzeitig zum Laufen zu bringen und es dann wiederholbar zu machen. Das erste Setup war eine lange Liste von Handgriffen. Ich habe es automatisiert, damit sich der Server neu aufsetzen lässt, ohne dass ich mir jeden Schritt merken muss.",
|
"CHALLENGE_2": "Alles gleichzeitig zum Laufen zu bringen und es dann wiederholbar zu machen. Das erste Setup war eine lange Liste von Handgriffen. Ich habe es automatisiert, damit sich der Server neu aufsetzen lässt, ohne dass ich mir jeden Schritt merken muss.",
|
||||||
"LEARNING_1": "Selbst hosten kostet wenig Geld und viel Zeit. Die meiste Zeit geht in die Teile, die niemand sieht: DNS, Zertifikate und Backups.",
|
"LEARNING_1": "Selbst hosten kostet wenig Geld und viel Zeit. Die meiste Zeit geht in die Teile, die niemand sieht: DNS, Zertifikate und Updates.",
|
||||||
"LEARNING_2": "Ein Git-Push, der ohne Handgriff dazwischen in einem laufenden Container auf dem VPS endet, ist der Teil dieses Setups, den ich nicht mehr hergeben würde."
|
"LEARNING_2": "Ein Git-Push, der ohne Handgriff dazwischen in einem laufenden Container auf dem VPS endet, ist der Teil dieses Setups, den ich nicht mehr hergeben würde.",
|
||||||
|
"DECISION_1": "Der VPS ist der einzige öffentliche Eingang. Traefik auf dem VPS leitet die Namen, die zu Hause gehostet werden, über Tailscale an den Heimserver weiter, mit ein paar YAML-Dateien im File-Provider. Die Alternative wäre eine Portweiterleitung im Router gewesen. So bleibt zu Hause kein Port offen, und die Adresse des Heimservers bleibt privat.",
|
||||||
|
"DECISION_2": "Auf dem VPS zieht Watchtower, zu Hause bleiben die Tags gepinnt. Der VPS schaut jede Minute nach neuen Images und tauscht nur die markierten Container aus, einen nach dem anderen; die CI braucht dafür keinen Zugang zum VPS. Der Heimserver bekommt kein „latest“: Dort ist jedes Image-Tag festgeschrieben, und ein Update ist ein manueller Schritt pro Stack, weil mit „latest“ eine Datenbankmigration als Nebenwirkung passiert, und die lässt sich nicht zurücknehmen.",
|
||||||
|
"DECISION_3": "Neu aufsetzen per Skript statt aus dem Gedächtnis. bootstrap.sh legt das Proxy-Netz und die Verzeichnisse an und warnt vor dem DNS-Port-Konflikt zwischen systemd-resolved und AdGuard; rebuild.sh startet die Stacks in fester Reihenfolge, Traefik zuerst, weil es alphabetisch zuletzt käme. Ansible hätte bei sechs Compose-Stacks mehr Aufwand gekostet, als es spart. Zwei Shell-Skripte reichen und sind leichter zu lesen. Die Skripte decken die Infrastruktur ab, nicht die Daten.",
|
||||||
|
"RESULT": "Heute laufen zu Hause sechs Stacks: Traefik, AdGuard Home, Gitea mit Runner, Jellyfin, Paperless-ngx und Leantime; Nextcloud ist wieder abgeschaltet. Auf dem VPS laufen Traefik, Watchtower und die öffentlichen Seiten. Jeder Push dieser Seite und von To Be Played wird zu Hause gebaut, in die Registry zu Hause geschoben und kommt ohne manuellen Schritt auf den VPS. Im September habe ich alle Stacks in einem Durchgang aktualisiert, Stack für Stack mit den gepinnten Tags, ohne dass ein Dienst ausgefallen ist."
|
||||||
},
|
},
|
||||||
"EL_MUCHO": {
|
"EL_MUCHO": {
|
||||||
"TITLE": "El Mucho (Steam Release)",
|
"TITLE": "El Mucho (Steam Release)",
|
||||||
@@ -380,17 +392,25 @@
|
|||||||
},
|
},
|
||||||
"CLAUDE_BOX": {
|
"CLAUDE_BOX": {
|
||||||
"TITLE": "Claude-in-a-Box",
|
"TITLE": "Claude-in-a-Box",
|
||||||
"SHORT_DESCRIPTION": "Ein Container-Setup, um mit einem KI-Coding-Agenten nach einem festen Regelsatz zu arbeiten.",
|
"SHORT_DESCRIPTION": "Ein Container-Setup für einen KI-Coding-Agenten: eine Pipeline aus kleinen Agenten mit je einer Aufgabe und Hooks, an denen keiner von ihnen vorbeikommt.",
|
||||||
"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.",
|
"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. Inzwischen ist aus der Box eine Pipeline geworden. Das Klären passiert im Chat, danach gibt es einen kleinen Agenten pro Aufgabe, jeder mit frischem Kontext: Einer plant, einer implementiert, einer vereinfacht, einer reviewt. Die Übergabe läuft über Dateien, nicht über den Chat, und ich sage an zwei Stellen Ja: zum Plan und zu jedem Commit. Die Hooks um das Ganze herum sind immer noch der Teil, auf den ich am häufigsten zurückkomme, weil sie das Einzige sind, woran sich kein Agent vorbeireden kann.",
|
||||||
"BULLET_1": "Container mit festen Toolchains: JDKs und Maven über SDKMAN, Node und ein Home-Volume, das einen Rebuild übersteht.",
|
"BULLET_1": "Agenten-Pipeline: Klären (die Hauptsession stellt gezielte Fragen und schreibt eine Spec), Planen, Implementieren, Vereinfachen, ein Security-Check, wenn die Änderung etwas Sicherheitsrelevantes berührt, Review, Commit-Message. Jeder Schritt nach dem Klären ist ein eigener Agent mit frischem Kontext und einem Modell, das zur Aufgabe passt: Fable für Klären, Planen und Review, Sonnet fürs Implementieren, Opus für Vereinfachen und Security, Haiku für die Commit-Message. Sind weniger als 20 % des wöchentlichen Fable-Budgets übrig, wechseln diese Schritte von selbst auf Opus, und die Statusleiste zeigt es an.",
|
||||||
"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_2": "Ich bleibe an zwei Stellen im Spiel: Der Plan braucht mein OK, bevor jemand Code schreibt, und jeder Commit braucht mein Ja. Der Reviewer ist kritisch und kennt den Chat nicht: Er lässt das Gate laufen, schreibt für jeden Fund einen fehlschlagenden Test und prüft, ob das Feature funktioniert, wenn nötig im Browser. Funde gehen zurück an den Implementierer, höchstens zwei Runden. Ein Agent, der denselben Fehler zweimal nicht beheben kann, hört auf und meldet RED, und ich entscheide. Kleine Änderungen überspringen Plan und Review.",
|
||||||
"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_3": "Die Regeln werden per Erkennung geladen. Ein Repo mit pom.xml bekommt die Java- und Spring-Regeln, eines mit package.json die TypeScript-Regeln, ein Unity-Projekt die datenorientierten Regeln, und Repos mit eigenen Architekturnotizen bekommen diese zusätzlich. Jeder Agent liest nur die Dokumente, die seine Aufgabe braucht, damit der Kontext klein bleibt.",
|
||||||
"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.",
|
"BULLET_4": "Hooks außerhalb der Agenten: ein Compile- oder Type-Check am Ende jedes Turns, ein Commit-Gate, das den vollen Build, die Tests und einen Secret-Scan ausführt und einen roten Commit verweigert, ein Guard, der das Lesen von .env-Dateien und Keys blockiert, und ein Work-Mode-Guard. Drumherum ein Container ohne Root mit festen Toolchains und MCP-Servern für Code-Graph-Abfragen, Gitea und lesendes Jira.",
|
||||||
"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.",
|
"BULLET_5": "Work-Mode assist, pro Chat: Ich schreibe den Kern (Domain, Services, Persistenz), die Agenten schreiben Gerüst, Boilerplate und Tests und reviewen meinen Code. Ein Hook verweigert den Agenten das Schreiben auf die Kernpfade, die Trennung wird also erzwungen und nicht nur erbeten.",
|
||||||
"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.",
|
"BULLET_6": "Eine Eval-Suite lässt die ganze Pipeline headless gegen feste Aufgaben laufen, Fallen eingeschlossen, jede mit versteckten Prüfungen. Die Ergebnisse werden pro Commit der Box protokolliert, eine Änderung an einer Regel oder einem Agenten wird also gemessen statt geraten.",
|
||||||
"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.",
|
"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, pro Repo und pro Aufgabe aufgeteilt, und jeder Agent lädt nur die, die für ihn gelten.",
|
||||||
|
"CHALLENGE_2": "Die Gates so zu bauen, dass kein Agent sich daran vorbeireden kann. Jede Prüfung läuft als Hook außerhalb des Agenten: Ein fehlgeschlagener Build blockiert den Commit, ein Lesezugriff auf Secrets wird verweigert, ein Schreibzugriff auf einen Kernpfad im Assist-Modus abgelehnt, unabhängig davon, was der Agent über seine eigene Arbeit denkt.",
|
||||||
|
"CHALLENGE_3": "Ein Reviewer, der zugesehen hat, wie der Code entstanden ist, winkt ihn durch. Also startet jeder Agent mit leerem Kontext und bekommt nur die Dateien, die seine Aufgabe braucht: Der Reviewer sieht Spec, Plan und Diff, nie den Chat. Der Preis ist, dass jeder Agent den Code neu liest, die Pipeline ist also langsamer als ein langer Chat.",
|
||||||
|
"CHALLENGE_4": "Zu wissen, ob eine Änderung an der Box etwas bringt. Jede angepasste Regel fühlte sich wie eine Verbesserung an, also habe ich die Eval-Suite gebaut: feste Aufgaben mit versteckten Prüfungen, vor und nach einer Änderung mit denselben Modellen ausgeführt. Die Suite prüft auch sich selbst: Die unveränderte Vorlage muss an jeder Aufgabe scheitern und die Referenzlösung muss bestehen, sonst sagt ein grüner Lauf nichts aus.",
|
||||||
"LEARNING_1": "Die Formulierung der Prompts war deutlich weniger wichtig, als ich dachte. Fast der ganze Unterschied kam vom Drumherum.",
|
"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."
|
"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.",
|
||||||
|
"LEARNING_3": "Kleine Agenten mit jeweils frischem Kontext schlagen einen langen Chat. Nicht weil das Modell klüger wird, sondern weil niemand frühere Fehler mitschleppt und jede Übergabe eine Datei ist, die ich lesen und korrigieren kann.",
|
||||||
|
"DECISION_1": "Hooks statt Berechtigungsregeln. Claude Code kann Werkzeuge per Regel sperren, aber eine Regel gilt pro Werkzeug: Ein Leseverbot für .env-Dateien hält „cat .env“ in der Shell nicht auf. Deshalb sind der Secret-Guard, das Commit-Gate und der Type-Check Hooks, die jeden Aufruf sehen. Und seit ein Timeout im Hook einmal Commits ungeprüft durchgelassen hat, schließt das Gate bei jedem Fehler: Keine Antwort heißt rot.",
|
||||||
|
"DECISION_2": "Der Docker-Socket des Hosts statt eines eigenen Docker-Daemons im Container. Damit laufen die Testcontainers-Tests der Java-Repos im Commit-Gate, statt übersprungen zu werden. Der Nachteil steht im README: Wer den Socket hat, ist auf dem Host root. Die Box ist also eine Arbeitsumgebung mit festen Werkzeugen, keine Sandbox.",
|
||||||
|
"DECISION_3": "Ein zweites Modell statt nur eines zweiten Kontexts. Anfangs lief alles auf einem Modell, das zweite wurde nie benutzt. Jetzt ist das Modell, das plant und reviewt, nicht das, das implementiert, weil eine zweite Meinung mehr wert ist, wenn sie nicht aus demselben Modell kommt. Für einen Ein-Datei-Fix lohnt der Wechsel nicht, deshalb überspringen kleine Änderungen einzelne Schritte der Pipeline.",
|
||||||
|
"RESULT": "Die Box ist seit Juli 2026 mein tägliches Werkzeug, in jedem Repo, an dem ich arbeite, im Job und zu Hause. Der Build mit echten Testcontainers läuft im Commit-Gate. Die Pipeline besteht jede Aufgabe der Eval-Suite, während der unveränderte Ausgangszustand an jeder scheitert. Das zeigt vor allem, dass die Aufgaben noch zu leicht sind; härtere sind der nächste Schritt."
|
||||||
},
|
},
|
||||||
"TBP": {
|
"TBP": {
|
||||||
"TITLE": "To Be Played",
|
"TITLE": "To Be Played",
|
||||||
@@ -406,7 +426,11 @@
|
|||||||
"CHALLENGE_3": "Ein Spiel vorzuschlagen ist schwerer, als es klingt. Jede Vorschlagsreihe braucht eigene Regeln, und die Listen hinter jeder Zahl müssen dieselben Regeln nutzen wie die Zahl selbst, damit ein Spiel überall gleich zählt.",
|
"CHALLENGE_3": "Ein Spiel vorzuschlagen ist schwerer, als es klingt. Jede Vorschlagsreihe braucht eigene Regeln, und die Listen hinter jeder Zahl müssen dieselben Regeln nutzen wie die Zahl selbst, damit ein Spiel überall gleich zählt.",
|
||||||
"CHALLENGE_4": "Missbrauch beim Login. Zu viele fehlgeschlagene Logins bekommen die Antwort 429 mit Retry-After. Auch bei einer unbekannten E-Mail läuft eine volle BCrypt-Prüfung, sodass die Antwortzeit nichts verrät. Ein Header-Schutz blockiert CSRF, CORS erlaubt nur exakte Origins, und das PlayStation-Token wird verschlüsselt gespeichert.",
|
"CHALLENGE_4": "Missbrauch beim Login. Zu viele fehlgeschlagene Logins bekommen die Antwort 429 mit Retry-After. Auch bei einer unbekannten E-Mail läuft eine volle BCrypt-Prüfung, sodass die Antwortzeit nichts verrät. Ein Header-Schutz blockiert CSRF, CORS erlaubt nur exakte Origins, und das PlayStation-Token wird verschlüsselt gespeichert.",
|
||||||
"LEARNING_1": "Die Aufteilung in Dienste lohnt sich nur mit einem klaren Vertrag. Die Token-Claims stehen in einer Vertragsdatei, gegen die beide Dienste testen, und ein Skript, das ich von Hand starte, prüft, dass beide Kopien gleich sind.",
|
"LEARNING_1": "Die Aufteilung in Dienste lohnt sich nur mit einem klaren Vertrag. Die Token-Claims stehen in einer Vertragsdatei, gegen die beide Dienste testen, und ein Skript, das ich von Hand starte, prüft, dass beide Kopien gleich sind.",
|
||||||
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: tägliche Datenbank-Dumps, die sieben Tage, vier Wochen und sechs Monate aufbewahrt werden, Migrationen und eine schriftlich festgehaltene Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature."
|
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: tägliche Datenbank-Dumps, die sieben Tage, vier Wochen und sechs Monate aufbewahrt werden, Migrationen und eine schriftlich festgehaltene Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature.",
|
||||||
|
"DECISION_1": "Ein Schlüsselpaar statt eines gemeinsamen Geheimnisses. Die erste Version hat die Tokens mit einem HMAC-Secret signiert, das Auth und Core beide kannten. Jetzt hält Auth den privaten Ed25519-Schlüssel und veröffentlicht den öffentlichen über einen JWKS-Endpunkt. Core kann einen Login prüfen, aber nie selbst ausstellen, und wenn ein Token eine unbekannte Key-ID trägt, holt Core sich den neuen Schlüssel selbst.",
|
||||||
|
"DECISION_2": "Mails über eine Outbox-Tabelle statt direkt per SMTP. Jede Mail wird in der Transaktion ihres Ereignisses als Zeile in mail_outbox geschrieben, ein Job verschickt die Zeilen jede Minute. So scheitert eine Registrierung nie am Mailserver, und beim Neustart von Auth geht keine Mail verloren. Mit einer Auth-Instanz reicht „mindestens einmal“.",
|
||||||
|
"DECISION_3": "Installierbar, aber ohne Service Worker. Die App hat ein Web-App-Manifest und lässt sich auf Handy und Desktop installieren, aber keinen Service Worker. So trifft ein Deploy nie auf ein altes, gecachtes Bundle. Der Nachteil ist ein fehlender Offline-Modus.",
|
||||||
|
"RESULT": "Heute laufen Auth, Core und das Frontend als eigene Container neben dieser Seite, mit einer PostgreSQL-Instanz mit zwei Datenbanken, nächtlichem Sync und täglichen Dumps. Registrieren kann sich jeder mit bestätigter E-Mail, freigeschaltet wird von Hand. Ich nutze es täglich: Was ich als Nächstes spiele, kommt jetzt aus einer Liste, nicht mehr aus dem Scrollen durch die Steam-Bibliothek."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IMPRINT": {
|
"IMPRINT": {
|
||||||
|
|||||||
+49
-25
@@ -197,12 +197,12 @@
|
|||||||
},
|
},
|
||||||
"CLAUDE_BOX": {
|
"CLAUDE_BOX": {
|
||||||
"TITLE": "Claude-in-a-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.",
|
"DESCRIPTION": "A development setup in a container where AI agents write code under fixed rules. A pipeline of small agents clarifies, plans, implements, simplifies and reviews, and I say yes to the plan and to every commit. Hooks outside the agents block red builds and reads of secrets.",
|
||||||
"LINK_INTERNAL": "Project details",
|
"LINK_INTERNAL": "Project details",
|
||||||
"HIGHLIGHTS": {
|
"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.",
|
"P1": "One agent per job, each with a fresh context and a model that fits the job. Hand-over runs through files, not through the chat.",
|
||||||
"P2": "A commit hook runs the build and the tests and blocks the commit when they fail.",
|
"P2": "A commit hook runs the build, the tests and a secret scan and blocks the commit when anything is red.",
|
||||||
"P3": "MCP servers for code search and read-only Jira access, plus separate review agents that go over the diff."
|
"P3": "An eval suite measures every change to rules or agents against fixed tasks with hidden checks."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"P2": {
|
"P2": {
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
},
|
},
|
||||||
"TRIBBLE": {
|
"TRIBBLE": {
|
||||||
"TITLE": "Homeserver 'Tribble'",
|
"TITLE": "Homeserver 'Tribble'",
|
||||||
"DESCRIPTION": "A mini PC at home runs Gitea with its Actions runner and registry, plus Jellyfin, Paperless-ngx and AdGuard Home. A VPS serves the public sites and pulls new images over Tailscale with Watchtower. Running since May 2024, backups to Google Drive.",
|
"DESCRIPTION": "A mini PC at home runs Gitea with its Actions runner and registry, plus Jellyfin, Paperless-ngx and AdGuard Home. A VPS serves the public sites and pulls new images over Tailscale with Watchtower. Running since May 2024.",
|
||||||
"LINK_INTERNAL": "Project details",
|
"LINK_INTERNAL": "Project details",
|
||||||
"HIGHLIGHTS": {
|
"HIGHLIGHTS": {
|
||||||
"P1": "Gitea CI builds and tests this site and To Be Played at home; Watchtower deploys the images on the VPS.",
|
"P1": "Gitea CI builds and tests this site and To Be Played at home; Watchtower deploys the images on the VPS.",
|
||||||
@@ -305,21 +305,30 @@
|
|||||||
"CLOSE": "Close",
|
"CLOSE": "Close",
|
||||||
"SECTION": {
|
"SECTION": {
|
||||||
"TECHNICAL": "Technical Challenges",
|
"TECHNICAL": "Technical Challenges",
|
||||||
"LEARNINGS": "Learnings & Soft Skills"
|
"LEARNINGS": "Learnings & Soft Skills",
|
||||||
|
"DECISIONS": "Decisions",
|
||||||
|
"RESULT": "Result"
|
||||||
},
|
},
|
||||||
"PLAYGROUND": {
|
"PLAYGROUND": {
|
||||||
"TITLE": "Playground",
|
"TITLE": "Playground",
|
||||||
"SHORT_DESCRIPTION": "This site: a portfolio with algorithm visualizations that run in the browser, some of them on the GPU.",
|
"SHORT_DESCRIPTION": "This site: a portfolio with algorithm visualizations that run in the browser, some of them on the GPU.",
|
||||||
"INTRODUCTION": "This site is where I try things out. I wanted to see how far a plain browser gets with the algorithms I like, so every visualization runs on the client: pathfinding, maze generation, sorting, Conway's Game of Life, fractals in GLSL shaders, and two physics simulations that run as compute shaders on WebGPU. The rest is a normal Angular app with German and English texts, a dark mode, and a CI pipeline that builds and tests it and pushes it as a Docker image to my own registry.",
|
"INTRODUCTION": "This site is where I try things out. I wanted to see how far a plain browser gets with the algorithms I like, so every visualization runs on the client: pathfinding, maze generation, sorting, Conway's Game of Life, fractals in GLSL shaders, and two physics simulations that run as compute shaders on WebGPU. The rest is a normal Angular app with German and English texts, a dark mode, and a pipeline that takes every push to main all the way to the live server.",
|
||||||
"BULLET_1": "Pathfinding with Dijkstra and A*, maze generation with Prim and Kruskal, five sorting algorithms with sound, Conway's Game of Life and a four-color map.",
|
"BULLET_1": "Pathfinding with Dijkstra and A*, maze generation with Prim and Kruskal, five sorting algorithms with sound, Conway's Game of Life and a four-color map.",
|
||||||
"BULLET_2": "2D fractals (Mandelbrot, Julia, Burning Ship, Newton) in one GLSL shader, and 3D fractals (Mandelbulb, Mandelbox, Julia-Bulb) by raymarching with normals from the distance field.",
|
"BULLET_2": "2D fractals (Mandelbrot, Julia, Burning Ship, Newton) in one GLSL shader, and 3D fractals (Mandelbulb, Mandelbox, Julia-Bulb) by raymarching with normals from the distance field.",
|
||||||
"BULLET_3": "A double pendulum and a cloth simulation run as compute shaders on WebGPU. Without WebGPU they fall back to the CPU or WebGL and say so in a snackbar.",
|
"BULLET_3": "A double pendulum and a cloth simulation run as compute shaders on WebGPU. Without WebGPU they fall back to the CPU or WebGL and say so in a snackbar.",
|
||||||
"BULLET_4": "Angular with Material and Tailwind, signals for state, German and English, and a service worker (PWA).",
|
"BULLET_4": "Angular with Material and Tailwind, signals for state, German and English. Every route is prerendered at build time into its own HTML with its own title and meta tags, so a shared link gets a proper preview. In the browser, Angular and a service worker (PWA) take over from the static page.",
|
||||||
"BULLET_5": "CI on Gitea Actions: lint, i18n check, tests, production build and a Lighthouse report. On main it builds a Docker image and pushes it to my own registry.",
|
"BULLET_5": "Privacy by design: the fonts come from the site itself, there are no cookies, no tracking and no access log on the server. Only language and theme are kept in the browser's local storage.",
|
||||||
|
"BULLET_6": "Operations: CI on Gitea Actions on my home server Tribble runs lint, i18n check, tests, the production build, a prerender check and Lighthouse. A push to main builds a Docker image; on the VPS, Watchtower pulls it, nginx serves the static files and Traefik handles HTTPS. Gitea itself is reached through Traefik over Tailscale.",
|
||||||
"CHALLENGE_1": "The cloth solver on the GPU. XPBD constraints that share a particle cannot be solved in parallel without a race. I group the constraints by graph coloring into eight phases, so inside one phase no two constraints touch the same particle and each phase runs fully in parallel.",
|
"CHALLENGE_1": "The cloth solver on the GPU. XPBD constraints that share a particle cannot be solved in parallel without a race. I group the constraints by graph coloring into eight phases, so inside one phase no two constraints touch the same particle and each phase runs fully in parallel.",
|
||||||
"CHALLENGE_2": "Not every browser has WebGPU. Both simulations have a CPU or WebGL path and a snackbar that says the fallback is active, so the page works everywhere and nobody wonders why it is slower.",
|
"CHALLENGE_2": "Not every browser has WebGPU. Both simulations have a CPU or WebGL path and a snackbar that says the fallback is active, so the page works everywhere and nobody wonders why it is slower.",
|
||||||
|
"CHALLENGE_3": "Prerendering a SPA that already existed. At build time every page is rendered in Node, and there is no WebGPU, no canvas for Babylon.js and no localStorage there. Browser-only code had to be guarded component by component until the build ran through. The client-side shell index.csr.html stays, because the service worker and the 404 page still need it.",
|
||||||
"LEARNING_1": "Compute shaders change how you think about an algorithm. The data layout and the order of writes matter more than the math.",
|
"LEARNING_1": "Compute shaders change how you think about an algorithm. The data layout and the order of writes matter more than the math.",
|
||||||
"LEARNING_2": "A small CI with a key-parity check and a Lighthouse run catches the boring mistakes, a missing translation or a slow page, before I see them on the live site."
|
"LEARNING_2": "A small CI with a key-parity check and a Lighthouse run catches the boring mistakes, a missing translation or a slow page, before I see them on the live site.",
|
||||||
|
"LEARNING_3": "A privacy page is a spec for the server. Once it says there is no access log and no connection to Google, nginx must keep no log and the fonts must ship with the site. Writing it down first made those decisions for me.",
|
||||||
|
"DECISION_1": "Prerender at build time instead of client rendering or an SSR server. Every route becomes its own HTML file at build time, and nginx serves it as a static file; no Node process runs on the VPS. The reason was the link preview: a shared link needs title and meta tags in the HTML, not only after the app has loaded. A Node server would have been too much effort for a site whose content runs in the browser anyway.",
|
||||||
|
"DECISION_2": "Vitest with jsdom instead of Karma with Chrome. Karma needed a headless Chrome in the CI container; Vitest runs with no browser. The downside: jsdom applies no real CSS, so two tests on the loaded fonts were dropped.",
|
||||||
|
"DECISION_3": "Tailwind without preflight instead of SCSS-first or all of Tailwind. The styles moved from one big styles.scss to utility classes in the templates, so a style sits next to the markup it affects; SCSS stays only for the Material theme and its overrides. Tailwind's own reset (preflight) stays out, so the reset of Angular Material keeps priority.",
|
||||||
|
"RESULT": "The site runs the way the privacy page promises: no cookies, no tracking, no Google Fonts request, and the build fails as soon as one appears. A push to main is live within a few minutes, with no manual step. Shared links now show a title and a preview, and CI catches missing translations and a missing title or canonical tag before I see them on the live site."
|
||||||
},
|
},
|
||||||
"TRIBBLE": {
|
"TRIBBLE": {
|
||||||
"TITLE": "Self-Hosted Infrastructure",
|
"TITLE": "Self-Hosted Infrastructure",
|
||||||
@@ -327,13 +336,16 @@
|
|||||||
"INTRODUCTION": "Two machines. At home a mini PC called \"Tribble\" runs Gitea with its Actions runner and container registry, plus the private services. A Hostinger VPS serves the public sites, this one included. A git push builds the image at home, the VPS pulls it over Tailscale with Watchtower, and Traefik does TLS and routing. It has been running since May 2024.",
|
"INTRODUCTION": "Two machines. At home a mini PC called \"Tribble\" runs Gitea with its Actions runner and container registry, plus the private services. A Hostinger VPS serves the public sites, this one included. A git push builds the image at home, the VPS pulls it over Tailscale with Watchtower, and Traefik does TLS and routing. It has been running since May 2024.",
|
||||||
"BULLET_1": "Hardware: a KODLIX GD50 mini PC with an 8-core Intel Core i5, 32 GB RAM and a 512 GB SSD.",
|
"BULLET_1": "Hardware: a KODLIX GD50 mini PC with an 8-core Intel Core i5, 32 GB RAM and a 512 GB SSD.",
|
||||||
"BULLET_2": "Gitea for git, the Actions runner and the container registry. This site and To Be Played are built and tested there.",
|
"BULLET_2": "Gitea for git, the Actions runner and the container registry. This site and To Be Played are built and tested there.",
|
||||||
"BULLET_3": "Private services: Jellyfin for media, Paperless-ngx for documents, AdGuard Home for DNS and ad blocking, Portainer to look at the containers, Traefik in front.",
|
"BULLET_3": "Private services: Jellyfin for media, Paperless-ngx for documents, AdGuard Home for DNS and ad blocking, Traefik in front.",
|
||||||
"BULLET_4": "Tailscale links the two machines. The VPS fetches new images from the registry at home over it, and Watchtower swaps the running container.",
|
"BULLET_4": "Tailscale links the two machines. The VPS fetches new images from the registry at home over it, and Watchtower swaps the running container.",
|
||||||
"BULLET_5": "Backups go to Google Drive.",
|
|
||||||
"CHALLENGE_1": "DNS. Names have to resolve to the right address from inside the house, over Tailscale and from the public internet, and AdGuard Home sits in the middle of it. Getting that right took longer than any single service.",
|
"CHALLENGE_1": "DNS. Names have to resolve to the right address from inside the house, over Tailscale and from the public internet, and AdGuard Home sits in the middle of it. Getting that right took longer than any single service.",
|
||||||
"CHALLENGE_2": "Getting everything to run at the same time, and then making it repeatable. The first setup was a long list of manual steps. I automated it so the server can be rebuilt without me remembering each one.",
|
"CHALLENGE_2": "Getting everything to run at the same time, and then making it repeatable. The first setup was a long list of manual steps. I automated it so the server can be rebuilt without me remembering each one.",
|
||||||
"LEARNING_1": "Self-hosting is cheap in money and expensive in time. Most of the time goes into the parts nobody sees: DNS, certificates and backups.",
|
"LEARNING_1": "Self-hosting is cheap in money and expensive in time. Most of the time goes into the parts nobody sees: DNS, certificates and updates.",
|
||||||
"LEARNING_2": "A git push that ends in a running container on the VPS, with no manual step in between, is the one part of this setup I would not give up."
|
"LEARNING_2": "A git push that ends in a running container on the VPS, with no manual step in between, is the one part of this setup I would not give up.",
|
||||||
|
"DECISION_1": "The VPS is the only public entrance. Traefik on the VPS forwards the names hosted at home over Tailscale to the home server, with a few YAML files in its file provider. The alternative would have been a port forward on the home router. This way no port is open at home, and the home server's address stays private.",
|
||||||
|
"DECISION_2": "Watchtower pulls on the VPS, tags stay pinned at home. The VPS checks for new images every minute and replaces only the labelled containers, one at a time; CI needs no access to the VPS for that. The home server gets no \"latest\": every image tag is pinned there, and an update is one manual step per stack, because with \"latest\" a database migration happens as a side effect, and that cannot be undone.",
|
||||||
|
"DECISION_3": "Rebuild by script instead of from memory. bootstrap.sh creates the proxy network and the directories and warns about the DNS port clash between systemd-resolved and AdGuard; rebuild.sh starts the stacks in a fixed order, Traefik first, because alphabetically it would come last. For six Compose stacks, Ansible would cost more effort than it saves. Two shell scripts are enough and easier to read. The scripts cover the infrastructure, not the data.",
|
||||||
|
"RESULT": "Today six stacks run at home: Traefik, AdGuard Home, Gitea with its runner, Jellyfin, Paperless-ngx and Leantime; Nextcloud is switched off again. The VPS runs Traefik, Watchtower and the public sites. Every push of this site and of To Be Played is built at home, pushed to my own registry on Tribble and reaches the VPS with no manual step. In September I updated all stacks in one pass, stack by stack with the pinned tags, and no service went down."
|
||||||
},
|
},
|
||||||
"EL_MUCHO": {
|
"EL_MUCHO": {
|
||||||
"TITLE": "El Mucho (Steam Release)",
|
"TITLE": "El Mucho (Steam Release)",
|
||||||
@@ -380,17 +392,25 @@
|
|||||||
},
|
},
|
||||||
"CLAUDE_BOX": {
|
"CLAUDE_BOX": {
|
||||||
"TITLE": "Claude-in-a-Box",
|
"TITLE": "Claude-in-a-Box",
|
||||||
"SHORT_DESCRIPTION": "A container setup for working with an AI coding agent under a fixed set of rules.",
|
"SHORT_DESCRIPTION": "A container setup for an AI coding agent: a pipeline of small agents, one job each, and hooks none of them can get past.",
|
||||||
"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.",
|
"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. Since then the box has grown into a pipeline. The chat clarifies the task; after that it is one small agent per job, each with a fresh context: one plans, one implements, one simplifies, one reviews. They hand over through files, not through chat, and I say yes at two points, to the plan and to every commit. The hooks around all of this are still the part I keep coming back to, because they are the one thing no agent can talk its way past.",
|
||||||
"BULLET_1": "Container with fixed toolchains: JDKs and Maven through SDKMAN, Node, and a home volume that survives a rebuild.",
|
"BULLET_1": "Agent pipeline: clarify (the main session asks sharp questions and writes a spec), plan, implement, simplify, a security check when the change touches something security relevant, review, commit message. Every step after clarify is its own agent with a fresh context and a model that fits the job: Fable for clarify, plan and review, Sonnet for implementing, Opus for simplify and security, Haiku for the commit message. When less than 20% of the weekly Fable budget is left, those steps fall back to Opus on their own, and the statusline shows it.",
|
||||||
"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_2": "I stay in the loop at two points: the plan needs my OK before anyone writes code, and every commit needs my yes. The reviewer is critical and has never seen the chat: it runs the gate, writes a failing test for each finding and checks that the feature works, in the browser if needed. Findings go back to the implementer, at most two rounds. An agent that fails to fix the same failure twice stops and returns RED, and I decide. Small changes skip plan and review.",
|
||||||
"BULLET_3": "A commit hook works out the stack, runs the matching build and tests, and refuses the commit if they fail.",
|
"BULLET_3": "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, a Unity project gets the data-oriented rules, and repos with their own architecture notes get those on top. Each agent reads only the documents its job needs, so the context stays small.",
|
||||||
"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.",
|
"BULLET_4": "Hooks outside the agents: a compile or type check at the end of every turn, a commit gate that runs the full build, the tests and a secret scan and refuses a red commit, a guard that blocks reads of .env files and keys, and a work-mode guard. Around it a rootless container with pinned toolchains and MCP servers for code graph queries, Gitea and read-only Jira.",
|
||||||
"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.",
|
"BULLET_5": "Work mode assist, per chat: I write the core code (domain, services, persistence), the agents write scaffolding, boilerplate and tests and review my code. A hook denies agent writes to the core paths, so the split is enforced, not just requested.",
|
||||||
"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.",
|
"BULLET_6": "An eval suite runs the whole pipeline headless against fixed tasks, trap tasks included, each with hidden checks. The results are logged per commit of the box, so a change to a rule or an agent is measured instead of guessed.",
|
||||||
"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.",
|
"CHALLENGE_1": "Keeping the rules short enough to stay useful. Long general guidelines just get ignored, so they are split into files per stack, per repo and per job, and each agent loads only the ones that apply to it.",
|
||||||
|
"CHALLENGE_2": "Making the gates something an agent cannot talk its way past. Every check runs as a hook outside the agent: a failing build blocks the commit, a read of a secret is refused, a write to a core path in assist mode is denied, no matter what the agent believes about its own work.",
|
||||||
|
"CHALLENGE_3": "A reviewer that has watched the code being written approves it. So every agent starts with an empty context and gets only the files its job needs: the reviewer sees spec, plan and diff, never the chat. The price is that each agent reads the code again, so the pipeline is slower than one long chat.",
|
||||||
|
"CHALLENGE_4": "Knowing whether a change to the box helps. Every edit to a rule felt like an improvement, so I built the eval suite: fixed tasks with hidden checks, run before and after a change with the same models. The suite checks itself too: the untouched fixture must fail every task and the reference solution must pass, otherwise a green run means nothing.",
|
||||||
"LEARNING_1": "Prompt wording mattered far less than I expected. Almost all of the difference came from the scaffolding.",
|
"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."
|
"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.",
|
||||||
|
"LEARNING_3": "Small agents with a fresh context each beat one long chat. Not because the model gets smarter, but because nobody carries earlier mistakes forward, and every hand-over is a file I can read and correct.",
|
||||||
|
"DECISION_1": "Hooks instead of permission rules. Claude Code can block tools by rule, but a rule is per tool: a deny on reading .env files does not stop \"cat .env\" in the shell. So the secret guard, the commit gate and the type check are hooks that see every call. And since a hook timeout once let commits through unchecked, the gate fails closed: no answer means red.",
|
||||||
|
"DECISION_2": "The host's Docker socket instead of a Docker daemon inside the container. That way the Testcontainers tests of the Java repos run in the commit gate instead of being skipped. The downside is in the README: whoever has the socket is root on the host. So the box is a work environment with pinned tools, not a sandbox.",
|
||||||
|
"DECISION_3": "A second model, not only a second context. At first one model did everything and the second was never used. Now the model that plans and reviews is not the one that implements, because a second opinion is worth more when it does not come from the same model. For a one-file fix the switch is not worth it, so small changes skip steps of the pipeline.",
|
||||||
|
"RESULT": "The box has been my daily tool since July 2026, in every repo I work in, at work and at home. The build with real Testcontainers runs in the commit gate. The pipeline passes every task of the eval suite, while the untouched starting point fails every one. Mostly that shows the tasks are still too easy; harder ones are the next step."
|
||||||
},
|
},
|
||||||
"TBP": {
|
"TBP": {
|
||||||
"TITLE": "To Be Played",
|
"TITLE": "To Be Played",
|
||||||
@@ -406,7 +426,11 @@
|
|||||||
"CHALLENGE_3": "Suggesting a game is harder than it sounds. Each row of suggestions needs its own rules, and the lists behind every number must use the same rules as the number, so a game counts the same way everywhere.",
|
"CHALLENGE_3": "Suggesting a game is harder than it sounds. Each row of suggestions needs its own rules, and the lists behind every number must use the same rules as the number, so a game counts the same way everywhere.",
|
||||||
"CHALLENGE_4": "Login abuse. Too many failed logins get a 429 with Retry-After. An unknown email still pays for a full BCrypt check, so the timing gives nothing away. A header guard blocks CSRF, CORS allows exact origins only, and the PlayStation token is encrypted at rest.",
|
"CHALLENGE_4": "Login abuse. Too many failed logins get a 429 with Retry-After. An unknown email still pays for a full BCrypt check, so the timing gives nothing away. A header guard blocks CSRF, CORS allows exact origins only, and the PlayStation token is encrypted at rest.",
|
||||||
"LEARNING_1": "Splitting an app into services only pays off with a clear contract. The token claims are pinned in a contract file that both services test against, and a script I run by hand checks that both copies match.",
|
"LEARNING_1": "Splitting an app into services only pays off with a clear contract. The token claims are pinned in a contract file that both services test against, and a script I run by hand checks that both copies match.",
|
||||||
"LEARNING_2": "A hobby project still needs the boring parts: daily database dumps kept for seven days, four weeks and six months, migrations, and a written deploy order. They decided more about how stable it runs than any feature did."
|
"LEARNING_2": "A hobby project still needs the boring parts: daily database dumps kept for seven days, four weeks and six months, migrations, and a written deploy order. They decided more about how stable it runs than any feature did.",
|
||||||
|
"DECISION_1": "A key pair instead of a shared secret. The first version signed the tokens with one HMAC secret that Auth and Core both knew. Now Auth holds the private Ed25519 key and publishes the public one on a JWKS endpoint. Core can check a login but never issue one, and when a token carries an unknown key id, Core fetches the new key itself.",
|
||||||
|
"DECISION_2": "Mail through an outbox table instead of sending over SMTP inline. Every mail is written as a row in mail_outbox inside the transaction of its event, and a job sends the rows every minute. So a registration never fails because of the mail server, and no mail is lost when Auth restarts. With one Auth instance, at-least-once is enough.",
|
||||||
|
"DECISION_3": "Installable, but no service worker. The app has a web app manifest and installs on phone and desktop, but no service worker, so a deploy never meets an old cached bundle. The downside is that there is no offline mode.",
|
||||||
|
"RESULT": "Today Auth, Core and the frontend run as their own containers next to this site, with one PostgreSQL instance with two databases, a nightly sync and daily dumps. Anyone with a confirmed e-mail can register; accounts are approved by hand. I use it every day: what I play next now comes from a list, not from scrolling through the Steam library."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IMPRINT": {
|
"IMPRINT": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-labelledby="title desc">
|
<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>
|
<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>
|
<desc id="desc">Repository detection loads the rules per stack and per repo into an agent pipeline: clarify, plan, implement, simplify, security when relevant, review and commit message, one agent per step with its model. The human approves the plan and every commit; review findings go back to the implementer for at most two rounds. Hooks outside the agents run a compile check at turn end, a commit gate, a secret guard and a work-mode guard.</desc>
|
||||||
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
<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"/>
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4a5563"/>
|
||||||
</marker>
|
</marker>
|
||||||
<marker id="arrowCyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
<marker id="arrowOrange" 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"/>
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ffa726"/>
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -33,77 +33,115 @@
|
|||||||
<text x="64" y="72" class="mono" fill="#26c6da" font-size="26" font-weight="600">claude-in-a-box</text>
|
<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>
|
<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>
|
<text x="64" y="176" class="sans cap">detect</text>
|
||||||
|
|
||||||
<g>
|
<g>
|
||||||
<rect x="64" y="196" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
<rect x="64" y="188" width="228" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
<text x="86" y="223" class="mono chip">pom.xml</text>
|
<text x="86" y="210" class="mono chip">pom.xml</text>
|
||||||
<text x="86" y="245" class="sans lbl" font-size="15">→ java / spring rules</text>
|
<text x="86" y="230" class="sans" fill="#8b97a6" font-size="15">→ java / spring rules</text>
|
||||||
</g>
|
</g>
|
||||||
<g>
|
<g>
|
||||||
<rect x="64" y="278" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
<rect x="64" y="248" width="228" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
<text x="86" y="305" class="mono chip">package.json</text>
|
<text x="86" y="270" class="mono chip">package.json</text>
|
||||||
<text x="86" y="327" class="sans lbl" font-size="15">→ typescript rules</text>
|
<text x="86" y="290" class="sans" fill="#8b97a6" font-size="15">→ typescript rules</text>
|
||||||
</g>
|
</g>
|
||||||
<g>
|
<g>
|
||||||
<rect x="64" y="360" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
<rect x="64" y="308" width="228" height="52" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
<text x="86" y="387" class="mono chip">pyproject.toml</text>
|
<text x="86" y="330" class="mono chip">Assets/</text>
|
||||||
<text x="86" y="409" class="sans lbl" font-size="15">→ python rules</text>
|
<text x="86" y="350" class="sans" fill="#8b97a6" font-size="15">→ unity rules</text>
|
||||||
</g>
|
</g>
|
||||||
<g>
|
<g>
|
||||||
<rect x="64" y="442" width="228" height="62" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
|
<rect x="64" y="368" width="228" height="52" 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="390" class="mono chip"><repo>.md</text>
|
||||||
<text x="86" y="491" class="sans lbl" font-size="15">→ architecture profile</text>
|
<text x="86" y="410" class="sans" fill="#8b97a6" font-size="15">→ architecture profile</text>
|
||||||
</g>
|
</g>
|
||||||
|
<path d="M 300 214 H 340" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 300 274 H 340" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 300 334 H 340" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 300 394 H 340" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
<path d="M 300 227 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
<rect x="356" y="150" width="780" height="290" rx="18" fill="#1a222c" stroke="#26c6da" stroke-width="2"/>
|
||||||
<path d="M 300 309 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
<text x="380" y="184" class="sans cap" style="fill:#26c6da">agent pipeline</text>
|
||||||
<path d="M 300 391 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
<text x="1112" y="184" class="sans cap" text-anchor="end" style="font-size:13px;letter-spacing:.08em">one job each · fresh context · hand-over by files</text>
|
||||||
<path d="M 300 473 H 360" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
<g>
|
||||||
|
<rect x="380" y="230" width="90" height="84" rx="12" fill="#243040" stroke="#33475c"/>
|
||||||
<!-- ── Centre: the container ──────────────────────────────── -->
|
<text x="425" y="262" class="mono" text-anchor="middle" fill="#e3e9f0" font-size="14" font-weight="600">clarify</text>
|
||||||
<rect x="376" y="150" width="330" height="380" rx="18" fill="#1a222c" stroke="#26c6da" stroke-width="2"/>
|
<text x="425" y="284" class="sans" text-anchor="middle" fill="#8b97a6" font-size="13">Fable</text>
|
||||||
<text x="400" y="184" class="sans cap" fill="#26c6da">container</text>
|
<text x="425" y="302" class="sans" text-anchor="middle" fill="#6c7887" font-size="13">main session</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>
|
</g>
|
||||||
<rect x="404" y="462" width="274" height="44" rx="10" fill="#2a2118" stroke="#5c4326"/>
|
<g>
|
||||||
<text x="424" y="490" class="mono" fill="#ffa726" font-size="16">review subagents</text>
|
<rect x="486" y="230" width="90" height="84" rx="12" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="531" y="262" class="mono" text-anchor="middle" fill="#e3e9f0" font-size="14" font-weight="600">plan</text>
|
||||||
<path d="M 714 340 H 776" stroke="#26c6da" stroke-width="2" marker-end="url(#arrowCyan)"/>
|
<text x="531" y="284" class="sans" text-anchor="middle" fill="#8b97a6" font-size="13">Fable</text>
|
||||||
|
|
||||||
<!-- ── 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>
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="592" y="230" width="90" height="84" rx="12" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="637" y="262" class="mono" text-anchor="middle" fill="#e3e9f0" font-size="14" font-weight="600">implement</text>
|
||||||
|
<text x="637" y="284" class="sans" text-anchor="middle" fill="#8b97a6" font-size="13">Sonnet</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="698" y="230" width="90" height="84" rx="12" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="743" y="262" class="mono" text-anchor="middle" fill="#e3e9f0" font-size="14" font-weight="600">simplify</text>
|
||||||
|
<text x="743" y="284" class="sans" text-anchor="middle" fill="#8b97a6" font-size="13">Opus</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="804" y="230" width="90" height="84" rx="12" fill="#243040" stroke="#33475c" stroke-dasharray="4 4"/>
|
||||||
|
<text x="849" y="262" class="mono" text-anchor="middle" fill="#e3e9f0" font-size="14" font-weight="600">security</text>
|
||||||
|
<text x="849" y="284" class="sans" text-anchor="middle" fill="#8b97a6" font-size="13">Opus</text>
|
||||||
|
<text x="849" y="302" class="sans" text-anchor="middle" fill="#6c7887" font-size="13">if relevant</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="910" y="230" width="90" height="84" rx="12" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="955" y="262" class="mono" text-anchor="middle" fill="#e3e9f0" font-size="14" font-weight="600">review</text>
|
||||||
|
<text x="955" y="284" class="sans" text-anchor="middle" fill="#8b97a6" font-size="13">Fable</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="1016" y="230" width="90" height="84" rx="12" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="1061" y="262" class="mono" text-anchor="middle" fill="#e3e9f0" font-size="14" font-weight="600">commit</text>
|
||||||
|
<text x="1061" y="284" class="sans" text-anchor="middle" fill="#8b97a6" font-size="13">Haiku</text>
|
||||||
|
<text x="1061" y="302" class="sans" text-anchor="middle" fill="#6c7887" font-size="13">message</text>
|
||||||
|
</g>
|
||||||
|
<path d="M 470 272 H 486" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 576 272 H 592" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 682 272 H 698" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 788 272 H 804" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 894 272 H 910" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 1000 272 H 1016" stroke="#4a5563" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 955 230 V 214 H 637 V 230" fill="none" stroke="#ffa726" stroke-width="2" marker-end="url(#arrowOrange)"/>
|
||||||
|
<text x="796" y="207" class="sans" text-anchor="middle" fill="#ffa726" font-size="13">findings · max 2 rounds</text>
|
||||||
|
|
||||||
<rect x="812" y="378" width="304" height="76" rx="10" fill="#2b1b1c" stroke="#7d3a3c"/>
|
<path d="M 531 314 V 352" stroke="#ffa726" stroke-width="2" marker-end="url(#arrowOrange)"/>
|
||||||
<text x="836" y="408" class="mono" fill="#ef8b8d" font-size="17">git commit — blocked</text>
|
<path d="M 1061 314 V 352" stroke="#ffa726" stroke-width="2" marker-end="url(#arrowOrange)"/>
|
||||||
<text x="836" y="434" class="sans lbl" font-size="15">hook runs outside the agent</text>
|
<rect x="486" y="352" width="90" height="40" rx="10" fill="#2a2118" stroke="#5c4326"/>
|
||||||
|
<text x="531" y="377" class="mono" text-anchor="middle" fill="#ffa726" font-size="14">you: ok?</text>
|
||||||
|
<rect x="1016" y="352" width="90" height="40" rx="10" fill="#2a2118" stroke="#5c4326"/>
|
||||||
|
<text x="1061" y="377" class="mono" text-anchor="middle" fill="#ffa726" font-size="14">you: yes?</text>
|
||||||
|
<text x="592" y="377" class="sans" fill="#8b97a6" font-size="14">RED after 2 failed fixes → you decide</text>
|
||||||
|
|
||||||
<text x="792" y="524" class="sans lbl" font-size="16">a failing build cannot be argued with</text>
|
<text x="380" y="424" class="mono" fill="#6c7887" font-size="13">size S: clarify → implement → commit</text>
|
||||||
|
<text x="1112" y="424" class="mono" text-anchor="end" fill="#6c7887" font-size="13">Fable budget < 20% → Opus, shown in statusline</text>
|
||||||
|
|
||||||
|
<text x="64" y="462" class="sans cap">hooks · outside every agent, no way around</text>
|
||||||
|
<g>
|
||||||
|
<rect x="64" y="474" width="256" height="66" rx="10" fill="#1c242e" stroke="#2f3a47"/>
|
||||||
|
<text x="84" y="501" class="mono" fill="#cdd6e0" font-size="15">turn end</text>
|
||||||
|
<text x="84" y="524" class="sans" fill="#8b97a6" font-size="13">compile or type check</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="336" y="474" width="256" height="66" rx="10" fill="#2b1b1c" stroke="#7d3a3c"/>
|
||||||
|
<text x="356" y="501" class="mono" fill="#ef8b8d" font-size="15">git commit → gate</text>
|
||||||
|
<text x="356" y="524" class="sans" fill="#8b97a6" font-size="13">build + tests · red blocks</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="608" y="474" width="256" height="66" rx="10" fill="#1c242e" stroke="#2f3a47"/>
|
||||||
|
<text x="628" y="501" class="mono" fill="#cdd6e0" font-size="15">secret guard</text>
|
||||||
|
<text x="628" y="524" class="sans" fill="#8b97a6" font-size="13">no reads of .env, keys, tokens</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="880" y="474" width="256" height="66" rx="10" fill="#1c242e" stroke="#2f3a47"/>
|
||||||
|
<text x="900" y="501" class="mono" fill="#cdd6e0" font-size="15">work-mode guard</text>
|
||||||
|
<text x="900" y="524" class="sans" fill="#8b97a6" font-size="13">assist: core code stays yours</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
|
<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>
|
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">rootless container · pinned toolchains · mcp: codegraph, jira (ro), gitea · evals</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,99 @@
|
|||||||
|
<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">Playground</title>
|
||||||
|
<desc id="desc">Architecture of this site: a push to Gitea on the home server runs lint, i18n check, unit tests, a production build with prerendering, a prerender check and Lighthouse, then builds a Docker image into the registry. Watchtower on the Hostinger VPS pulls it over Tailscale, Traefik terminates HTTPS and nginx serves the prerendered HTML. Traefik also forwards git.andreas-dahm.eu to Gitea over Tailscale. The browser gets prerendered HTML first, then Angular and the service worker take over; the WebGPU and WebGL demos run on the client.</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; }
|
||||||
|
.name { fill: #cdd6e0; font-size: 22px; font-weight: 600; }
|
||||||
|
.sub { fill: #8b97a6; font-size: 16px; }
|
||||||
|
.arr { fill: #8b97a6; font-size: 15px; }
|
||||||
|
</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">Playground</text>
|
||||||
|
<text x="64" y="102" class="sans lbl">push to main, prerender at build time, serve static</text>
|
||||||
|
<rect x="120" y="140" width="320" height="390" rx="18" fill="#1a222c" stroke="#26c6da" stroke-width="2"/>
|
||||||
|
<text x="144" y="168" class="sans cap" text-anchor="start">HOME SERVER "TRIBBLE"</text>
|
||||||
|
<rect x="144" y="186" width="272" height="98" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="164" y="220" class="sans name">Gitea</text>
|
||||||
|
<text x="164" y="246" class="sans sub">git, Actions runner, registry</text>
|
||||||
|
<text x="164" y="268" class="sans sub">git.andreas-dahm.eu</text>
|
||||||
|
<text x="144" y="316" class="sans cap" text-anchor="start">ACTIONS RUNNER</text>
|
||||||
|
<rect x="144" y="328" width="130" height="40" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="209" y="353" class="sans chip" style="font-size:15px" text-anchor="middle">lint</text>
|
||||||
|
<rect x="286" y="328" width="130" height="40" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="351" y="353" class="sans chip" style="font-size:15px" text-anchor="middle">i18n check</text>
|
||||||
|
<rect x="144" y="376" width="130" height="40" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="209" y="401" class="sans chip" style="font-size:15px" text-anchor="middle">unit tests</text>
|
||||||
|
<rect x="286" y="376" width="130" height="40" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="351" y="401" class="sans chip" style="font-size:15px" text-anchor="middle">build + prerender</text>
|
||||||
|
<rect x="144" y="424" width="130" height="40" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="209" y="449" class="sans chip" style="font-size:15px" text-anchor="middle">prerender check</text>
|
||||||
|
<rect x="286" y="424" width="130" height="40" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="351" y="449" class="sans chip" style="font-size:15px" text-anchor="middle">Lighthouse</text>
|
||||||
|
<rect x="144" y="476" width="272" height="40" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="280" y="501" class="mono chip" style="font-size:17px" text-anchor="middle">Docker image → registry</text>
|
||||||
|
<rect x="556" y="140" width="320" height="390" rx="18" fill="#1a222c" stroke="#ffa726" stroke-width="2"/>
|
||||||
|
<text x="580" y="168" class="sans cap" text-anchor="start">HOSTINGER VPS</text>
|
||||||
|
<rect x="580" y="186" width="272" height="98" rx="10" fill="#2a2118" stroke="#5c4326"/>
|
||||||
|
<text x="600" y="220" class="sans name">Traefik</text>
|
||||||
|
<text x="600" y="246" class="sans sub">HTTPS, Let's Encrypt</text>
|
||||||
|
<text x="600" y="268" class="sans sub">andreas-dahm.eu</text>
|
||||||
|
<rect x="580" y="300" width="272" height="98" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="600" y="334" class="sans name">nginx</text>
|
||||||
|
<text x="600" y="360" class="sans sub">prerendered HTML, gzip</text>
|
||||||
|
<text x="600" y="382" class="sans sub">no access log</text>
|
||||||
|
<rect x="580" y="440" width="272" height="76" rx="10" fill="#243040" stroke="#33475c"/>
|
||||||
|
<text x="600" y="474" class="sans name">Watchtower</text>
|
||||||
|
<text x="600" y="500" class="sans sub">pulls the new image</text>
|
||||||
|
<path d="M 716 284 V 292" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 716 440 V 406" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||||
|
<text x="730" y="424" class="sans arr" text-anchor="start">deploy</text>
|
||||||
|
<rect x="936" y="140" width="216" height="390" rx="18" fill="#1a222c" stroke="#2f3a47" stroke-width="2"/>
|
||||||
|
<text x="960" y="168" class="sans cap" text-anchor="start">BROWSER</text>
|
||||||
|
<rect x="952" y="186" width="184" height="76" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="972" y="220" class="sans name" style="font-size:17px">Prerendered HTML</text>
|
||||||
|
<text x="972" y="246" class="sans sub">title + meta per route</text>
|
||||||
|
<rect x="952" y="300" width="184" height="76" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="972" y="334" class="sans name" style="font-size:17px">Angular + PWA</text>
|
||||||
|
<text x="972" y="360" class="sans sub">service worker, offline</text>
|
||||||
|
<rect x="952" y="414" width="184" height="76" rx="10" fill="#212a35" stroke="#2f3a47"/>
|
||||||
|
<text x="972" y="448" class="sans name" style="font-size:17px">WebGPU / WebGL</text>
|
||||||
|
<text x="972" y="474" class="sans sub">demos on the client</text>
|
||||||
|
<path d="M 1044 262 V 292" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 1044 376 V 406" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||||
|
<path d="M 40 235 H 112" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
|
||||||
|
<text x="44" y="224" class="mono arr" text-anchor="start">git push</text>
|
||||||
|
<path d="M 548 224 H 448" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
|
||||||
|
<text x="498" y="212" class="sans arr" text-anchor="middle">via Tailscale</text>
|
||||||
|
<path d="M 424 496 H 548" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
|
||||||
|
<text x="486" y="466" class="sans arr" text-anchor="middle">new image</text>
|
||||||
|
<text x="486" y="484" class="sans arr" text-anchor="middle">via Tailscale</text>
|
||||||
|
<path d="M 884 224 H 928" stroke="#4a5563" stroke-width="2" fill="none" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
|
||||||
|
<text x="906" y="212" class="sans arr" text-anchor="middle">HTTPS</text>
|
||||||
|
<line x1="64" y1="566" x2="1136" y2="566" stroke="#2a333e"/>
|
||||||
|
<text x="64" y="598" class="mono" fill="#5b6673" font-size="15">angular · prerender · nginx · traefik · gitea actions · docker</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
Reference in new issue
Block a user