From 55d012cc10f0f654f1c507ccae366febb83d5146 Mon Sep 17 00:00:00 2001 From: Antonio Ledebuhr Date: Thu, 27 Aug 2026 00:41:20 +0200 Subject: [PATCH] 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 --- src/app/app.html | 18 +- src/app/app.scss | 64 +--- src/app/app.spec.ts | 7 +- .../pages/skills/skill-card/skill-card.scss | 10 - src/app/shared/case-card/case-card.html | 23 +- src/app/shared/case-card/case-card.scss | 37 +-- src/app/shared/case-card/case-card.ts | 2 + src/app/shared/case-study/case-study.html | 7 + src/app/shared/case-study/case-study.scss | 16 + .../contact-briefing/contact-briefing.html | 3 - .../contact-briefing/contact-briefing.spec.ts | 5 +- .../contact-briefing/contact-briefing.ts | 6 - src/app/shared/systems-map/systems-map.html | 54 +++- .../shared/systems-map/systems-map.model.ts | 235 ++++++++++----- src/app/shared/systems-map/systems-map.scss | 34 ++- .../shared/systems-map/systems-map.spec.ts | 175 ++++++++--- src/app/shared/systems-map/systems-map.ts | 185 +++++++++--- .../shared/terminal/terminal-commands.spec.ts | 23 +- src/app/shared/terminal/terminal-commands.ts | 47 ++- src/app/shared/terminal/terminal-dock.html | 82 ++--- src/app/shared/terminal/terminal-dock.scss | 22 +- .../shared/terminal/terminal-dock.service.ts | 2 +- src/app/shared/terminal/terminal-dock.spec.ts | 285 ++++++++++-------- src/app/shared/terminal/terminal-dock.ts | 91 ++++-- src/styles.scss | 26 ++ 25 files changed, 915 insertions(+), 544 deletions(-) diff --git a/src/app/app.html b/src/app/app.html index 7591de4..72aae99 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -25,25 +25,10 @@ {{ item.label }} - @if (item.children; as children) { - - } } @@ -57,7 +42,6 @@ > {{ shell().otherLocaleName }} - {{ shell().contactCta }} diff --git a/src/app/app.scss b/src/app/app.scss index bffcff9..17335bc 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -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; - } - } } diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 4573d46..22dbfb6 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -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); }); diff --git a/src/app/components/pages/skills/skill-card/skill-card.scss b/src/app/components/pages/skills/skill-card/skill-card.scss index cffb423..29df6bc 100644 --- a/src/app/components/pages/skills/skill-card/skill-card.scss +++ b/src/app/components/pages/skills/skill-card/skill-card.scss @@ -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); diff --git a/src/app/shared/case-card/case-card.html b/src/app/shared/case-card/case-card.html index 37a2da3..5bd5eb4 100644 --- a/src/app/shared/case-card/case-card.html +++ b/src/app/shared/case-card/case-card.html @@ -1,14 +1,11 @@ -
-

{{ caseStudy().headline }}

-

{{ caseStudy().client }} · {{ caseStudy().role }} · {{ caseStudy().period }}

-

{{ caseStudy().summary }}

-
    - @for (tag of caseStudy().tags; track $index) { -
  • {{ tag }}
  • - } -
- {{ caseStudy().linkLabel }} -
+

{{ caseStudy().client }}

+

{{ caseStudy().role }} · {{ caseStudy().period }}

+

{{ caseStudy().teaser }}

