Open the systems-map gist dialog and turn the dock into a default-open TTY.

Plain node activation no longer navigates, case cards stay quiet evidence rows, and the terminal keeps one top-to-bottom scrollport.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 00:41:20 +02:00
parent 2635d0c639
commit 55d012cc10
25 changed files with 915 additions and 544 deletions

View File

@@ -25,25 +25,10 @@
<a
[routerLink]="item.link"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
[routerLinkActiveOptions]="{ exact: item.routeId === 'home' }"
ariaCurrentWhenActive="page"
>{{ item.label }}</a
>
@if (item.children; as children) {
<ul class="submenu">
@for (child of children; track child.routeId) {
<li>
<a
[routerLink]="child.link"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
>{{ child.label }}</a
>
</li>
}
</ul>
}
</li>
}
</ul>
@@ -57,7 +42,6 @@
>
{{ shell().otherLocaleName }}
</a>
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
</div>
</div>
</header>

View File

@@ -24,7 +24,7 @@
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-3);
align-items: start;
align-items: center;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
}
@@ -114,17 +114,6 @@
gap: var(--space-2);
}
.primary-nav ul {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-inline-start: var(--space-4);
}
.contact-cta {
color: var(--color-text);
}
.is-active {
color: var(--color-accent-cool);
}
@@ -157,7 +146,8 @@
}
@include bp.respond-to(md) {
.nav-toggle {
.nav-toggle,
.site-toolbar {
display: none;
}
@@ -166,28 +156,22 @@
}
.site-header-inner {
grid-template-columns: 1fr auto auto;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-2);
gap: var(--space-4);
padding-block: var(--space-2);
}
.site-nav,
.nav-collapsed .site-nav {
display: flex;
grid-column: 1 / -1;
order: 5;
}
.nav-collapsed .site-nav,
.site-actions,
.nav-collapsed .site-actions {
display: flex;
grid-column: auto;
order: 3;
}
.site-toolbar {
order: 2;
.site-nav {
justify-content: center;
}
.primary-nav {
@@ -197,39 +181,7 @@
gap: var(--space-3);
}
.primary-nav > li {
position: relative;
z-index: 31;
}
.primary-nav > li > a {
white-space: nowrap;
}
.primary-nav > li > .submenu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: max-content;
z-index: 32;
padding: var(--space-3) var(--space-4);
gap: var(--space-1);
border-radius: var(--radius-md);
background: var(--color-surface-menu);
border: 1px solid var(--surface-glass-border);
box-shadow: var(--shadow-raised);
}
.primary-nav > li:focus-within > .submenu {
display: flex;
flex-direction: column;
}
@media (hover: hover) and (pointer: fine) {
.primary-nav > li:hover > .submenu {
display: flex;
flex-direction: column;
}
}
}

View File

@@ -103,14 +103,9 @@ describe('App', () => {
expect(compiled.querySelector('header a[href="/cv/CV.pdf"]')).toBeNull();
expect(compiled.querySelector('header .command-palette-trigger')).toBeNull();
(trigger as HTMLButtonElement).click();
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
const panel = compiled.querySelector('.terminal-dock-panel');
expect(panel).toBeTruthy();
expect((trigger as HTMLButtonElement).hasAttribute('hidden')).toBe(true);
expect(site?.contains(panel)).toBe(false);
expect(site?.hasAttribute('inert')).toBe(false);
});

View File

@@ -4,10 +4,6 @@
height: 100%;
display: flex;
flex-direction: column;
transition:
transform var(--duration-base) var(--ease-standard),
box-shadow var(--duration-base) var(--ease-standard),
border-color var(--duration-base) var(--ease-standard);
h3 {
margin: 0 0 var(--space-4);
@@ -43,12 +39,6 @@
}
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-raised);
border-color: var(--surface-glass-border-strong);
}
.card .icons a:hover img {
filter: none;
transform: scale(1.1);

View File

@@ -1,14 +1,11 @@
<article
class="case-card glass-surface stack"
[attr.aria-labelledby]="'case-card-' + caseStudy().id"
<a
class="case-card interactive-lift stack"
[routerLink]="projectsLink()"
[fragment]="caseStudy().id"
[attr.aria-labelledby]="headingId()"
>
<h3 [id]="'case-card-' + caseStudy().id">{{ caseStudy().headline }}</h3>
<p class="meta">{{ caseStudy().client }} · {{ caseStudy().role }} · {{ caseStudy().period }}</p>
<p>{{ caseStudy().summary }}</p>
<ul class="cluster tags">
@for (tag of caseStudy().tags; track $index) {
<li>{{ tag }}</li>
}
</ul>
<a [routerLink]="projectsLink()" [fragment]="caseStudy().id">{{ caseStudy().linkLabel }}</a>
</article>
<h3 [id]="headingId()">{{ caseStudy().client }}</h3>
<p class="meta">{{ caseStudy().role }} · {{ caseStudy().period }}</p>
<p class="teaser">{{ caseStudy().teaser }}</p>
<span class="case-card-link">{{ caseStudy().linkLabel }}</span>
</a>

View File

@@ -1,36 +1,31 @@
.case-card {
padding: var(--space-5);
border-radius: var(--radius-md);
height: 100%;
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border-radius: var(--radius-sm);
border-inline-start: 3px solid var(--color-accent);
background: var(--color-surface-raised);
color: inherit;
text-decoration: none;
}
h3 {
margin: 0;
font-size: var(--text-lg);
font-size: var(--text-md);
font-weight: 600;
}
.meta,
p {
.teaser {
margin: 0;
color: var(--color-text-muted);
}
.tags {
list-style: none;
margin: 0;
padding: 0;
color: var(--color-text-subtle);
.teaser {
max-width: 40rem;
}
.case-card-link {
color: var(--color-accent-cool);
font-size: var(--text-sm);
}
a {
color: var(--color-accent-cool);
text-decoration: none;
}
@media (hover: hover) and (pointer: fine) {
a:hover {
text-decoration: underline;
}
}

View File

@@ -12,8 +12,10 @@ import { NavigationService } from '../../core/navigation/navigation.service';
})
export class CaseCard {
readonly caseStudy = input.required<CaseStudyCopy>();
readonly idPrefix = input('case-card');
private readonly navigation = inject(NavigationService);
protected readonly projectsLink = computed(() => this.navigation.link('projects'));
protected readonly headingId = computed(() => `${this.idPrefix()}-${this.caseStudy().id}`);
}

View File

@@ -10,6 +10,13 @@
@if (caseStudy().transparencyNote; as note) {
<p class="transparency" role="note">{{ note }}</p>
}
@if (caseStudy().externalUrl; as url) {
<p>
<a class="external-link" [href]="url" rel="noopener noreferrer" target="_blank">
{{ caseStudy().externalLabel }}
</a>
</p>
}
</header>
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-situation'">

View File

@@ -48,3 +48,19 @@ ul {
font-size: var(--text-sm);
color: var(--color-text-subtle);
}
.external-link {
color: var(--color-accent-cool);
}
.external-link::after {
content: '';
display: inline-block;
width: 0.65em;
height: 0.65em;
margin-inline-start: var(--space-1);
border-block-end: 1px solid currentColor;
border-inline-end: 1px solid currentColor;
transform: translateY(-0.15em) rotate(-45deg);
vertical-align: middle;
}

View File

@@ -78,8 +78,5 @@
</button>
}
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
@if (showCalendar()) {
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
}
</p>
</div>

View File

