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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -78,8 +78,5 @@
|
||||
</button>
|
||||
}
|
||||
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
|
||||
@if (showCalendar()) {
|
||||
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user