+ {{ caseStudy().linkLabel }} + diff --git a/src/app/shared/case-card/case-card.scss b/src/app/shared/case-card/case-card.scss index 780b1d6..cb88802 100644 --- a/src/app/shared/case-card/case-card.scss +++ b/src/app/shared/case-card/case-card.scss @@ -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; - } -} diff --git a/src/app/shared/case-card/case-card.ts b/src/app/shared/case-card/case-card.ts index 525fbeb..7eb109e 100644 --- a/src/app/shared/case-card/case-card.ts +++ b/src/app/shared/case-card/case-card.ts @@ -12,8 +12,10 @@ import { NavigationService } from '../../core/navigation/navigation.service'; }) export class CaseCard { readonly caseStudy = input.required(); + 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}`); } diff --git a/src/app/shared/case-study/case-study.html b/src/app/shared/case-study/case-study.html index ba0ec35..0db185d 100644 --- a/src/app/shared/case-study/case-study.html +++ b/src/app/shared/case-study/case-study.html @@ -10,6 +10,13 @@ @if (caseStudy().transparencyNote; as note) {

{{ note }}

} + @if (caseStudy().externalUrl; as url) { +

+ + {{ caseStudy().externalLabel }} + +

+ }
diff --git a/src/app/shared/case-study/case-study.scss b/src/app/shared/case-study/case-study.scss index 7786362..1f695df 100644 --- a/src/app/shared/case-study/case-study.scss +++ b/src/app/shared/case-study/case-study.scss @@ -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; +} diff --git a/src/app/shared/contact-briefing/contact-briefing.html b/src/app/shared/contact-briefing/contact-briefing.html index 6668621..c04c94b 100644 --- a/src/app/shared/contact-briefing/contact-briefing.html +++ b/src/app/shared/contact-briefing/contact-briefing.html @@ -78,8 +78,5 @@ } {{ copy().directEmailLabel }} - @if (showCalendar()) { - {{ copy().calendarLabel }} - }

diff --git a/src/app/shared/contact-briefing/contact-briefing.spec.ts b/src/app/shared/contact-briefing/contact-briefing.spec.ts index 0507669..faede33 100644 --- a/src/app/shared/contact-briefing/contact-briefing.spec.ts +++ b/src/app/shared/contact-briefing/contact-briefing.spec.ts @@ -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}`); diff --git a/src/app/shared/contact-briefing/contact-briefing.ts b/src/app/shared/contact-briefing/contact-briefing.ts index 3c47087..0c8ee01 100644 --- a/src/app/shared/contact-briefing/contact-briefing.ts +++ b/src/app/shared/contact-briefing/contact-briefing.ts @@ -27,7 +27,6 @@ export class ContactBriefing { protected readonly values = signal>({ ...EMPTY_VALUES }); protected readonly touched = signal>>({}); 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 })); diff --git a/src/app/shared/systems-map/systems-map.html b/src/app/shared/systems-map/systems-map.html index 115a48c..c82f3cd 100644 --- a/src/app/shared/systems-map/systems-map.html +++ b/src/app/shared/systems-map/systems-map.html @@ -5,15 +5,13 @@

{{ headingText() }}

}

{{ introText() }}

+

{{ copy().nodeLinkHint }}

