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

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

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

View File

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