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
|
<a
|
||||||
[routerLink]="item.link"
|
[routerLink]="item.link"
|
||||||
routerLinkActive="is-active"
|
routerLinkActive="is-active"
|
||||||
[routerLinkActiveOptions]="{ exact: true }"
|
[routerLinkActiveOptions]="{ exact: item.routeId === 'home' }"
|
||||||
ariaCurrentWhenActive="page"
|
ariaCurrentWhenActive="page"
|
||||||
>{{ item.label }}</a
|
>{{ 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>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -57,7 +42,6 @@
|
|||||||
>
|
>
|
||||||
{{ shell().otherLocaleName }}
|
{{ shell().otherLocaleName }}
|
||||||
</a>
|
</a>
|
||||||
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
align-items: start;
|
align-items: center;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
@@ -114,17 +114,6 @@
|
|||||||
gap: var(--space-2);
|
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 {
|
.is-active {
|
||||||
color: var(--color-accent-cool);
|
color: var(--color-accent-cool);
|
||||||
}
|
}
|
||||||
@@ -157,7 +146,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@include bp.respond-to(md) {
|
@include bp.respond-to(md) {
|
||||||
.nav-toggle {
|
.nav-toggle,
|
||||||
|
.site-toolbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,28 +156,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-header-inner {
|
.site-header-inner {
|
||||||
grid-template-columns: 1fr auto auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-4);
|
||||||
padding-block: var(--space-2);
|
padding-block: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav,
|
.site-nav,
|
||||||
.nav-collapsed .site-nav {
|
.nav-collapsed .site-nav,
|
||||||
display: flex;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
order: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-actions,
|
.site-actions,
|
||||||
.nav-collapsed .site-actions {
|
.nav-collapsed .site-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
order: 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-toolbar {
|
.site-nav {
|
||||||
order: 2;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-nav {
|
.primary-nav {
|
||||||
@@ -197,39 +181,7 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-nav > li {
|
|
||||||
position: relative;
|
|
||||||
z-index: 31;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-nav > li > a {
|
.primary-nav > li > a {
|
||||||
white-space: nowrap;
|
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 a[href="/cv/CV.pdf"]')).toBeNull();
|
||||||
expect(compiled.querySelector('header .command-palette-trigger')).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');
|
const panel = compiled.querySelector('.terminal-dock-panel');
|
||||||
expect(panel).toBeTruthy();
|
expect(panel).toBeTruthy();
|
||||||
|
expect((trigger as HTMLButtonElement).hasAttribute('hidden')).toBe(true);
|
||||||
expect(site?.contains(panel)).toBe(false);
|
expect(site?.contains(panel)).toBe(false);
|
||||||
expect(site?.hasAttribute('inert')).toBe(false);
|
expect(site?.hasAttribute('inert')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
h3 {
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
@@ -43,12 +39,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@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 {
|
.card .icons a:hover img {
|
||||||
filter: none;
|
filter: none;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
<article
|
<a
|
||||||
class="case-card glass-surface stack"
|
class="case-card interactive-lift stack"
|
||||||
[attr.aria-labelledby]="'case-card-' + caseStudy().id"
|
[routerLink]="projectsLink()"
|
||||||
|
[fragment]="caseStudy().id"
|
||||||
|
[attr.aria-labelledby]="headingId()"
|
||||||
>
|
>
|
||||||
<h3 [id]="'case-card-' + caseStudy().id">{{ caseStudy().headline }}</h3>
|
<h3 [id]="headingId()">{{ caseStudy().client }}</h3>
|
||||||
<p class="meta">{{ caseStudy().client }} · {{ caseStudy().role }} · {{ caseStudy().period }}</p>
|
<p class="meta">{{ caseStudy().role }} · {{ caseStudy().period }}</p>
|
||||||
<p>{{ caseStudy().summary }}</p>
|
<p class="teaser">{{ caseStudy().teaser }}</p>
|
||||||
<ul class="cluster tags">
|
<span class="case-card-link">{{ caseStudy().linkLabel }}</span>
|
||||||
@for (tag of caseStudy().tags; track $index) {
|
</a>
|
||||||
<li>{{ tag }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
<a [routerLink]="projectsLink()" [fragment]="caseStudy().id">{{ caseStudy().linkLabel }}</a>
|
|
||||||
</article>
|
|
||||||
|
|||||||
@@ -1,36 +1,31 @@
|
|||||||
.case-card {
|
.case-card {
|
||||||
padding: var(--space-5);
|
display: grid;
|
||||||
border-radius: var(--radius-md);
|
gap: var(--space-2);
|
||||||
height: 100%;
|
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 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta,
|
.meta,
|
||||||
p {
|
.teaser {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags {
|
.teaser {
|
||||||
list-style: none;
|
max-width: 40rem;
|
||||||
margin: 0;
|
}
|
||||||
padding: 0;
|
|
||||||
color: var(--color-text-subtle);
|
.case-card-link {
|
||||||
|
color: var(--color-accent-cool);
|
||||||
font-size: var(--text-sm);
|
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 {
|
export class CaseCard {
|
||||||
readonly caseStudy = input.required<CaseStudyCopy>();
|
readonly caseStudy = input.required<CaseStudyCopy>();
|
||||||
|
readonly idPrefix = input('case-card');
|
||||||
|
|
||||||
private readonly navigation = inject(NavigationService);
|
private readonly navigation = inject(NavigationService);
|
||||||
|
|
||||||
protected readonly projectsLink = computed(() => this.navigation.link('projects'));
|
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) {
|
@if (caseStudy().transparencyNote; as note) {
|
||||||
<p class="transparency" role="note">{{ note }}</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-situation'">
|
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-situation'">
|
||||||
|
|||||||
@@ -48,3 +48,19 @@ ul {
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-subtle);
|
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>
|
</button>
|
||||||
}
|
}
|
||||||
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
|
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
|
||||||
@if (showCalendar()) {
|
|
||||||
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,12 +46,11 @@ describe('ContactBriefing', () => {
|
|||||||
expect(disabledSubmit?.textContent).toContain(copy.submitLabel);
|
expect(disabledSubmit?.textContent).toContain(copy.submitLabel);
|
||||||
expect(root.querySelector('[aria-invalid="true"]')).toBeNull();
|
expect(root.querySelector('[aria-invalid="true"]')).toBeNull();
|
||||||
expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
|
expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
|
||||||
expect(root.textContent).not.toContain(copy.calendarLabel);
|
|
||||||
|
|
||||||
const special = 'Äpfel & Birnen?\nPreis=100';
|
const special = 'Äpfel & Birnen?\nPreis=100';
|
||||||
setControl(root, 'contact-name', special);
|
setControl(root, 'contact-name', special);
|
||||||
setControl(root, 'contact-email', 'team@example.com');
|
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-situation', special);
|
||||||
setControl(root, 'contact-timeframe', 'Q3');
|
setControl(root, 'contact-timeframe', 'Q3');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -79,7 +78,7 @@ describe('ContactBriefing', () => {
|
|||||||
expect(decodedBody).toContain('team@example.com');
|
expect(decodedBody).toContain('team@example.com');
|
||||||
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
||||||
const projectTypeLabel = projectType?.options?.find(
|
const projectTypeLabel = projectType?.options?.find(
|
||||||
(option) => option.value === 'software',
|
(option) => option.value === 'software-data',
|
||||||
)?.label;
|
)?.label;
|
||||||
expect(projectTypeLabel).toBeTruthy();
|
expect(projectTypeLabel).toBeTruthy();
|
||||||
expect(decodedBody).toContain(`${projectType!.label}: ${projectTypeLabel}`);
|
expect(decodedBody).toContain(`${projectType!.label}: ${projectTypeLabel}`);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export class ContactBriefing {
|
|||||||
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
|
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
|
||||||
protected readonly touched = signal<Partial<Record<ContactFieldId, boolean>>>({});
|
protected readonly touched = signal<Partial<Record<ContactFieldId, boolean>>>({});
|
||||||
protected readonly contactEmail = SITE_CONFIG.contactEmail;
|
protected readonly contactEmail = SITE_CONFIG.contactEmail;
|
||||||
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
|
|
||||||
protected readonly incompleteId = 'contact-incomplete';
|
protected readonly incompleteId = 'contact-incomplete';
|
||||||
|
|
||||||
protected readonly missingLabels = computed(() => {
|
protected readonly missingLabels = computed(() => {
|
||||||
@@ -56,11 +55,6 @@ export class ContactBriefing {
|
|||||||
return `mailto:${SITE_CONFIG.contactEmail}?subject=${encodeURIComponent(copy.mailSubject)}&body=${encodeURIComponent(body)}`;
|
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 {
|
protected onInput(fieldId: ContactFieldId, event: Event): void {
|
||||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||||
this.values.update((current) => ({ ...current, [fieldId]: target.value }));
|
this.values.update((current) => ({ ...current, [fieldId]: target.value }));
|
||||||
|
|||||||
@@ -5,15 +5,13 @@
|
|||||||
<h2 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h2>
|
<h2 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h2>
|
||||||
}
|
}
|
||||||
<p class="systems-map-intro">{{ introText() }}</p>
|
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 1000 560"
|
viewBox="0 0 1000 560"
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
role="group"
|
|
||||||
[attr.aria-labelledby]="svgTitleId"
|
|
||||||
[attr.aria-describedby]="svgDescId"
|
|
||||||
>
|
>
|
||||||
<title [id]="svgTitleId">{{ headingText() }}</title>
|
<title [id]="svgTitleId">{{ headingText() }}</title>
|
||||||
<desc [id]="svgDescId">{{ copy().diagramDescription }}</desc>
|
<desc [id]="svgDescId">{{ copy().diagramDescription }}</desc>
|
||||||
@@ -26,25 +24,22 @@
|
|||||||
class="systems-map-edge"
|
class="systems-map-edge"
|
||||||
[class.is-connected]="isConnectedEdge(edge)"
|
[class.is-connected]="isConnectedEdge(edge)"
|
||||||
[class.is-dimmed]="isDimmedEdge(edge)"
|
[class.is-dimmed]="isDimmedEdge(edge)"
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@for (node of nodes(); track node.id) {
|
@for (node of nodes(); track node.id) {
|
||||||
<a
|
<a
|
||||||
[attr.href]="node.href"
|
[attr.href]="node.href"
|
||||||
[attr.aria-label]="node.accessibleName"
|
tabindex="-1"
|
||||||
class="systems-map-node"
|
class="systems-map-node"
|
||||||
[attr.data-kind]="node.kind"
|
[attr.data-kind]="node.kind"
|
||||||
[class.is-connected]="isConnectedNode(node.id)"
|
[class.is-connected]="isConnectedNode(node.id)"
|
||||||
[class.is-dimmed]="isDimmedNode(node.id)"
|
[class.is-dimmed]="isDimmedNode(node.id)"
|
||||||
(click)="onNodeActivate($event, node)"
|
(click)="onNodeActivate($event, node)"
|
||||||
(focusin)="onNodeEnter(node.id)"
|
|
||||||
(focusout)="onNodeLeave(node.id)"
|
|
||||||
(mouseenter)="onNodeEnter(node.id)"
|
(mouseenter)="onNodeEnter(node.id)"
|
||||||
(mouseleave)="onNodeLeave(node.id)"
|
(mouseleave)="onNodeLeave(node.id)"
|
||||||
>
|
>
|
||||||
@if (node.kind === 'ai' || node.kind === 'cluster') {
|
@if (node.kind === 'ai' || node.kind === 'platform') {
|
||||||
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" aria-hidden="true" />
|
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" />
|
||||||
} @else {
|
} @else {
|
||||||
<rect
|
<rect
|
||||||
[attr.x]="node.x - node.shapeWidth / 2"
|
[attr.x]="node.x - node.shapeWidth / 2"
|
||||||
@@ -52,10 +47,9 @@
|
|||||||
[attr.width]="node.shapeWidth"
|
[attr.width]="node.shapeWidth"
|
||||||
[attr.height]="node.shapeHeight"
|
[attr.height]="node.shapeHeight"
|
||||||
rx="8"
|
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) {
|
@for (line of node.labelLines; track $index) {
|
||||||
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
|
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
|
||||||
{{ line }}
|
{{ line }}
|
||||||
@@ -72,7 +66,12 @@
|
|||||||
<ul class="systems-map-cards" [attr.aria-labelledby]="listHeadingId">
|
<ul class="systems-map-cards" [attr.aria-labelledby]="listHeadingId">
|
||||||
@for (node of nodes(); track node.id) {
|
@for (node of nodes(); track node.id) {
|
||||||
<li>
|
<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-label">{{ node.label }}</span>
|
||||||
<span class="systems-map-card-summary">{{ node.summary }}</span>
|
<span class="systems-map-card-summary">{{ node.summary }}</span>
|
||||||
<span class="systems-map-card-relation">{{ node.relationship }}</span>
|
<span class="systems-map-card-relation">{{ node.relationship }}</span>
|
||||||
@@ -89,5 +88,32 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { type AppLocale } from '../../core/i18n/locale';
|
import { type AppLocale } from '../../core/i18n/locale';
|
||||||
import { type RouteId } from '../../core/routing/route-ids';
|
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 =
|
export type SystemsMapNodeId =
|
||||||
| 'ai'
|
| 'infrastructure'
|
||||||
| 'clusters'
|
| 'platform'
|
||||||
| 'hardware'
|
|
||||||
| 'software'
|
| 'software'
|
||||||
| 'stack'
|
| 'ai'
|
||||||
| 'caseMigration'
|
| 'caseInnofocus'
|
||||||
| 'casePlatform'
|
| 'caseRoesterei'
|
||||||
| 'caseAutomation';
|
| 'caseBannier'
|
||||||
|
| 'caseHdi';
|
||||||
|
|
||||||
export const SYSTEMS_MAP_NODE_KINDS: readonly SystemsMapNodeKind[] = [
|
export const SYSTEMS_MAP_NODE_KINDS: readonly SystemsMapNodeKind[] = [
|
||||||
'ai',
|
'infrastructure',
|
||||||
'cluster',
|
'platform',
|
||||||
'hardware',
|
|
||||||
'software',
|
'software',
|
||||||
|
'ai',
|
||||||
'project',
|
'project',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -25,9 +26,12 @@ export interface SystemsMapNode {
|
|||||||
readonly id: SystemsMapNodeId;
|
readonly id: SystemsMapNodeId;
|
||||||
readonly kind: SystemsMapNodeKind;
|
readonly kind: SystemsMapNodeKind;
|
||||||
readonly routeId: RouteId;
|
readonly routeId: RouteId;
|
||||||
|
readonly fragment: string;
|
||||||
readonly label: Record<AppLocale, string>;
|
readonly label: Record<AppLocale, string>;
|
||||||
readonly labelLines: Record<AppLocale, readonly string[]>;
|
readonly labelLines: Record<AppLocale, readonly string[]>;
|
||||||
readonly summary: Record<AppLocale, string>;
|
readonly summary: Record<AppLocale, string>;
|
||||||
|
readonly gist: Record<AppLocale, string>;
|
||||||
|
readonly relationships: Record<AppLocale, string>;
|
||||||
readonly x: number;
|
readonly x: number;
|
||||||
readonly y: number;
|
readonly y: number;
|
||||||
}
|
}
|
||||||
@@ -39,121 +43,192 @@ export interface SystemsMapEdge {
|
|||||||
|
|
||||||
export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||||
{
|
{
|
||||||
id: 'hardware',
|
id: 'infrastructure',
|
||||||
kind: 'hardware',
|
kind: 'infrastructure',
|
||||||
routeId: 'servicesHardwareNetwork',
|
routeId: 'services',
|
||||||
label: { de: 'Hardware', en: 'Hardware' },
|
fragment: 'infrastructure-network',
|
||||||
labelLines: { de: ['Hardware'], en: ['Hardware'] },
|
label: { de: 'Server und Netz', en: 'Infrastructure' },
|
||||||
|
labelLines: { de: ['Server und', 'Netz'], en: ['Infrastructure'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Geräte, Netz und physische Schicht',
|
de: 'Netz, Server und Virtualisierung vor Ort',
|
||||||
en: 'Devices, network and the physical layer',
|
en: 'Servers, networks and virtualisation',
|
||||||
},
|
},
|
||||||
x: 140,
|
gist: {
|
||||||
y: 300,
|
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',
|
id: 'platform',
|
||||||
kind: 'cluster',
|
kind: 'platform',
|
||||||
routeId: 'servicesClusters',
|
routeId: 'services',
|
||||||
label: { de: 'Cluster', en: 'Clusters' },
|
fragment: 'platform-operations',
|
||||||
labelLines: { de: ['Cluster'], en: ['Clusters'] },
|
label: { de: 'Plattform', en: 'Platform' },
|
||||||
|
labelLines: { de: ['Plattform'], en: ['Platform'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Orchestrierung und Betrieb von Cluster-Umgebungen',
|
de: 'Microsoft 365, Container, Cluster und Pipelines',
|
||||||
en: 'Orchestration and cluster operations',
|
en: 'Microsoft 365, containers, clusters and pipelines',
|
||||||
},
|
},
|
||||||
x: 340,
|
gist: {
|
||||||
y: 140,
|
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',
|
id: 'software',
|
||||||
kind: 'software',
|
kind: 'software',
|
||||||
routeId: 'servicesSoftware',
|
routeId: 'services',
|
||||||
label: { de: 'Software', en: 'Software' },
|
fragment: 'software-data',
|
||||||
labelLines: { de: ['Software'], en: ['Software'] },
|
label: { de: 'Software und Daten', en: 'Software' },
|
||||||
|
labelLines: { de: ['Software', 'und Daten'], en: ['Software'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Anwendungen und Schnittstellen',
|
de: 'Produktsoftware, Schnittstellen und SQL-Datenarbeit',
|
||||||
en: 'Applications and interfaces',
|
en: 'Product software, APIs and data work',
|
||||||
},
|
},
|
||||||
x: 520,
|
gist: {
|
||||||
y: 300,
|
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',
|
id: 'ai',
|
||||||
kind: 'ai',
|
kind: 'ai',
|
||||||
routeId: 'servicesAi',
|
routeId: 'services',
|
||||||
label: { de: 'KI', en: 'AI' },
|
fragment: 'ai-workflows',
|
||||||
labelLines: { de: ['KI'], en: ['AI'] },
|
label: { de: 'KI-Abläufe', en: 'AI' },
|
||||||
|
labelLines: { de: ['KI-Abläufe'], en: ['AI'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Angebot: lokale Modelle und Integrationsarbeit',
|
de: 'Zwei umgesetzte Werkzeuge, der Rest ist Angebot',
|
||||||
en: 'Offered as local models and integration work',
|
en: 'Two delivered tools, everything else scoped per case',
|
||||||
},
|
},
|
||||||
x: 720,
|
gist: {
|
||||||
y: 140,
|
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',
|
id: 'caseInnofocus',
|
||||||
kind: 'software',
|
kind: 'project',
|
||||||
routeId: 'stack',
|
routeId: 'projects',
|
||||||
label: { de: 'Stack', en: 'Stack' },
|
fragment: 'innofocus',
|
||||||
labelLines: { de: ['Stack'], en: ['Stack'] },
|
label: { de: 'Innofocus', en: 'Innofocus' },
|
||||||
|
labelLines: { de: ['Innofocus'], en: ['Innofocus'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Werkzeuge und Laufzeitumgebung',
|
de: 'ERP-Ablösung mit vollständiger Datenmigration',
|
||||||
en: 'Tools and runtime environment',
|
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,
|
y: 460,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'caseMigration',
|
id: 'caseRoesterei',
|
||||||
kind: 'project',
|
kind: 'project',
|
||||||
routeId: 'projects',
|
routeId: 'projects',
|
||||||
label: { de: 'Datenmigration', en: 'Data migration' },
|
fragment: 'roesterei',
|
||||||
labelLines: { de: ['Datenmigration'], en: ['Data migration'] },
|
label: { de: 'Rösterei', en: 'Rösterei' },
|
||||||
|
labelLines: { de: ['Rösterei'], en: ['Rösterei'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Datenbestände strukturiert überführen',
|
de: 'Gesamte Technik: Netz, Microsoft 365, Shop, KI-Werkzeuge',
|
||||||
en: 'Moving data stores in a structured way',
|
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,
|
y: 460,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'casePlatform',
|
id: 'caseBannier',
|
||||||
kind: 'project',
|
kind: 'project',
|
||||||
routeId: 'projects',
|
routeId: 'projects',
|
||||||
label: { de: 'Plattform und Betrieb', en: 'Platform and operations' },
|
fragment: 'bannier',
|
||||||
labelLines: { de: ['Plattform und', 'Betrieb'], en: ['Platform and', 'operations'] },
|
label: { de: 'Bannier', en: 'Bannier' },
|
||||||
|
labelLines: { de: ['Bannier'], en: ['Bannier'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Plattformen betreiben und weiterentwickeln',
|
de: 'Gesamte IT einer Fahrschule seit etwa 2018',
|
||||||
en: 'Operating and evolving platforms',
|
en: "A driving school's whole IT since roughly 2018",
|
||||||
},
|
},
|
||||||
x: 860,
|
gist: {
|
||||||
y: 300,
|
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',
|
kind: 'project',
|
||||||
routeId: 'projects',
|
routeId: 'projects',
|
||||||
label: { de: 'Automatisierung und AI', en: 'Automation and AI' },
|
fragment: 'hdi',
|
||||||
labelLines: { de: ['Automatisierung', 'und AI'], en: ['Automation', 'and AI'] },
|
label: { de: 'HDI Specialty', en: 'HDI' },
|
||||||
|
labelLines: { de: ['HDI', 'Specialty'], en: ['HDI'] },
|
||||||
summary: {
|
summary: {
|
||||||
de: 'Angebot: Abläufe automatisieren und Modelle anbinden',
|
de: 'Exposure Management und Umzug auf Azure AKS',
|
||||||
en: 'Offered as workflow automation and model connections',
|
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,
|
y: 140,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SYSTEMS_MAP_EDGES: readonly SystemsMapEdge[] = [
|
export const SYSTEMS_MAP_EDGES: readonly SystemsMapEdge[] = [
|
||||||
{ from: 'hardware', to: 'clusters' },
|
{ from: 'infrastructure', to: 'platform' },
|
||||||
{ from: 'clusters', to: 'software' },
|
{ from: 'platform', to: 'software' },
|
||||||
{ from: 'software', to: 'ai' },
|
{ from: 'software', to: 'ai' },
|
||||||
{ from: 'ai', to: 'clusters' },
|
{ from: 'infrastructure', to: 'caseBannier' },
|
||||||
{ from: 'hardware', to: 'software' },
|
{ from: 'infrastructure', to: 'caseRoesterei' },
|
||||||
{ from: 'stack', to: 'software' },
|
{ from: 'platform', to: 'caseHdi' },
|
||||||
{ from: 'ai', to: 'caseAutomation' },
|
{ from: 'software', to: 'caseInnofocus' },
|
||||||
{ from: 'software', to: 'casePlatform' },
|
{ from: 'ai', to: 'caseRoesterei' },
|
||||||
{ from: 'software', to: 'caseMigration' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeId[] {
|
export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeId[] {
|
||||||
@@ -169,3 +244,7 @@ export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeI
|
|||||||
|
|
||||||
return connected;
|
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);
|
border-inline-start: 2px solid var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.systems-map-readout {
|
.systems-map-dialog {
|
||||||
min-height: var(--text-md);
|
display: grid;
|
||||||
font-size: var(--text-sm);
|
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) {
|
@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 { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter, Router } from '@angular/router';
|
import { provideRouter, Router } from '@angular/router';
|
||||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||||
import { type AppLocale } from '../../core/i18n/locale';
|
import { type AppLocale } from '../../core/i18n/locale';
|
||||||
import { LocaleService } from '../../core/i18n/locale.service';
|
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 { SystemsMap } from './systems-map';
|
||||||
import { connectedNodeIds, SYSTEMS_MAP_NODES } from './systems-map.model';
|
import { nodeHref, SYSTEMS_MAP_NODES } from './systems-map.model';
|
||||||
|
|
||||||
describe('SystemsMap', () => {
|
describe('SystemsMap', () => {
|
||||||
async function createFixture(
|
async function configure(
|
||||||
providers: { provide: unknown; useValue: unknown }[] = [],
|
providers: { provide: unknown; useValue: unknown }[] = [],
|
||||||
): Promise<ComponentFixture<SystemsMap>> {
|
): Promise<void> {
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [SystemsMap],
|
imports: [SystemsMap],
|
||||||
providers: [provideRouter([]), ...providers],
|
providers: [provideRouter([]), ...providers],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixture(
|
||||||
|
providers: { provide: unknown; useValue: unknown }[] = [],
|
||||||
|
): Promise<ComponentFixture<SystemsMap>> {
|
||||||
|
await configure(providers);
|
||||||
const fixture = TestBed.createComponent(SystemsMap);
|
const fixture = TestBed.createComponent(SystemsMap);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
return fixture;
|
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[] {
|
function hrefsOf(root: ParentNode, selector: string): string[] {
|
||||||
return Array.from(root.querySelectorAll(selector)).map((element) => {
|
return Array.from(root.querySelectorAll(selector)).map((element) => {
|
||||||
return element.getAttribute('href') ?? '';
|
return element.getAttribute('href') ?? '';
|
||||||
@@ -32,81 +43,146 @@ describe('SystemsMap', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expectedNodes(locale: AppLocale) {
|
function expectedNodes(locale: AppLocale) {
|
||||||
const relationshipLabel = SIGNATURE_COPY[locale].systemsMap.relationshipLabel;
|
return SYSTEMS_MAP_NODES.map((node) => ({
|
||||||
|
...node,
|
||||||
return SYSTEMS_MAP_NODES.map((node) => {
|
label: node.label[locale],
|
||||||
const connected = new Set(connectedNodeIds(node.id));
|
summary: node.summary[locale],
|
||||||
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
|
gist: node.gist[locale],
|
||||||
(entry) => entry.label[locale],
|
href: nodeHref(node, locale),
|
||||||
);
|
relationship: node.relationships[locale],
|
||||||
return {
|
}));
|
||||||
...node,
|
|
||||||
label: node.label[locale],
|
|
||||||
summary: node.summary[locale],
|
|
||||||
href: routePath(node.routeId, locale),
|
|
||||||
connectedLabels,
|
|
||||||
relationship: relationshipLabel.replace('{targets}', connectedLabels.join(', ')),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 fixture = await createFixture();
|
||||||
const locale = TestBed.inject(LocaleService).locale();
|
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;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
|
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
|
||||||
expect(hrefsOf(compiled, '.systems-map-cards 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 fixture = await createFixture();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
const nodes = expectedNodes(TestBed.inject(LocaleService).locale());
|
const nodes = expectedNodes(TestBed.inject(LocaleService).locale());
|
||||||
|
const figure = compiled.querySelector('.systems-map-figure');
|
||||||
const svgAnchors = compiled.querySelectorAll('svg a');
|
const svgAnchors = compiled.querySelectorAll('svg a');
|
||||||
const listAnchors = compiled.querySelectorAll('.systems-map-cards a');
|
const listAnchors = compiled.querySelectorAll('.systems-map-cards a');
|
||||||
|
|
||||||
|
expect(figure?.getAttribute('aria-hidden')).toBe('true');
|
||||||
expect(svgAnchors.length).toBe(nodes.length);
|
expect(svgAnchors.length).toBe(nodes.length);
|
||||||
expect(listAnchors.length).toBe(nodes.length);
|
expect(listAnchors.length).toBe(nodes.length);
|
||||||
|
|
||||||
nodes.forEach((node, index) => {
|
svgAnchors.forEach((anchor) => {
|
||||||
const svgName = svgAnchors.item(index).getAttribute('aria-label') ?? '';
|
expect(anchor.getAttribute('tabindex')).toBe('-1');
|
||||||
const listName = listAnchors.item(index).getAttribute('aria-label') ?? '';
|
});
|
||||||
|
|
||||||
expect(svgName.length).toBeGreaterThan(0);
|
nodes.forEach((node, index) => {
|
||||||
|
const listName = listAnchors.item(index).getAttribute('aria-label') ?? '';
|
||||||
expect(listName.length).toBeGreaterThan(0);
|
expect(listName.length).toBeGreaterThan(0);
|
||||||
expect(svgName).toContain(node.label);
|
|
||||||
expect(svgName).toContain(node.summary);
|
|
||||||
expect(listName).toContain(node.label);
|
expect(listName).toContain(node.label);
|
||||||
expect(listName).toContain(node.summary);
|
expect(listName).toContain(node.summary);
|
||||||
|
expect(listName).toContain(node.relationship);
|
||||||
for (const label of node.connectedLabels) {
|
|
||||||
expect(svgName).toContain(label);
|
|
||||||
expect(listName).toContain(label);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 fixture = await createFixture();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
const navigation = TestBed.inject(NavigationService);
|
|
||||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
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) => {
|
const anchor = listAnchor(compiled);
|
||||||
expect(anchor.getAttribute('tabindex')).not.toBe('-1');
|
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 dialog = compiled.querySelector('[role="dialog"]');
|
||||||
const svgAnchor = compiled.querySelector('svg a');
|
expect(dialog).toBeTruthy();
|
||||||
expect(svgAnchor).toBeTruthy();
|
expect(dialog?.getAttribute('aria-modal')).toBe('true');
|
||||||
svgAnchor?.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
|
expect(dialog?.textContent).toContain(first.label);
|
||||||
fixture.detectChanges();
|
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 () => {
|
it('rewrites every href when the locale switches to English', async () => {
|
||||||
@@ -116,7 +192,7 @@ describe('SystemsMap', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
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;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
|
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
|
||||||
@@ -177,6 +253,7 @@ describe('SystemsMap', () => {
|
|||||||
|
|
||||||
expect(svgAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
|
expect(svgAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
|
||||||
expect(listAnchors.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) => {
|
expectedNodes(TestBed.inject(LocaleService).locale()).forEach((node, index) => {
|
||||||
expect(svgAnchors.item(index).getAttribute('href')).toBe(node.href);
|
expect(svgAnchors.item(index).getAttribute('href')).toBe(node.href);
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
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 { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||||
import { type AppLocale } from '../../core/i18n/locale';
|
import { type AppLocale } from '../../core/i18n/locale';
|
||||||
import { LocaleService } from '../../core/i18n/locale.service';
|
import { LocaleService } from '../../core/i18n/locale.service';
|
||||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||||
import { routePath } from '../../core/routing/route-paths';
|
import { isBrowserPlatform } from '../../core/platform/browser';
|
||||||
import {
|
import {
|
||||||
connectedNodeIds,
|
connectedNodeIds,
|
||||||
|
nodeHref,
|
||||||
SYSTEMS_MAP_EDGES,
|
SYSTEMS_MAP_EDGES,
|
||||||
SYSTEMS_MAP_NODE_KINDS,
|
SYSTEMS_MAP_NODE_KINDS,
|
||||||
SYSTEMS_MAP_NODES,
|
SYSTEMS_MAP_NODES,
|
||||||
@@ -20,15 +33,18 @@ export interface SystemsMapNodeView {
|
|||||||
readonly id: SystemsMapNodeId;
|
readonly id: SystemsMapNodeId;
|
||||||
readonly kind: SystemsMapNode['kind'];
|
readonly kind: SystemsMapNode['kind'];
|
||||||
readonly routeId: SystemsMapNode['routeId'];
|
readonly routeId: SystemsMapNode['routeId'];
|
||||||
|
readonly fragment: string;
|
||||||
readonly x: number;
|
readonly x: number;
|
||||||
readonly y: number;
|
readonly y: number;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly labelLines: readonly string[];
|
readonly labelLines: readonly string[];
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
|
readonly gist: string;
|
||||||
readonly href: string;
|
readonly href: string;
|
||||||
readonly link: unknown[];
|
readonly link: unknown[];
|
||||||
readonly relationship: string;
|
readonly relationship: string;
|
||||||
readonly accessibleName: string;
|
readonly accessibleName: string;
|
||||||
|
readonly linkLabel: string;
|
||||||
readonly shapeWidth: number;
|
readonly shapeWidth: number;
|
||||||
readonly shapeHeight: number;
|
readonly shapeHeight: number;
|
||||||
readonly textStartDy: number;
|
readonly textStartDy: number;
|
||||||
@@ -55,18 +71,25 @@ export class SystemsMap {
|
|||||||
readonly intro = input('');
|
readonly intro = input('');
|
||||||
readonly headingLevel = input<2 | 3>(2);
|
readonly headingLevel = input<2 | 3>(2);
|
||||||
|
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
private readonly navigation = inject(NavigationService);
|
private readonly navigation = inject(NavigationService);
|
||||||
private readonly localeService = inject(LocaleService);
|
private readonly localeService = inject(LocaleService);
|
||||||
private readonly router = inject(Router);
|
private readonly isBrowser = isBrowserPlatform();
|
||||||
private readonly instanceId = systemsMapInstanceId++;
|
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 headingId = `systems-map-heading-${this.instanceId}`;
|
||||||
protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`;
|
protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`;
|
||||||
protected readonly svgDescId = `systems-map-svg-desc-${this.instanceId}`;
|
protected readonly svgDescId = `systems-map-svg-desc-${this.instanceId}`;
|
||||||
protected readonly listHeadingId = `systems-map-list-${this.instanceId}`;
|
protected readonly listHeadingId = `systems-map-list-${this.instanceId}`;
|
||||||
protected readonly legendHeadingId = `systems-map-legend-${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 activeNodeId = signal<SystemsMapNodeId | null>(null);
|
||||||
|
protected readonly openNodeId = signal<SystemsMapNodeId | null>(null);
|
||||||
|
|
||||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].systemsMap);
|
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].systemsMap);
|
||||||
|
|
||||||
@@ -82,9 +105,7 @@ export class SystemsMap {
|
|||||||
|
|
||||||
protected readonly nodes = computed(() => {
|
protected readonly nodes = computed(() => {
|
||||||
const locale = this.localeService.locale();
|
const locale = this.localeService.locale();
|
||||||
const copy = this.copy();
|
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale));
|
||||||
|
|
||||||
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale, copy.relationshipLabel));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
protected readonly edges = computed((): readonly SystemsMapEdgeView[] => {
|
protected readonly edges = computed((): readonly SystemsMapEdgeView[] => {
|
||||||
@@ -114,15 +135,14 @@ export class SystemsMap {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
protected readonly activeReadout = computed(() => {
|
protected readonly openNode = computed(() => {
|
||||||
const activeId = this.activeNodeId();
|
const id = this.openNodeId();
|
||||||
|
return id ? (this.nodes().find((node) => node.id === id) ?? null) : null;
|
||||||
|
});
|
||||||
|
|
||||||
if (!activeId) {
|
protected readonly dialogTitle = computed(() => {
|
||||||
return '';
|
const node = this.openNode();
|
||||||
}
|
return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : '';
|
||||||
|
|
||||||
const node = this.nodes().find((entry) => entry.id === activeId);
|
|
||||||
return node ? `${node.label}: ${node.relationship}` : '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
|
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
|
||||||
@@ -131,7 +151,16 @@ export class SystemsMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
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 {
|
protected onNodeEnter(id: SystemsMapNodeId): void {
|
||||||
@@ -144,78 +173,136 @@ export class SystemsMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected isConnectedNode(id: SystemsMapNodeId): boolean {
|
protected closeDialog(): void {
|
||||||
const active = this.activeNodeId();
|
this.openNodeId.set(null);
|
||||||
|
const opener = this.opener;
|
||||||
|
this.opener = null;
|
||||||
|
|
||||||
if (!active) {
|
if (!this.isBrowser || !(opener instanceof HTMLElement)) {
|
||||||
return false;
|
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 {
|
protected isDimmedNode(id: SystemsMapNodeId): boolean {
|
||||||
const active = this.activeNodeId();
|
const active = this.activeNodeId();
|
||||||
|
return !!active && active !== id && !this.neighborSet(active).has(id);
|
||||||
if (!active) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return active !== id && !this.neighborSet(active).has(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected isConnectedEdge(edge: SystemsMapEdgeView): boolean {
|
protected isConnectedEdge(edge: SystemsMapEdgeView): boolean {
|
||||||
const active = this.activeNodeId();
|
const active = this.activeNodeId();
|
||||||
|
return !!active && (edge.from === active || edge.to === active);
|
||||||
if (!active) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return edge.from === active || edge.to === active;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected isDimmedEdge(edge: SystemsMapEdgeView): boolean {
|
protected isDimmedEdge(edge: SystemsMapEdgeView): boolean {
|
||||||
const active = this.activeNodeId();
|
const active = this.activeNodeId();
|
||||||
|
return !!active && edge.from !== active && edge.to !== active;
|
||||||
if (!active) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return edge.from !== active && edge.to !== active;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toNodeView(
|
private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void {
|
||||||
node: SystemsMapNode,
|
this.opener = opener;
|
||||||
locale: AppLocale,
|
this.openNodeId.set(id);
|
||||||
relationshipLabel: string,
|
|
||||||
): SystemsMapNodeView {
|
afterNextRender(
|
||||||
const connected = new Set(connectedNodeIds(node.id));
|
() => {
|
||||||
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
|
const dialog = this.dialogRef()?.nativeElement;
|
||||||
(entry) => entry.label[locale],
|
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 label = node.label[locale];
|
||||||
const labelLines = node.labelLines[locale];
|
const labelLines = node.labelLines[locale];
|
||||||
const summary = node.summary[locale];
|
const summary = node.summary[locale];
|
||||||
|
const gist = node.gist[locale];
|
||||||
|
const relationship = node.relationships[locale];
|
||||||
const multiline = labelLines.length > 1;
|
const multiline = labelLines.length > 1;
|
||||||
const longest = labelLines.reduce((max, line) => Math.max(max, line.length), 0);
|
const longest = labelLines.reduce((max, line) => Math.max(max, line.length), 0);
|
||||||
|
const linkPattern = node.kind === 'project' ? copy.caseLinkPattern : copy.sectionLinkPattern;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
kind: node.kind,
|
kind: node.kind,
|
||||||
routeId: node.routeId,
|
routeId: node.routeId,
|
||||||
|
fragment: node.fragment,
|
||||||
x: node.x,
|
x: node.x,
|
||||||
y: node.y,
|
y: node.y,
|
||||||
label,
|
label,
|
||||||
labelLines,
|
labelLines,
|
||||||
summary,
|
summary,
|
||||||
|
gist,
|
||||||
shapeWidth: Math.max(140, longest * 8 + 24),
|
shapeWidth: Math.max(140, longest * 8 + 24),
|
||||||
shapeHeight: multiline ? 52 : 40,
|
shapeHeight: multiline ? 52 : 40,
|
||||||
textStartDy: multiline ? -6 : 4,
|
textStartDy: multiline ? -6 : 4,
|
||||||
href: routePath(node.routeId, locale),
|
href: nodeHref(node, locale),
|
||||||
link: this.navigation.link(node.routeId),
|
link: this.navigation.link(node.routeId),
|
||||||
relationship,
|
relationship,
|
||||||
accessibleName: `${label}. ${summary} ${relationship}`,
|
accessibleName: `${label}. ${summary} ${relationship}`,
|
||||||
|
linkLabel: linkPattern.replace('{node}', label),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('terminal command grammar', () => {
|
|||||||
expect(parseCommand('')).toEqual({ kind: 'empty' });
|
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({
|
expect(parseCommand('navigate home')).toEqual({
|
||||||
kind: 'navigate',
|
kind: 'navigate',
|
||||||
resolution: { kind: 'route', routeId: 'home' },
|
resolution: { kind: 'route', routeId: 'home' },
|
||||||
@@ -28,16 +28,31 @@ describe('terminal command grammar', () => {
|
|||||||
resolution: { kind: 'route', routeId: 'pitch' },
|
resolution: { kind: 'route', routeId: 'pitch' },
|
||||||
targetKey: 'pitch',
|
targetKey: 'pitch',
|
||||||
});
|
});
|
||||||
|
expect(parseCommand('navigate services')).toEqual({
|
||||||
|
kind: 'navigate',
|
||||||
|
resolution: { kind: 'route', routeId: 'services' },
|
||||||
|
targetKey: 'services',
|
||||||
|
});
|
||||||
expect(parseCommand('navigate services ai')).toEqual({
|
expect(parseCommand('navigate services ai')).toEqual({
|
||||||
kind: 'navigate',
|
kind: 'navigate',
|
||||||
resolution: { kind: 'route', routeId: 'servicesAi' },
|
resolution: { kind: 'route', routeId: 'services', fragment: 'ai-workflows' },
|
||||||
targetKey: 'services ai',
|
targetKey: 'services ai',
|
||||||
});
|
});
|
||||||
expect(parseCommand('navigate leistungen software')).toEqual({
|
expect(parseCommand('navigate leistungen software')).toEqual({
|
||||||
kind: 'navigate',
|
kind: 'navigate',
|
||||||
resolution: { kind: 'route', routeId: 'servicesSoftware' },
|
resolution: { kind: 'route', routeId: 'services', fragment: 'software-data' },
|
||||||
targetKey: 'services software',
|
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({
|
expect(parseCommand('navigate cv')).toEqual({
|
||||||
kind: 'navigate',
|
kind: 'navigate',
|
||||||
resolution: { kind: 'cv' },
|
resolution: { kind: 'cv' },
|
||||||
@@ -62,7 +77,7 @@ describe('terminal command grammar', () => {
|
|||||||
it('completes the current token against commands and navigate targets', () => {
|
it('completes the current token against commands and navigate targets', () => {
|
||||||
expect(suggestCompletions('he')).toEqual(['help']);
|
expect(suggestCompletions('he')).toEqual(['help']);
|
||||||
expect(applyCompletion('he')).toEqual({ value: 'help', candidates: ['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({
|
expect(applyCompletion('navigate pit')).toEqual({
|
||||||
value: 'navigate pitch',
|
value: 'navigate pitch',
|
||||||
candidates: ['pitch'],
|
candidates: ['pitch'],
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { type RouteId } from '../../core/routing/route-ids';
|
|||||||
export { COMMAND_IDS, type CommandId };
|
export { COMMAND_IDS, type CommandId };
|
||||||
|
|
||||||
export type NavigateResolution =
|
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 {
|
export interface NavigateTarget {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
@@ -34,34 +35,48 @@ export type ParsedCommand =
|
|||||||
|
|
||||||
export const NAVIGATE_TARGETS: readonly NavigateTarget[] = [
|
export const NAVIGATE_TARGETS: readonly NavigateTarget[] = [
|
||||||
{ key: 'home', aliases: ['start', 'startseite'], resolution: { kind: 'route', routeId: 'home' } },
|
{ 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', aliases: ['leistungen'], resolution: { kind: 'route', routeId: 'services' } },
|
||||||
{
|
{
|
||||||
key: 'services ai',
|
key: 'services infrastructure',
|
||||||
aliases: ['leistungen ai'],
|
aliases: [
|
||||||
resolution: { kind: 'route', routeId: 'servicesAi' },
|
'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',
|
key: 'services software',
|
||||||
aliases: ['leistungen software'],
|
aliases: ['services data', 'leistungen software', 'leistungen daten'],
|
||||||
resolution: { kind: 'route', routeId: 'servicesSoftware' },
|
resolution: { kind: 'route', routeId: 'services', fragment: 'software-data' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'services network',
|
key: 'services ai',
|
||||||
aliases: ['leistungen network', 'leistungen netzwerk', 'services hardware'],
|
aliases: ['leistungen ki', 'leistungen ai'],
|
||||||
resolution: { kind: 'route', routeId: 'servicesHardwareNetwork' },
|
resolution: { kind: 'route', routeId: 'services', fragment: 'ai-workflows' },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'services clusters',
|
|
||||||
aliases: ['leistungen clusters', 'leistungen cluster'],
|
|
||||||
resolution: { kind: 'route', routeId: 'servicesClusters' },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'projects',
|
key: 'projects',
|
||||||
aliases: ['projekte'],
|
aliases: ['projekte'],
|
||||||
resolution: { kind: 'route', routeId: 'projects' },
|
resolution: { kind: 'route', routeId: 'projects' },
|
||||||
},
|
},
|
||||||
{ key: 'stack', aliases: [], resolution: { kind: 'route', routeId: 'stack' } },
|
{ key: 'stack', aliases: ['technik'], resolution: { kind: 'route', routeId: 'stack' } },
|
||||||
{
|
{
|
||||||
key: 'about',
|
key: 'about',
|
||||||
aliases: ['ueber-mich', 'über-mich'],
|
aliases: ['ueber-mich', 'über-mich'],
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
class="terminal-dock-trigger"
|
class="terminal-dock-trigger"
|
||||||
[attr.aria-expanded]="open()"
|
[attr.aria-expanded]="open()"
|
||||||
[attr.aria-controls]="open() ? panelId : null"
|
[attr.aria-controls]="open() ? panelId : null"
|
||||||
[attr.aria-label]="copy().triggerLabel"
|
[attr.aria-label]="copy().triggerAccessibleName"
|
||||||
|
[attr.title]="triggerTooltip()"
|
||||||
|
[hidden]="open()"
|
||||||
(click)="onTriggerClick()"
|
(click)="onTriggerClick()"
|
||||||
>
|
>
|
||||||
<span>{{ copy().triggerLabel }}</span>
|
<span>{{ copy().triggerLabel }}</span>
|
||||||
@@ -22,57 +24,61 @@
|
|||||||
>
|
>
|
||||||
<div class="terminal-dock-toolbar cluster">
|
<div class="terminal-dock-toolbar cluster">
|
||||||
<p class="terminal-dock-title">{{ copy().panelLabel }}</p>
|
<p class="terminal-dock-title">{{ copy().panelLabel }}</p>
|
||||||
<button
|
<div class="terminal-dock-actions cluster">
|
||||||
type="button"
|
<button
|
||||||
class="terminal-dock-control"
|
type="button"
|
||||||
[attr.aria-label]="copy().maximizeLabel"
|
class="terminal-dock-control"
|
||||||
[attr.aria-pressed]="maximized()"
|
[attr.aria-label]="sizeLabel()"
|
||||||
(click)="toggleMaximized()"
|
[attr.title]="sizeTooltip()"
|
||||||
>
|
[attr.aria-pressed]="maximized()"
|
||||||
{{ copy().maximizeLabel }}
|
(click)="toggleMaximized()"
|
||||||
</button>
|
>
|
||||||
<button
|
<span aria-hidden="true">{{ maximized() ? '▭' : '▢' }}</span>
|
||||||
type="button"
|
</button>
|
||||||
class="terminal-dock-control"
|
<button
|
||||||
[attr.aria-label]="copy().collapseLabel"
|
type="button"
|
||||||
(click)="collapse()"
|
class="terminal-dock-control"
|
||||||
>
|
[attr.aria-label]="copy().collapseLabel"
|
||||||
{{ copy().collapseLabel }}
|
[attr.title]="copy().collapseTooltip"
|
||||||
</button>
|
(click)="collapse()"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">–</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
#outputLog
|
#outputLog
|
||||||
class="terminal-dock-log"
|
class="terminal-dock-session"
|
||||||
[id]="logId"
|
[id]="logId"
|
||||||
role="log"
|
role="log"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="false"
|
aria-atomic="false"
|
||||||
[attr.aria-label]="copy().outputLabel"
|
[attr.aria-label]="copy().outputLabel"
|
||||||
|
(scroll)="onSessionScroll()"
|
||||||
>
|
>
|
||||||
@for (line of log(); track line.id) {
|
@for (line of log(); track line.id) {
|
||||||
<p [class.echo]="line.kind === 'echo'">{{ line.text }}</p>
|
<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>
|
</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>
|
</section>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
.terminal-dock-panel {
|
.terminal-dock-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: min(70vh, 32rem);
|
max-height: min(70vh, 32rem);
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
justify-items: stretch;
|
justify-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock.is-open .terminal-dock-trigger {
|
.terminal-dock-trigger[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +68,13 @@
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-dock-toolbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-dock-title,
|
.terminal-dock-title,
|
||||||
.terminal-dock-log,
|
.terminal-dock-session,
|
||||||
.terminal-dock-log p,
|
.terminal-dock-session p,
|
||||||
.terminal-dock-form {
|
.terminal-dock-form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -95,16 +99,16 @@
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock-log {
|
.terminal-dock-session {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 6rem;
|
min-height: 8rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock-log .echo {
|
.terminal-dock-session .echo {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +169,6 @@
|
|||||||
justify-items: end;
|
justify-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-dock.is-open .terminal-dock-trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-dock-panel {
|
.terminal-dock-panel {
|
||||||
width: min(28rem, calc(100vw - var(--space-8)));
|
width: min(28rem, calc(100vw - var(--space-8)));
|
||||||
max-height: min(22rem, 60vh);
|
max-height: min(22rem, 60vh);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class TerminalDockService {
|
|||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
|
||||||
readonly state = signal<TerminalDockState>('collapsed');
|
readonly state = signal<TerminalDockState>('expanded');
|
||||||
readonly panelId = 'terminal-dock-panel';
|
readonly panelId = 'terminal-dock-panel';
|
||||||
readonly inputId = 'terminal-dock-input';
|
readonly inputId = 'terminal-dock-input';
|
||||||
readonly logId = 'terminal-dock-log';
|
readonly logId = 'terminal-dock-log';
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ describe('TerminalDock', () => {
|
|||||||
return fixture.nativeElement.querySelector('input');
|
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> {
|
async function openViaTrigger(fixture: ComponentFixture<TerminalDock>): Promise<void> {
|
||||||
trigger(fixture).click();
|
trigger(fixture).click();
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
@@ -73,66 +93,73 @@ describe('TerminalDock', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logText(fixture: ComponentFixture<TerminalDock>): string {
|
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) => {
|
it.each(APP_LOCALES)(
|
||||||
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
'renders the expanded panel on the server without stealing focus (%s)',
|
||||||
expect(trigger(fixture)).toBeTruthy();
|
async (locale) => {
|
||||||
expect(panel(fixture)).toBeNull();
|
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||||
});
|
expect(trigger(fixture)).toBeTruthy();
|
||||||
|
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
|
||||||
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
|
expect(panel(fixture)).toBeTruthy();
|
||||||
const fixture = await createFixture(locale);
|
expect(session(fixture)).toBeTruthy();
|
||||||
await openViaTrigger(fixture);
|
expect(session(fixture)?.contains(input(fixture))).toBe(true);
|
||||||
expect(panel(fixture)).toBeTruthy();
|
expect(document.activeElement).not.toBe(input(fixture));
|
||||||
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)(
|
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) => {
|
async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await openViaTrigger(fixture);
|
|
||||||
expect(document.activeElement).toBe(input(fixture));
|
|
||||||
|
|
||||||
trigger(fixture).click();
|
|
||||||
await flush(fixture);
|
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(panel(fixture)).toBeNull();
|
||||||
|
expect(dock.state()).toBe('collapsed');
|
||||||
|
expect(trigger(fixture).hasAttribute('hidden')).toBe(false);
|
||||||
expect(document.activeElement).toBe(trigger(fixture));
|
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) => {
|
it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
const dock = TestBed.inject(TerminalDockService);
|
const dock = TestBed.inject(TerminalDockService);
|
||||||
expect(dock.state()).toBe('collapsed');
|
|
||||||
|
|
||||||
await openViaTrigger(fixture);
|
|
||||||
expect(dock.state()).toBe('expanded');
|
expect(dock.state()).toBe('expanded');
|
||||||
|
|
||||||
fixture.nativeElement.querySelector('[aria-pressed]')?.click();
|
fixture.nativeElement.querySelector('[aria-pressed]')?.click();
|
||||||
@@ -147,39 +174,28 @@ describe('TerminalDock', () => {
|
|||||||
expect(dock.state()).toBe('expanded');
|
expect(dock.state()).toBe('expanded');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(APP_LOCALES)(
|
it.each(APP_LOCALES)('uses distinct maximize and restore names (%s)', async (locale) => {
|
||||||
'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) => {
|
|
||||||
const fixture = await createFixture(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);
|
await flush(fixture);
|
||||||
|
|
||||||
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
expect(button.getAttribute('aria-label')).toBe(copy.restoreLabel);
|
||||||
if (panel(fixture)) {
|
expect(button.getAttribute('title')).toBe(copy.restoreTooltip);
|
||||||
input(fixture)?.dispatchEvent(
|
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
});
|
||||||
);
|
|
||||||
await flush(fixture);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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', {
|
const event = new KeyboardEvent('keydown', {
|
||||||
key: 'k',
|
key: 'k',
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
@@ -191,13 +207,15 @@ describe('TerminalDock', () => {
|
|||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
|
|
||||||
expect(prevent).toHaveBeenCalled();
|
expect(prevent).toHaveBeenCalled();
|
||||||
expect(panel(fixture)).toBeTruthy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(panel(fixture)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => {
|
it('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
|
expect(panel(fixture)).toBeTruthy();
|
||||||
|
|
||||||
for (const modifier of [
|
for (const modifier of [
|
||||||
{ ctrlKey: true, shiftKey: true },
|
{ ctrlKey: true, shiftKey: true },
|
||||||
@@ -214,33 +232,33 @@ describe('TerminalDock', () => {
|
|||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
|
|
||||||
expect(prevent).not.toHaveBeenCalled();
|
expect(prevent).not.toHaveBeenCalled();
|
||||||
expect(panel(fixture)).toBeNull();
|
expect(panel(fixture)).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEvent = new KeyboardEvent('keydown', {
|
const closeEvent = new KeyboardEvent('keydown', {
|
||||||
key: 'k',
|
key: 'k',
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
});
|
});
|
||||||
const preventOpen = vi.spyOn(openEvent, 'preventDefault');
|
const preventClose = vi.spyOn(closeEvent, 'preventDefault');
|
||||||
document.dispatchEvent(openEvent);
|
document.dispatchEvent(closeEvent);
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
expect(preventOpen).toHaveBeenCalled();
|
expect(preventClose).toHaveBeenCalled();
|
||||||
expect(panel(fixture)).toBeTruthy();
|
expect(panel(fixture)).toBeNull();
|
||||||
|
|
||||||
const shiftWhileOpen = new KeyboardEvent('keydown', {
|
const shiftWhileClosed = new KeyboardEvent('keydown', {
|
||||||
key: 'k',
|
key: 'k',
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
shiftKey: true,
|
shiftKey: true,
|
||||||
});
|
});
|
||||||
const preventShift = vi.spyOn(shiftWhileOpen, 'preventDefault');
|
const preventShift = vi.spyOn(shiftWhileClosed, 'preventDefault');
|
||||||
document.dispatchEvent(shiftWhileOpen);
|
document.dispatchEvent(shiftWhileClosed);
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
expect(preventShift).not.toHaveBeenCalled();
|
expect(preventShift).not.toHaveBeenCalled();
|
||||||
expect(panel(fixture)).toBeTruthy();
|
expect(panel(fixture)).toBeNull();
|
||||||
|
|
||||||
const metaOpen = new KeyboardEvent('keydown', {
|
const metaOpen = new KeyboardEvent('keydown', {
|
||||||
key: 'k',
|
key: 'k',
|
||||||
@@ -252,12 +270,11 @@ describe('TerminalDock', () => {
|
|||||||
document.dispatchEvent(metaOpen);
|
document.dispatchEvent(metaOpen);
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
expect(preventMeta).toHaveBeenCalled();
|
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) => {
|
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await openViaTrigger(fixture);
|
|
||||||
input(fixture)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
input(fixture)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
|
|
||||||
@@ -269,8 +286,6 @@ describe('TerminalDock', () => {
|
|||||||
'closes on Escape from a panel control and returns focus to the trigger (%s)',
|
'closes on Escape from a panel control and returns focus to the trigger (%s)',
|
||||||
async (locale) => {
|
async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await openViaTrigger(fixture);
|
|
||||||
|
|
||||||
const maximize = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
const maximize = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
||||||
maximize.focus();
|
maximize.focus();
|
||||||
expect(document.activeElement).toBe(maximize);
|
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) => {
|
it.each(APP_LOCALES)('does not lock scroll or mark the page inert (%s)', async (locale) => {
|
||||||
document.body.style.overflow = 'auto';
|
document.body.style.overflow = 'auto';
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await openViaTrigger(fixture);
|
await flush(fixture);
|
||||||
|
|
||||||
expect(document.body.style.overflow).toBe('auto');
|
expect(document.body.style.overflow).toBe('auto');
|
||||||
expect(document.querySelector('.site')?.hasAttribute('inert')).toBeFalsy();
|
expect(document.querySelector('.site')?.hasAttribute('inert')).toBeFalsy();
|
||||||
@@ -298,7 +313,6 @@ describe('TerminalDock', () => {
|
|||||||
'echoes the prompt, keeps the log and walks command history (%s)',
|
'echoes the prompt, keeps the log and walks command history (%s)',
|
||||||
async (locale) => {
|
async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await openViaTrigger(fixture);
|
|
||||||
const copy = SIGNATURE_COPY[locale].terminal;
|
const copy = SIGNATURE_COPY[locale].terminal;
|
||||||
|
|
||||||
await submitQuery(fixture, 'help');
|
await submitQuery(fixture, 'help');
|
||||||
@@ -324,31 +338,32 @@ describe('TerminalDock', () => {
|
|||||||
expect(field?.value).toBe('history');
|
expect(field?.value).toBe('history');
|
||||||
|
|
||||||
await submitQuery(fixture, 'clear');
|
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) => {
|
it.each(APP_LOCALES)(
|
||||||
const fixture = await createFixture(locale);
|
'scrolls the session to keep the prompt visible after output (%s)',
|
||||||
await openViaTrigger(fixture);
|
async (locale) => {
|
||||||
const logEl = fixture.nativeElement.querySelector('.terminal-dock-log') as HTMLElement;
|
const fixture = await createFixture(locale);
|
||||||
const scrollTo = vi.fn();
|
const sessionEl = session(fixture) as HTMLElement;
|
||||||
Object.defineProperty(logEl, 'scrollHeight', { configurable: true, get: () => 480 });
|
const scrollTo = vi.fn();
|
||||||
Object.defineProperty(logEl, 'scrollTo', { configurable: true, value: scrollTo });
|
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).toHaveBeenCalled();
|
||||||
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
|
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
top: 480,
|
top: 480,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => {
|
it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await openViaTrigger(fixture);
|
|
||||||
await submitQuery(fixture, 'help');
|
await submitQuery(fixture, 'help');
|
||||||
await submitQuery(fixture, 'brew');
|
await submitQuery(fixture, 'brew');
|
||||||
await submitQuery(fixture, 'rev');
|
await submitQuery(fixture, 'rev');
|
||||||
@@ -393,21 +408,51 @@ describe('TerminalDock', () => {
|
|||||||
expect(field?.value).toBe('');
|
expect(field?.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(APP_LOCALES)('navigates a known target and opens the CV asset (%s)', async (locale) => {
|
it.each(APP_LOCALES)(
|
||||||
const fixture = await createFixture(locale);
|
'restores an in-progress draft after walking off the newest history (%s)',
|
||||||
const router = TestBed.inject(Router);
|
async (locale) => {
|
||||||
const navigation = TestBed.inject(NavigationService);
|
const fixture = await createFixture(locale);
|
||||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
await submitQuery(fixture, 'help');
|
||||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
const field = input(fixture);
|
||||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
field!.value = 'nav';
|
||||||
|
field!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
await openViaTrigger(fixture);
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||||
await submitQuery(fixture, 'navigate pitch');
|
await flush(fixture);
|
||||||
expect(navigate).toHaveBeenCalledWith(navigation.link('pitch'));
|
expect(field?.value).toBe('help');
|
||||||
|
|
||||||
await submitQuery(fixture, 'navigate cv');
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
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 () => {
|
it('registers no document listener on the server', async () => {
|
||||||
const add = vi.spyOn(document, 'addEventListener');
|
const add = vi.spyOn(document, 'addEventListener');
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ interface LogLine {
|
|||||||
readonly text: string;
|
readonly text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_LOG_LINES = 200;
|
||||||
|
const MAX_HISTORY = 80;
|
||||||
|
|
||||||
let terminalLogId = 0;
|
let terminalLogId = 0;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -67,11 +70,22 @@ export class TerminalDock {
|
|||||||
protected readonly log = signal<readonly LogLine[]>([]);
|
protected readonly log = signal<readonly LogLine[]>([]);
|
||||||
protected readonly entered = signal<readonly string[]>([]);
|
protected readonly entered = signal<readonly string[]>([]);
|
||||||
protected readonly historyIndex = signal<number | null>(null);
|
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 copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].terminal);
|
||||||
protected readonly shortcutHint = computed(() =>
|
protected readonly shortcutHint = computed(() =>
|
||||||
this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint,
|
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 open = computed(() => this.state() !== 'collapsed');
|
||||||
protected readonly maximized = computed(() => this.state() === 'maximized');
|
protected readonly maximized = computed(() => this.state() === 'maximized');
|
||||||
|
|
||||||
@@ -87,22 +101,9 @@ export class TerminalDock {
|
|||||||
{ injector: this.injector },
|
{ injector: this.injector },
|
||||||
);
|
);
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (!this.open()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterNextRender(
|
|
||||||
() => {
|
|
||||||
this.inputRef()?.nativeElement.focus();
|
|
||||||
},
|
|
||||||
{ injector: this.injector },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const lines = this.log();
|
const lines = this.log();
|
||||||
if (!this.isBrowser || !this.open() || lines.length === 0) {
|
if (!this.isBrowser || !this.open() || lines.length === 0 || !this.followOutput) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,9 @@ export class TerminalDock {
|
|||||||
|
|
||||||
protected onTriggerClick(): void {
|
protected onTriggerClick(): void {
|
||||||
this.dock.toggle(this.triggerRef()?.nativeElement ?? null);
|
this.dock.toggle(this.triggerRef()?.nativeElement ?? null);
|
||||||
|
if (this.open()) {
|
||||||
|
this.focusInput();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onQueryInput(event: Event): void {
|
protected onQueryInput(event: Event): void {
|
||||||
@@ -126,6 +130,9 @@ export class TerminalDock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.query.set(target.value);
|
this.query.set(target.value);
|
||||||
|
if (this.historyIndex() === null) {
|
||||||
|
this.historyDraft.set(target.value);
|
||||||
|
}
|
||||||
this.historyIndex.set(null);
|
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 {
|
protected toggleMaximized(): void {
|
||||||
this.dock.toggleMaximized();
|
this.dock.toggleMaximized();
|
||||||
}
|
}
|
||||||
@@ -170,6 +187,15 @@ export class TerminalDock {
|
|||||||
this.dock.collapse();
|
this.dock.collapse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private focusInput(): void {
|
||||||
|
afterNextRender(
|
||||||
|
() => {
|
||||||
|
this.inputRef()?.nativeElement.focus();
|
||||||
|
},
|
||||||
|
{ injector: this.injector },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private runRaw(raw: string): void {
|
private runRaw(raw: string): void {
|
||||||
const parsed = parseCommand(raw);
|
const parsed = parseCommand(raw);
|
||||||
if (parsed.kind === 'empty') {
|
if (parsed.kind === 'empty') {
|
||||||
@@ -178,9 +204,11 @@ export class TerminalDock {
|
|||||||
|
|
||||||
const copy = this.copy();
|
const copy = this.copy();
|
||||||
this.echo(raw);
|
this.echo(raw);
|
||||||
this.entered.update((items) => [...items, raw]);
|
this.entered.update((items) => [...items, raw].slice(-MAX_HISTORY));
|
||||||
this.historyIndex.set(null);
|
this.historyIndex.set(null);
|
||||||
|
this.historyDraft.set('');
|
||||||
this.writeQuery('');
|
this.writeQuery('');
|
||||||
|
this.followOutput = true;
|
||||||
|
|
||||||
switch (parsed.kind) {
|
switch (parsed.kind) {
|
||||||
case 'help': {
|
case 'help': {
|
||||||
@@ -188,6 +216,8 @@ export class TerminalDock {
|
|||||||
copy.helpIntro,
|
copy.helpIntro,
|
||||||
copy.helpGrammar,
|
copy.helpGrammar,
|
||||||
...COMMAND_IDS.map((id) => `${id} — ${copy.commandDescriptions[id]}`),
|
...COMMAND_IDS.map((id) => `${id} — ${copy.commandDescriptions[id]}`),
|
||||||
|
copy.helpTargetsIntro,
|
||||||
|
...navigateTargetKeys(),
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -226,8 +256,12 @@ export class TerminalDock {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.router.navigate(this.navigation.link(parsed.resolution.routeId));
|
const extras = parsed.resolution.fragment
|
||||||
this.append([copy.navigating.replace('{target}', parsed.targetKey)]);
|
? { 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;
|
return;
|
||||||
}
|
}
|
||||||
case 'unknown-command': {
|
case 'unknown-command': {
|
||||||
@@ -273,6 +307,7 @@ export class TerminalDock {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.historyDraft.set(this.query());
|
||||||
const newest = commands.length - 1;
|
const newest = commands.length - 1;
|
||||||
this.historyIndex.set(newest);
|
this.historyIndex.set(newest);
|
||||||
this.writeQuery(commands[newest] ?? '');
|
this.writeQuery(commands[newest] ?? '');
|
||||||
@@ -286,7 +321,7 @@ export class TerminalDock {
|
|||||||
|
|
||||||
if (next >= commands.length) {
|
if (next >= commands.length) {
|
||||||
this.historyIndex.set(null);
|
this.historyIndex.set(null);
|
||||||
this.writeQuery('');
|
this.writeQuery(this.historyDraft());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,16 +356,20 @@ export class TerminalDock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private echo(input: string): void {
|
private echo(input: string): void {
|
||||||
this.log.update((lines) => [
|
this.log.update((lines) =>
|
||||||
...lines,
|
[
|
||||||
{ id: terminalLogId++, kind: 'echo', text: `${this.copy().prompt} ${input}` },
|
...lines,
|
||||||
]);
|
{ id: terminalLogId++, kind: 'echo' as const, text: `${this.copy().prompt} ${input}` },
|
||||||
|
].slice(-MAX_LOG_LINES),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private append(texts: readonly string[]): void {
|
private append(texts: readonly string[]): void {
|
||||||
this.log.update((lines) => [
|
this.log.update((lines) =>
|
||||||
...lines,
|
[
|
||||||
...texts.map((text) => ({ id: terminalLogId++, kind: 'output' as const, text })),
|
...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);
|
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 {
|
.content-container {
|
||||||
width: min(100% - 2 * var(--content-gutter), var(--content-max));
|
width: min(100% - 2 * var(--content-gutter), var(--content-max));
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user