-
diff --git a/src/app/shared/systems-map/systems-map.model.ts b/src/app/shared/systems-map/systems-map.model.ts index b97ee1c..90837c7 100644 --- a/src/app/shared/systems-map/systems-map.model.ts +++ b/src/app/shared/systems-map/systems-map.model.ts @@ -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; readonly labelLines: Record; readonly summary: Record; + readonly gist: Record; + readonly relationships: Record; 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}`; +} diff --git a/src/app/shared/systems-map/systems-map.scss b/src/app/shared/systems-map/systems-map.scss index f614b03..0608436 100644 --- a/src/app/shared/systems-map/systems-map.scss +++ b/src/app/shared/systems-map/systems-map.scss @@ -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) { diff --git a/src/app/shared/systems-map/systems-map.spec.ts b/src/app/shared/systems-map/systems-map.spec.ts index 628c032..70c48d5 100644 --- a/src/app/shared/systems-map/systems-map.spec.ts +++ b/src/app/shared/systems-map/systems-map.spec.ts @@ -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> { + ): Promise { TestBed.resetTestingModule(); await TestBed.configureTestingModule({ imports: [SystemsMap], providers: [provideRouter([]), ...providers], }).compileComponents(); + } + async function createFixture( + providers: { provide: unknown; useValue: unknown }[] = [], + ): Promise> { + await configure(providers); const fixture = TestBed.createComponent(SystemsMap); fixture.detectChanges(); await fixture.whenStable(); return fixture; } + async function flush(fixture: ComponentFixture): Promise { + 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('.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('.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); diff --git a/src/app/shared/systems-map/systems-map.ts b/src/app/shared/systems-map/systems-map.ts index e0585b5..72ef756 100644 --- a/src/app/shared/systems-map/systems-map.ts +++ b/src/app/shared/systems-map/systems-map.ts @@ -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>('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(null); + protected readonly openNodeId = signal(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( + '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), }; } diff --git a/src/app/shared/terminal/terminal-commands.spec.ts b/src/app/shared/terminal/terminal-commands.spec.ts index 5a0e197..cfc069c 100644 --- a/src/app/shared/terminal/terminal-commands.spec.ts +++ b/src/app/shared/terminal/terminal-commands.spec.ts @@ -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'], diff --git a/src/app/shared/terminal/terminal-commands.ts b/src/app/shared/terminal/terminal-commands.ts index 1578b59..360545e 100644 --- a/src/app/shared/terminal/terminal-commands.ts +++ b/src/app/shared/terminal/terminal-commands.ts @@ -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'], diff --git a/src/app/shared/terminal/terminal-dock.html b/src/app/shared/terminal/terminal-dock.html index a1ca529..1926485 100644 --- a/src/app/shared/terminal/terminal-dock.html +++ b/src/app/shared/terminal/terminal-dock.html @@ -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()" > {{ copy().triggerLabel }} @@ -22,57 +24,61 @@ >

{{ copy().panelLabel }}

- - +
+ + +
@for (line of log(); track line.id) {

{{ line.text }}

} +
+ + +
- -
- - -
} diff --git a/src/app/shared/terminal/terminal-dock.scss b/src/app/shared/terminal/terminal-dock.scss index 4dcc095..cab5456 100644 --- a/src/app/shared/terminal/terminal-dock.scss +++ b/src/app/shared/terminal/terminal-dock.scss @@ -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); diff --git a/src/app/shared/terminal/terminal-dock.service.ts b/src/app/shared/terminal/terminal-dock.service.ts index 0ba0c27..b6b3974 100644 --- a/src/app/shared/terminal/terminal-dock.service.ts +++ b/src/app/shared/terminal/terminal-dock.service.ts @@ -18,7 +18,7 @@ export class TerminalDockService { private readonly injector = inject(Injector); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - readonly state = signal('collapsed'); + readonly state = signal('expanded'); readonly panelId = 'terminal-dock-panel'; readonly inputId = 'terminal-dock-input'; readonly logId = 'terminal-dock-log'; diff --git a/src/app/shared/terminal/terminal-dock.spec.ts b/src/app/shared/terminal/terminal-dock.spec.ts index 267c17b..5009076 100644 --- a/src/app/shared/terminal/terminal-dock.spec.ts +++ b/src/app/shared/terminal/terminal-dock.spec.ts @@ -53,6 +53,26 @@ describe('TerminalDock', () => { return fixture.nativeElement.querySelector('input'); } + function session(fixture: ComponentFixture): HTMLElement | null { + return fixture.nativeElement.querySelector('.terminal-dock-session'); + } + + function collapseControl( + fixture: ComponentFixture, + locale: AppLocale, + ): HTMLButtonElement { + const label = SIGNATURE_COPY[locale].terminal.collapseLabel; + return fixture.nativeElement.querySelector(`[aria-label="${label}"]`); + } + + async function collapseViaControl( + fixture: ComponentFixture, + locale: AppLocale, + ): Promise { + collapseControl(fixture, locale).click(); + await flush(fixture); + } + async function openViaTrigger(fixture: ComponentFixture): Promise { trigger(fixture).click(); await flush(fixture); @@ -73,66 +93,73 @@ describe('TerminalDock', () => { } function logText(fixture: ComponentFixture): 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'); diff --git a/src/app/shared/terminal/terminal-dock.ts b/src/app/shared/terminal/terminal-dock.ts index 85c8375..dc6c3ca 100644 --- a/src/app/shared/terminal/terminal-dock.ts +++ b/src/app/shared/terminal/terminal-dock.ts @@ -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([]); protected readonly entered = signal([]); protected readonly historyIndex = signal(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), + ); } } diff --git a/src/styles.scss b/src/styles.scss index cb018a0..2d2b465 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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;