Polish CBM Lens Beta branding and architecture navigation

Rename the reading workspace to CBM Lens Beta, distinguish Beta with an amber badge, and keep build diagnostics out of the header. Put architecture history beside the active context and record meaningful exploration steps.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-11 15:56:10 +02:00
1 parent 7cfc1b8771
commit b0b0ffdef8
37 files changed
+569 -410

No files matched your search

+4 -4
View File
@@ -131,9 +131,9 @@ Package-manager setup (npm, PyPI, or Go) verifies and publishes a coherent priva
The ordinary `cli` mode is intentionally separate: it runs one command locally and never starts or connects to the coordination daemon, registers a daemon session, or starts watchers/UI. Its only shared state is the OS admission barrier plus per-project locks for graph mutations. While the command is running, a temporary monitor lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
### CBM Atlas — the human interface
### CBM Lens Beta — the human interface
The MCP tools are the agent's interface to the code graph; **CBM Atlas** is the human's. It is built into the binary — every install on every channel has it:
The MCP tools are the agent's interface to the code graph; **CBM Lens Beta** is the human's. It is built into the binary — every install on every channel has it:
```bash
codebase-memory-mcp --ui=true --port=9749
@@ -141,9 +141,9 @@ codebase-memory-mcp --ui=true --port=9749
Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers.
Atlas is **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess.
CBM Lens builds on **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess.
When something in Atlas does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file.
When something in CBM Lens does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file.
- **Explorer and reader** — the indexed file tree and a read-only Monaco reader whose source comes from the index.
- **Semantic twin** — follows the caret: what the symbol under it holds, calls, raises and touches, as facts or as pseudocode, pitched at who is reading.
+1 -1
View File
@@ -1,4 +1,4 @@
# CodeAtlasWeb
# CBM Lens Beta
## In this repository
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3E%3Crect width=%2716%27 height=%2716%27 fill=%27%230a0e0d%27/%3E%3Ccircle cx=%278%27 cy=%278%27 r=%274%27 fill=%27%2333ff99%27/%3E%3C/svg%3E" />
<title>CodeAtlas</title>
<title>CBM Lens Beta</title>
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "codeatlasweb",
"name": "cbm-lens",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeatlasweb",
"name": "cbm-lens",
"version": "0.0.1",
"dependencies": {
"@huggingface/transformers": "4.2.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "codeatlasweb",
"name": "cbm-lens",
"version": "0.0.1",
"private": true,
"type": "module",
+5 -12
View File
@@ -162,8 +162,9 @@ describe('AtlasChrome', () => {
it('zeigt Marke und Versions-Chip in der Kopfzeile', async () => {
await render(props());
expect(container.querySelector('.atlas-brand')?.textContent).toBe('CODEATLAS');
expect(testId('atlas-version')?.textContent).toBe('v0.0.0-dirty');
expect(container.querySelector('.atlas-brand')?.textContent).toBe('CBM LENS Beta');
expect(container.querySelector('.atlas-brand-beta')?.textContent).toBe('Beta');
expect(testId('atlas-version')?.textContent).toBe('v0.0.0');
});
it('keeps project switching at the end of the header without a duplicate project chip', async () => {
@@ -177,18 +178,10 @@ describe('AtlasChrome', () => {
expect(header?.querySelector('[data-chip="sym"]')?.textContent).toContain('76');
});
/*
* Der Zustand des Arbeitsbaums steht neben der Fassung, nicht in ihr.
*
* Der Chip beantwortet "welche Fassung ist das" und `v1.0.0-dirty` waere
* darauf eine Antwort, die es als Release nicht gibt. Ohne Zusatz steht
* daneben nichts: ein leeres Element waere ein Platz, an dem jemand eine
* Angabe vermutet.
*/
it('haelt den Bau-Zusatz getrennt vom Versions-Chip', async () => {
it('omits build diagnostics from the header', async () => {
await render(props({ version: 'v1.0.0', buildSuffix: 'dirty' }));
expect(testId('atlas-version')?.textContent).toBe('v1.0.0');
expect(testId('atlas-version-suffix')?.textContent).toBe('dirty');
expect(testId('atlas-version-suffix')).toBeNull();
});
it('zeigt gar kein Zusatz-Element, wenn der Baum sauber war', async () => {
+3 -17
View File
@@ -164,16 +164,7 @@ export interface AtlasChromeProps {
guidance?: Guidance;
/** Versions-Chip, zur Buildzeit injiziert. Nur die Fassung, ohne Zusatz. */
version: string;
/**
* Der Zusatz neben dem Chip: `dirty`, wenn beim Bauen etwas uncommitted war.
*
* Ein eigenes Element und nicht ein Anhaengsel im Chip, weil es eine andere
* Aussage ist: der Chip nennt die Fassung, der Zusatz den Zustand des
* Baums, aus dem sie gebaut wurde. Zusammengeschrieben liest sich das wie
* eine Fassung namens "1.0.0-dirty", die es als Release nie gibt. Fehlt der
* Zusatz, steht dort nichts: ein leeres Element waere ein Platz, an dem
* jemand eine Angabe vermutet.
*/
/** Build diagnostics are accepted for compatibility but not shown in the header. */
buildSuffix?: string;
chips: Chip[];
tabs: TabDescriptor[];
@@ -625,15 +616,10 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
return (
<div className="atlas-shell" data-workspace={props.workspace ?? 'explore'} data-guidance={props.guidance ?? 'brief'} data-chat-open={showChatColumn} data-has-status={props.workspaceStatus !== undefined}>
<header className="atlas-header" data-testid="atlas-header">
<h1 className="atlas-brand">{messages.app.brand}</h1>
<h1 className="atlas-brand">{messages.app.brand}{' '}<span className="atlas-brand-beta">{messages.app.releaseLabel}</span></h1>
<span className="atlas-version" data-testid="atlas-version">
{props.version}
{props.version.replace(/-dirty$/, '')}
</span>
{(props.buildSuffix ?? '').length > 0 && (
<span className="atlas-version-suffix" data-testid="atlas-version-suffix">
{props.buildSuffix}
</span>
)}
{/*
* Sechs Eintraege, EINE Gestalt.
*
@@ -5,11 +5,10 @@
* Auswahl, Doppelklick und leere Flaeche; Analyse und Routen kommen aus
* festen Antworten.
*/
import { act } from 'react';
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ArchitecturePanel, { type ArchitecturePanelProps } from './ArchitecturePanel';
import { fitToolbar } from '../galaxy/toolbar-fit';
import type { ArchitectureSceneProps } from './ArchitectureScene';
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
import type { GraphData, GraphNode } from '../galaxy/types';
@@ -19,7 +18,8 @@ import type { SystemArchitectureLoader, SystemProjection, SystemSymbol } from '.
vi.mock('./ArchitectureScene', () => ({ ArchitectureScene: (props: ArchitectureSceneProps) => <div data-testid="scene">
{props.model.nodes.map(node => <button key={node.id} data-node={node.id} onClick={() => props.onSelect(node.id)} onDoubleClick={() => props.onOpen?.(node.id)}>{node.label}</button>)}
<button aria-label="Empty background" onClick={props.onClearSelection} /></div> }));
vi.mock('./ContainerMap', () => ({ default: ({ filter }: { filter: string }) => <div data-testid="container-map" data-filter={filter} /> }));
vi.mock('./ContainerMap', () => ({ default: ({ filter, navigation }: { filter: string; navigation?: ReactNode }) =>
<div data-testid="container-map" data-filter={filter}><div className="atlas-arch-context" data-testid="container-location">{navigation}</div></div> }));
vi.mock('./route-graph-source', () => ({ loadRouteGraph: vi.fn(async () => ({ relationships: [], truncated: false, warnings: [] })) }));
vi.mock('./BehaviorSourceEvidence', () => ({ default: () => <div /> }));
vi.mock('../app/atlas-api', () => ({ AtlasApi: class { flows() { return Promise.resolve([]); } } }));
@@ -127,7 +127,7 @@ const filter = () => container.querySelector<HTMLInputElement>('input[type="sear
const focus = () => container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-focus');
const heading = () => container.querySelector('.behavior-heading h2')?.textContent;
const sceneLabels = () => [...container.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent);
/** The "← Back" System structure and Behavior had in their own toolbars before K27; the shared one carries the same words. */
/** System structure and Behavior use the shared history rather than another local Back. */
const inViewBack = () => buttons().find(button => button.textContent === '← Back' && !history()?.contains(button));
const systemCamera = () => container.querySelector('[aria-label="System camera"] button[aria-pressed="true"]')?.textContent;
const behaviorCamera = () => container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent;
@@ -144,89 +144,118 @@ async function openArea(name: string): Promise<void> {
await press(container.querySelector<HTMLButtonElement>(`[data-testid="scene"] [data-node="area:${name}"]`) ?? byText(name, container.querySelector('[data-testid="scene"]')!));
await clickText('Open area →');
}
const selectView = (name: string) => press(container.querySelector<HTMLButtonElement>(`[data-view="${name}"]`));
const position = () => history()?.getAttribute('data-position');
async function openRouteGroup(): Promise<void> {
await press(byText('/edit · 2', container.querySelector('[data-testid="scene"]')!));
await clickText('Show these 2 routes →');
}
describe('Architecture back and forward (K27)', () => {
it('goes back and forward across subtabs, an opened area and Plan, naming each target', async () => {
it('restores meaningful visits across views, with each visit’s camera and location', async () => {
await render();
expect(back()?.disabled).toBe(true);
expect(back()?.title).toBe('Nothing to go back to yet');
expect(forward()?.disabled).toBe(true);
await openArea('django');
expect(trail()).toEqual(['sample', 'django']);
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
await clickText('Plan', container.querySelector('[aria-label="Map camera"]')!);
expect(back()?.title).toBe('Back to Overview · django (Alt+Left)');
await press(container.querySelector<HTMLButtonElement>('[data-view="hotspots"]'));
expect(position()).toBe('2/2');
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
await doubleClick('[data-node="file:django/shortcuts.py"]');
expect(container.querySelector('[aria-label="Architecture location"]')?.textContent).toContain('shortcuts.py');
expect(position()).toBe('3/3');
expect(back()?.title).toBe('Back to Overview · django · Plan (Alt+Left)');
await selectView('routes');
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
expect(position()).toBe('3/3');
await openRouteGroup();
expect(position()).toBe('4/4');
await goBack();
expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '']);
await goBack();
expect([view(), trail(), planar()]).toEqual(['overview', ['sample', 'django'], 'Plan']);
expect(forward()?.title).toBe('Forward to Hotspots · Plan (Alt+Right)');
expect(forward()?.title).toBe('Forward to Routes · Endpoints · Plan (Alt+Right)');
await goBack();
expect([view(), trail(), planar()]).toEqual(['overview', ['sample', 'django'], '3D']);
await goBack();
expect(trail()).toEqual(['sample']);
expect([trail(), planar()]).toEqual([['sample'], '3D']);
expect(back()?.disabled).toBe(true);
await goForward(); await goForward(); await goForward();
expect(view()).toBe('hotspots');
expect([view(), perspective(), filter(), planar()]).toEqual(['routes', 'Endpoints', '/edit', 'Plan']);
expect(forward()?.disabled).toBe(true);
// The location trail stays: it still leads back to the repository, and that is a step of its own.
await goBack();
await goBack(); await goBack();
await press(byText('sample', container.querySelector('[aria-label="Architecture location"]')!));
expect(trail()).toEqual(['sample']);
expect(back()?.title).toBe('Back to Overview · django · Plan (Alt+Left)');
expect(forward()?.disabled).toBe(true);
});
it('restores the Routes perspective and an opened route group', async () => {
it('replaces view and Routes perspective context while route-group drill-ins create stops', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await selectView('routes');
expect(perspective()).toBe('Service map');
expect(container.querySelector('[data-testid="container-map"]')).not.toBeNull();
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
expect(sceneLabels()).toContain('/edit · 2');
await press(byText('/edit · 2', container.querySelector('[data-testid="scene"]')!));
await clickText('Show these 2 routes →');
expect(position()).toBe('1/1');
expect(back()?.disabled).toBe(true);
await openRouteGroup();
expect(filter()).toBe('/edit');
expect(sceneLabels()).toEqual(['/edit/one/', '/edit/two/']);
expect(back()?.title).toBe('Back to Routes · Endpoints (Alt+Left)');
await goBack();
expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '']);
await goBack();
expect([perspective(), filter()]).toEqual(['Service map', '']);
expect(back()?.disabled).toBe(true);
await clickText('Service map', container.querySelector('[aria-label="Routes perspective"]')!);
expect(position()).toBe('1/2');
expect(container.querySelector('[data-testid="container-map"]')).not.toBeNull();
await goBack();
expect(view()).toBe('overview');
await goForward(); await goForward(); await goForward();
expect(forward()?.disabled).toBe(false);
await goForward();
expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '/edit']);
expect(sceneLabels()).toEqual(['/edit/one/', '/edit/two/']);
await goBack();
expect([perspective(), filter()]).toEqual(['Service map', '']);
});
it('takes a typed filter as one step once typing pauses, not one per key', async () => {
it('saves every typed filter immediately without adding stops', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await selectView('routes');
await type('/e'); await type('/ed'); await type('/edit');
expect(filter()).toBe('/edit');
await act(async () => { await new Promise(resolve => setTimeout(resolve, 900)); });
expect(back()?.title).toBe('Back to Routes · Service map (Alt+Left)');
expect(position()).toBe('1/1');
expect(back()?.disabled).toBe(true);
expect(forward()?.disabled).toBe(true);
await type('');
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
await openRouteGroup();
await type('/edit/one');
expect(position()).toBe('2/2');
await goBack();
expect([view(), filter()]).toEqual(['routes', '']);
await goBack();
expect(view()).toBe('overview');
await goForward(); await goForward();
expect(filter()).toBe('/edit');
expect(filter()).toBe('');
expect(forward()?.title).toBe('Forward to Routes · Endpoints · /edit/one (Alt+Right)');
await goForward();
expect(filter()).toBe('/edit/one');
expect(position()).toBe('2/2');
});
it('makes the System structure focus and expanded groups steps, and its old Back the shared Back', async () => {
it('records System structure focus and restores expanded groups without expansion-only stops', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
await selectView('structure');
expect(focus()).toBe('');
// The old "← Back" is absorbed into the shared Back beside the subtabs: one Back control, one meaning.
expect(inViewBack()).toBeUndefined();
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
expect(back()?.disabled).toBe(true);
await doubleClick('[data-system-node="g:api"]');
expect(focus()).toBe('g:api');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull();
expect(back()?.title).toBe('Back to System structure (Alt+Left)');
await press(container.querySelector<HTMLButtonElement>('[data-system-node="g:api"]'));
await clickText('Collapse group');
expect(position()).toBe('2/2');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull();
await clickText('Expand group');
expect(position()).toBe('2/2');
await press(container.querySelector<HTMLButtonElement>('[data-system-node="g:service"]'));
await clickText('Focus here');
expect(focus()).toBe('g:service');
@@ -235,16 +264,13 @@ describe('Architecture back and forward (K27)', () => {
await goBack();
expect(focus()).toBe('g:api');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull();
await goBack();
expect(focus()).toBe('');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull();
// One meaning of Back: the same Back also leaves System structure.
await goBack();
expect(view()).toBe('overview');
await goForward(); await goForward();
expect(back()?.disabled).toBe(true);
await goForward();
expect([view(), focus()]).toEqual(['structure', 'g:api']);
expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull();
// "Whole system" stays the way to the top, as a new step that drops the forward branch.
await clickText('Whole system');
expect(focus()).toBe('');
expect(forward()?.disabled).toBe(true);
@@ -253,10 +279,11 @@ describe('Architecture back and forward (K27)', () => {
it('steps through Behavior starts and followed calls, and an automatic start is no extra step', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
await press(container.querySelector<HTMLButtonElement>('[data-view="behavior"]'));
await selectView('structure');
await doubleClick('[data-system-node="g:api"]');
await selectView('behavior');
expect(heading()).toBe('What can main call?');
// The suggested start completes the Behavior step instead of adding one Back would bounce on.
expect(position()).toBe('2/2');
expect(back()?.title).toBe('Back to System structure (Alt+Left)');
await choose('Behavior entry point', '2');
expect(heading()).toBe('What can handle call?');
@@ -273,18 +300,16 @@ describe('Architecture back and forward (K27)', () => {
await goBack();
expect(heading()).toBe('What can main call?');
await goBack();
expect(view()).toBe('structure');
expect([view(), focus()]).toEqual(['structure', '']);
await goForward(); await goForward(); await goForward();
expect(heading()).toBe('What can save call?');
expect(container.querySelector<HTMLSelectElement>('select[aria-label="Behavior entry point"]')?.value).toBe('3');
// Empty background still returns from a followed call to where the hops started, as a step.
await clickText('System background');
expect(heading()).toBe('What can handle call?');
expect(back()?.title).toBe('Back to Behavior · save · followed from handle (Alt+Left)');
});
it('names the start a reset Behavior step shows, after the suggested start was rejected for a stale snapshot', async () => {
// django-demo: the suggested main is rejected once for another analysis generation; the journey then shows main on its own.
it('names the shown Behavior start after the suggested start was rejected for a stale snapshot', async () => {
let calls = 0;
loader.mockImplementation(async request => {
calls++;
@@ -292,8 +317,10 @@ describe('Architecture back and forward (K27)', () => {
return { status: 'ready', generation: calls === 1 ? 'g1' : 'g2', result: projection() };
});
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="behavior"]'));
await openArea('django');
await selectView('behavior');
expect(heading()).toBe('What can main call?');
expect(position()).toBe('2/2');
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
await choose('Behavior entry point', '2');
expect(heading()).toBe('What can handle call?');
@@ -306,135 +333,140 @@ describe('Architecture back and forward (K27)', () => {
expect(forward()?.title).toBe('Forward to Behavior · main (Alt+Right)');
});
it('makes Plan or 3D in System structure a step that Back restores', async () => {
it('restores System Plan or 3D with the focused visit without making camera or tab stops', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
await selectView('structure');
expect(systemCamera()).toBe('3D');
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('false');
await doubleClick('[data-system-node="g:api"]');
await clickText('Plan', container.querySelector('[aria-label="System camera"]')!);
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
expect(back()?.title).toBe('Back to System structure (Alt+Left)');
await press(container.querySelector<HTMLButtonElement>('[data-view="overview"]'));
expect(back()?.title).toBe('Back to System structure · Plan (Alt+Left)');
expect(position()).toBe('2/2');
await press(container.querySelector<HTMLButtonElement>('[data-system-node="g:service"]'));
await clickText('Focus here');
await selectView('overview');
expect(position()).toBe('3/3');
expect(back()?.title).toBe('Back to System structure · api · 1 group open · Plan (Alt+Left)');
await goBack();
expect([view(), systemCamera()]).toEqual(['structure', 'Plan']);
expect([view(), focus(), systemCamera()]).toEqual(['structure', 'g:api', 'Plan']);
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
await goBack();
expect([view(), systemCamera()]).toEqual(['structure', '3D']);
expect([view(), focus(), systemCamera()]).toEqual(['structure', '', '3D']);
await goForward(); await goForward();
expect(view()).toBe('overview');
// Behavior keeps a camera of its own, as before.
await goBack();
await press(container.querySelector<HTMLButtonElement>('[data-view="behavior"]'));
await selectView('behavior');
expect(behaviorCamera()).toBe('3D');
expect(position()).toBe('3/3');
});
it('keeps the Behavior path, the operation on it and Plan or 3D in the entry, and only a path or camera change is a step', async () => {
it('records Behavior destinations and paths while retaining chain position and camera as context', async () => {
loader.mockImplementation(async () => ({ status: 'ready', generation: 'g1', result: twoPaths() }));
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="behavior"]'));
await selectView('behavior');
expect(heading()).toBe('What can main call?');
expect(position()).toBe('1/1');
await choose('Behavior destination', '3');
expect(heading()).toBe('main → save');
expect([chosenPath(), chainPosition()]).toEqual([1, '1 / 3']);
await clickText('Next →');
expect(chainPosition()).toBe('2 / 3');
// Walking the chain is no step of its own: Previous and Next do that; the entry keeps where it stands.
expect(position()).toBe('2/2');
expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
await press(pathButtons()[1]);
expect([chosenPath(), chainPosition()]).toEqual([2, '1 / 2']);
expect(back()?.title).toBe('Back to Behavior · main → save (Alt+Left)');
await clickText('Plan', container.querySelector('[aria-label="Behavior camera"]')!);
expect(back()?.title).toBe('Back to Behavior · main → save · Path 2 (Alt+Left)');
await press(container.querySelector<HTMLButtonElement>('[data-view="overview"]'));
expect(back()?.title).toBe('Back to Behavior · main → save · Path 2 · Plan (Alt+Left)');
expect(position()).toBe('3/3');
expect(back()?.title).toBe('Back to Behavior · main → save (Alt+Left)');
await goBack();
expect([view(), behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['behavior', 'Plan', 2, '1 / 2']);
await goBack();
expect([behaviorCamera(), chosenPath()]).toEqual(['3D', 2]);
await goBack();
expect([heading(), chosenPath(), chainPosition()]).toEqual(['main → save', 1, '2 / 3']);
expect([heading(), behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['main → save', '3D', 1, '2 / 3']);
expect(forward()?.title).toBe('Forward to Behavior · main → save · Path 2 · Plan (Alt+Right)');
await goBack();
expect([heading(), container.querySelector('[aria-label="Walk the call chain"]')]).toEqual(['What can main call?', null]);
expect(back()?.disabled).toBe(true);
await goForward();
expect([chosenPath(), chainPosition()]).toEqual([1, '2 / 3']);
await goForward(); await goForward();
await goForward();
expect([behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['Plan', 2, '1 / 2']);
// System structure keeps a camera of its own, as before.
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
await selectView('structure');
expect(systemCamera()).toBe('3D');
expect(position()).toBe('3/3');
});
it('records typing that has not paused yet as a step when Back, Forward or Recent leaves it', async () => {
it('preserves the forward branch while typing and saves the latest text before a recent jump', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await type('/adm');
await selectView('routes');
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
await openRouteGroup();
await goBack();
expect([view(), filter()]).toEqual(['routes', '']);
expect(forward()?.title).toBe('Forward to Routes · Service map · /adm (Alt+Right)');
await goForward();
expect([view(), filter()]).toEqual(['routes', '/adm']);
await act(async () => { await new Promise(resolve => setTimeout(resolve, 900)); });
expect(history()?.getAttribute('data-position')).toBe('3/3');
// A recent place while typing: the text stays a step before the jump.
await type('/home');
const recent = container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent')!;
const overviewPlace = [...recent.querySelectorAll<HTMLButtonElement>('[aria-label="Recently visited places"] li button')]
.find(item => item.querySelector('strong')?.textContent === 'Overview');
await press(overviewPlace);
expect(view()).toBe('overview');
expect(back()?.title).toBe('Back to Routes · Service map · /home (Alt+Left)');
await goBack();
expect([view(), filter()]).toEqual(['routes', '/home']);
// Forward while typing: the text is a new step and drops the forward branch, as after the pause.
await goBack();
expect(filter()).toBe('/adm');
await type('/admin');
expect(position()).toBe('1/2');
expect(forward()?.title).toBe('Forward to Routes · Endpoints · /edit (Alt+Right)');
await goForward();
expect(filter()).toBe('/admin');
expect(filter()).toBe('/edit');
await goBack();
expect(filter()).toBe('/home');
await type('/admin');
const recent = container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent')!;
const editPlace = [...recent.querySelectorAll<HTMLButtonElement>('[aria-label="Recently visited places"] li button')]
.find(item => item.querySelector('span')?.textContent === 'Endpoints · /edit');
await press(editPlace);
expect(filter()).toBe('/edit');
expect(position()).toBe('2/2');
expect(forward()?.disabled).toBe(true);
expect(back()?.title).toBe('Back to Routes · Service map · /adm (Alt+Left)');
expect(back()?.title).toBe('Back to Routes · Endpoints · /admin (Alt+Left)');
await goBack();
expect(filter()).toBe('/admin');
});
it('reports an opened area only to the subtab that shows it, never to Routes or Hotspots', async () => {
it('reports opened-area evidence only in its view and restores it with meaningful history visits', async () => {
const onSelectionEvidence = vi.fn();
await render({ onSelectionEvidence });
await openArea('django');
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await selectView('routes');
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
expect(sceneLabels()).toContain('/edit · 2');
// Nothing is selected on the Endpoints map, so the chat context names nothing from Overview.
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
await press(container.querySelector<HTMLButtonElement>('[data-view="hotspots"]'));
await selectView('hotspots');
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
await press(container.querySelector<HTMLButtonElement>('[data-view="overview"]'));
await selectView('overview');
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
expect(position()).toBe('2/2');
await doubleClick('[data-node="file:django/shortcuts.py"]');
await selectView('routes');
await goBack();
expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
await goBack(); await goBack();
await goForward();
expect([view(), perspective()]).toEqual(['routes', 'Endpoints']);
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
});
it('drops the forward branch on a new navigation after Back and keeps at most 25 steps', async () => {
it('drops the forward branch on a new focus after Back and keeps at most 25 real stops', async () => {
await render();
for (const tab of ['routes', 'hotspots', 'structure']) await press(container.querySelector<HTMLButtonElement>(`[data-view="${tab}"]`));
await goBack(); await goBack();
expect(view()).toBe('routes');
await selectView('structure');
await doubleClick('[data-system-node="g:api"]');
await doubleClick('[data-system-node="g:service"]');
await goBack();
expect(focus()).toBe('g:api');
expect(forward()?.disabled).toBe(false);
await press(container.querySelector<HTMLButtonElement>('[data-view="behavior"]'));
await selectView('behavior');
expect(position()).toBe('2/3');
expect(forward()?.disabled).toBe(false);
await choose('Behavior entry point', '2');
expect(forward()?.disabled).toBe(true);
await goBack();
expect(view()).toBe('routes');
expect(forward()?.title).toBe('Forward to Behavior · main (Alt+Right)');
expect(heading()).toBe('What can main call?');
expect(forward()?.title).toBe('Forward to Behavior · handle (Alt+Right)');
for (let step = 0; step < 15; step++) for (const tab of ['overview', 'hotspots']) await press(container.querySelector<HTMLButtonElement>(`[data-view="${tab}"]`));
expect(history()?.getAttribute('data-position')).toBe('25/25');
for (let step = 0; step < 15; step++) {
await choose('Behavior entry point', '2');
await choose('Behavior entry point', '1');
}
expect(position()).toBe('25/25');
let steps = 0;
while (!back()!.disabled && steps < 100) { await goBack(); steps++; }
expect(steps).toBe(24);
@@ -442,7 +474,8 @@ describe('Architecture back and forward (K27)', () => {
it('answers Alt+Left and Alt+Right only in the active workspace and never while typing', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await openArea('django');
await selectView('routes');
let event = key({ key: 'ArrowLeft', altKey: true });
expect(event.defaultPrevented).toBe(true);
await settle();
@@ -450,19 +483,15 @@ describe('Architecture back and forward (K27)', () => {
event = key({ key: 'ArrowRight', altKey: true });
await settle();
expect(view()).toBe('routes');
// Typing in the route filter keeps the arrows for the text field.
const search = container.querySelector<HTMLInputElement>('input[type="search"]')!;
event = key({ key: 'ArrowLeft', altKey: true }, search);
expect(event.defaultPrevented).toBe(false);
expect(view()).toBe('routes');
// Without Alt, or with another modifier, the arrows are not history keys.
expect(key({ key: 'ArrowLeft' }).defaultPrevented).toBe(false);
expect(key({ key: 'ArrowLeft', altKey: true, metaKey: true }).defaultPrevented).toBe(false);
// Another surface on top (help, settings) keeps its keys.
await render({ escapeTaken: true });
expect(key({ key: 'ArrowLeft', altKey: true }).defaultPrevented).toBe(false);
expect(view()).toBe('routes');
// A hidden Architecture (Galaxy is active) leaves Alt+arrows to that workspace.
await render({ active: false });
expect(key({ key: 'ArrowLeft', altKey: true }).defaultPrevented).toBe(false);
await settle();
@@ -472,15 +501,17 @@ describe('Architecture back and forward (K27)', () => {
it('jumps to a recent place as a new navigation and starts a fresh history per project', async () => {
await render();
await openArea('django');
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
await doubleClick('[data-node="file:django/shortcuts.py"]');
await selectView('routes');
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
await openRouteGroup();
await goBack();
const recent = container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent')!;
expect(recent).not.toBeNull();
expect(recent.querySelector('summary')?.getAttribute('aria-label')).toBe('Recent');
const places = [...recent.querySelectorAll('[aria-label="Recently visited places"] li button')];
expect(places.map(item => [item.querySelector('strong')?.textContent, item.querySelector('span')?.textContent ?? ''])).toEqual([
['Routes', 'Service map'], ['System structure', ''], ['Overview', 'django'], ['Overview', ''],
['Routes', 'Endpoints'], ['Routes', 'Endpoints · /edit'], ['Overview', 'django'], ['Overview', ''],
]);
expect(places[0]?.getAttribute('aria-current')).toBe('true');
await act(async () => { recent.open = true; });
@@ -488,7 +519,7 @@ describe('Architecture back and forward (K27)', () => {
expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
expect(recent.open).toBe(false);
expect(forward()?.disabled).toBe(true);
expect(back()?.title).toBe('Back to Routes · Service map (Alt+Left)');
expect(back()?.title).toBe('Back to Routes · Endpoints (Alt+Left)');
await render({ projectName: 'other', overview: { ...overview, projectName: 'other' } });
expect(back()?.disabled).toBe(true);
@@ -497,60 +528,70 @@ describe('Architecture back and forward (K27)', () => {
});
});
/*
* Hand test 2026-10-04: Back, Forward and Recent stood as bare arrows at the right end of the subtab row,
* while Galaxy has "← Back", "Forward →" and "▾" at the start of its toolbar (A1), and the Recent menu stayed
* open over the content through several steps (A2).
*/
describe('Architecture history controls as in Galaxy (hand test 2026-10-04)', () => {
describe('Architecture history controls beside the current location', () => {
const recentMenu = () => container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent');
async function withRecent(): Promise<HTMLDetailsElement> {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await press(container.querySelector<HTMLButtonElement>('[data-view="hotspots"]'));
await openArea('django');
const menu = recentMenu();
expect(menu).not.toBeNull();
await act(async () => { menu!.open = true; });
return menu!;
}
it('A1: puts Back, Forward and Recent at the start of the subtab row, worded as in Galaxy', async () => {
it('keeps compact, accessible history controls inside the current view and out of the subtab row', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
const row = container.querySelector<HTMLElement>('.atlas-arch-tabrow')!;
expect([...row.children].map(item => item.getAttribute('aria-label'))).toEqual(['Architecture history', 'Architecture views']);
expect(back()?.textContent).toBe('← Back');
expect(back()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('←');
expect(forward()?.textContent).toBe('Forward →');
expect(forward()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('→');
await openArea('django');
const contexts = [
['overview', '[data-testid="spatial-architecture"]'],
['routes', '[data-testid="container-map"]'],
['hotspots', '[data-testid="spatial-architecture"]'],
['structure', '[data-testid="system-architecture"]'],
['behavior', '.behavior-journey'],
];
for (const [name, selector] of contexts) {
await selectView(name!);
const row = container.querySelector<HTMLElement>('.atlas-arch-tabrow')!;
expect([...row.children].map(item => item.getAttribute('aria-label'))).toEqual(['Architecture views']);
expect(row.querySelector('[aria-label="Architecture history"]')).toBeNull();
expect(container.querySelectorAll('[aria-label="Architecture history"]')).toHaveLength(1);
const currentView = container.querySelector(selector!);
expect(currentView?.contains(history()!)).toBe(true);
expect(history()?.parentElement).toBe(currentView?.querySelector(name === 'behavior' ? '.behavior-requests' : '.atlas-arch-context'));
expect(back()?.textContent).toBe('');
expect(forward()?.textContent).toBe('');
expect(back()?.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
expect(forward()?.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
expect(back()?.getAttribute('aria-label')).toBe('Back');
expect(forward()?.getAttribute('aria-label')).toBe('Forward');
expect(recentMenu()?.querySelector('summary')?.getAttribute('aria-label')).toBe('Recent');
expect(recentMenu()?.querySelector('summary svg')?.getAttribute('aria-hidden')).toBe('true');
}
await selectView('routes');
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
expect(container.querySelector('[data-testid="spatial-architecture"]')?.contains(history()!)).toBe(true);
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
expect(recentMenu()?.querySelector('summary')?.textContent).toBe('▾');
// The row measures itself like Galaxy's toolbar: words while the tabs fit, the glyphs once they would scroll.
expect(row.dataset.fit).toBe('full');
const tabs = row.querySelector<HTMLElement>('nav')!;
Object.defineProperties(tabs, { clientWidth: { configurable: true, value: 300 }, scrollWidth: { configurable: true, value: 520 } });
expect(fitToolbar(row)).toBe('compact');
});
it('A2: closes the Recent menu on a press elsewhere and on Escape, which stays with the menu', async () => {
it('closes Recent on a press elsewhere and on Escape, which stays with the menu', async () => {
const menu = await withRecent();
await act(async () => { container.querySelector('.atlas-arch-tabs')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); });
expect(menu.open).toBe(false);
await act(async () => { menu.open = true; });
expect(key({ key: 'Escape' }, menu.querySelector('summary')!).defaultPrevented).toBe(true);
expect(menu.open).toBe(false);
expect(view()).toBe('hotspots');
expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
});
it('A2: closes the Recent menu when Back or Alt+Right change the place', async () => {
it('closes Recent when Back or Alt+Right changes the place', async () => {
const menu = await withRecent();
await goBack();
expect(view()).toBe('routes');
expect(trail()).toEqual(['sample']);
expect(menu.open).toBe(false);
await act(async () => { menu.open = true; });
expect(key({ key: 'ArrowRight', altKey: true }).defaultPrevented).toBe(true);
await settle();
expect(view()).toBe('hotspots');
expect(trail()).toEqual(['sample', 'django']);
expect(menu.open).toBe(false);
});
});
+16 -26
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import type { JSX, ReactNode } from 'react';
import type { ArchitectureHotspot, ArchitectureOverviewDto } from '../core/intelligence-provider';
import {
@@ -8,7 +8,6 @@ import type { ArchitectureView, ConfigStorage } from './architecture-model';
import { architectureHistoryText as historyText, architectureText as text } from './strings';
import { peekNavigation, type NavigationHistory } from '../graph/navigation-history';
import { useDismissibleMenu } from '../graph/use-dismissible-menu';
import { FitLabel, useToolbarFit } from '../galaxy/toolbar-fit';
import {
architectureEntryDetail, architectureEntryLabel, architectureEntryName, architectureHistoryOptions, initialArchitecturePlace,
type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SpatialPlace, type SystemPlace,
@@ -123,14 +122,7 @@ function Findings({ view, data, empty, onNavigate }: {
const paletteStyle = scenePaletteStyle();
/**
* Back, Forward and the Recent list for the whole workspace (K27). The
* tooltips name the target ("Back to Overview · django"); the list holds the
* last distinct places, newest first, and the current one is not a target.
* They stand at the start of the subtab row and read as in Galaxy, "← Back",
* "Forward →" and "▾", with the glyphs alone once the tabs would scroll
* (hand test 2026-10-04, A1). The menu closes as Galaxy's does (A2).
*/
/** Compact exploration history beside the current scope. Tooltips name each destination. */
function HistoryControls({ history, place, onGo, onJump }: {
history: NavigationHistory<ArchitectureHistoryEntry>; place: ArchitectureHistoryEntry;
onGo: (step: -1 | 1) => void; onJump: (entry: ArchitectureHistoryEntry) => void;
@@ -141,12 +133,12 @@ function HistoryControls({ history, place, onGo, onJump }: {
return <div className="atlas-arch-history" role="group" aria-label={historyText.group} data-position={`${history.index + 1}/${history.entries.length}`}>
<button type="button" aria-label={historyText.back} disabled={!back} onClick={() => onGo(-1)}
title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}>
<FitLabel wide={historyText.backWide} narrow={historyText.backGlyph} /></button>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12 5-5 5 5 5" /></svg></button>
<button type="button" aria-label={historyText.forward} disabled={!forward} onClick={() => onGo(1)}
title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}>
<FitLabel wide={historyText.forwardWide} narrow={historyText.forwardGlyph} /></button>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m8 5 5 5-5 5" /></svg></button>
{history.recent.length > 1 && <details className="atlas-arch-recent" ref={menu.ref}>
<summary title={historyText.recentTitle} aria-label={historyText.recent}>{historyText.recentGlyph}</summary>
<summary title={historyText.recentTitle} aria-label={historyText.recent}><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 6a7 7 0 1 1-1 7M4 2v4H0m10 0v4l3 2" /></svg></summary>
<ul className="atlas-arch-recent-menu" aria-label={historyText.recentList}>{history.recent.map(entry => {
const id = architectureHistoryOptions.recentKey?.(entry);
const current = id === here;
@@ -169,25 +161,22 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
* route perspective and filter, System structure focus and groups, Behavior
* start, destination and position, and Plan or 3D of each scene. The views
* below read their part of it and report changes back, so Back and Forward
* restore every part at once; the one Back for all of them sits before the
* subtabs. A filter is a search for this visit: one saved by an earlier
* restore every part at once. Only deliberate exploration adds a stop;
* tabs and display controls update the current visit. A filter saved by an earlier
* session would silently hide routes, so only the subtab is read back.
*/
const navigation = useArchitectureHistory(() => initialArchitecturePlace(readArchitectureConfig(storage, projectName).view), {
active, escapeTaken, onRestore: (from, to) => { if (from.view !== to.view) onSelectionEvidence?.(undefined); },
});
const { place, navigate } = navigation;
// The row measures itself as Galaxy's toolbar does (toolbar-fit.tsx): it counts as narrow once the tabs would scroll.
const tabRow = useRef<HTMLDivElement>(null);
useToolbarFit(tabRow, true);
useEffect(() => { saveArchitectureConfig(storage, projectName, { view: place.view, filter: place.filter }); }, [place.view, place.filter, projectName, storage]);
const setView = useCallback((view: ArchitectureView) => navigate(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view })), [navigate]);
const setView = useCallback((view: ArchitectureView) => navigate(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view }), true), [navigate]);
const changeSpatial = useCallback<PlaceChange<SpatialPlace>>((change, automatic) =>
navigate(current => ({ ...current, spatial: { ...current.spatial, ...change } }), automatic), [navigate]);
const changeSystem = useCallback<PlaceChange<SystemPlace>>((change, automatic) =>
navigate(current => ({ ...current, system: { ...current.system, ...change } }), automatic), [navigate]);
const changePerspective = useCallback((routes: RoutesPerspective) => navigate(current => ({ ...current, routes })), [navigate]);
// An opened route group ("Show these N routes") is a step at once; typing waits for a pause.
const changePerspective = useCallback((routes: RoutesPerspective) => navigate(current => ({ ...current, routes }), true), [navigate]);
// Opening a route group is navigation; typing only refines the current visit.
const openRouteGroup = useCallback((filter: string) => navigate(current => ({ ...current, filter })), [navigate]);
// A cached summary from another project must never appear here.
const data = overview?.projectName && overview.projectName !== projectName ? undefined : overview;
@@ -198,24 +187,25 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
}, [active, projectName, systemView, ready, graph, onSelectionEvidence]);
// Only the Routes view offers the filter, so no other view may receive a stale one.
const filter = place.view === 'routes' ? navigation.filter : '';
const historyControls = <HistoryControls history={navigation.history} place={place} onGo={navigation.go} onJump={navigation.jump} />;
const spatialProps = !systemView && ready && data && graph ? {
project: projectName, generation: graphGeneration, graph, overview: data,
view: place.view === 'dependencies' || place.view === 'structure' || place.view === 'behavior' ? 'overview' as const : place.view,
filter, onFilter: openRouteGroup, active, graphNote, coverage, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate,
filter, onFilter: openRouteGroup, active, graphNote, coverage, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, navigation: historyControls,
onView: (view: ArchitectureView) => { onSelectionEvidence?.(undefined); setView(view); }, place: place.spatial, onPlace: changeSpatial,
} : undefined;
// Every scene below draws with the same palette; the DOM reads it as CSS variables.
return <section className="atlas-architecture" data-testid="atlas-architecture" data-system-view={systemView} aria-label={text.title} aria-busy={!systemView && loading} style={paletteStyle}>
<div className="atlas-arch-tabrow" ref={tabRow}>
<HistoryControls history={navigation.history} place={place} onGo={navigation.go} onJump={navigation.jump} />
<nav className="atlas-arch-tabs" aria-label={text.navigation} data-fit-whole="">{ARCHITECTURE_VIEWS.map(view =>
<div className="atlas-arch-tabrow">
<nav className="atlas-arch-tabs" aria-label={text.navigation}>{ARCHITECTURE_VIEWS.map(view =>
<button className="atlas-arch-tab" key={view} aria-pressed={place.view === view || (view === 'overview' && ['dependencies', 'entryPoints'].includes(place.view))} data-view={view}
onClick={() => { if (view !== place.view) onSelectionEvidence?.(undefined); setView(view); }}>{text.views[view]}</button>)}</nav>
</div>
{systemView && projectName ? <Suspense fallback={<div role="status"><Empty>Preparing system analysis…</Empty></div>}><SystemArchitecture
project={projectName} generation={graphGeneration} view={systemView} filter={filter} active={active}
graph={graph} onSelect={onSelect} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence} onNavigate={onNavigate} loader={systemArchitectureLoader}
place={place.system} onPlace={changeSystem} /></Suspense> : null}
place={place.system} onPlace={changeSystem} navigation={historyControls} /></Suspense> : null}
{!systemView && !spatialProps && <div className="atlas-arch-context">{historyControls}<span>{projectName}</span></div>}
{!systemView && (!projectName ? <Empty>{text.chooseProject}</Empty> : loading ? <div role="status"><Empty>{text.loading}</Empty></div>
: error ? <div role="alert" className="atlas-arch-empty" data-error="true"><p>{text.loadFailed}</p><p className="atlas-arch-error-detail">{error}</p>
{onRefresh && <button className="atlas-arch-action" onClick={onRefresh}>{text.retry}</button>}</div>
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { behaviorJourney } from './behavior-journey-model';
import { projectionLimits, type SystemSceneModel } from './system-architecture-model';
import { architectureText as text } from './strings';
@@ -27,6 +27,7 @@ export interface BehaviorJourneyProps {
*/
place?: JourneyPlace;
onPlace?: PlaceChange<JourneyPlace>;
navigation?: ReactNode;
/** Reports the start shown when none was requested, so a Back or Forward tooltip can name it (K27). */
onShownStart?: (name: string) => void;
onClearSelection?: () => void;
@@ -61,7 +62,7 @@ export function journeyPage(scene: SystemSceneModel, step: number, choices = fal
}
export default function BehaviorJourney({ project, generation, data, entries, targets, entryId, targetId, active, pending, error, filter,
onRequest, onRefresh, refresh, suggestedEntries, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) {
onRequest, onRefresh, refresh, suggestedEntries, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart, navigation }: BehaviorJourneyProps) {
const [place, changePlace] = useLiftedPlace<JourneyPlace>(liftedPlace, onPlace, () => ({}));
const { path: pathIndex = 0, step = 0, page: branchPage = 0, planar = false } = place;
const [overview, setOverview] = useState(false);
@@ -173,6 +174,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
<p>{path ? 'Follow one recorded call chain across the parts it touches.' : 'Choose a starting operation. Explore its calls, or follow a path to a destination.'}</p></div>
<RefreshControl labels={text.refreshFeedback.behavior} feedback={refresh} onRefresh={onRefresh} /></header>
<div className="behavior-requests">
{navigation}
{/*
* The field names the operation the journey shows, also when the projection chose it. Suggestions come
* first, then every operation alphabetically; the filter before it narrows both (hand test 2026-10-04, A4).
+3 -2
View File
@@ -15,6 +15,7 @@ interface Props {
project: string; generation?: string; active: boolean; filter: string; onNavigate: (path: string, line?: number) => void; onClearSelection?: () => void; onSelectionEvidence?: SelectionEvidenceListener;
/** Leads from a project without deployment files to the routes it does have. */
onShowEndpoints?: () => void;
navigation?: ReactNode;
}
/** Reading the deployment inventory: a project without Compose files is an answer, not a failure. */
type Discovery = keyof typeof text.serviceMap | undefined;
@@ -29,7 +30,7 @@ const relativeSource = (absolute: string, selection: ContainerSelection) => {
return absolute.startsWith(prefix) ? absolute.slice(prefix.length) : undefined;
};
export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection, onSelectionEvidence, onShowEndpoints }: Props) {
export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection, onSelectionEvidence, onShowEndpoints, navigation }: Props) {
const [available, setAvailable] = useState<ProjectEntry[]>([]);
const [selections, setSelections] = useState<ContainerSelection[]>([]);
const [discovery, setDiscovery] = useState<Discovery>('reading');
@@ -160,7 +161,7 @@ export default function ContainerMap({ project, generation, active, filter, onNa
{index > 0 && <button aria-label={`Remove ${selection.inventory.project}`} onClick={() => setSelections(current => current.filter((_, i) => i !== index))}>×</button>}</label>)}
{selections.length < 4 && <select aria-label="Compare indexed project" value="" disabled={adding} onChange={event => { void addProject(event.target.value); }}><option value="">{adding ? 'Reading project…' : '+ Compare project'}</option>
{available.filter(entry => !selections.some(selection => selection.inventory.project === entry.name)).map(entry => <option key={entry.name} value={entry.name}>{entry.name}</option>)}</select>}</div>
<div className="spatial-controls"><nav aria-label="Service navigation"><button onClick={clearSelection}>All services</button>{focus && <span> / {servicesById.get(focus)?.name} and neighbors</span>}</nav>
<div className="spatial-controls"><div className="atlas-arch-context">{navigation}<nav aria-label="Service navigation"><button onClick={clearSelection}>All services</button>{focus && <span> / {servicesById.get(focus)?.name} and neighbors</span>}</nav></div>
<div className="container-edge-key"><span><i className="container-call-key" />Calls</span><label><input type="checkbox" checked={configured} onChange={event => setConfigured(event.target.checked)} />Configured destinations</label>
<label><input type="checkbox" checked={startup} onChange={event => setStartup(event.target.checked)} />Startup dependencies</label></div></div>
{discovery && <div role="status" className="spatial-notice container-discovery" data-discovery={discovery}><p>{text.serviceMap[discovery]}</p>
@@ -26,6 +26,6 @@ export default function RoutesArchitecture({ perspective, onPerspective, ...prop
<button aria-pressed={mode === 'endpoints'} onClick={() => show('endpoints')}>{text.endpoints}</button>
</nav>{mode === 'services' ? <Suspense fallback={<p role="status">{text.preparing}</p>}><ContainerMap
project={props.project} generation={props.generation} active={props.active} filter={props.filter} onNavigate={props.onNavigate} onClearSelection={props.onClearSelection} onSelectionEvidence={props.onSelectionEvidence}
onShowEndpoints={() => show('endpoints')} /></Suspense>
onShowEndpoints={() => show('endpoints')} navigation={props.navigation} /></Suspense>
: <SpatialArchitecture {...props} />}</>;
}
@@ -31,6 +31,7 @@ interface Props {
/** The opened area or file, the hotspot area and Plan or 3D, lifted to the workspace for Back and Forward (K27). */
place?: SpatialPlace;
onPlace?: PlaceChange<SpatialPlace>;
navigation?: ReactNode;
}
class SceneBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
@@ -50,7 +51,7 @@ const viewNotes: Record<SemanticView, string> = {
};
const relationKinds = ['CALLS', 'IMPORTS', 'USAGE', 'INHERITS', 'IMPLEMENTS', 'DATA_FLOWS'];
export default function SpatialArchitecture({ project, generation, graph, overview, view, filter, active, graphNote, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, onView, onFilter, coverage, place: liftedPlace, onPlace }: Props) {
export default function SpatialArchitecture({ project, generation, graph, overview, view, filter, active, graphNote, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, onView, onFilter, coverage, place: liftedPlace, onPlace, navigation }: Props) {
const [place, changePlace] = useLiftedPlace<SpatialPlace>(liftedPlace, onPlace, () => ({ planar: false }));
const { planar } = place;
// The place keeps the opened area or file and the hotspot area for Back and Forward (K27), but only the view
@@ -188,14 +189,17 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
</div>
</div>
<div className="spatial-controls">
{['overview', 'dependencies', 'entryPoints'].includes(view) && <div className="spatial-projection-modes" role="group" aria-label="Overview mode"><button aria-pressed={view !== 'entryPoints'} onClick={() => onView('overview')}>Structure</button><button aria-pressed={view === 'entryPoints'} onClick={() => onView('entryPoints')}>Entry points</button></div>}
<div className="atlas-arch-context">{navigation}
{(view === 'overview' || view === 'dependencies') && <nav aria-label="Architecture location"><button onClick={clearScope}>{project}</button>
{areaPath && areaLevels(areaPath).map((level, index, levels) => <Fragment key={level}><span>/</span><button onClick={() => { changePlace({ areaPath: level, filePath: undefined }); setSelection(undefined); }}>{index ? level.slice(levels[index - 1].length + 1) : level}</button></Fragment>)}{filePath && <><span>/</span><span>{filePath.split('/').at(-1)}</span></>}
</nav>}
{view === 'hotspots' && (hotspotArea ? <nav aria-label="Hotspot area"><button onClick={clearScope}>All hotspots</button><span>/ {hotspotArea}</span></nav> : <span>All hotspots</span>)}
{view === 'routes' && <span title={filter || undefined}>{filter || 'All endpoints'}</span>}
</div>
{['overview', 'dependencies', 'entryPoints'].includes(view) && <div className="spatial-projection-modes" role="group" aria-label="Overview mode"><button aria-pressed={view !== 'entryPoints'} onClick={() => onView('overview')}>Structure</button><button aria-pressed={view === 'entryPoints'} onClick={() => onView('entryPoints')}>Entry points</button></div>}
{view === 'entryPoints' && <><label>Start <select aria-label="Entry point" value={currentEntry?.id ?? ''} onChange={event => { const node = entries.find(node => node.id === Number(event.target.value)); setEntryChoice(node ? { node, generation } : undefined); setSelection(undefined); }}>
{!entries.length && <option value="">No indexed entry points</option>}{entries.map(node => <option key={node.id} value={node.id}>{node.name} · {node.file_path}</option>)}
</select></label><label>Call depth <select aria-label="Call depth" value={depth} onChange={event => setDepth(Number(event.target.value))}>{[1, 2, 3, 4].map(value => <option key={value}>{value}</option>)}</select></label></>}
{view === 'hotspots' && hotspotArea && <nav aria-label="Hotspot area"><button onClick={clearScope}>All hotspots</button><span>/ {hotspotArea}</span></nav>}
{view === 'routes' && <RefreshControl labels={text.refreshFeedback.routes} feedback={routeRefresh.feedback}
onRefresh={() => { routeRefresh.begin(); setRouteRevision(value => value + 1); }} />}
{view === 'routes' && <label className="spatial-gravity-toggle"><input type="checkbox" checked={includeTestRoutes} onChange={event => { setIncludeTestRoutes(event.target.checked); setSelection(undefined); }} />{routesChecking ? text.includeTestRoutesChecking : text.includeTestRoutes(model.hiddenRoutes ?? 0)}</label>}
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { GraphData, GraphNode } from '../galaxy/types';
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
import { loadSystemArchitecture, type SystemArchitectureLoader, type SystemArchitectureResponse, type SystemProjection, type SystemSymbol, type SystemWitness, type SystemCallEvidence } from './system-architecture-source';
@@ -29,6 +29,7 @@ export interface SystemArchitectureProps {
/** Focus, expanded groups, the Behavior start and position, and both cameras, lifted to the workspace for Back and Forward (K27). */
place?: SystemPlace;
onPlace?: PlaceChange<SystemPlace>;
navigation?: ReactNode;
}
/** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */
export type FlowsLoader = (project: string) => Promise<FlowSummary[]>;
@@ -80,7 +81,7 @@ const connectionAllowed = (type: string, view: ConnectionView) => view === 'all'
|| (view === 'calls' ? ['CALLS', 'IMPORTS', 'HTTP_CALLS', 'ASYNC_CALLS'] : ['INHERITS', 'IMPLEMENTS']).includes(type);
/** Poll only while this view is active. A new request key hides stale results before its effect runs. */
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, operationNames = nameFlowStarts, place: liftedPlace, onPlace }: SystemArchitectureProps) {
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, operationNames = nameFlowStarts, place: liftedPlace, onPlace, navigation }: SystemArchitectureProps) {
const [place, changePlace] = useLiftedPlace<SystemPlace>(liftedPlace, onPlace, () => ({ expanded: [] }));
const entryChoice = place.behavior;
/** A start the page picks itself (the suggestion, a reset) completes the current step instead of adding one. */
@@ -347,7 +348,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
const behaviorPage = view === 'behavior' ? <BehaviorJourney project={project} generation={projectionGeneration} data={queryData}
entries={entries} targets={targets} entryId={requestedEntry} targetId={requestedTarget} active={active} pending={queryPending}
error={error} filter={filter} onRefresh={refreshAnalysis} refresh={refresh.feedback} suggestedEntries={suggestedStarts} onNavigate={onNavigate} onSelectSymbol={selectSymbol} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence}
from={requestedEntry !== undefined ? entryChoice?.from : undefined} place={journeyPlace} onPlace={changeJourney}
from={requestedEntry !== undefined ? entryChoice?.from : undefined} place={journeyPlace} onPlace={changeJourney} navigation={navigation}
onShownStart={name => { if (name !== place.shown) changePlace({ shown: name }, true); }}
onRequest={(entry, targetId, detail) => {
const analysisGeneration = projectionGeneration ?? sourceGeneration;
@@ -363,7 +364,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
<RefreshControl className="atlas-arch-action system-refresh" labels={text.refreshFeedback.structure} feedback={refresh.feedback}
onRefresh={() => { refreshAnalysis(); setResetKey(value => value + 1); }} />
</div><div className="system-controls">
<div className="system-scope-actions"><button disabled={!focusId} onClick={clearSelection}>Whole system</button></div>
<div className="system-scope-actions atlas-arch-context">{navigation}<button disabled={!focusId} onClick={clearSelection}>Whole system</button>{place.focus && <span title={place.focus.label}>/ {place.focus.label}</span>}</div>
{view === 'structure' && <label>Connections <select aria-label="Connection view" value={connectionView} onChange={event => { setConnectionView(event.target.value as ConnectionView); setCyclesOnly(false); }}>
<option value="calls">Calls &amp; imports</option><option value="types">Type relationships</option><option value="all">All relationships</option>
</select></label>}
@@ -28,24 +28,26 @@ describe('the Architecture history entry (K27)', () => {
expect(initialArchitecturePlace('dependencies').view).toBe('overview');
});
it('is a new step for every subtab and for what each subtab opens', () => {
it('identifies what each subtab opens without treating display settings as exploration', () => {
const views = ['overview', 'entryPoints', 'routes', 'hotspots', 'structure', 'behavior'] as const;
expect(new Set(views.map(view => key(at({ view }))))).toHaveProperty('size', views.length);
// Overview: the opened area and file, and Plan or 3D.
// Overview: the opened area and file. Plan or 3D stays with that location.
const django = at({ spatial: { planar: false, areaPath: 'django' } });
expect(key(django)).not.toBe(key(start));
expect(key(at({ spatial: { planar: false, areaPath: 'django', filePath: 'django/shortcuts.py' } }))).not.toBe(key(django));
expect(key(at({ spatial: { planar: true, areaPath: 'django' } }))).not.toBe(key(django));
// Routes: the perspective and the filter an opened route group sets.
expect(key(at({ spatial: { planar: true, areaPath: 'django' } }))).toBe(key(django));
// Routes: explicit route-group navigation uses the perspective and filter.
// The hook replaces the current entry when these are changed by context controls.
const routes = at({ view: 'routes' });
expect(key({ ...routes, routes: 'endpoints' })).not.toBe(key(routes));
expect(key({ ...routes, routes: 'endpoints', filter: '/edit' })).not.toBe(key({ ...routes, routes: 'endpoints' }));
// System structure: the focus and the expanded groups, in any order.
// System structure: the focus is exploration; expanded groups stay with it.
const structure = at({ view: 'structure' });
const focused = { ...structure, system: { expanded: [], focus: { id: 'g:django', label: 'django' } } };
expect(key(focused)).not.toBe(key(structure));
expect(key({ ...structure, system: { expanded: ['g:a', 'g:b'] } })).toBe(key({ ...structure, system: { expanded: ['g:b', 'g:a'] } }));
expect(key({ ...structure, system: { expanded: ['g:a'] } })).not.toBe(key(structure));
expect(key({ ...structure, system: { expanded: ['g:a'] } })).toBe(key(structure));
expect(key(at({ view: 'hotspots', spatial: { planar: false, hotspotArea: 'django/db' } }))).not.toBe(key(at({ view: 'hotspots' })));
// Behavior: the start and the destination ("Reach").
expect(key(behavior(7, 'handle'))).not.toBe(key(behavior(8, 'get_autocommit')));
expect(key(behavior(7, 'handle', { targetId: 9, targetName: 'save' }))).not.toBe(key(behavior(7, 'handle')));
@@ -61,7 +63,7 @@ describe('the Architecture history entry (K27)', () => {
expect(key(behavior(7, 'handle'))).toBe(key(behavior(7, 'renamed', { from: symbol(3, 'main'), generation: 'g2' })));
// A Service map does not draw Plan or 3D.
expect(key(at({ view: 'routes', spatial: { planar: true } }))).toBe(key(at({ view: 'routes' })));
expect(key(at({ view: 'routes', routes: 'endpoints', spatial: { planar: true } }))).not.toBe(key(at({ view: 'routes', routes: 'endpoints' })));
expect(key(at({ view: 'routes', routes: 'endpoints', spatial: { planar: true } }))).toBe(key(at({ view: 'routes', routes: 'endpoints' })));
});
it('names the target of Back and Forward', () => {
@@ -89,11 +91,11 @@ describe('the Architecture history entry (K27)', () => {
it('holds Plan or 3D in System structure and Behavior, and where a call chain stands (K27)', () => {
const structure = at({ view: 'structure' });
// Plan or 3D is a step in every view that draws a scene, each with its own camera as before.
expect(key({ ...structure, system: { expanded: [], structurePlanar: true } })).not.toBe(key(structure));
// Plan or 3D is saved context in every view that draws a scene, each with its own camera as before.
expect(key({ ...structure, system: { expanded: [], structurePlanar: true } })).toBe(key(structure));
expect(key({ ...structure, system: { expanded: [], behaviorPlanar: true } })).toBe(key(structure));
const reach = behavior(1, 'main', { targetId: 3, targetName: 'save' });
expect(key({ ...reach, system: { ...reach.system, behaviorPlanar: true } })).not.toBe(key(reach));
expect(key({ ...reach, system: { ...reach.system, behaviorPlanar: true } })).toBe(key(reach));
expect(key({ ...reach, system: { ...reach.system, structurePlanar: true } })).toBe(key(reach));
// Another indexed path to the destination is a step; the operation on it and the page of direct calls are not.
const second = { ...reach, system: { ...reach.system, behavior: { ...reach.system.behavior!, position: { path: 1 } } } };
@@ -2,12 +2,13 @@
* The Architecture entry for Back and Forward (hand test K27): which place the
* workspace shows, without what it loaded for it.
*
* One entry is the subtab and what that subtab has opened: in Overview the area
* or file, in Routes the perspective and the route filter (an opened route
* group is that filter), in System structure the focus and the expanded groups,
* One stop is a meaningful exploration: in Overview the area or file, in
* Routes an explicitly opened route group, in System structure the focus,
* in Behavior the start, the destination, where a followed call began and where
* the journey stands; and in every view that draws a scene, Plan or 3D. It holds
* identities, names and small numbers only, never projections, scenes or
* the journey stands. Each entry also keeps its subtab, perspective, filter,
* expanded groups and Plan or 3D setting so returning restores that context.
* Context changes update the current stop. It holds identities, names and small
* numbers only, never projections, scenes or
* cameras, so nothing grows with the session. The model behind it is the one
* Galaxy uses (src/graph/navigation-history.ts); the concept is in
* docs/development/pr-2068-galaxy-history.md.
@@ -61,7 +62,7 @@ export interface ArchitectureHistoryEntry {
view: ArchitectureView;
spatial: SpatialPlace;
routes: RoutesPerspective;
/** The Routes filter, as far as it is a step: a route group or typing that paused. */
/** The Routes filter: typing updates context; explicitly opening a route group creates a stop. */
filter: string;
system: SystemPlace;
}
@@ -106,8 +107,8 @@ const behaviorPath = (entry: ArchitectureHistoryEntry): number => (entry.view ==
const viewOf = (entry: ArchitectureHistoryEntry): ArchitectureView => (entry.view === 'dependencies' ? 'overview' : entry.view);
export const architectureHistoryOptions: NavigationHistoryOptions<ArchitectureHistoryEntry> = {
key: (entry) => JSON.stringify([viewOf(entry), ...location(entry), planar(entry),
entry.view === 'structure' ? [...entry.system.expanded].sort() : null, behaviorPath(entry)]),
// View, perspective and filter identify restored locations, but their controls replace the current entry.
key: (entry) => JSON.stringify([viewOf(entry), ...location(entry), behaviorPath(entry)]),
recentKey: (entry) => JSON.stringify([viewOf(entry), ...location(entry)]),
};
+14 -19
View File
@@ -45,27 +45,22 @@
}
.atlas-arch-tab[aria-pressed="true"] { color: var(--atlas-fg); border-bottom-color: var(--atlas-phosphor); }
.atlas-arch-tab:hover { color: var(--atlas-fg); background: var(--atlas-panel); }
/*
* K27: one Back, Forward and Recent for the whole workspace and the tabs share a row. The menu sits outside the
* scrolling tab strip. Hand test 2026-10-04 (A1): the controls come first and read as in Galaxy, "← Back" and
* "Forward →" while the tabs fit, the glyphs alone once they would scroll (`data-fit`, galaxy/toolbar-fit.tsx).
*/
.atlas-arch-tabrow { display: flex; align-items: center; gap: 14px; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; }
/* Exploration history belongs to the current scope; tabs only choose a lens. */
.atlas-arch-tabrow { display: flex; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; }
.atlas-arch-tabrow > .atlas-arch-tabs { flex: 1 1 auto; min-width: 0; border-bottom: 0; margin-bottom: 0; }
.atlas-arch-history { display: flex; align-items: center; gap: 4px; flex: none; position: relative; }
.atlas-arch-history > button, .atlas-arch-recent > summary {
border: 1px solid var(--atlas-line); border-radius: 6px; background: var(--atlas-panel); color: var(--atlas-fg);
min-width: 30px; min-height: 30px; padding: 3px 10px; line-height: 1.4; white-space: nowrap;
.atlas-arch-context { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
.atlas-arch-context > span { max-width: min(32rem, 70vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--atlas-dim); }
.atlas-arch-history { display: flex; align-items: center; gap: 2px; flex: none; position: relative; }
.atlas-architecture .atlas-arch-history > button, .atlas-arch-recent > summary {
display: grid; place-items: center; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--atlas-dim);
width: 32px; height: 32px; min-width: 32px; min-height: 32px; padding: 6px; line-height: 1;
}
.atlas-arch-history > button:hover:not(:disabled), .atlas-arch-recent > summary:hover { background: var(--atlas-panel-raised); border-color: var(--atlas-dim); }
.atlas-arch-history > button:disabled { opacity: 0.4; }
.atlas-arch-history .atlas-fit-narrow { display: none; }
.atlas-arch-history .atlas-fit-narrow::before { content: attr(data-label); }
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history .atlas-fit-wide { display: none; }
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history .atlas-fit-narrow { display: inline; }
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history > button { padding-inline: 8px; }
.atlas-arch-recent { position: relative; }
.atlas-arch-recent > summary { display: block; cursor: pointer; list-style: none; text-align: center; color: var(--atlas-dim); padding-inline: 8px; }
.atlas-architecture .atlas-arch-history > button:hover:not(:disabled), .atlas-arch-recent > summary:hover { background: var(--atlas-panel-raised); color: var(--atlas-fg); border-color: var(--atlas-line); }
.atlas-architecture .atlas-arch-history > button:disabled { opacity: 0.28; cursor: default; }
.atlas-arch-history svg { display: block; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.atlas-arch-history :is(button, summary):focus-visible { outline: 2px solid var(--atlas-phosphor); outline-offset: 2px; }
.atlas-arch-recent { position: static; }
.atlas-arch-recent > summary { cursor: pointer; list-style: none; }
.atlas-arch-recent > summary::-webkit-details-marker { display: none; }
.atlas-arch-recent-menu {
position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; width: min(24rem, calc(100vw - 3rem)); max-height: 20rem; overflow: auto;
@@ -1,18 +1,15 @@
/*
* Review of K40: since A1 Back, Forward and the subtabs share one row, but the
* row jumped between Overview, Routes and Hotspots on one side and System
* structure and Behavior on the other: those two set the view's frame to 16 px
* instead of 28 and 32 and made the tabs narrower. "← Back" stood at x 32, y 98
* in the first three and at x 16, y 86 in the other two. No rule for the
* system views may set the view's frame or the row differently, so the row
* stands alike in every subtab.
* Review of K40: the subtab row must stay in the same place across Overview,
* Routes, Hotspots, System structure and Behavior. History now lives beside
* each view's location, so those local controls may have their own layout.
* No system-view rule may change the workspace frame or the tabs themselves.
*/
import { readFileSync } from 'node:fs';
import { expect, it } from 'vitest';
const SHEETS = ['architecture.css', 'system-architecture.css', 'behavior-journey.css', 'spatial-architecture.css', 'container-map.css', 'architecture-scene.css', 'source-evidence.css']
.map((name) => ({ name, css: readFileSync(new URL(`./${name}`, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') }));
const ROW = /\.atlas-arch-(tabrow|tabs|tab|history|recent)\b/;
const ROW = /\.atlas-arch-(tabrow|tabs|tab)\b/;
const BOX = /^(padding|margin)(-|$)/;
it('K40: System structure and Behavior keep the subtab row where the other subtabs have it', () => {
@@ -22,7 +19,7 @@ it('K40: System structure and Behavior keep the subtab row where the other subta
for (const selector of match[1]!.split(',').map((item) => item.trim())) {
if (!selector.includes('[data-system-view')) continue;
const properties = match[2]!.split(';').map((part) => part.split(':')[0]!.trim()).filter(Boolean);
// The view's own frame, or anything about the row with the history buttons and the subtabs.
// Keep the workspace frame and the view-only tab row consistent.
const frame = /^\.atlas-architecture\[data-system-view[^\]]*\]$/.test(selector) && properties.some((property) => BOX.test(property));
if (frame || ROW.test(selector)) offending.push(`${name}: ${selector} { ${properties.join(', ')} }`);
}
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initialArchitecturePlace, type ArchitectureHistoryEntry } from './architecture-history';
import { useArchitectureHistory, type ArchitectureHistory, type ArchitectureHistoryKeys } from './use-architecture-history';
let host: HTMLDivElement, root: Root;
let current: ArchitectureHistory;
let initial: ArchitectureHistoryEntry;
const restored = vi.fn();
function Probe({ active = true, escapeTaken = false }: Partial<ArchitectureHistoryKeys>) {
current = useArchitectureHistory(() => initial, { active, escapeTaken, onRestore: restored });
return <input aria-label="Routes filter" readOnly value={current.filter} />;
}
const render = async (keys: Partial<ArchitectureHistoryKeys> = {}) => {
await act(async () => { root.render(<Probe {...keys} />); });
};
const change = async (update: Parameters<ArchitectureHistory['navigate']>[0], context = false) => {
await act(async () => { current.navigate(update, context); });
};
const go = async (step: -1 | 1) => { await act(async () => { current.go(step); }); };
const type = async (filter: string) => { await act(async () => { current.type(filter); }); };
const area = (areaPath: string) => change(place => ({ ...place, spatial: { ...place.spatial, areaPath, filePath: undefined } }));
const file = (filePath: string) => change(place => ({ ...place, spatial: { ...place.spatial, filePath } }));
const keypress = async (key: string, target: EventTarget = window, modifiers: KeyboardEventInit = {}) => {
const event = new KeyboardEvent('keydown', { key, altKey: true, bubbles: true, cancelable: true, ...modifiers });
await act(async () => { target.dispatchEvent(event); });
return event;
};
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
initial = initialArchitecturePlace('overview');
restored.mockReset();
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => { root.unmount(); }); host.remove(); });
describe('Architecture exploration history', () => {
it('updates tabs, perspective and scene settings in place while keeping Forward available', async () => {
await render();
await area('src');
await file('src/main.ts');
await go(-1);
const forward = current.forward;
await change(place => ({ ...place, view: 'routes' }), true);
await change(place => ({ ...place, routes: 'endpoints' }), true);
await change(place => ({ ...place, spatial: { ...place.spatial, planar: true } }));
await change(place => ({ ...place, system: { ...place.system, expanded: ['g:src'], structurePlanar: true, behaviorPlanar: true } }));
expect(current.history.entries).toHaveLength(3);
expect(current.history.index).toBe(1);
expect(current.forward).toBe(forward);
expect(current.history.entries[1]).toBe(current.place);
const context = current.place;
await go(1);
expect(current.place).toBe(forward);
expect(current.place.view).toBe('overview');
expect(current.place.spatial.planar).toBe(false);
await go(-1);
expect(current.place).toBe(context);
expect(current.place).toMatchObject({ view: 'routes', routes: 'endpoints', spatial: { planar: true }, system: { expanded: ['g:src'], structurePlanar: true, behaviorPlanar: true } });
expect(current.history.entries).toHaveLength(3);
});
it('saves System structure expansion and Plan as context without inserting stops', async () => {
initial = initialArchitecturePlace('structure');
await render();
await change(place => ({ ...place, system: { ...place.system, focus: { id: 'g:src', label: 'src' } } }));
await change(place => ({ ...place, system: { ...place.system, expanded: ['g:src'], structurePlanar: true } }));
expect(current.history.entries).toHaveLength(2);
await change(place => ({ ...place, system: { ...place.system, focus: { id: 'g:api', label: 'api' }, expanded: [], structurePlanar: false } }));
await go(-1);
expect(current.place.system).toMatchObject({ focus: { id: 'g:src' }, expanded: ['g:src'], structurePlanar: true });
expect(current.history.entries).toHaveLength(3);
});
it('drops the forward branch only when opening another exploration location', async () => {
await render();
await area('src');
await file('src/old.ts');
await go(-1);
await change(place => ({ ...place, spatial: { ...place.spatial, planar: true } }));
expect(current.forward?.spatial.filePath).toBe('src/old.ts');
await file('src/new.ts');
expect(current.history.entries.map(entry => entry.spatial.filePath)).toEqual([undefined, undefined, 'src/new.ts']);
expect(current.forward).toBeUndefined();
await go(-1);
expect(current.place.spatial).toMatchObject({ areaPath: 'src', planar: true });
await go(1);
expect(current.place.spatial.filePath).toBe('src/new.ts');
expect(current.history.entries).toHaveLength(3);
});
it('stores every filter edit immediately without a history stop', async () => {
initial = initialArchitecturePlace('routes');
await render();
for (const filter of ['/', '/a', '/api', '']) {
await type(filter);
expect(current.filter).toBe(filter);
expect(current.place.filter).toBe(filter);
expect(current.history.entries).toHaveLength(1);
expect(current.history.entries[0].filter).toBe(filter);
expect(current.back).toBeUndefined();
}
});
it('restores typed filters through immediate Back and Forward while explicit route groups create stops', async () => {
initial = initialArchitecturePlace('routes');
await render();
await type('/typed-at-root');
await change(place => ({ ...place, routes: 'endpoints', filter: '/users' }));
await type('/users/active');
await change(place => ({ ...place, filter: '/orders' }));
expect(current.history.entries.map(entry => entry.filter)).toEqual(['/typed-at-root', '/users/active', '/orders']);
await type('/orders/recent');
await go(-1);
expect(current.filter).toBe('/users/active');
await type('/users/new');
expect(current.forward?.filter).toBe('/orders/recent');
await go(1);
expect(current.filter).toBe('/orders/recent');
await go(-1);
expect(current.filter).toBe('/users/new');
expect(current.history.entries.map(entry => entry.filter)).toEqual(['/typed-at-root', '/users/new', '/orders/recent']);
expect(current.history.index).toBe(1);
});
it('replaces automatic starts and reindex resets, and restores them without pushing again', async () => {
initial = initialArchitecturePlace('behavior');
await render();
await change(place => ({ ...place, system: { ...place.system, behavior: { project: 'sample', id: 1, name: 'main' } } }), true);
expect(current.history.entries).toHaveLength(1);
await change(place => ({ ...place, system: { ...place.system, behavior: { project: 'sample', id: 2, name: 'save' } } }));
await go(-1);
await change(place => ({ ...place, system: { ...place.system, behavior: { project: 'sample', id: 3, name: 'reindexed main', generation: 'next' } } }), true);
expect(current.history.entries).toHaveLength(2);
expect(current.forward?.system.behavior?.id).toBe(2);
await go(1);
expect(current.place.system.behavior?.id).toBe(2);
await go(-1);
expect(current.place.system.behavior?.id).toBe(3);
expect(current.history.entries).toHaveLength(2);
expect(restored).toHaveBeenCalledTimes(3);
expect(restored.mock.calls.at(-1)?.map((entry: ArchitectureHistoryEntry) => entry.system.behavior?.id)).toEqual([2, 3]);
});
it('records another Behavior path while saving steps, pages and Plan with that path', async () => {
initial = { ...initialArchitecturePlace('behavior'), system: { expanded: [], behavior: { project: 'sample', id: 1, targetId: 2 } } };
await render();
await change(place => ({ ...place, system: { ...place.system, behaviorPlanar: true, behavior: { ...place.system.behavior!, position: { path: 0, step: 2, page: 1 } } } }));
expect(current.history.entries).toHaveLength(1);
await change(place => ({ ...place, system: { ...place.system, behavior: { ...place.system.behavior!, position: { path: 1 } } } }));
expect(current.history.entries).toHaveLength(2);
await go(-1);
expect(current.place.system.behavior?.position).toEqual({ path: 0, step: 2, page: 1 });
expect(current.place.system.behaviorPlanar).toBe(true);
});
it('keeps Alt+Left and Alt+Right available for meaningful stops and leaves typing alone', async () => {
await render();
await area('src');
await file('src/main.ts');
expect((await keypress('ArrowLeft')).defaultPrevented).toBe(true);
expect(current.place.spatial.filePath).toBeUndefined();
expect(current.history.index).toBe(1);
expect((await keypress('ArrowRight', host.querySelector('input')!)).defaultPrevented).toBe(false);
expect(current.history.index).toBe(1);
expect((await keypress('ArrowRight', window, { ctrlKey: true })).defaultPrevented).toBe(false);
expect((await keypress('ArrowRight')).defaultPrevented).toBe(true);
expect(current.place.spatial.filePath).toBe('src/main.ts');
expect(current.history.entries).toHaveLength(3);
expect((await keypress('ArrowRight')).defaultPrevented).toBe(false);
await render({ active: false });
expect((await keypress('ArrowLeft')).defaultPrevented).toBe(false);
await render({ escapeTaken: true });
expect((await keypress('ArrowLeft')).defaultPrevented).toBe(false);
expect(current.history.index).toBe(2);
});
});
@@ -1,17 +1,15 @@
/**
* Back and Forward for the Architecture workspace (hand test K27), wired the
* way Galaxy wires K2: the current place is state, every change of its key is
* way Galaxy wires K2: the current place is state, meaningful exploration is
* a step in the shared bounded model, and a restored entry pushes nothing.
*
* Three refinements Architecture needs and Galaxy does not:
*
* - Typing in the Routes filter becomes one step once it pauses
* (`FILTER_STEP_MS`), not one step per key. A navigation, Back, Forward or
* a recent jump while typing first records what the field showed, so no
* typed text is lost.
* - Context controls (subtab, perspective and typed Routes filter) replace
* the current step immediately and preserve its forward branch.
* - A change the page makes itself (the suggested Behavior start, a reset
* after reindexing) replaces the current step instead of adding one, so Back
* never lands on a state the page would immediately fill in again.
* after reindexing) also replaces the current step, so Back never lands on
* a state the page would immediately fill in again.
* - Alt+Left and Alt+Right act only while this workspace is the active one,
* never while typing and never while another surface takes the keys.
*
@@ -25,19 +23,16 @@ import {
import { isTypingTarget } from '../app/keyboard';
import { architectureHistoryOptions as options, type ArchitectureHistoryEntry } from './architecture-history';
/** Typing in the Routes filter becomes a step once it pauses this long. */
export const FILTER_STEP_MS = 600;
type Update = (current: ArchitectureHistoryEntry) => ArchitectureHistoryEntry;
export interface ArchitectureHistory {
place: ArchitectureHistoryEntry;
/** What the filter field shows, including typing that is not a step yet. */
/** The Routes filter saved with the current place, including the latest typing. */
filter: string;
history: NavigationHistory<ArchitectureHistoryEntry>;
back?: ArchitectureHistoryEntry;
forward?: ArchitectureHistoryEntry;
/** A navigation; `automatic` marks a change the page made itself. Stable across renders. */
/** A navigation; `automatic` replaces the current stop for context controls and page-made changes. Stable across renders. */
navigate: (update: Update, automatic?: boolean) => void;
type: (filter: string) => void;
go: (step: -1 | 1) => void;
@@ -54,11 +49,9 @@ export interface ArchitectureHistoryKeys {
export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry, { active, escapeTaken, onRestore }: ArchitectureHistoryKeys): ArchitectureHistory {
const [place, setPlace] = useState(initial);
const [draft, setDraft] = useState<string>();
const [history, setHistory] = useState(() => emptyNavigationHistory<ArchitectureHistoryEntry>());
const latest = useRef(place);
latest.current = place;
const typed = useRef<string | undefined>(undefined);
const restoringKey = useRef<string | undefined>(undefined);
/** An automatic change: the key it was computed from and the key it leads to. */
const automaticStep = useRef<{ base: string; next: string } | undefined>(undefined);
@@ -84,69 +77,32 @@ export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry,
// The current step keeps the newest details of its place (a name known only later); after the push above, so a new key is never a refresh.
useEffect(() => { setHistory((current) => refreshNavigation(current, place, options)); }, [place]);
const clearDraft = useCallback(() => { typed.current = undefined; setDraft(undefined); }, []);
const navigate = useCallback((update: Update, automatic = false) => {
const text = automatic ? undefined : typed.current;
if (text !== undefined) {
clearDraft();
// What the field showed is a step of its own, before the navigation that leaves it.
setHistory((current) => pushNavigation(current, { ...latest.current, filter: text }, options));
}
setPlace((current) => {
const result = update(text === undefined ? current : { ...current, filter: text });
const result = update(current);
if (automatic) {
const before = options.key(current), after = options.key(result);
if (after !== before) automaticStep.current = { base: before, next: after };
}
return result;
});
}, [clearDraft]);
}, []);
const type = useCallback((value: string) => { typed.current = value; setDraft(value); }, []);
useEffect(() => {
if (draft === undefined) return;
const timer = setTimeout(() => {
clearDraft();
setPlace((current) => ({ ...current, filter: draft }));
}, FILTER_STEP_MS);
return () => clearTimeout(timer);
}, [draft, clearDraft]);
const type = useCallback((filter: string) => { navigate((current) => ({ ...current, filter }), true); }, [navigate]);
const apply = useCallback((entry: ArchitectureHistoryEntry) => {
restore.current?.(latest.current, entry);
clearDraft();
automaticStep.current = undefined;
setPlace(entry);
}, [clearDraft]);
/** Typing that has not paused yet, as the step it is about to become; undefined while nothing is typed. */
const typedStep = useCallback((): ArchitectureHistoryEntry | undefined =>
(typed.current === undefined ? undefined : { ...latest.current, filter: typed.current }), []);
/** The history with that typing as its newest step, the way the pause would record it. */
const withTyping = useCallback((): NavigationHistory<ArchitectureHistoryEntry> => {
const entry = typedStep();
return entry === undefined ? history : pushNavigation(history, entry, options);
}, [history, typedStep]);
}, []);
const go = useCallback((step: -1 | 1) => {
const typedEntry = typedStep();
const base = withTyping();
const entry = peekNavigation(base, step);
if (entry === undefined) {
// Forward while typing: the text is a new step and drops the forward branch, as after the pause.
if (typedEntry === undefined) return;
clearDraft();
setHistory(base);
setPlace(typedEntry);
return;
}
const entry = peekNavigation(history, step);
if (entry === undefined) return;
restoringKey.current = options.key(entry);
setHistory(moveNavigation(base, step, options));
setHistory(moveNavigation(history, step, options));
apply(entry);
}, [typedStep, withTyping, apply, clearDraft]);
const jump = useCallback((entry: ArchitectureHistoryEntry) => {
setHistory(withTyping());
apply(entry);
}, [withTyping, apply]);
}, [history, apply]);
const jump = useCallback((entry: ArchitectureHistoryEntry) => { apply(entry); }, [apply]);
useEffect(() => {
if (!active || escapeTaken) return;
@@ -155,13 +111,13 @@ export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry,
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
if (isTypingTarget(event.target instanceof Element ? event.target : null)) return;
const step = event.key === 'ArrowLeft' ? -1 : 1;
if (peekNavigation(withTyping(), step) === undefined) return;
if (peekNavigation(history, step) === undefined) return;
event.preventDefault();
go(step);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, escapeTaken, withTyping, go]);
}, [active, escapeTaken, history, go]);
return { place, filter: draft ?? place.filter, history, back: peekNavigation(history, -1), forward: peekNavigation(history, 1), navigate, type, go, jump };
return { place, filter: place.filter, history, back: peekNavigation(history, -1), forward: peekNavigation(history, 1), navigate, type, go, jump };
}
+3 -3
View File
@@ -14,13 +14,13 @@
import { SIDECAR_PORT } from '../llm/sidecar';
/** The word over the panel. */
export const CHAT_TITLE = 'ATLAS_CHAT';
export const CHAT_TITLE = 'CBM LENS CHAT';
/** What the command line hint says when a question would be sent. */
export const CHAT_HINT_READY = 'enter asks the atlas';
export const CHAT_HINT_READY = 'enter asks CBM Lens';
/** What it says when the line looks like a question and the model is off. */
export const CHAT_HINT_OFF = 'enter asks the atlas: the local model is off';
export const CHAT_HINT_OFF = 'enter asks CBM Lens: the local model is off';
/** The sentence when the model is off and a question was typed. */
export const CHAT_OFF_MESSAGE =
+10 -9
View File
@@ -68,7 +68,8 @@ export const messages = {
},
/** Die Kopfzeile und die Flaechen, die zu keinem Panel gehoeren. */
app: {
brand: 'CODEATLAS',
brand: 'CBM LENS',
releaseLabel: 'Beta',
/** Steht in der Tab-Leiste und im Breadcrumb, wenn nichts offen ist. */
noFileOpen: 'no file open',
/** Der Name, den eine Oberflaeche ohne Projekt traegt. */
@@ -147,14 +148,14 @@ export const messages = {
* Repository.
*/
help: {
title: 'CODEATLAS HELP',
title: 'CBM LENS HELP',
subtitle: 'a reading surface over an indexed repository',
close: '[esc] close',
closeLabel: CLOSE,
whatTitle: 'What this is',
what: [
'CodeAtlasWeb reads a repository that the analysis server has indexed and explains it: '
'CBM Lens reads a repository that the analysis server has indexed and explains it: '
+ 'the tree and the source, a twin of the symbol under the caret, a walk along the calls, '
+ 'the scope of a change, and the answers of a local model.',
'Everything on this screen comes from that index or from a file the server handed over, '
@@ -400,7 +401,7 @@ export const messages = {
* Beispiele, wie man den Chat nutzt, sonst weiss niemand, wie man es
* nutzt."
*/
placeholder: 'type a command or ask the atlas...',
placeholder: 'type a command or ask CBM Lens...',
examplesLabel: 'what you can type here',
hintIdle: 'type 2 letters to search by meaning',
hintOpen: 'up/down picks, enter opens, esc closes',
@@ -422,7 +423,7 @@ export const messages = {
* Schweigen. Jetzt steht der Satz an der Stelle, an der die Antwort
* erwartet wird.
*/
oneMoreLetter: 'one more letter, then the atlas searches by meaning',
oneMoreLetter: 'one more letter, then CBM Lens searches by meaning',
/*
* Die Sofort-Vorschlaege, und dass sie vorlaeufig sind.
*
@@ -648,7 +649,7 @@ export const messages = {
/** Der Rahmen des Antwort-Panels. Seine Saetze stehen in chat-strings.ts. */
chat: {
panelLabel: 'atlas chat',
panelLabel: 'CBM Lens chat',
depthGroupLabel: 'neighbourhood depth',
/*
* `clear` bleibt der einzige Knopf, der loescht, und sagt es.
@@ -706,7 +707,7 @@ export const messages = {
* das wurde am 2026-08-29 gemessen, und der Satz sagt es so.
*/
settings: {
title: 'CODEATLAS SETTINGS',
title: 'CBM LENS SETTINGS',
subtitle: 'which model answers, and everything that costs computing time',
panelLabel: 'settings',
close: '[esc] close',
@@ -974,7 +975,7 @@ export const messages = {
* not about one subject area.
*/
projects: {
title: 'CODEATLAS PROJECTS',
title: 'CBM LENS PROJECTS',
subtitle: 'what this server has indexed, and how to give it more',
panelLabel: 'projects',
close: '[esc] close',
@@ -1100,7 +1101,7 @@ export const messages = {
/** Der Rahmen des Twins. Seine Fakten-Saetze stehen in twin/strings.ts. */
twin: {
/** Das Etikett des Leseprofils, mit dem dieses Produkt startet. */
profileLabel: 'CodeAtlasWeb Twin',
profileLabel: 'CBM Lens Twin',
/** Der Kopf des Panels, solange kein Symbol vor dem Leser steht. */
noSymbol: 'no symbol',
},
+5 -5
View File
@@ -69,7 +69,7 @@ export function impactRiskTooltip(level: 'low' | 'medium' | 'high', reasons: str
/** How a route was tied to the change. Two different strengths of claim. */
export const IMPACT_ENDPOINT_VIA_HANDLER = 'The registration names a handler this change reaches.';
export const IMPACT_ENDPOINT_VIA_FILE =
'The registration is written in a file this change reaches. CodeAtlas cannot name the handler here, so this is the file and not the function.';
'The registration is written in a file this change reaches. CBM Lens cannot name the handler here, so this is the file and not the function.';
// Die Erzaehlung, ein Satz je Lesung -----------------------------------------
@@ -110,7 +110,7 @@ export function impactDownstreamSentence(count: number, distance: number): strin
export function impactEndpointSentence(paths: string[]): string {
if (paths.length === 0) {
return 'No HTTP endpoint CodeAtlas recovered is registered in a file this change reaches.';
return 'No HTTP endpoint CBM Lens recovered is registered in a file this change reaches.';
}
return `${countOf(paths.length, 'endpoint is', 'endpoints are')} registered in a file `
+ `this change reaches: ${paths.join(', ')}.`;
@@ -152,7 +152,7 @@ export const IMPACT_EVIDENCE_TITLE = 'What this rests on';
export const IMPACT_EVIDENCE_SOURCES: Readonly<Record<'detect_changes' | 'architecture' | 'facts', string>> = {
detect_changes: 'the change set the analysis reported, plus the calls into it',
architecture: 'the whole-project summary, including the routes CodeAtlas recovered',
architecture: 'the whole-project summary, including the routes CBM Lens recovered',
facts: 'the per-symbol readings: complexity, and callers flagged as test code',
};
@@ -192,7 +192,7 @@ export const IMPACT_MENU_LABEL = '[c]hange scope';
/** Der Satz darunter: was gelesen wird und was das Wort am Ende bedeutet. */
export const IMPACT_SUBLINE =
'Read from the change set the analysis reports, the calls into it, and the routes CodeAtlas recovered. '
'Read from the change set the analysis reports, the calls into it, and the routes CBM Lens recovered. '
+ 'The word on the right is a rule applied to those readings, never a prediction.';
export const IMPACT_MODE_LABEL = 'Compare against';
@@ -244,7 +244,7 @@ export const IMPACT_DIRECT_EMPTY =
export const IMPACT_DOWNSTREAM_EMPTY =
'Nothing in the indexed workspace calls the changed symbols.';
export const IMPACT_ENDPOINTS_EMPTY =
'No route CodeAtlas recovered is registered in a file this change reaches.';
'No route CBM Lens recovered is registered in a file this change reaches.';
export const IMPACT_TESTS_EMPTY = 'No affected symbol could be checked for test callers.';
/**
+1 -1
View File
@@ -85,7 +85,7 @@ describe('the sentence over the block', () => {
it('leads with what cannot be checked when nothing is unused', () => {
const lead = blockLeadOf(document, importsOf([USED, UNCHECKABLE]), 'getOrder');
expect(lead.kind).toBe('unchecked-imports');
expect(lead.text).toContain('CodeAtlas cannot check either way');
expect(lead.text).toContain('CBM Lens cannot check either way');
});
it('leads with what lies behind the calls when the imports check out', () => {
@@ -252,7 +252,7 @@ describe('where a reader is sent, and what the group says about itself', () => {
expect(built.used).toBe(5);
expect(built.unused).toBe(2);
expect(built.unknown).toBe(0);
expect(built.tally).toBe('5 used in this file, 2 not used in it, 0 that CodeAtlas cannot check.');
expect(built.tally).toBe('5 used in this file, 2 not used in it, 0 that CBM Lens cannot check.');
});
it('says how many entries the cap left out rather than trailing off', () => {
+13 -13
View File
@@ -106,7 +106,7 @@ export function pseudocodeCappedNote(hidden: number): string {
* the one way this feature could mislead without saying anything untrue.
*/
export const PSEUDOCODE_PARTIAL_SCOPE =
'CodeAtlas sampled this range rather than reading every line of it, so a very short member may be missing.';
'CBM Lens sampled this range rather than reading every line of it, so a very short member may be missing.';
/**
* The footer, and the sentence that keeps the whole block honest.
@@ -169,7 +169,7 @@ export function pseudocodeLeadUnused(symbol: string, unused: number, total: numb
export function pseudocodeLeadUnchecked(symbol: string, unknown: number): string {
return `You cannot see this in the code: every name this file pulls in is one the index links to `
+ `${symbol}, except ${countOf(unknown, 'one', 'ones')} CodeAtlas cannot check either way.`;
+ `${symbol}, except ${countOf(unknown, 'one', 'ones')} CBM Lens cannot check either way.`;
}
export function pseudocodeLeadBehind(steps: number): string {
@@ -179,7 +179,7 @@ export function pseudocodeLeadBehind(steps: number): string {
export function pseudocodeLeadNone(symbol: string, total: number): string {
if (total === 0) {
return 'Nothing stands above the steps this time: CodeAtlas found no import in this file, and the '
return 'Nothing stands above the steps this time: CBM Lens found no import in this file, and the '
+ 'index records nothing behind the steps below.';
}
return `Nothing stands above the steps this time: every name this file pulls in is one the index links `
@@ -293,7 +293,7 @@ export function importsFindingHeading(unused: number, unknown: number, total: nu
+ 'as far as the index shows';
}
if (unknown > 0) {
return `${IMPORTS_GROUP_HEADING}: ${countOf(unknown, 'name', 'names')} CodeAtlas cannot check `
return `${IMPORTS_GROUP_HEADING}: ${countOf(unknown, 'name', 'names')} CBM Lens cannot check `
+ 'against this file';
}
return `${IMPORTS_GROUP_HEADING}: every name here is linked to a symbol in this file`;
@@ -318,7 +318,7 @@ export function importsUnusedLine(name: string, module: string): string {
/** Said on an entry that could not be checked at all. */
export function importsUnknownLine(name: string, module: string): string {
return `${name} from ${module}: CodeAtlas cannot tell whether this file uses it`;
return `${name} from ${module}: CBM Lens cannot tell whether this file uses it`;
}
/** Said on `import * as db from ...` and its equivalents in the other languages. */
@@ -344,7 +344,7 @@ export const IMPORTS_NOTE_UNUSED =
'The index answered every checked symbol in this file, and none of their recorded facts mentions this name.';
export const IMPORTS_NOTE_UNKNOWN =
'CodeAtlas does not have a complete answered reading for every callable in this file, so it will not claim the import is unused.';
'CBM Lens does not have a complete answered reading for every callable in this file, so it will not claim the import is unused.';
export const IMPORTS_NOTE_NAMESPACE =
'A namespace import binds every export of the module under one name, and the index records a call through it under the member\'s name. So nothing here can be matched against the binding.';
@@ -353,7 +353,7 @@ export const IMPORTS_NOTE_SIDE_EFFECT =
'This statement binds no name, so there is nothing for the index to match it against. What the module does when it loads is not something the analysis records.';
export const IMPORTS_NOTE_INDEX_ONLY =
'This came from the dependency the index recorded rather than from the file\'s text, so CodeAtlas has the imported file and not the imported name.';
'This came from the dependency the index recorded rather than from the file\'s text, so CBM Lens has the imported file and not the imported name.';
/** Said at the end of a group the display cap cut short. */
export function importsCappedNote(hidden: number, shown: number): string {
@@ -362,11 +362,11 @@ export function importsCappedNote(hidden: number, shown: number): string {
/** Said when the file's own text could not be read at all. */
export const IMPORTS_SOURCE_UNREAD =
'CodeAtlas could not read this file\'s text, so what follows is the dependencies the index recorded and not the import statements themselves.';
'CBM Lens could not read this file\'s text, so what follows is the dependencies the index recorded and not the import statements themselves.';
/** Said when the file pulls in nothing at all. */
export const IMPORTS_EMPTY =
'The index records no dependency for this file and CodeAtlas found no import statement in it.';
'The index records no dependency for this file and CBM Lens found no import statement in it.';
/**
* The standing sentence under the group, in both surfaces.
@@ -377,7 +377,7 @@ export const IMPORTS_EMPTY =
* the judgement about use comes from every callable declaration the file read.
*/
export const IMPORTS_SOURCE_NOTE =
'The dependency comes from the index; the statement, its line and the name it binds are CodeAtlas reading '
'The dependency comes from the index; the statement, its line and the name it binds are CBM Lens reading '
+ 'this file. Use is checked against the recorded facts of every callable symbol in the file.';
/** The three markers. Short enough to sit beside a name, and never the only carrier of meaning. */
@@ -395,7 +395,7 @@ export const IMPORTS_SECTION_NOTE = IMPORTS_SOURCE_NOTE;
/** How the group's own tally reads, under the entries. */
export function importsTally(used: number, unused: number, unknown: number): string {
return `${used} used in this file, ${unused} not used in it, ${unknown} that CodeAtlas cannot check.`;
return `${used} used in this file, ${unused} not used in it, ${unknown} that CBM Lens cannot check.`;
}
// ---------------------------------------------------------------------------
@@ -581,7 +581,7 @@ export function explainerWalkBound(depth: number, cap: number): string {
* entworfen, muss sagen, dass sie aus einem Index gerechnet ist.
*/
export const EXPLAINER_GENERATED_NOTE =
'Nobody drew this picture. CodeAtlas laid it out from what the index recorded for this walk, so it can '
'Nobody drew this picture. CBM Lens laid it out from what the index recorded for this walk, so it can '
+ 'be incomplete about the code and exact about what was reported.';
/** Die Notiz im Bild, wenn eine Kante in eine schon besuchte Kette zurueckfuehrt. */
@@ -636,4 +636,4 @@ export const FLOW_LOADING = 'Reading what this symbol reaches.';
* hier nichts zu zeigen, und keiner davon heisst "dieses Symbol ruft nichts".
*/
export const FLOW_UNAVAILABLE =
'CodeAtlas has no walk for this symbol right now. Place the caret in an indexed function, or reindex the workspace.';
'CBM Lens has no walk for this symbol right now. Place the caret in an indexed function, or reindex the workspace.';
+2 -2
View File
@@ -93,8 +93,8 @@ describe('commandPlaceholderFor', () => {
});
it('faellt auf die Gattung zurueck, statt einen Namen zu erfinden', () => {
expect(commandPlaceholderFor(undefined, 'type a command or ask the atlas...'))
.toBe('type a command or ask the atlas...');
expect(commandPlaceholderFor(undefined, 'type a command or ask CBM Lens...'))
.toBe('type a command or ask CBM Lens...');
});
it('sagt ueber der Liste, worum es geht', () => {
+1 -1
View File
@@ -127,7 +127,7 @@ export function commandExamplesFor(symbol: string): CommandExample[] {
{
id: 'question',
text: `Who calls ${symbol}?`,
note: 'ask the atlas',
note: 'ask CBM Lens',
symbol,
},
];
+16 -12
View File
@@ -85,6 +85,9 @@ body {
}
.atlas-brand {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 700;
@@ -92,22 +95,23 @@ body {
color: var(--atlas-phosphor);
}
.atlas-version {
color: var(--atlas-dim);
.atlas-brand-beta {
padding: 3px 6px;
border: 1px solid rgb(200 173 114 / 28%);
border-radius: 4px;
background: rgb(200 173 114 / 7%);
color: #c8ad72;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
letter-spacing: 0.06em;
text-shadow: none;
}
/*
* Der Zustand des Arbeitsbaums, neben der Fassung und leiser als sie.
*
* Kleiner und gedimmt, weil er eine Randbemerkung ist: wer die Fassung sucht,
* liest den Chip, und wer wissen will, ob ein Bild zu einem Commit gehoert,
* findet daneben die Antwort.
*/
.atlas-version-suffix {
.atlas-version {
color: var(--atlas-dim);
opacity: 0.7;
font-size: 10px;
opacity: 0.65;
font-size: 9px;
letter-spacing: 0.06em;
}
+1 -1
View File
@@ -2,7 +2,7 @@
.atlas-shell[data-has-status=true] { grid-template-rows: auto auto minmax(0, 1fr) auto; }
.atlas-header { gap: 12px; padding: 12px 18px; overflow: visible; position: relative; z-index: 20; border-bottom-color: var(--atlas-phosphor-mute); box-shadow: 0 2px 18px var(--atlas-glow); }
.atlas-brand { font-size: 15px; font-weight: 700; letter-spacing: .22em; color: var(--atlas-phosphor); text-shadow: 0 0 16px var(--atlas-glow); }
.atlas-version, .atlas-version-suffix { font-size: 10px; color: var(--atlas-dim); }
.atlas-version { font-size: 9px; color: var(--atlas-dim); }
.atlas-workspace-tabs { display: flex; gap: 4px; margin-left: 16px; }
.atlas-workspace-tabs button, .atlas-guidance, .atlas-tools-menu > summary { border: 1px solid transparent; border-radius: 2px; background: transparent; color: var(--atlas-dim); padding: 7px 12px; font: inherit; cursor: pointer; }
.atlas-workspace-tabs button[aria-selected=true] { background: var(--atlas-select); color: var(--atlas-phosphor); border-color: var(--atlas-phosphor-mute); box-shadow: inset 0 1px 0 var(--atlas-phosphor); }
+2 -2
View File
@@ -80,7 +80,7 @@ export const BUG_WIZARD_ENTRY_TOOLTIP =
* the first would be honest and useless.
*/
export const BUG_WIZARD_NO_TRACES =
'No observed call came back for this symbol. CodeAtlas does not watch code while it runs; '
'No observed call came back for this symbol. CBM Lens does not watch code while it runs; '
+ 'it reads back what somebody handed the analysis backend.';
export const BUG_WIZARD_NO_TRACES_HOW =
@@ -114,7 +114,7 @@ export const BUG_WIZARD_NO_TRACES_WHERE =
/** Said above the observed chains, so nobody reads them as a reading of the index. */
export const BUG_WIZARD_OBSERVED_NOTE =
'Read back from the analysis backend, along the calls the index connects. CodeAtlas did not '
'Read back from the analysis backend, along the calls the index connects. CBM Lens did not '
+ 'observe any of this itself.';
/** How many times an observed call was seen, and under which run. */
+2 -2
View File
@@ -433,7 +433,7 @@ describe('wenn es nichts zu zeigen gibt', () => {
ir: undefined,
symbolName: 'nothing',
message: 'This file is not indexed yet.',
hint: 'CodeAtlas is still reading this workspace.',
hint: 'CBM Lens is still reading this workspace.',
});
const empty = q('[data-testid="atlas-twin-empty"]');
expect(empty?.textContent).toContain('not indexed yet');
@@ -627,7 +627,7 @@ describe('die Pseudocode-Ansicht', () => {
expect(q('[data-testid="atlas-pseudocode-group"]')?.textContent)
.toContain('not used anywhere in this file as far as the index shows');
expect(q('[data-testid="atlas-pseudocode-tally"]')?.textContent)
.toContain('that CodeAtlas cannot check');
.toContain('that CBM Lens cannot check');
});
it('nennt an jeder Schrittzeile Datei und Zeile des aufgerufenen Symbols', async () => {
+15 -15
View File
@@ -79,7 +79,7 @@ export function countOf(count: number, singular: string, plural: string): string
// ---------------------------------------------------------------------------
export const TWIN_EMPTY_MESSAGE = 'Place the cursor inside a function, method, or class.';
export const TWIN_EMPTY_HINT = 'CodeAtlas follows the caret and explains whatever it lands in.';
export const TWIN_EMPTY_HINT = 'CBM Lens follows the caret and explains whatever it lands in.';
// ---------------------------------------------------------------------------
// Twin identity and provenance
@@ -94,13 +94,13 @@ export const TWIN_FILE_NOT_INDEXED = 'This file is not indexed yet.';
/** Says what would change the sentence above. */
export const TWIN_FILE_NOT_INDEXED_HINT =
'CodeAtlas is still reading this workspace. Facts about this symbol appear as soon as it has.';
'CBM Lens is still reading this workspace. Facts about this symbol appear as soon as it has.';
/** Shown while the IR for a symbol is being built. */
export const TWIN_LOADING = 'Reading what the index knows about this symbol.';
/** Shown when the IR call itself failed, which is a different failure from an empty fact. */
export const TWIN_LOAD_FAILED = 'CodeAtlas could not reach the analysis backend for this symbol.';
export const TWIN_LOAD_FAILED = 'CBM Lens could not reach the analysis backend for this symbol.';
/**
* What the flow switch in the twin head says, in both states.
@@ -550,7 +550,7 @@ export function proseMoreParts(hidden: number): string {
/** Said out loud, because the sentence above it was assembled rather than written. */
export const PROSE_GENERATED_NOTE =
'Nobody wrote that description. CodeAtlas put it together from what the index recorded, so it can be wrong about intent and right about counts.';
'Nobody wrote that description. CBM Lens put it together from what the index recorded, so it can be wrong about intent and right about counts.';
export const PROSE_TESTS_NONE =
'Nothing that looks like a test calls this, so no automatic check would notice a change here.';
@@ -573,13 +573,13 @@ export const PLAIN_SUBJECT_ERRORS = 'How this can fail';
export function plainAbsence(state: KnowledgeState, subject: string): string {
switch (state) {
case 'notIndexed':
return `${subject} is unknown: CodeAtlas has not read this file yet.`;
return `${subject} is unknown: CBM Lens has not read this file yet.`;
case 'unsupported':
return `${subject} is unknown: CodeAtlas cannot see this for this language.`;
return `${subject} is unknown: CBM Lens cannot see this for this language.`;
case 'inferred':
return `${subject} was worked out from what calls it, not read from the code itself.`;
case 'unknown':
return `${subject} is unknown: nothing answered when CodeAtlas asked.`;
return `${subject} is unknown: nothing answered when CBM Lens asked.`;
case 'ambiguous':
case 'known':
return '';
@@ -752,7 +752,7 @@ export const TERM_EXPLANATIONS: Readonly<Record<string, string>> = {
'a value this code takes from outside itself, such as a setting or a configuration key, rather than '
+ 'from what it was handed.',
'test caller':
'a caller that lives in test code. It is how CodeAtlas answers "is this checked", because this index '
'a caller that lives in test code. It is how CBM Lens answers "is this checked", because this index '
+ 'records no test relation of its own for TypeScript.',
'entry point':
'a place the outside world can start this program: a route, a command, a handler. Work arrives here '
@@ -814,7 +814,7 @@ export const GROUND_LEAD_SITS_ON =
export const GROUND_LEAD_CARRIED =
'What holds this up: the callers, and whether the outside world can reach it directly.';
export const GROUND_LEAD_DEBTS =
'What is owed against this symbol, from the rules CodeAtlas applies to the recorded facts. Every line is a '
'What is owed against this symbol, from the rules CBM Lens applies to the recorded facts. Every line is a '
+ 'rule over facts and not a fact, which is why they all wear the derived marker.';
export const GROUND_LEAD_LIMITS =
'What is not on this page, and why. This is part of the answer and not a footnote: an architect who cannot '
@@ -827,7 +827,7 @@ export const GROUND_EMPTY_CARRIED =
'Nothing carries this: no recorded caller, and nothing marking it as reachable from outside. A symbol with '
+ 'no carrier is either an entry the index cannot see or a piece nobody uses.';
export const GROUND_EMPTY_DEBTS =
'No rule fired against this symbol. That is not a clean bill: the rules CodeAtlas can apply are the ones '
'No rule fired against this symbol. That is not a clean bill: the rules CBM Lens can apply are the ones '
+ 'listed under what this index stops at, and the ones it cannot apply stay silent either way.';
/** How many call sites reach into one module. */
@@ -877,7 +877,7 @@ export const LIMIT_NO_HANDLER_SITES =
'Raised error types are recorded; the places they are caught are not. A short error list is therefore not '
+ 'evidence that the failure is contained.';
export const LIMIT_NO_ROUTES =
'Routes for TypeScript are read out of the source text by CodeAtlas, not reported by the engine, so an '
'Routes for TypeScript are read out of the source text by CBM Lens, not reported by the engine, so an '
+ 'entry point the engine did not mark can still exist.';
export const LIMIT_CALLS_DEDUPLICATED =
'Two calls to the same target inside one body are recorded once, with the last site. A count of call sites '
@@ -895,12 +895,12 @@ export const VOICE_TITLE =
export const VOICE_RUNNING = 'The local model is rewording the sentences above.';
export const VOICE_RESTORE_LABEL = 'Back to the built text';
export const VOICE_RESTORE_TITLE =
'Drop the reworded sentences and show the ones CodeAtlas assembled from the index.';
'Drop the reworded sentences and show the ones CBM Lens assembled from the index.';
/** The marker beside a sentence the model worded. Same idiom as the state markers. */
export const VOICE_MARKER = '[~]';
export const VOICE_MARKER_NOTE =
'Worded by the local model from the sentence CodeAtlas built. The facts in it were checked against the '
'Worded by the local model from the sentence CBM Lens built. The facts in it were checked against the '
+ 'original one by one; the wording is the model.';
export const VOICE_APPLIED =
'The sentences above were reworded by the local model. Every name, number, file and line in them was '
@@ -911,7 +911,7 @@ export function voiceRefused(reason: string): string {
}
export const VOICE_UNAVAILABLE =
'The local model is off, so these sentences are the ones CodeAtlas assembled. Nothing here waits for it.';
'The local model is off, so these sentences are the ones CBM Lens assembled. Nothing here waits for it.';
export const FACET_LABELS: Readonly<Record<Facet, string>> = {
[Facet.Logic]: 'Logic',
@@ -1075,7 +1075,7 @@ export const RUNTIME_UNEXPECTED_TOOLTIP =
/** Why the runtime section wears its marker: nothing is missing from the index here. */
export const RUNTIME_SECTION_NOTE =
'Read from an imported recording, not from the index. CodeAtlas did not watch this run.';
'Read from an imported recording, not from the index. CBM Lens did not watch this run.';
// The runtime badge in the evidence popover ---------------------------------
+1 -1
View File
@@ -273,7 +273,7 @@ describe('importsSection', () => {
});
it('schreibt die Bilanz ueber die Zeilen und die Herkunft darunter', () => {
expect(built.text).toBe('1 used in this file, 1 not used in it, 0 that CodeAtlas cannot check.');
expect(built.text).toBe('1 used in this file, 1 not used in it, 0 that CBM Lens cannot check.');
expect(built.note).toContain('The dependency comes from the index');
expect(built.populated).toBe(true);
});
+3 -2
View File
@@ -24,7 +24,8 @@ test('AC1: design tokens and chrome are in place', () => {
'atlas-statusbar', 'atlas-tree', 'atlas-breadcrumb']) {
assert.ok(smoke.testids.includes(id), `data-testid ${id} wurde im Smoke nicht gesehen`);
}
assert.ok(/CODEATLAS/.test(smoke.brandText), 'Marke CODEATLAS fehlt in der Kopfzeile');
// Archived W2 evidence predates the CBM Lens name; new smoke runs require the current brand.
assert.ok(/CBM LENS|CODEATLAS/.test(smoke.brandText), 'Frontend-Marke fehlt in der Kopfzeile');
assert.match(smoke.versionChip, /^v\d+\.\d+\.\d+/, 'Versions-Chip fehlt oder falsch');
// Spezifikations-Korrektur 2026-08-29 (Orchestrator, Nutzerauftrag):
// Der Test nagelte [f]ile fest, also ausgerechnet einen der vier
@@ -32,7 +33,7 @@ test('AC1: design tokens and chrome are in place', () => {
// sein. Verlangt bleibt das Buchstaben-Menue als Form, belegt am
// verdrahteten [a]tlas.
assert.ok(/\[a\]tlas/.test(smoke.menuText), 'Buchstaben-Menue fehlt');
assert.ok(/type a command or ask the atlas/.test(smoke.commandPlaceholder),
assert.ok(/type a command or ask (?:CBM Lens|the atlas)/.test(smoke.commandPlaceholder),
'Kommandozeilen-Platzhalter fehlt');
});
+2 -2
View File
@@ -534,14 +534,14 @@ async function main() {
const ok =
failure === null &&
missingTestids.length === 0 &&
/CODEATLAS/.test(report.brandText) &&
/CBM LENS/.test(report.brandText) &&
/^v\d+\.\d+\.\d+/.test(report.versionChip) &&
// Nachgezogen am 2026-08-29 (W7a, Nutzerauftrag): der Lauf nagelte
// [f]ile fest, also ausgerechnet einen der vier Menuepunkte ohne
// Verdrahtung. Eine Attrappe darf keine Zusicherung sein. Verlangt
// bleibt das Buchstaben-Menue als Form, belegt am verdrahteten [a]tlas.
/\[a\]tlas/.test(report.menuText) &&
/type a command or ask the atlas/.test(report.commandPlaceholder) &&
/type a command or ask CBM Lens/.test(report.commandPlaceholder) &&
report.treeEntries >= 10 &&
report.treeSource === '/api/tree' &&
report.openedFile === OPEN_FILE &&
+2 -2
View File
@@ -543,7 +543,7 @@ async function main() {
&& short.imports.top < short.firstLineTop;
report.importHonestyWordsKept = short.imports.entries
.some((entry) => entry.text.includes('not used by this symbol as far as the index shows'))
&& short.imports.tally.includes('that CodeAtlas cannot check')
&& short.imports.tally.includes('that CBM Lens cannot check')
&& short.imports.heading.includes('as far as the index shows');
log(`Importgruppe: "${short.imports.heading}" / "${short.imports.tally}"`);
@@ -681,7 +681,7 @@ async function main() {
.filter((line) => !line.targetKnown)
.every((line) => line.target.includes('the index records no place'));
report.importHonestyWordsKept = report.importHonestyWordsKept
&& long.imports.tally.includes('that CodeAtlas cannot check');
&& long.imports.tally.includes('that CBM Lens cannot check');
report.headSaysWhatTheFindingIs = report.headSaysWhatTheFindingIs
&& long.lead.text.length > 0
&& long.lead.kind.length > 0;