@@ -46,12 +46,11 @@ describe('ContactBriefing', () => {
expect(disabledSubmit?.textContent).toContain(copy.submitLabel);
expect(root.querySelector('[aria-invalid="true"]')).toBeNull();
expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
expect(root.textContent).not.toContain(copy.calendarLabel);
const special = 'Äpfel & Birnen?\nPreis=100';
setControl(root, 'contact-name', special);
setControl(root, 'contact-email', 'team@example.com');
setControl(root, 'contact-projectType', 'software');
setControl(root, 'contact-projectType', 'software-data');
setControl(root, 'contact-situation', special);
setControl(root, 'contact-timeframe', 'Q3');
fixture.detectChanges();
@@ -79,7 +78,7 @@ describe('ContactBriefing', () => {
expect(decodedBody).toContain('team@example.com');
const projectType = copy.fields.find((field) => field.id === 'projectType');
const projectTypeLabel = projectType?.options?.find(
(option) => option.value === 'software',
(option) => option.value === 'software-data',
)?.label;
expect(projectTypeLabel).toBeTruthy();
expect(decodedBody).toContain(`${projectType!.label}: ${projectTypeLabel}`);

View File

@@ -27,7 +27,6 @@ export class ContactBriefing {
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
protected readonly touched = signal<Partial<Record<ContactFieldId, boolean>>>({});
protected readonly contactEmail = SITE_CONFIG.contactEmail;
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
protected readonly incompleteId = 'contact-incomplete';
protected readonly missingLabels = computed(() => {
@@ -56,11 +55,6 @@ export class ContactBriefing {
return `mailto:${SITE_CONFIG.contactEmail}?subject=${encodeURIComponent(copy.mailSubject)}&body=${encodeURIComponent(body)}`;
});
protected readonly showCalendar = computed(() => {
const url = SITE_CONFIG.calendarUrl;
return typeof url === 'string' && url.length > 0;
});
protected onInput(fieldId: ContactFieldId, event: Event): void {
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
this.values.update((current) => ({ ...current, [fieldId]: target.value }));

View File

@@ -5,15 +5,13 @@
<h2 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h2>
}
<p class="systems-map-intro">{{ introText() }}</p>
<p class="systems-map-kicker">{{ copy().nodeLinkHint }}</p>
<div class="systems-map-figure">
<div class="systems-map-figure" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 560"
preserveAspectRatio="xMidYMid meet"
role="group"
[attr.aria-labelledby]="svgTitleId"
[attr.aria-describedby]="svgDescId"
>
<title [id]="svgTitleId">{{ headingText() }}</title>
<desc [id]="svgDescId">{{ copy().diagramDescription }}</desc>
@@ -26,25 +24,22 @@
class="systems-map-edge"
[class.is-connected]="isConnectedEdge(edge)"
[class.is-dimmed]="isDimmedEdge(edge)"
aria-hidden="true"
/>
}
@for (node of nodes(); track node.id) {
<a
[attr.href]="node.href"
[attr.aria-label]="node.accessibleName"
tabindex="-1"
class="systems-map-node"
[attr.data-kind]="node.kind"
[class.is-connected]="isConnectedNode(node.id)"
[class.is-dimmed]="isDimmedNode(node.id)"
(click)="onNodeActivate($event, node)"
(focusin)="onNodeEnter(node.id)"
(focusout)="onNodeLeave(node.id)"
(mouseenter)="onNodeEnter(node.id)"
(mouseleave)="onNodeLeave(node.id)"
>
@if (node.kind === 'ai' || node.kind === 'cluster') {
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" aria-hidden="true" />
@if (node.kind === 'ai' || node.kind === 'platform') {
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" />
} @else {
<rect
[attr.x]="node.x - node.shapeWidth / 2"
@@ -52,10 +47,9 @@
[attr.width]="node.shapeWidth"
[attr.height]="node.shapeHeight"
rx="8"
aria-hidden="true"
/>
}
<text [attr.x]="node.x" [attr.y]="node.y" aria-hidden="true">
<text [attr.x]="node.x" [attr.y]="node.y">
@for (line of node.labelLines; track $index) {
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
{{ line }}
@@ -72,7 +66,12 @@
<ul class="systems-map-cards" [attr.aria-labelledby]="listHeadingId">
@for (node of nodes(); track node.id) {
<li>
<a [routerLink]="node.link" [attr.aria-label]="node.accessibleName">
<a
[href]="node.href"
[attr.aria-label]="node.accessibleName"
(click)="onNodeActivate($event, node)"
(keydown)="onNodeKeydown($event, node)"
>
<span class="systems-map-card-label">{{ node.label }}</span>
<span class="systems-map-card-summary">{{ node.summary }}</span>
<span class="systems-map-card-relation">{{ node.relationship }}</span>
@@ -89,5 +88,32 @@
}
</ul>
<p class="systems-map-readout" aria-hidden="true">{{ activeReadout() }}</p>
@if (openNode(); as node) {
<div
#gistDialog
class="systems-map-dialog"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="dialogTitleId"
[attr.aria-describedby]="dialogDescId"
tabindex="-1"
(keydown)="onDialogKeydown($event)"
>
<div class="systems-map-dialog-bar cluster">
<h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3>
<button
type="button"
class="systems-map-dialog-close"
[attr.aria-label]="copy().closeLabel"
[attr.title]="copy().closeTooltip"
(click)="closeDialog()"
>
{{ copy().closeLabel }}
</button>
</div>
<p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</p>
<p class="systems-map-dialog-relation">{{ node.relationship }}</p>
<a [routerLink]="node.link" [fragment]="node.fragment">{{ node.linkLabel }}</a>
</div>
}
</section>

View File

@@ -1,23 +1,24 @@
import { type AppLocale } from '../../core/i18n/locale';
import { type RouteId } from '../../core/routing/route-ids';
import { routePath } from '../../core/routing/route-paths';
export type SystemsMapNodeKind = 'ai' | 'cluster' | 'hardware' | 'software' | 'project';
export type SystemsMapNodeKind = 'infrastructure' | 'platform' | 'software' | 'ai' | 'project';
export type SystemsMapNodeId =
| 'ai'
| 'clusters'
| 'hardware'
| 'infrastructure'
| 'platform'
| 'software'
| 'stack'
| 'caseMigration'
| 'casePlatform'
| 'caseAutomation';
| 'ai'
| 'caseInnofocus'
| 'caseRoesterei'
| 'caseBannier'
| 'caseHdi';
export const SYSTEMS_MAP_NODE_KINDS: readonly SystemsMapNodeKind[] = [
'ai',
'cluster',
'hardware',
'infrastructure',
'platform',
'software',
'ai',
'project',
];
@@ -25,9 +26,12 @@ export interface SystemsMapNode {
readonly id: SystemsMapNodeId;
readonly kind: SystemsMapNodeKind;
readonly routeId: RouteId;
readonly fragment: string;
readonly label: Record<AppLocale, string>;
readonly labelLines: Record<AppLocale, readonly string[]>;
readonly summary: Record<AppLocale, string>;
readonly gist: Record<AppLocale, string>;
readonly relationships: Record<AppLocale, string>;
readonly x: number;
readonly y: number;
}
@@ -39,121 +43,192 @@ export interface SystemsMapEdge {
export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
{
id: 'hardware',
kind: 'hardware',
routeId: 'servicesHardwareNetwork',
label: { de: 'Hardware', en: 'Hardware' },
labelLines: { de: ['Hardware'], en: ['Hardware'] },
id: 'infrastructure',
kind: 'infrastructure',
routeId: 'services',
fragment: 'infrastructure-network',
label: { de: 'Server und Netz', en: 'Infrastructure' },
labelLines: { de: ['Server und', 'Netz'], en: ['Infrastructure'] },
summary: {
de: 'Geräte, Netz und physische Schicht',
en: 'Devices, network and the physical layer',
de: 'Netz, Server und Virtualisierung vor Ort',
en: 'Servers, networks and virtualisation',
},
x: 140,
y: 300,
gist: {
de: 'Die unterste Schicht: Netzstrukturen, Server und Virtualisierung im Haus. Was darüber läuft, hängt an ihrer Stabilität.',
en: 'Servers, company networks, virtualisation and on-premise structures — the layer everything else on this map stands on.',
},
relationships: {
de: 'Trägt den Plattformbetrieb und ist die Grundlage der laufenden Betreuung bei der Fahrschule Bannier und bei der Rösterei Tangermünde.',
en: 'Carries the platform layer and the software above it. The Bannier and Rösterei cases attach here directly.',
},
x: 160,
y: 320,
},
{
id: 'clusters',
kind: 'cluster',
routeId: 'servicesClusters',
label: { de: 'Cluster', en: 'Clusters' },
labelLines: { de: ['Cluster'], en: ['Clusters'] },
id: 'platform',
kind: 'platform',
routeId: 'services',
fragment: 'platform-operations',
label: { de: 'Plattform', en: 'Platform' },
labelLines: { de: ['Plattform'], en: ['Platform'] },
summary: {
de: 'Orchestrierung und Betrieb von Cluster-Umgebungen',
en: 'Orchestration and cluster operations',
de: 'Microsoft 365, Container, Cluster und Pipelines',
en: 'Microsoft 365, containers, clusters and pipelines',
},
x: 340,
y: 140,
gist: {
de: 'Die Betriebsschicht zwischen Blech und Anwendung: Microsoft 365, Container, Kubernetes und die Pipelines, mit denen ein Stand ausgerollt wird. Hier entscheidet sich auch, wer danach betreibt.',
en: 'Microsoft 365, Docker and Kubernetes on-premise or on Azure AKS, CI/CD pipelines and GitOps. This is where software gets deployed, run and handed over.',
},
relationships: {
de: 'Steht auf Server und Netz, trägt die Produktsoftware und zeigt sich am deutlichsten im Fall HDI Specialty.',
en: 'Sits on the infrastructure and carries the software above it. HDI Specialty is the case that documents this layer.',
},
x: 380,
y: 160,
},
{
id: 'software',
kind: 'software',
routeId: 'servicesSoftware',
label: { de: 'Software', en: 'Software' },
labelLines: { de: ['Software'], en: ['Software'] },
routeId: 'services',
fragment: 'software-data',
label: { de: 'Software und Daten', en: 'Software' },
labelLines: { de: ['Software', 'und Daten'], en: ['Software'] },
summary: {
de: 'Anwendungen und Schnittstellen',
en: 'Applications and interfaces',
de: 'Produktsoftware, Schnittstellen und SQL-Datenarbeit',
en: 'Product software, APIs and data work',
},
x: 520,
y: 300,
gist: {
de: 'Anwendungen von der Fachlichkeit über die Schnittstelle bis zur Oberfläche, dazu die Datenarbeit in SQL und T-SQL. Der Aufwand steckt meistens in den Daten, nicht im Bildschirm.',
en: 'Fullstack product software from the domain through the API to the screen, plus SQL and T-SQL data work and migrations. It runs on the platform below it.',
},
relationships: {
de: 'Läuft auf der Plattformschicht, liefert die Abläufe, an die KI-Werkzeuge andocken, und ist der Kern des Falls Innofocus / reifen.com.',
en: 'Runs on the platform, feeds the AI workflows above, and is what the Innofocus / reifen.com case is about.',
},
x: 600,
y: 320,
},
{
id: 'ai',
kind: 'ai',
routeId: 'servicesAi',
label: { de: 'KI', en: 'AI' },
labelLines: { de: ['KI'], en: ['AI'] },
routeId: 'services',
fragment: 'ai-workflows',
label: { de: 'KI-Abläufe', en: 'AI' },
labelLines: { de: ['KI-Abläufe'], en: ['AI'] },
summary: {
de: 'Angebot: lokale Modelle und Integrationsarbeit',
en: 'Offered as local models and integration work',
de: 'Zwei umgesetzte Werkzeuge, der Rest ist Angebot',
en: 'Two delivered tools, everything else scoped per case',
},
x: 720,
y: 140,
gist: {
de: 'KI hängt hier an einem konkreten Ablauf. Zwei Werkzeuge sind bei der Rösterei Tangermünde umgesetzt, weitere Bausteine werden je Auftrag geschnitten und geprüft.',
en: 'Two AI tools are delivered and in daily use at Rösterei Tangermünde: review categorisation and a shop-management assistant. Everything beyond those two is offered and scoped per engagement.',
},
relationships: {
de: 'Hängt an der Software- und Datenschicht und ist ausschließlich im Fall Rösterei Tangermünde belegt.',
en: 'Sits on top of the software and the platform. Rösterei Tangermünde is the only case behind it.',
},
x: 820,
y: 160,
},
{
id: 'stack',
kind: 'software',
routeId: 'stack',
label: { de: 'Stack', en: 'Stack' },
labelLines: { de: ['Stack'], en: ['Stack'] },
id: 'caseInnofocus',
kind: 'project',
routeId: 'projects',
fragment: 'innofocus',
label: { de: 'Innofocus', en: 'Innofocus' },
labelLines: { de: ['Innofocus'], en: ['Innofocus'] },
summary: {
de: 'Werkzeuge und Laufzeitumgebung',
en: 'Tools and runtime environment',
de: 'ERP-Ablösung mit vollständiger Datenmigration',
en: 'ERP data migration in SQL Server',
},
x: 340,
gist: {
de: 'Ablösung des bestehenden ERP durch ein normalisiertes System, mit vollständiger Migration der Altdaten im Microsoft SQL Server. Ein Lauf dauert rund 90 Minuten statt zuvor etwa acht Stunden.',
en: 'Sole responsibility for moving a grown ERP data store into a normalised system inside Microsoft SQL Server. The migration run went from around eight hours to about 90 minutes.',
},
relationships: {
de: 'Belegt die Software- und Datenschicht und reicht über Kubernetes, Argo CD und Azure DevOps in die Plattformschicht.',
en: 'Attaches to software and data, and reaches into the platform layer through Kubernetes and Argo CD.',
},
x: 820,
y: 460,
},
{
id: 'caseMigration',
id: 'caseRoesterei',
kind: 'project',
routeId: 'projects',
label: { de: 'Datenmigration', en: 'Data migration' },
labelLines: { de: ['Datenmigration'], en: ['Data migration'] },
fragment: 'roesterei',
label: { de: 'Rösterei', en: 'Rösterei' },
labelLines: { de: ['Rösterei'], en: ['Rösterei'] },
summary: {
de: 'Datenbestände strukturiert überführen',
en: 'Moving data stores in a structured way',
de: 'Gesamte Technik: Netz, Microsoft 365, Shop, KI-Werkzeuge',
en: 'One company, from the network to the shop',
},
x: 860,
gist: {
de: 'Die gesamte Technik eines Betriebs in einer Hand, vom Netz über Microsoft 365 bis zum Onlineshop und zwei internen KI-Werkzeugen. Ich bin an dem Unternehmen wirtschaftlich beteiligt.',
en: 'Full technical responsibility for Rösterei Tangermünde: network and server structures, Microsoft 365, the online shop and two internal AI tools. I hold an economic participation in the company.',
},
relationships: {
de: 'Berührt alle vier Bereiche und ist der einzige Beleg für umgesetzte KI-Arbeit.',
en: 'Touches all four areas, and it is the only case behind the AI workflows.',
},
x: 380,
y: 460,
},
{
id: 'casePlatform',
id: 'caseBannier',
kind: 'project',
routeId: 'projects',
label: { de: 'Plattform und Betrieb', en: 'Platform and operations' },
labelLines: { de: ['Plattform und', 'Betrieb'], en: ['Platform and', 'operations'] },
fragment: 'bannier',
label: { de: 'Bannier', en: 'Bannier' },
labelLines: { de: ['Bannier'], en: ['Bannier'] },
summary: {
de: 'Plattformen betreiben und weiterentwickeln',
en: 'Operating and evolving platforms',
de: 'Gesamte IT einer Fahrschule seit etwa 2018',
en: "A driving school's whole IT since roughly 2018",
},
x: 860,
y: 300,
gist: {
de: 'Rechner, Netz, Support und Website einer Fahrschule, durchgehend betreut seit etwa 2018. Die eingesetzte Fachsoftware betreibe und betreue ich; entwickelt habe ich sie nicht.',
en: 'The entire IT of Fahrschule Bannier — the computers, the website, and the support and operation of the specialist software the school uses. I operate that software; I did not develop it.',
},
relationships: {
de: 'Belegt Server und Netz sowie den laufenden Betrieb; Software und Daten berührt der Fall nicht.',
en: 'Attaches to infrastructure and to day-to-day operation. It does not touch the software and data layer.',
},
x: 160,
y: 460,
},
{
id: 'caseAutomation',
id: 'caseHdi',
kind: 'project',
routeId: 'projects',
label: { de: 'Automatisierung und AI', en: 'Automation and AI' },
labelLines: { de: ['Automatisierung', 'und AI'], en: ['Automation', 'and AI'] },
fragment: 'hdi',
label: { de: 'HDI Specialty', en: 'HDI' },
labelLines: { de: ['HDI', 'Specialty'], en: ['HDI'] },
summary: {
de: 'Angebot: Abläufe automatisieren und Modelle anbinden',
en: 'Offered as workflow automation and model connections',
de: 'Exposure Management und Umzug auf Azure AKS',
en: 'Exposure management, moved onto Azure AKS',
},
x: 900,
gist: {
de: 'Fullstack- und DevOps-Arbeit an einer Software für das Exposure Management, einschließlich der Auswertungen für die BaFin. Entwicklungs-, QS- und Produktivumgebung zogen von Azure App Services auf Azure AKS um.',
en: 'Senior fullstack and DevOps work on exposure-management software for simulations, stochastic evaluation and BaFin reporting. Dev, QA and production moved from Azure App Services onto Azure AKS.',
},
relationships: {
de: 'Belegt die Plattformschicht und reicht über Angular und .NET in Software und Daten hinein.',
en: 'Attaches to the platform layer, and to software through the Angular and .NET work.',
},
x: 600,
y: 140,
},
];
export const SYSTEMS_MAP_EDGES: readonly SystemsMapEdge[] = [
{ from: 'hardware', to: 'clusters' },
{ from: 'clusters', to: 'software' },
{ from: 'infrastructure', to: 'platform' },
{ from: 'platform', to: 'software' },
{ from: 'software', to: 'ai' },
{ from: 'ai', to: 'clusters' },
{ from: 'hardware', to: 'software' },
{ from: 'stack', to: 'software' },
{ from: 'ai', to: 'caseAutomation' },
{ from: 'software', to: 'casePlatform' },
{ from: 'software', to: 'caseMigration' },
{ from: 'infrastructure', to: 'caseBannier' },
{ from: 'infrastructure', to: 'caseRoesterei' },
{ from: 'platform', to: 'caseHdi' },
{ from: 'software', to: 'caseInnofocus' },
{ from: 'ai', to: 'caseRoesterei' },
];
export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeId[] {
@@ -169,3 +244,7 @@ export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeI
return connected;
}
export function nodeHref(node: SystemsMapNode, locale: AppLocale): string {
return `${routePath(node.routeId, locale)}#${node.fragment}`;
}

View File

@@ -153,9 +153,37 @@
border-inline-start: 2px solid var(--color-accent);
}
.systems-map-readout {
min-height: var(--text-md);
font-size: var(--text-sm);
.systems-map-dialog {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
max-width: 40rem;
}
.systems-map-dialog-bar {
justify-content: space-between;
align-items: start;
}
.systems-map-dialog h3,
.systems-map-dialog p {
margin: 0;
}
.systems-map-dialog-close {
min-height: 2.75rem;
padding-inline: var(--space-3);
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
}
.systems-map-dialog a {
color: var(--color-accent-cool);
}
@media (hover: hover) and (pointer: fine) {

View File

@@ -1,30 +1,41 @@
import { PLATFORM_ID } from '@angular/core';
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
import { type AppLocale } from '../../core/i18n/locale';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
import { routePath } from '../../core/routing/route-paths';
import { SystemsMap } from './systems-map';
import { connectedNodeIds, SYSTEMS_MAP_NODES } from './systems-map.model';
import { nodeHref, SYSTEMS_MAP_NODES } from './systems-map.model';
describe('SystemsMap', () => {
async function createFixture(
async function configure(
providers: { provide: unknown; useValue: unknown }[] = [],
): Promise<ComponentFixture<SystemsMap>> {
): Promise<void> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [SystemsMap],
providers: [provideRouter([]), ...providers],
}).compileComponents();
}
async function createFixture(
providers: { provide: unknown; useValue: unknown }[] = [],
): Promise<ComponentFixture<SystemsMap>> {
await configure(providers);
const fixture = TestBed.createComponent(SystemsMap);
fixture.detectChanges();
await fixture.whenStable();
return fixture;
}
async function flush(fixture: ComponentFixture<SystemsMap>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
await fixture.whenStable();
}
function hrefsOf(root: ParentNode, selector: string): string[] {
return Array.from(root.querySelectorAll(selector)).map((element) => {
return element.getAttribute('href') ?? '';
@@ -32,81 +43,146 @@ describe('SystemsMap', () => {
}
function expectedNodes(locale: AppLocale) {
const relationshipLabel = SIGNATURE_COPY[locale].systemsMap.relationshipLabel;
return SYSTEMS_MAP_NODES.map((node) => {
const connected = new Set(connectedNodeIds(node.id));
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
(entry) => entry.label[locale],
);
return {
...node,
label: node.label[locale],
summary: node.summary[locale],
href: routePath(node.routeId, locale),
connectedLabels,
relationship: relationshipLabel.replace('{targets}', connectedLabels.join(', ')),
};
});
return SYSTEMS_MAP_NODES.map((node) => ({
...node,
label: node.label[locale],
summary: node.summary[locale],
gist: node.gist[locale],
href: nodeHref(node, locale),
relationship: node.relationships[locale],
}));
}
it('exposes the same ordered hrefs in the SVG and the card list from the routing contract', async () => {
function listAnchor(compiled: HTMLElement, index = 0): HTMLAnchorElement {
return compiled.querySelectorAll<HTMLAnchorElement>('.systems-map-cards a').item(index);
}
it('exposes the same ordered fragment hrefs in the SVG and the card list', async () => {
const fixture = await createFixture();
const locale = TestBed.inject(LocaleService).locale();
const expected = SYSTEMS_MAP_NODES.map((node) => routePath(node.routeId, locale));
const expected = SYSTEMS_MAP_NODES.map((node) => nodeHref(node, locale));
const compiled = fixture.nativeElement as HTMLElement;
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
expect(hrefsOf(compiled, '.systems-map-cards a')).toEqual(expected);
});
it('gives every node a non-empty accessible name with label, summary and neighbors', async () => {
it('gives every list node a non-empty accessible name and hides the SVG copy from the tree', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const nodes = expectedNodes(TestBed.inject(LocaleService).locale());
const figure = compiled.querySelector('.systems-map-figure');
const svgAnchors = compiled.querySelectorAll('svg a');
const listAnchors = compiled.querySelectorAll('.systems-map-cards a');
expect(figure?.getAttribute('aria-hidden')).toBe('true');
expect(svgAnchors.length).toBe(nodes.length);
expect(listAnchors.length).toBe(nodes.length);
nodes.forEach((node, index) => {
const svgName = svgAnchors.item(index).getAttribute('aria-label') ?? '';
const listName = listAnchors.item(index).getAttribute('aria-label') ?? '';
svgAnchors.forEach((anchor) => {
expect(anchor.getAttribute('tabindex')).toBe('-1');
});
expect(svgName.length).toBeGreaterThan(0);
nodes.forEach((node, index) => {
const listName = listAnchors.item(index).getAttribute('aria-label') ?? '';
expect(listName.length).toBeGreaterThan(0);
expect(svgName).toContain(node.label);
expect(svgName).toContain(node.summary);
expect(listName).toContain(node.label);
expect(listName).toContain(node.summary);
for (const label of node.connectedLabels) {
expect(svgName).toContain(label);
expect(listName).toContain(label);
}
expect(listName).toContain(node.relationship);
});
});
it('keeps every node keyboard reachable and navigates on a plain left click', async () => {
it('opens the gist dialog on a plain left click and does not navigate', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const router = TestBed.inject(Router);
const navigation = TestBed.inject(NavigationService);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const anchors = compiled.querySelectorAll('svg a, .systems-map-cards a');
const first = expectedNodes(TestBed.inject(LocaleService).locale())[0];
const copy = SIGNATURE_COPY.de.systemsMap;
anchors.forEach((anchor) => {
expect(anchor.getAttribute('tabindex')).not.toBe('-1');
});
const anchor = listAnchor(compiled);
expect(anchor.getAttribute('tabindex')).not.toBe('-1');
anchor.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
await flush(fixture);
const first = SYSTEMS_MAP_NODES[0];
const svgAnchor = compiled.querySelector('svg a');
expect(svgAnchor).toBeTruthy();
svgAnchor?.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
fixture.detectChanges();
const dialog = compiled.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
expect(dialog?.getAttribute('aria-modal')).toBe('true');
expect(dialog?.textContent).toContain(first.label);
expect(dialog?.textContent).toContain(first.gist);
expect(dialog?.textContent).toContain(first.relationship);
expect(dialog?.querySelector('a')?.getAttribute('href')).toBe(first.href);
expect(dialog?.textContent).toContain(copy.closeLabel);
expect(navigate).not.toHaveBeenCalled();
});
expect(navigate).toHaveBeenCalledWith(navigation.link(first.routeId));
it('lets modified clicks and middle clicks fall through to the real href', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const router = TestBed.inject(Router);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const anchor = listAnchor(compiled);
for (const init of [
{ button: 0, ctrlKey: true },
{ button: 0, metaKey: true },
{ button: 0, shiftKey: true },
{ button: 0, altKey: true },
{ button: 1 },
] as const) {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...init });
const prevent = vi.spyOn(event, 'preventDefault');
anchor.dispatchEvent(event);
await flush(fixture);
expect(prevent, JSON.stringify(init)).not.toHaveBeenCalled();
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
}
expect(navigate).not.toHaveBeenCalled();
});
it('moves focus into the dialog and restores it to the activating node on close', async () => {
const fixture = await createFixture();
const compiled = fixture.nativeElement as HTMLElement;
const copy = SIGNATURE_COPY.de.systemsMap;
const anchor = listAnchor(compiled);
anchor.focus();
expect(document.activeElement).toBe(anchor);
anchor.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await flush(fixture);
const dialog = compiled.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog).toBeTruthy();
expect(dialog.contains(document.activeElement)).toBe(true);
compiled
.querySelector<HTMLButtonElement>('.systems-map-dialog-close')
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(anchor);
expect(copy.closeLabel.length).toBeGreaterThan(0);
});
it('keeps instance-scoped ids unique when two maps are rendered', async () => {
await configure();
const first = TestBed.createComponent(SystemsMap);
const second = TestBed.createComponent(SystemsMap);
first.detectChanges();
second.detectChanges();
const firstIds = [...first.nativeElement.querySelectorAll('[id]')].map(
(element) => (element as HTMLElement).id,
);
const secondIds = [...second.nativeElement.querySelectorAll('[id]')].map(
(element) => (element as HTMLElement).id,
);
expect(firstIds.length).toBeGreaterThan(0);
expect(new Set(firstIds).size).toBe(firstIds.length);
expect(new Set([...firstIds, ...secondIds]).size).toBe(firstIds.length + secondIds.length);
});
it('rewrites every href when the locale switches to English', async () => {
@@ -116,7 +192,7 @@ describe('SystemsMap', () => {
fixture.detectChanges();
await fixture.whenStable();
const expected = SYSTEMS_MAP_NODES.map((node) => routePath(node.routeId, 'en'));
const expected = SYSTEMS_MAP_NODES.map((node) => nodeHref(node, 'en'));
const compiled = fixture.nativeElement as HTMLElement;
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
@@ -177,6 +253,7 @@ describe('SystemsMap', () => {
expect(svgAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
expect(listAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
expect(compiled.querySelector('[role="dialog"]')).toBeNull();
expectedNodes(TestBed.inject(LocaleService).locale()).forEach((node, index) => {
expect(svgAnchors.item(index).getAttribute('href')).toBe(node.href);

View File

@@ -1,12 +1,25 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
Injector,
input,
signal,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
import { type AppLocale } from '../../core/i18n/locale';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
import { routePath } from '../../core/routing/route-paths';
import { isBrowserPlatform } from '../../core/platform/browser';
import {
connectedNodeIds,
nodeHref,
SYSTEMS_MAP_EDGES,
SYSTEMS_MAP_NODE_KINDS,
SYSTEMS_MAP_NODES,
@@ -20,15 +33,18 @@ export interface SystemsMapNodeView {
readonly id: SystemsMapNodeId;
readonly kind: SystemsMapNode['kind'];
readonly routeId: SystemsMapNode['routeId'];
readonly fragment: string;
readonly x: number;
readonly y: number;
readonly label: string;
readonly labelLines: readonly string[];
readonly summary: string;
readonly gist: string;
readonly href: string;
readonly link: unknown[];
readonly relationship: string;
readonly accessibleName: string;
readonly linkLabel: string;
readonly shapeWidth: number;
readonly shapeHeight: number;
readonly textStartDy: number;
@@ -55,18 +71,25 @@ export class SystemsMap {
readonly intro = input('');
readonly headingLevel = input<2 | 3>(2);
private readonly document = inject(DOCUMENT);
private readonly injector = inject(Injector);
private readonly navigation = inject(NavigationService);
private readonly localeService = inject(LocaleService);
private readonly router = inject(Router);
private readonly isBrowser = isBrowserPlatform();
private readonly instanceId = systemsMapInstanceId++;
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('gistDialog');
private opener: HTMLElement | null = null;
protected readonly headingId = `systems-map-heading-${this.instanceId}`;
protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`;
protected readonly svgDescId = `systems-map-svg-desc-${this.instanceId}`;
protected readonly listHeadingId = `systems-map-list-${this.instanceId}`;
protected readonly legendHeadingId = `systems-map-legend-${this.instanceId}`;
protected readonly dialogTitleId = `systems-map-dialog-title-${this.instanceId}`;
protected readonly dialogDescId = `systems-map-dialog-desc-${this.instanceId}`;
protected readonly activeNodeId = signal<SystemsMapNodeId | null>(null);
protected readonly openNodeId = signal<SystemsMapNodeId | null>(null);
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].systemsMap);
@@ -82,9 +105,7 @@ export class SystemsMap {
protected readonly nodes = computed(() => {
const locale = this.localeService.locale();
const copy = this.copy();
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale, copy.relationshipLabel));
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale));
});
protected readonly edges = computed((): readonly SystemsMapEdgeView[] => {
@@ -114,15 +135,14 @@ export class SystemsMap {
}));
});
protected readonly activeReadout = computed(() => {
const activeId = this.activeNodeId();
protected readonly openNode = computed(() => {
const id = this.openNodeId();
return id ? (this.nodes().find((node) => node.id === id) ?? null) : null;
});
if (!activeId) {
return '';
}
const node = this.nodes().find((entry) => entry.id === activeId);
return node ? `${node.label}: ${node.relationship}` : '';
protected readonly dialogTitle = computed(() => {
const node = this.openNode();
return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : '';
});
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
@@ -131,7 +151,16 @@ export class SystemsMap {
}
event.preventDefault();
void this.router.navigate(this.navigation.link(node.routeId));
this.openDialog(node.id, event.currentTarget as HTMLElement);
}
protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
this.openDialog(node.id, event.currentTarget as HTMLElement);
}
protected onNodeEnter(id: SystemsMapNodeId): void {
@@ -144,78 +173,136 @@ export class SystemsMap {
}
}
protected isConnectedNode(id: SystemsMapNodeId): boolean {
const active = this.activeNodeId();
protected closeDialog(): void {
this.openNodeId.set(null);
const opener = this.opener;
this.opener = null;
if (!active) {
return false;
if (!this.isBrowser || !(opener instanceof HTMLElement)) {
return;
}
return active === id || this.neighborSet(active).has(id);
afterNextRender(
() => {
if (opener.isConnected) {
opener.focus();
}
},
{ injector: this.injector },
);
}
protected onDialogKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
this.closeDialog();
return;
}
if (event.key !== 'Tab') {
return;
}
const dialog = this.dialogRef()?.nativeElement;
if (!dialog) {
return;
}
const focusable = this.focusableIn(dialog);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = this.document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
protected isConnectedNode(id: SystemsMapNodeId): boolean {
const active = this.activeNodeId();
return !!active && (active === id || this.neighborSet(active).has(id));
}
protected isDimmedNode(id: SystemsMapNodeId): boolean {
const active = this.activeNodeId();
if (!active) {
return false;
}
return active !== id && !this.neighborSet(active).has(id);
return !!active && active !== id && !this.neighborSet(active).has(id);
}
protected isConnectedEdge(edge: SystemsMapEdgeView): boolean {
const active = this.activeNodeId();
if (!active) {
return false;
}
return edge.from === active || edge.to === active;
return !!active && (edge.from === active || edge.to === active);
}
protected isDimmedEdge(edge: SystemsMapEdgeView): boolean {
const active = this.activeNodeId();
if (!active) {
return false;
}
return edge.from !== active && edge.to !== active;
return !!active && edge.from !== active && edge.to !== active;
}
private toNodeView(
node: SystemsMapNode,
locale: AppLocale,
relationshipLabel: string,
): SystemsMapNodeView {
const connected = new Set(connectedNodeIds(node.id));
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
(entry) => entry.label[locale],
private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void {
this.opener = opener;
this.openNodeId.set(id);
afterNextRender(
() => {
const dialog = this.dialogRef()?.nativeElement;
const target = this.focusableIn(dialog)[0] ?? dialog;
target?.focus();
},
{ injector: this.injector },
);
const relationship = relationshipLabel.replace('{targets}', connectedLabels.join(', '));
}
private focusableIn(root: HTMLElement | undefined): HTMLElement[] {
if (!root) {
return [];
}
return Array.from(
root.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((element) => !element.hasAttribute('hidden'));
}
private toNodeView(node: SystemsMapNode, locale: AppLocale): SystemsMapNodeView {
const copy = this.copy();
const label = node.label[locale];
const labelLines = node.labelLines[locale];
const summary = node.summary[locale];
const gist = node.gist[locale];
const relationship = node.relationships[locale];
const multiline = labelLines.length > 1;
const longest = labelLines.reduce((max, line) => Math.max(max, line.length), 0);
const linkPattern = node.kind === 'project' ? copy.caseLinkPattern : copy.sectionLinkPattern;
return {
id: node.id,
kind: node.kind,
routeId: node.routeId,
fragment: node.fragment,
x: node.x,
y: node.y,
label,
labelLines,
summary,
gist,
shapeWidth: Math.max(140, longest * 8 + 24),
shapeHeight: multiline ? 52 : 40,
textStartDy: multiline ? -6 : 4,
href: routePath(node.routeId, locale),
href: nodeHref(node, locale),
link: this.navigation.link(node.routeId),
relationship,
accessibleName: `${label}. ${summary} ${relationship}`,
linkLabel: linkPattern.replace('{node}', label),
};
}

View File

@@ -17,7 +17,7 @@ describe('terminal command grammar', () => {
expect(parseCommand('')).toEqual({ kind: 'empty' });
});
it('resolves navigate targets including the services ai sub-target', () => {
it('resolves navigate targets including the four service fragments', () => {
expect(parseCommand('navigate home')).toEqual({
kind: 'navigate',
resolution: { kind: 'route', routeId: 'home' },
@@ -28,16 +28,31 @@ describe('terminal command grammar', () => {
resolution: { kind: 'route', routeId: 'pitch' },
targetKey: 'pitch',
});
expect(parseCommand('navigate services')).toEqual({
kind: 'navigate',
resolution: { kind: 'route', routeId: 'services' },
targetKey: 'services',
});
expect(parseCommand('navigate services ai')).toEqual({
kind: 'navigate',
resolution: { kind: 'route', routeId: 'servicesAi' },
resolution: { kind: 'route', routeId: 'services', fragment: 'ai-workflows' },
targetKey: 'services ai',
});
expect(parseCommand('navigate leistungen software')).toEqual({
kind: 'navigate',
resolution: { kind: 'route', routeId: 'servicesSoftware' },
resolution: { kind: 'route', routeId: 'services', fragment: 'software-data' },
targetKey: 'services software',
});
expect(parseCommand('navigate services infrastructure')).toEqual({
kind: 'navigate',
resolution: { kind: 'route', routeId: 'services', fragment: 'infrastructure-network' },
targetKey: 'services infrastructure',
});
expect(parseCommand('navigate services platform')).toEqual({
kind: 'navigate',
resolution: { kind: 'route', routeId: 'services', fragment: 'platform-operations' },
targetKey: 'services platform',
});
expect(parseCommand('navigate cv')).toEqual({
kind: 'navigate',
resolution: { kind: 'cv' },
@@ -62,7 +77,7 @@ describe('terminal command grammar', () => {
it('completes the current token against commands and navigate targets', () => {
expect(suggestCompletions('he')).toEqual(['help']);
expect(applyCompletion('he')).toEqual({ value: 'help', candidates: ['help'] });
expect(suggestCompletions('navigate p')).toEqual(['pitch', 'projects', 'projekte']);
expect(suggestCompletions('navigate p')).toEqual(['pitch', 'profil', 'projects', 'projekte']);
expect(applyCompletion('navigate pit')).toEqual({
value: 'navigate pitch',
candidates: ['pitch'],

View File

@@ -4,7 +4,8 @@ import { type RouteId } from '../../core/routing/route-ids';
export { COMMAND_IDS, type CommandId };
export type NavigateResolution =
{ readonly kind: 'route'; readonly routeId: RouteId } | { readonly kind: 'cv' };
| { readonly kind: 'route'; readonly routeId: RouteId; readonly fragment?: string }
| { readonly kind: 'cv' };
export interface NavigateTarget {
readonly key: string;
@@ -34,34 +35,48 @@ export type ParsedCommand =
export const NAVIGATE_TARGETS: readonly NavigateTarget[] = [
{ key: 'home', aliases: ['start', 'startseite'], resolution: { kind: 'route', routeId: 'home' } },
{ key: 'pitch', aliases: [], resolution: { kind: 'route', routeId: 'pitch' } },
{ key: 'pitch', aliases: ['profil'], resolution: { kind: 'route', routeId: 'pitch' } },
{ key: 'services', aliases: ['leistungen'], resolution: { kind: 'route', routeId: 'services' } },
{
key: 'services ai',
aliases: ['leistungen ai'],
resolution: { kind: 'route', routeId: 'servicesAi' },
key: 'services infrastructure',
aliases: [
'services network',
'services servers',
'services hardware',
'leistungen infrastruktur',
'leistungen netz',
'leistungen netzwerk',
'leistungen server',
],
resolution: { kind: 'route', routeId: 'services', fragment: 'infrastructure-network' },
},
{
key: 'services platform',
aliases: [
'services operations',
'services clusters',
'leistungen plattform',
'leistungen betrieb',
'leistungen cluster',
],
resolution: { kind: 'route', routeId: 'services', fragment: 'platform-operations' },
},
{
key: 'services software',
aliases: ['leistungen software'],
resolution: { kind: 'route', routeId: 'servicesSoftware' },
aliases: ['services data', 'leistungen software', 'leistungen daten'],
resolution: { kind: 'route', routeId: 'services', fragment: 'software-data' },
},
{
key: 'services network',
aliases: ['leistungen network', 'leistungen netzwerk', 'services hardware'],
resolution: { kind: 'route', routeId: 'servicesHardwareNetwork' },
},
{
key: 'services clusters',
aliases: ['leistungen clusters', 'leistungen cluster'],
resolution: { kind: 'route', routeId: 'servicesClusters' },
key: 'services ai',
aliases: ['leistungen ki', 'leistungen ai'],
resolution: { kind: 'route', routeId: 'services', fragment: 'ai-workflows' },
},
{
key: 'projects',
aliases: ['projekte'],
resolution: { kind: 'route', routeId: 'projects' },
},
{ key: 'stack', aliases: [], resolution: { kind: 'route', routeId: 'stack' } },
{ key: 'stack', aliases: ['technik'], resolution: { kind: 'route', routeId: 'stack' } },
{
key: 'about',
aliases: ['ueber-mich', 'über-mich'],

View File

@@ -5,7 +5,9 @@
class="terminal-dock-trigger"
[attr.aria-expanded]="open()"
[attr.aria-controls]="open() ? panelId : null"
[attr.aria-label]="copy().triggerLabel"
[attr.aria-label]="copy().triggerAccessibleName"
[attr.title]="triggerTooltip()"
[hidden]="open()"
(click)="onTriggerClick()"
>
<span>{{ copy().triggerLabel }}</span>
@@ -22,57 +24,61 @@
>
<div class="terminal-dock-toolbar cluster">
<p class="terminal-dock-title">{{ copy().panelLabel }}</p>
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="copy().maximizeLabel"
[attr.aria-pressed]="maximized()"
(click)="toggleMaximized()"
>
{{ copy().maximizeLabel }}
</button>
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="copy().collapseLabel"
(click)="collapse()"
>
{{ copy().collapseLabel }}
</button>
<div class="terminal-dock-actions cluster">
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="sizeLabel()"
[attr.title]="sizeTooltip()"
[attr.aria-pressed]="maximized()"
(click)="toggleMaximized()"
>
<span aria-hidden="true">{{ maximized() ? '▭' : '▢' }}</span>
</button>
<button
type="button"
class="terminal-dock-control"
[attr.aria-label]="copy().collapseLabel"
[attr.title]="copy().collapseTooltip"
(click)="collapse()"
>
<span aria-hidden="true"></span>
</button>
</div>
</div>
<div
#outputLog
class="terminal-dock-log"
class="terminal-dock-session"
[id]="logId"
role="log"
aria-live="polite"
aria-atomic="false"
[attr.aria-label]="copy().outputLabel"
(scroll)="onSessionScroll()"
>
@for (line of log(); track line.id) {
<p [class.echo]="line.kind === 'echo'">{{ line.text }}</p>
}
<form class="terminal-dock-form" (submit)="onSubmit($event)">
<label class="terminal-dock-prompt" [attr.for]="inputId">
<span aria-hidden="true">{{ copy().prompt }}</span>
<span class="visually-hidden">{{ copy().inputLabel }}</span>
</label>
<input
#commandInput
[id]="inputId"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
[attr.placeholder]="copy().inputPlaceholder"
[value]="query()"
(input)="onQueryInput($event)"
(keydown)="onInputKeydown($event)"
/>
</form>
</div>
<form class="terminal-dock-form" (submit)="onSubmit($event)">
<label class="terminal-dock-prompt" [attr.for]="inputId">
<span aria-hidden="true">{{ copy().prompt }}</span>
<span class="visually-hidden">{{ copy().inputLabel }}</span>
</label>
<input
#commandInput
[id]="inputId"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
[attr.placeholder]="copy().inputPlaceholder"
[value]="query()"
(input)="onQueryInput($event)"
(keydown)="onInputKeydown($event)"
/>
</form>
</section>
}
</div>

View File

@@ -38,7 +38,7 @@
.terminal-dock-panel {
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-rows: auto 1fr;
gap: var(--space-3);
width: 100%;
max-height: min(70vh, 32rem);
@@ -60,7 +60,7 @@
justify-items: stretch;
}
.terminal-dock.is-open .terminal-dock-trigger {
.terminal-dock-trigger[hidden] {
display: none;
}
@@ -68,9 +68,13 @@
max-height: 90vh;
}
.terminal-dock-toolbar {
justify-content: space-between;
}
.terminal-dock-title,
.terminal-dock-log,
.terminal-dock-log p,
.terminal-dock-session,
.terminal-dock-session p,
.terminal-dock-form {
margin: 0;
}
@@ -95,16 +99,16 @@
background: var(--color-surface);
}
.terminal-dock-log {
.terminal-dock-session {
overflow: auto;
min-height: 6rem;
min-height: 8rem;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-text-muted);
white-space: pre-wrap;
}
.terminal-dock-log .echo {
.terminal-dock-session .echo {
color: var(--color-text);
}
@@ -165,10 +169,6 @@
justify-items: end;
}
.terminal-dock.is-open .terminal-dock-trigger {
display: inline-flex;
}
.terminal-dock-panel {
width: min(28rem, calc(100vw - var(--space-8)));
max-height: min(22rem, 60vh);

View File

@@ -18,7 +18,7 @@ export class TerminalDockService {
private readonly injector = inject(Injector);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
readonly state = signal<TerminalDockState>('collapsed');
readonly state = signal<TerminalDockState>('expanded');
readonly panelId = 'terminal-dock-panel';
readonly inputId = 'terminal-dock-input';
readonly logId = 'terminal-dock-log';

View File

@@ -53,6 +53,26 @@ describe('TerminalDock', () => {
return fixture.nativeElement.querySelector('input');
}
function session(fixture: ComponentFixture<TerminalDock>): HTMLElement | null {
return fixture.nativeElement.querySelector('.terminal-dock-session');
}
function collapseControl(
fixture: ComponentFixture<TerminalDock>,
locale: AppLocale,
): HTMLButtonElement {
const label = SIGNATURE_COPY[locale].terminal.collapseLabel;
return fixture.nativeElement.querySelector(`[aria-label="${label}"]`);
}
async function collapseViaControl(
fixture: ComponentFixture<TerminalDock>,
locale: AppLocale,
): Promise<void> {
collapseControl(fixture, locale).click();
await flush(fixture);
}
async function openViaTrigger(fixture: ComponentFixture<TerminalDock>): Promise<void> {
trigger(fixture).click();
await flush(fixture);
@@ -73,66 +93,73 @@ describe('TerminalDock', () => {
}
function logText(fixture: ComponentFixture<TerminalDock>): string {
return fixture.nativeElement.querySelector('.terminal-dock-log')?.textContent ?? '';
return session(fixture)?.textContent ?? '';
}
it.each(APP_LOCALES)('renders the collapsed trigger on the server (%s)', async (locale) => {
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
expect(trigger(fixture)).toBeTruthy();
expect(panel(fixture)).toBeNull();
});
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(document.activeElement).toBe(input(fixture));
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
});
it.each(APP_LOCALES)('toggles the trigger open, closed, then open again (%s)', async (locale) => {
const fixture = await createFixture(locale);
const dock = TestBed.inject(TerminalDockService);
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(dock.state()).toBe('expanded');
expect(document.activeElement).toBe(input(fixture));
trigger(fixture).click();
await flush(fixture);
expect(panel(fixture)).toBeNull();
expect(dock.state()).toBe('collapsed');
expect(document.activeElement).toBe(trigger(fixture));
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(dock.state()).toBe('expanded');
expect(document.activeElement).toBe(input(fixture));
});
it.each(APP_LOCALES)(
'renders the expanded panel on the server without stealing focus (%s)',
async (locale) => {
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
expect(trigger(fixture)).toBeTruthy();
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
expect(panel(fixture)).toBeTruthy();
expect(session(fixture)).toBeTruthy();
expect(session(fixture)?.contains(input(fixture))).toBe(true);
expect(document.activeElement).not.toBe(input(fixture));
},
);
it.each(APP_LOCALES)(
'returns focus to the trigger after a trigger-initiated collapse (%s)',
'defaults to expanded and does not steal focus on load (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
expect(document.activeElement).toBe(input(fixture));
trigger(fixture).click();
await flush(fixture);
const dock = TestBed.inject(TerminalDockService);
const copy = SIGNATURE_COPY[locale].terminal;
expect(dock.state()).toBe('expanded');
expect(panel(fixture)).toBeTruthy();
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
expect(document.activeElement).not.toBe(input(fixture));
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
const size = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
const collapse = collapseControl(fixture, locale);
expect(size.getAttribute('aria-label')).toBe(copy.maximizeLabel);
expect(collapse.getAttribute('aria-label')).toBe(copy.collapseLabel);
expect(size.getAttribute('title')).toBe(copy.maximizeTooltip);
expect(collapse.getAttribute('title')).toBe(copy.collapseTooltip);
expect(size.getAttribute('aria-label')).not.toBe(collapse.getAttribute('aria-label'));
},
);
it.each(APP_LOCALES)(
'hides the trigger while open and shows it again after collapse (%s)',
async (locale) => {
const fixture = await createFixture(locale);
const dock = TestBed.inject(TerminalDockService);
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
expect(panel(fixture)).toBeTruthy();
await collapseViaControl(fixture, locale);
expect(panel(fixture)).toBeNull();
expect(dock.state()).toBe('collapsed');
expect(trigger(fixture).hasAttribute('hidden')).toBe(false);
expect(document.activeElement).toBe(trigger(fixture));
await openViaTrigger(fixture);
expect(panel(fixture)).toBeTruthy();
expect(dock.state()).toBe('expanded');
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
expect(document.activeElement).toBe(input(fixture));
},
);
it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => {
const fixture = await createFixture(locale);
const dock = TestBed.inject(TerminalDockService);
expect(dock.state()).toBe('collapsed');
await openViaTrigger(fixture);
expect(dock.state()).toBe('expanded');
fixture.nativeElement.querySelector('[aria-pressed]')?.click();
@@ -147,39 +174,28 @@ describe('TerminalDock', () => {
expect(dock.state()).toBe('expanded');
});
it.each(APP_LOCALES)(
'keeps one stable maximize name across both aria-pressed states (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const label = SIGNATURE_COPY[locale].terminal.maximizeLabel;
const button = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
expect(button.getAttribute('aria-label')).toBe(label);
expect(button.textContent?.trim()).toBe(label);
expect(button.getAttribute('aria-pressed')).toBe('false');
button.click();
await flush(fixture);
expect(button.getAttribute('aria-label')).toBe(label);
expect(button.textContent?.trim()).toBe(label);
expect(button.getAttribute('aria-pressed')).toBe('true');
},
);
it.each(APP_LOCALES)('opens from Ctrl+K and Meta+K (%s)', async (locale) => {
it.each(APP_LOCALES)('uses distinct maximize and restore names (%s)', async (locale) => {
const fixture = await createFixture(locale);
const copy = SIGNATURE_COPY[locale].terminal;
const button = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
expect(button.getAttribute('aria-label')).toBe(copy.maximizeLabel);
expect(button.getAttribute('title')).toBe(copy.maximizeTooltip);
button.click();
await flush(fixture);
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
if (panel(fixture)) {
input(fixture)?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await flush(fixture);
}
expect(button.getAttribute('aria-label')).toBe(copy.restoreLabel);
expect(button.getAttribute('title')).toBe(copy.restoreTooltip);
expect(button.getAttribute('aria-pressed')).toBe('true');
});
it.each(APP_LOCALES)('toggles from Ctrl+K and Meta+K (%s)', async (locale) => {
const fixture = await createFixture(locale);
await flush(fixture);
expect(panel(fixture)).toBeTruthy();
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
const event = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
@@ -191,13 +207,15 @@ describe('TerminalDock', () => {
await flush(fixture);
expect(prevent).toHaveBeenCalled();
expect(panel(fixture)).toBeTruthy();
}
expect(panel(fixture)).toBeTruthy();
});
it('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => {
const fixture = await createFixture();
await flush(fixture);
expect(panel(fixture)).toBeTruthy();
for (const modifier of [
{ ctrlKey: true, shiftKey: true },
@@ -214,33 +232,33 @@ describe('TerminalDock', () => {
await flush(fixture);
expect(prevent).not.toHaveBeenCalled();
expect(panel(fixture)).toBeNull();
expect(panel(fixture)).toBeTruthy();
}
const openEvent = new KeyboardEvent('keydown', {
const closeEvent = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
ctrlKey: true,
});
const preventOpen = vi.spyOn(openEvent, 'preventDefault');
document.dispatchEvent(openEvent);
const preventClose = vi.spyOn(closeEvent, 'preventDefault');
document.dispatchEvent(closeEvent);
await flush(fixture);
expect(preventOpen).toHaveBeenCalled();
expect(panel(fixture)).toBeTruthy();
expect(preventClose).toHaveBeenCalled();
expect(panel(fixture)).toBeNull();
const shiftWhileOpen = new KeyboardEvent('keydown', {
const shiftWhileClosed = new KeyboardEvent('keydown', {
key: 'k',
bubbles: true,
cancelable: true,
ctrlKey: true,
shiftKey: true,
});
const preventShift = vi.spyOn(shiftWhileOpen, 'preventDefault');
document.dispatchEvent(shiftWhileOpen);
const preventShift = vi.spyOn(shiftWhileClosed, 'preventDefault');
document.dispatchEvent(shiftWhileClosed);
await flush(fixture);
expect(preventShift).not.toHaveBeenCalled();
expect(panel(fixture)).toBeTruthy();
expect(panel(fixture)).toBeNull();
const metaOpen = new KeyboardEvent('keydown', {
key: 'k',
@@ -252,12 +270,11 @@ describe('TerminalDock', () => {
document.dispatchEvent(metaOpen);
await flush(fixture);
expect(preventMeta).toHaveBeenCalled();
expect(panel(fixture)).toBeNull();
expect(panel(fixture)).toBeTruthy();
});
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
input(fixture)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush(fixture);
@@ -269,8 +286,6 @@ describe('TerminalDock', () => {
'closes on Escape from a panel control and returns focus to the trigger (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const maximize = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
maximize.focus();
expect(document.activeElement).toBe(maximize);
@@ -286,7 +301,7 @@ describe('TerminalDock', () => {
it.each(APP_LOCALES)('does not lock scroll or mark the page inert (%s)', async (locale) => {
document.body.style.overflow = 'auto';
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
await flush(fixture);
expect(document.body.style.overflow).toBe('auto');
expect(document.querySelector('.site')?.hasAttribute('inert')).toBeFalsy();
@@ -298,7 +313,6 @@ describe('TerminalDock', () => {
'echoes the prompt, keeps the log and walks command history (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const copy = SIGNATURE_COPY[locale].terminal;
await submitQuery(fixture, 'help');
@@ -324,31 +338,32 @@ describe('TerminalDock', () => {
expect(field?.value).toBe('history');
await submitQuery(fixture, 'clear');
expect(logText(fixture).trim()).toBe(copy.clearedMessage);
expect(logText(fixture)).toContain(copy.clearedMessage);
},
);
it.each(APP_LOCALES)('scrolls the log to the newest line after output (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const logEl = fixture.nativeElement.querySelector('.terminal-dock-log') as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperty(logEl, 'scrollHeight', { configurable: true, get: () => 480 });
Object.defineProperty(logEl, 'scrollTo', { configurable: true, value: scrollTo });
it.each(APP_LOCALES)(
'scrolls the session to keep the prompt visible after output (%s)',
async (locale) => {
const fixture = await createFixture(locale);
const sessionEl = session(fixture) as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperty(sessionEl, 'scrollHeight', { configurable: true, get: () => 480 });
Object.defineProperty(sessionEl, 'scrollTo', { configurable: true, value: scrollTo });
await submitQuery(fixture, 'help');
await submitQuery(fixture, 'help');
expect(scrollTo).toHaveBeenCalled();
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
top: 480,
}),
);
});
expect(scrollTo).toHaveBeenCalled();
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
top: 480,
}),
);
},
);
it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
await submitQuery(fixture, 'help');
await submitQuery(fixture, 'brew');
await submitQuery(fixture, 'rev');
@@ -393,21 +408,51 @@ describe('TerminalDock', () => {
expect(field?.value).toBe('');
});
it.each(APP_LOCALES)('navigates a known target and opens the CV asset (%s)', async (locale) => {
const fixture = await createFixture(locale);
const router = TestBed.inject(Router);
const navigation = TestBed.inject(NavigationService);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const view = TestBed.inject(DOCUMENT).defaultView;
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
it.each(APP_LOCALES)(
'restores an in-progress draft after walking off the newest history (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await submitQuery(fixture, 'help');
const field = input(fixture);
field!.value = 'nav';
field!.dispatchEvent(new Event('input', { bubbles: true }));
await flush(fixture);
await openViaTrigger(fixture);
await submitQuery(fixture, 'navigate pitch');
expect(navigate).toHaveBeenCalledWith(navigation.link('pitch'));
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('help');
await submitQuery(fixture, 'navigate cv');
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
});
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flush(fixture);
expect(field?.value).toBe('nav');
},
);
it.each(APP_LOCALES)(
'navigates a known target, jumps to a fragment, and opens the CV asset (%s)',
async (locale) => {
const fixture = await createFixture(locale);
const router = TestBed.inject(Router);
const navigation = TestBed.inject(NavigationService);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const view = TestBed.inject(DOCUMENT).defaultView;
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
const copy = SIGNATURE_COPY[locale].terminal;
await submitQuery(fixture, 'navigate pitch');
expect(navigate).toHaveBeenCalledWith(navigation.link('pitch'), undefined);
expect(logText(fixture)).toContain(copy.navigating.replace('{target}', 'pitch'));
await submitQuery(fixture, 'navigate services software');
expect(navigate).toHaveBeenCalledWith(navigation.link('services'), {
fragment: 'software-data',
});
expect(logText(fixture)).toContain(copy.jumping.replace('{target}', 'services software'));
await submitQuery(fixture, 'navigate cv');
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
},
);
it('registers no document listener on the server', async () => {
const add = vi.spyOn(document, 'addEventListener');

View File

@@ -35,6 +35,9 @@ interface LogLine {
readonly text: string;
}
const MAX_LOG_LINES = 200;
const MAX_HISTORY = 80;
let terminalLogId = 0;
@Component({
@@ -67,11 +70,22 @@ export class TerminalDock {
protected readonly log = signal<readonly LogLine[]>([]);
protected readonly entered = signal<readonly string[]>([]);
protected readonly historyIndex = signal<number | null>(null);
private readonly historyDraft = signal('');
private followOutput = true;
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].terminal);
protected readonly shortcutHint = computed(() =>
this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint,
);
protected readonly triggerTooltip = computed(() =>
this.useAppleHint() ? this.copy().triggerTooltipApple : this.copy().triggerTooltip,
);
protected readonly sizeLabel = computed(() =>
this.maximized() ? this.copy().restoreLabel : this.copy().maximizeLabel,
);
protected readonly sizeTooltip = computed(() =>
this.maximized() ? this.copy().restoreTooltip : this.copy().maximizeTooltip,
);
protected readonly open = computed(() => this.state() !== 'collapsed');
protected readonly maximized = computed(() => this.state() === 'maximized');
@@ -87,22 +101,9 @@ export class TerminalDock {
{ injector: this.injector },
);
effect(() => {
if (!this.open()) {
return;
}
afterNextRender(
() => {
this.inputRef()?.nativeElement.focus();
},
{ injector: this.injector },
);
});
effect(() => {
const lines = this.log();
if (!this.isBrowser || !this.open() || lines.length === 0) {
if (!this.isBrowser || !this.open() || lines.length === 0 || !this.followOutput) {
return;
}
@@ -117,6 +118,9 @@ export class TerminalDock {
protected onTriggerClick(): void {
this.dock.toggle(this.triggerRef()?.nativeElement ?? null);
if (this.open()) {
this.focusInput();
}
}
protected onQueryInput(event: Event): void {
@@ -126,6 +130,9 @@ export class TerminalDock {
}
this.query.set(target.value);
if (this.historyIndex() === null) {
this.historyDraft.set(target.value);
}
this.historyIndex.set(null);
}
@@ -162,6 +169,16 @@ export class TerminalDock {
}
}
protected onSessionScroll(): void {
const element = this.logRef()?.nativeElement;
if (!element) {
return;
}
const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
this.followOutput = distance < 24;
}
protected toggleMaximized(): void {
this.dock.toggleMaximized();
}
@@ -170,6 +187,15 @@ export class TerminalDock {
this.dock.collapse();
}
private focusInput(): void {
afterNextRender(
() => {
this.inputRef()?.nativeElement.focus();
},
{ injector: this.injector },
);
}
private runRaw(raw: string): void {
const parsed = parseCommand(raw);
if (parsed.kind === 'empty') {
@@ -178,9 +204,11 @@ export class TerminalDock {
const copy = this.copy();
this.echo(raw);
this.entered.update((items) => [...items, raw]);
this.entered.update((items) => [...items, raw].slice(-MAX_HISTORY));
this.historyIndex.set(null);
this.historyDraft.set('');
this.writeQuery('');
this.followOutput = true;
switch (parsed.kind) {
case 'help': {
@@ -188,6 +216,8 @@ export class TerminalDock {
copy.helpIntro,
copy.helpGrammar,
...COMMAND_IDS.map((id) => `${id}${copy.commandDescriptions[id]}`),
copy.helpTargetsIntro,
...navigateTargetKeys(),
]);
return;
}
@@ -226,8 +256,12 @@ export class TerminalDock {
return;
}
void this.router.navigate(this.navigation.link(parsed.resolution.routeId));
this.append([copy.navigating.replace('{target}', parsed.targetKey)]);
const extras = parsed.resolution.fragment
? { fragment: parsed.resolution.fragment }
: undefined;
void this.router.navigate(this.navigation.link(parsed.resolution.routeId), extras);
const message = parsed.resolution.fragment ? copy.jumping : copy.navigating;
this.append([message.replace('{target}', parsed.targetKey)]);
return;
}
case 'unknown-command': {
@@ -273,6 +307,7 @@ export class TerminalDock {
return;
}
this.historyDraft.set(this.query());
const newest = commands.length - 1;
this.historyIndex.set(newest);
this.writeQuery(commands[newest] ?? '');
@@ -286,7 +321,7 @@ export class TerminalDock {
if (next >= commands.length) {
this.historyIndex.set(null);
this.writeQuery('');
this.writeQuery(this.historyDraft());
return;
}
@@ -321,16 +356,20 @@ export class TerminalDock {
}
private echo(input: string): void {
this.log.update((lines) => [
...lines,
{ id: terminalLogId++, kind: 'echo', text: `${this.copy().prompt} ${input}` },
]);
this.log.update((lines) =>
[
...lines,
{ id: terminalLogId++, kind: 'echo' as const, text: `${this.copy().prompt} ${input}` },
].slice(-MAX_LOG_LINES),
);
}
private append(texts: readonly string[]): void {
this.log.update((lines) => [
...lines,
...texts.map((text) => ({ id: terminalLogId++, kind: 'output' as const, text })),
]);
this.log.update((lines) =>
[
...lines,
...texts.map((text) => ({ id: terminalLogId++, kind: 'output' as const, text })),
].slice(-MAX_LOG_LINES),
);
}
}

View File

@@ -159,6 +159,32 @@ body {
box-shadow: var(--shadow-soft);
}
.interactive-lift {
transition:
transform var(--duration-base) var(--ease-standard),
box-shadow var(--duration-base) var(--ease-standard),
border-color var(--duration-base) var(--ease-standard);
}
@media (hover: hover) and (pointer: fine) {
.interactive-lift:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-raised);
border-color: var(--surface-glass-border-strong);
}
}
@media (prefers-reduced-motion: reduce) {
.interactive-lift {
transform: none;
transition: none;
}
.interactive-lift:hover {
transform: none;
}
}
.content-container {
width: min(100% - 2 * var(--content-gutter), var(--content-max));
margin-inline: auto;