Merge third round: opaque Galaxy panels, hover card, path labels, Expand hint while loading, steady tab row, current start apart, Refresh feedback across the app

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 15:54:51 +02:00
commit 424dbb9dd5
44 files changed
+932 -97

No files matched your search

@@ -0,0 +1,60 @@
// @vitest-environment jsdom
/*
* Review of K42 ("other Refresh buttons the same way"): Refresh in ADR read
* the decision record again and nothing on screen changed (measured in the
* browser: no visible state for a single frame). It now says that it runs and
* then when it ran and whether anything changed, as the Architecture
* Refresh buttons do.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import AdrWorkspace from './AdrWorkspace';
import type { AdrRecord } from '../projects/projects-model';
import { clearAdrDraft } from './adr-model';
let host: HTMLDivElement, root: Root;
const project = 'adr-refresh-test';
const record = (content: string): AdrRecord => ({ hasAdr: true, content, updatedAt: '2026-09-14T12:00:00Z' });
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
clearAdrDraft(project);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); clearAdrDraft(project); });
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
const refresh = () => [...host.querySelectorAll('.adr-actions button')][0] as HTMLButtonElement;
const status = () => host.querySelector('.adr-actions .atlas-refresh-status')?.textContent;
const TIME = '\\d\\d:\\d\\d:\\d\\d';
it('K42: Refresh in ADR says that it runs, then when it ran and whether the record changed, or that it failed', async () => {
let next = deferred<AdrRecord>();
const adr = vi.fn().mockResolvedValueOnce(record('# Decisions')).mockImplementation(() => next.promise);
await act(async () => root.render(<AdrWorkspace project={project} active source={{ adr, saveAdr: vi.fn() }} />));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toBe('');
await act(async () => refresh().click());
expect(refresh().textContent).toBe('Refreshing…');
expect(refresh().getAttribute('aria-disabled')).toBe('true');
await act(async () => next.resolve(record('# Decisions')));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
next = deferred<AdrRecord>();
await act(async () => refresh().click());
await act(async () => next.resolve(record('# Decisions\n\n## Storage')));
expect(status()).toMatch(new RegExp(`^Decisions refreshed at ${TIME}$`));
next = deferred<AdrRecord>();
await act(async () => refresh().click());
await act(async () => next.reject(new Error('offline')));
// The alert below the toolbar says what failed and offers Try again; the status beside the button only says when.
expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`));
expect(host.querySelector('.adr-notice[role="alert"]')?.textContent).toContain('Could not load the decision record.');
});
+14 -3
View File
@@ -5,6 +5,7 @@ import type { ProjectsSource } from '../projects/ProjectsPanel';
import type { AdrRecord } from '../projects/projects-model';
import { ADR_CONTENT_LIMIT, adrSize, appendDecision, clearAdrDraft, readAdrDraft, sameAdr, storeAdrDraft } from './adr-model';
import { adrStrings as s } from './adr-strings';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import './adr.css';
export interface AdrWorkspaceProps {
@@ -27,13 +28,15 @@ interface EditorState {
notice: string;
conflict?: AdrRecord;
reviewing: boolean;
/** How many readings of the record started; the Refresh status speaks about one of them (review of K42). */
reads: number;
}
function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JSX.Element {
const [state, setState] = useState<EditorState>(() => {
const draft = readAdrDraft(project);
return { base: draft?.base, content: draft?.content ?? '', editing: !!draft, preview: false,
phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false };
phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false, reads: 0 };
});
const [saving, setSaving] = useState(false);
const [storageAvailable, setStorageAvailable] = useState(true);
@@ -60,7 +63,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
lastReload.current = reload;
if (!active || savingRef.current || (hasLoaded.current && current.phase === 'ready' && current.base && current.content !== current.base.content && !forced)) return;
const ticket = ++loadTicket.current;
setState(value => ({ ...value, phase: 'loading', error: '' }));
setState(value => ({ ...value, phase: 'loading', error: '', reads: value.reads + 1 }));
void source.adr(project).then(record => {
if (!mounted.current || ticket !== loadTicket.current) return;
hasLoaded.current = true;
@@ -154,6 +157,14 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
editing: !discard, conflict: undefined, reviewing: false, phase: 'ready', error: '', notice: discard ? '' : s.rebased }) : value);
const ready = state.phase === 'ready';
const documentVisible = !state.editing || state.preview;
/*
* Review of K42: Refresh read the record again and nothing on screen
* changed. Now it says that it runs, then when and whether the record
* changed; a failure only gets its time here, the alert below says what
* failed and offers Try again.
*/
const refresh = useRefreshFeedback({ key: String(state.reads), settled: state.phase === 'ready' || state.phase === 'error',
value: state.base && { content: state.base.content, updatedAt: state.base.updatedAt, hasAdr: state.base.hasAdr }, error: state.phase === 'error' ? '' : undefined });
return <section className="adr-workspace" aria-label={s.label} aria-busy={state.phase === 'loading' || saving}>
<header className="adr-toolbar">
@@ -164,7 +175,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
<button type="button" disabled={saving} onClick={cancel}>{s.cancel}</button>
<button type="button" className="adr-primary" disabled={!ready || !dirty || !size.valid || saving || !!state.conflict} onClick={() => { void save(); }}>{saving ? s.saving : s.save}</button>
</> : <>
<button type="button" disabled={state.phase === 'loading'} onClick={() => setReload(value => value + 1)}>{s.refresh}</button>
<RefreshControl labels={s.refreshFeedback} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); setReload(value => value + 1); }} />
{ready && state.base?.hasAdr && <button type="button" onClick={() => startEditing()}>{s.edit}</button>}
{ready && state.base?.hasAdr && <button type="button" className="adr-primary" onClick={() => startEditing(true)}>{s.add}</button>}
</>}</div>
+2 -1
View File
@@ -2,7 +2,8 @@ export const adrStrings = {
label: 'Architecture decisions',
noProject: 'Select a project to read its architecture decisions.',
loading: 'Loading decisions…',
refresh: 'Refresh',
/** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */
refreshFeedback: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Decisions refreshed at ${time}` },
retry: 'Try again',
edit: 'Edit',
add: 'Add decision',
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
/*
* Review of K43: with a filter, "Matching operations · 5 of 61" held six
* entries, the last the current start that did not match. The current start
* now has a group of its own, and the count of every group equals its
* entries.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import BehaviorJourney, { type BehaviorJourneyProps } from './BehaviorJourney';
import { architectureText } from './strings';
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
vi.mock('./BehaviorSourceEvidence', () => ({ default: () => <div data-source-evidence /> }));
vi.mock('./SystemArchitectureScene', () => ({ default: () => <div data-scene /> }));
const symbol = (id: number, name = `operation${id}`, file_path = `src/part${id % 2}.ts`): SystemSymbol => ({ id, name, qualified_name: `sample.${name}`, label: 'Function',
component_id: `component${id % 2}`, file_path, start_line: id });
const LOADDATA = 'django/core/management/commands/loaddata.py';
const entries = [symbol(1), symbol(21, 'handle', 'django/core/management/commands/makemigrations.py'), symbol(22, 'handle', LOADDATA), symbol(23, 'as_sql', 'django/db/models/fields/json.py')];
function fixture(): SystemProjection {
const nodes = [symbol(1), symbol(2)];
return { schema_version: 1, status: 'ready', kind: 'static_projection', complete: true, components: [], dependencies: [], cycles: [], entrypoints: [nodes[0]!],
paths: [{ entrypoint_id: 1, nodes, edges: [{ id: 10, source_id: 1, target_id: 2, type: 'CALLS', callsite: { file_path: 'src/part1.ts', line: 20 } }] }],
totals: {}, limits: {}, warnings: [] };
}
let container: HTMLDivElement, root: Root;
beforeEach(() => { (globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement('div'); document.body.append(container); root = createRoot(container); });
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
const select = () => container.querySelector<HTMLSelectElement>('select[aria-label="Behavior entry point"]')!;
const filterField = () => container.querySelector<HTMLInputElement>('input[aria-label="Filter operations"]')!;
const groups = () => [...select().querySelectorAll('optgroup')].map(group => ({ label: group.label,
options: [...group.querySelectorAll('option')].filter(option => !option.disabled).map(option => option.textContent) }));
async function type(value: string) {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(filterField(), value);
filterField().dispatchEvent(new Event('input', { bubbles: true }));
});
}
it('K43: the current start that does not match the filter stands in a group of its own, and each count equals its entries', async () => {
const data = fixture();
const props: BehaviorJourneyProps = { project: 'sample', generation: 'g1', data, entries, suggestedEntries: [1, 21],
targets: [{ ...data.paths[0]!.nodes[1]!, distance: 1 }], entryId: 1, active: true, pending: false, filter: '',
onRequest: vi.fn(), onRefresh: vi.fn(), onNavigate: vi.fn(), onSelectSymbol: vi.fn() };
await act(async () => root.render(<BehaviorJourney {...props} />));
await type('loaddata');
expect(groups()).toEqual([
{ label: architectureText.behaviorStart.current, options: ['operation1 · src/part1.ts'] },
{ label: 'Matching operations · 1 of 4', options: [`handle · ${LOADDATA}`] },
]);
expect(architectureText.behaviorStart.current).toBe('Current start, not matching the filter');
// The field still shows the current start.
expect(select().value).toBe('1');
expect(select().selectedOptions[0]?.textContent).toBe('operation1 · src/part1.ts');
// A filter the current start matches: no group of its own, it stands among the matches.
await type('operation1');
expect(groups()).toEqual([
{ label: 'Suggested', options: ['operation1 · src/part1.ts'] },
{ label: 'Matching operations · 1 of 4', options: ['operation1 · src/part1.ts'] },
]);
// Nothing matches: the current start alone stands apart, the matches say so.
await type('no such operation');
expect(groups().map(group => group.label)).toEqual([architectureText.behaviorStart.current, 'Matching operations · 0 of 4']);
expect(select().querySelector('optgroup:last-child option:last-child')?.textContent).toBe('No operation matches "no such operation"');
});
@@ -215,13 +215,16 @@ describe('Behavior start field', () => {
const props = await render(fixture(), { entries, suggestedEntries: [1, 21] });
await type('loaddata');
expect(group(1).label).toBe('Matching operations · 1 of 4');
expect(options(1)).toEqual([`handle · ${LOADDATA}`, 'operation1 · src/part1.ts']);
expect(options(1)).toEqual([`handle · ${LOADDATA}`]);
// The chosen start stands apart while the filter does not match it (review of K43).
expect(group(0).label).toBe('Current start, not matching the filter');
expect(options(0)).toEqual(['operation1 · src/part1.ts']);
expect(select().value).toBe('1');
await act(async () => { select().value = '22'; select().dispatchEvent(new Event('change', { bubbles: true })); });
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 22, file_path: LOADDATA }), undefined, {});
await type('no such operation');
expect(options(1)).toEqual(['operation1 · src/part1.ts', 'No operation matches "no such operation"']);
expect(options(0)).toEqual(['operation1 · src/part1.ts']);
expect(options(1)).toEqual(['No operation matches "no such operation"']);
expect(group(1).querySelector('option:last-child')?.hasAttribute('disabled')).toBe(true);
});
});
@@ -7,7 +7,7 @@ import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence';
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
import { RefreshControl, type RefreshFeedback } from './refresh-feedback';
import { RefreshControl, type RefreshFeedback } from '../ui/refresh/refresh-feedback';
import { operationChoices } from './operation-choices';
import './behavior-journey.css';
@@ -181,6 +181,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
<input type="search" aria-label={startText.filter} placeholder={startText.filterPlaceholder} value={startQuery} onChange={event => setStartQuery(event.target.value)} />
<select aria-label={startText.field} value={entry?.id ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
<option value="">{startText.choose}</option>
{startChoices.current && <optgroup label={startText.current}><option value={startChoices.current.id}>{startChoices.current.label}</option></optgroup>}
{startChoices.suggested.length > 0 && <optgroup label={startText.suggested}>{startChoices.suggested.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}</optgroup>}
<optgroup label={startQuery.trim() ? startText.matching(startChoices.matching, startChoices.total) : startText.all(startChoices.total)}>
{startChoices.all.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
@@ -79,7 +79,7 @@ describe('service map interactions', () => {
/* Hand test 2026-10-04 (A3): a Refresh that reads everything again says so, and says what it found. */
describe('service map refresh feedback', () => {
const refresh = () => [...container.querySelectorAll('button')].find(element => /^Refresh(ing…)?$/.test(element.textContent ?? ''))!;
const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent;
afterEach(() => { vi.useRealTimers(); });
it('A3: reads the declared services again, busy meanwhile, then names the time and whether anything changed', async () => {
+2 -2
View File
@@ -7,7 +7,7 @@ import { loadContainerInventory, loadContainerTopology, type ContainerReading, t
import type { ServiceEvidence } from './container-topology';
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
import { architectureText as text } from './strings';
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import './spatial-architecture.css';
import './container-map.css';
@@ -149,7 +149,7 @@ export default function ContainerMap({ project, generation, active, filter, onNa
<header className="spatial-heading"><div><span className="spatial-eyebrow">Routes / Services</span><h2>How the services connect</h2>
<p>One square per declared service. Follow a connection to see the configuration and code behind it.</p></div>
<div className="spatial-camera-controls"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button>
<button onClick={() => setResetKey(value => value + 1)}>Fit map</button><RefreshControl labels={text.refreshFeedback.services} feedback={refresh.feedback} onRefresh={() => {
<button onClick={() => setResetKey(value => value + 1)}>{text.fitMap}</button><RefreshControl labels={text.refreshFeedback.services} feedback={refresh.feedback} onRefresh={() => {
refresh.begin();
setRevision(value => value + 1);
if (!selections.length || !selections[0].inventory.manifests.length) setDiscoveryRevision(value => value + 1);
@@ -210,7 +210,7 @@ it('A3: Refresh connections says that it runs and what it found, and keeps the m
const input: GraphData = { nodes: [node, route], edges: [], total_nodes: 2 };
await act(async () => root.render(<SpatialArchitecture project="sample" graph={input} overview={overview} view="routes" filter="" active onNavigate={vi.fn()} onView={vi.fn()} />));
const refresh = () => [...host.querySelectorAll('button')].find(button => /^Refresh(ing)? connections/.test(button.textContent ?? ''))!;
const status = () => host.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
const status = () => host.querySelector('[role="status"].atlas-refresh-status')?.textContent;
expect(refresh().textContent).toBe('Refresh connections');
expect(status()).toBe('');
@@ -15,7 +15,7 @@ import type { CoverageIndex } from '../app/tree-model';
import { buildHotspotGraph, collectHotspots, hotspotsForNode, hotspotSignals, hotspotIdentity } from './hotspot-map';
import { hotspotAreas } from './hotspot-areas';
import { useViewPreferences } from '../settings/view-preferences';
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import './spatial-architecture.css';
interface Props {
@@ -184,7 +184,7 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
<div className="spatial-heading"><div><span className="spatial-eyebrow">Repository atlas / {view === 'entryPoints' ? 'entry points' : view}</span><h2>{model.title}</h2><p>{viewNotes[view]}</p></div>
<div className="spatial-camera-controls" role="group" aria-label="Map camera">
<button aria-pressed={!planar} onClick={() => changePlace({ planar: false })}>3D</button><button aria-pressed={planar} onClick={() => changePlace({ planar: true })}>Plan</button>
<button onClick={() => setResetKey(value => value + 1)}>Fit map</button>
<button onClick={() => setResetKey(value => value + 1)}>{text.fitMap}</button>
</div>
</div>
<div className="spatial-controls">
@@ -641,7 +641,7 @@ describe('system architecture workspace', () => {
/* Hand test 2026-10-04 (A3): "Refresh analysis" and the Behavior "Refresh" read again and said nothing. */
describe('system architecture refresh feedback', () => {
const refresh = (pattern: RegExp) => [...container.querySelectorAll('button')].find(element => pattern.test(element.textContent ?? ''))!;
const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent;
afterEach(() => { vi.useRealTimers(); });
it('A3: Refresh analysis is busy while it runs, then names the time and whether the index changed', async () => {
@@ -672,7 +672,7 @@ describe('system architecture refresh feedback', () => {
vi.setSystemTime(new Date(2026, 9, 4, 18, 1, 0));
const loader = vi.fn<SystemArchitectureLoader>().mockResolvedValue(response(overviewFixture()));
await render(loader, { view: 'behavior' });
const button = () => container.querySelector<HTMLButtonElement>('.behavior-heading .atlas-arch-refresh button')!;
const button = () => container.querySelector<HTMLButtonElement>('.behavior-heading .atlas-refresh button')!;
expect(button()?.textContent).toBe('Refresh');
let finish!: (value: SystemArchitectureResponse) => void;
loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
@@ -11,7 +11,7 @@ import BehaviorJourney from './BehaviorJourney';
import { defaultJourneyStart } from './behavior-journey-model';
import type { BehaviorPlace, JourneyPlace, PlaceChange, SystemPlace } from './architecture-history';
import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import { nameOperations } from './operation-choices';
import { RpcIntelligenceClient } from '../provider/rpc-client';
import './system-architecture.css';
@@ -78,12 +78,6 @@
.atlas-arch-recent-menu button:hover:not(:disabled) { background: var(--atlas-panel-raised); }
.atlas-arch-recent-menu button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--atlas-dim); }
.atlas-arch-recent-menu button[aria-current="true"] { opacity: 0.6; }
/* Hand test 2026-10-04 (A3): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */
.atlas-arch-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; }
.atlas-arch-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; }
.atlas-arch-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; }
.atlas-arch-refresh-status:not(:empty) { margin-left: 8px; }
.atlas-arch-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); }
.atlas-arch-toolbar { margin-bottom: 24px; gap: 10px; }
.atlas-arch-filter { position: relative; flex: 1; max-width: 370px; }
.atlas-arch-filter svg { color: var(--atlas-dim); left: 11px; position: absolute; top: 10px; pointer-events: none; }
@@ -3,7 +3,7 @@
.behavior-heading h2 { font-size: 25px; font-weight: 500; letter-spacing: -.6px; margin: 7px 0; overflow-wrap: anywhere; }
.behavior-heading p { color: var(--atlas-dim); font-size: 12px; margin: 0; }
.behavior-journey button { cursor: pointer; }
.behavior-heading > button, .behavior-heading > .atlas-arch-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
.behavior-heading > button, .behavior-heading > .atlas-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
.behavior-journey button:disabled { opacity: .35; cursor: default; }
.behavior-journey button:focus-visible { outline: 2px solid var(--arch-label-focus); outline-offset: 3px; }
.behavior-requests { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--journey-line); border-radius: 10px; background: var(--journey-tint); }
@@ -0,0 +1,17 @@
/*
* Review of K42: the line of the service map that got the shared Refresh
* control still carried "Fit map" as text in the TSX. Visible words live in
* architecture/strings.ts; both maps take the button's name from there.
*/
import { readFileSync } from 'node:fs';
import { expect, it } from 'vitest';
import { architectureText } from './strings';
it('K42: "Fit map" comes from the strings of Architecture, not from the TSX of the maps', () => {
expect(architectureText.fitMap).toBe('Fit map');
for (const file of ['ContainerMap.tsx', 'SpatialArchitecture.tsx']) {
const source = readFileSync(new URL(`./${file}`, import.meta.url), 'utf8');
expect(source, file).not.toMatch(/>\s*Fit map\s*</);
expect(source, file).toContain('{text.fitMap}');
}
});
@@ -0,0 +1,44 @@
import { expect, it } from 'vitest';
import { operationChoices } from './operation-choices';
import type { SystemSymbol } from './system-architecture-source';
/*
* Review of K43: with a filter the current start was kept under "Matching
* operations · N of M" even when it did not match, so the group showed one
* entry more than its count ("5 of 61" with six entries, the last one
* "main · …/manage.py-tpl"). The kept start now stands apart, and every
* group holds exactly what it says.
*/
const operation = (id: number, name: string, file_path: string): SystemSymbol =>
({ id, name, qualified_name: '', label: '', file_path, component_id: '' });
const entries = [
operation(1, 'main', 'django/conf/project_template/manage.py-tpl'),
operation(2, '__init__', 'django/db/models/aggregates.py'),
operation(3, '__init__', 'django/forms/fields.py'),
operation(4, 'handle', 'django/core/management/commands/loaddata.py'),
];
it('K43: a current start that does not match the filter stands apart, not among the matches', () => {
const choices = operationChoices(entries, { query: '__init__', keep: 1, suggested: [1, 4] });
expect(choices.current?.id).toBe(1);
expect(choices.current?.label).toBe('main · django/conf/project_template/manage.py-tpl');
expect(choices.all.map(item => item.id)).toEqual([2, 3]);
expect(choices.all).toHaveLength(choices.matching);
// The suggestions are filtered the same way: the current start is not repeated there either.
expect(choices.suggested).toEqual([]);
});
it('K43: a current start that matches stays in its place, and there is no separate entry', () => {
const choices = operationChoices(entries, { query: 'handle', keep: 4, suggested: [1, 4] });
expect(choices.current).toBeUndefined();
expect(choices.all.map(item => item.id)).toEqual([4]);
expect(choices.suggested.map(item => item.id)).toEqual([4]);
expect(choices.matching).toBe(1);
});
it('K43: without a filter every operation is listed, the current one in its place', () => {
const choices = operationChoices(entries, { keep: 1, suggested: [1, 4] });
expect(choices.current).toBeUndefined();
expect(choices.all).toHaveLength(4);
expect(choices.suggested.map(item => item.id)).toEqual([1, 4]);
});
@@ -64,9 +64,11 @@ describe('Behavior start choices (A4)', () => {
it('narrows to the operations that match every word of the filter, and keeps the chosen one', () => {
expect(labels(operationChoices(scouted, { query: 'loaddata' }).all)).toEqual(['handle · django/core/management/commands/loaddata.py']);
expect(operationChoices(scouted, { query: 'handle MIGRATE' }).all.map(item => item.id)).toEqual([5]);
// The chosen start that does not match stands apart (review of K43), so the matches hold what their count says.
const kept = operationChoices(scouted, { query: 'loaddata', keep: 1, suggested: [1, 3] });
expect(kept.all.map(item => item.id)).toEqual([4, 1]);
expect(kept.suggested.map(item => item.id)).toEqual([1]);
expect(kept.all.map(item => item.id)).toEqual([4]);
expect(kept.suggested.map(item => item.id)).toEqual([]);
expect(kept.current?.id).toBe(1);
expect(kept.matching).toBe(1);
// A chosen start that matches counts as a match (measured in the browser: "0 of 61" after picking loaddata).
expect(operationChoices(scouted, { query: 'loaddata', keep: 4 }).matching).toBe(1);
@@ -12,7 +12,9 @@
* the class is the same too, and their position when the index names
* neither;
* - a filter keeps the operations whose name, class or path contain every
* word typed, and always the chosen one, so the field can still show it.
* word typed. The chosen one stays offered so the field can still show it,
* and when it does not match it stands apart (`current`): among the matches
* it made "5 of 61" a group of six (review of K43).
*/
import type { SystemSymbol } from './system-architecture-source';
import { architectureText } from './strings';
@@ -21,6 +23,8 @@ const text = architectureText.behaviorStart;
export interface OperationChoice { id: number; label: string; symbol: SystemSymbol }
export interface OperationChoices {
/** The chosen start while a filter it does not match is set; it is in neither list then. */
current?: OperationChoice;
suggested: OperationChoice[];
all: OperationChoice[];
/** How many operations there are, and how many of them match the filter (the kept one only when it matches). */
@@ -71,13 +75,14 @@ export function operationChoices(entries: SystemSymbol[], { suggested = [], quer
const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
const matches = (entry: SystemSymbol) => words.every(word => `${labels.get(entry.id)} ${entry.qualified_name}`.toLocaleLowerCase().includes(word));
const choice = (symbol: SystemSymbol): OperationChoice => ({ id: symbol.id, label: labels.get(symbol.id)!, symbol });
const shown = (entry: SystemSymbol) => entry.id === keep || matches(entry);
const byId = new Map(entries.map(entry => [entry.id, entry]));
const offered = [...new Set(suggested)].filter(id => byId.has(id));
const kept = keep === undefined ? undefined : byId.get(keep);
return {
...(kept && !matches(kept) ? { current: choice(kept) } : {}),
// Suggestions that name every operation would only repeat the list.
suggested: offered.length >= entries.length ? [] : offered.flatMap(id => shown(byId.get(id)!) ? [choice(byId.get(id)!)] : []),
all: entries.filter(shown).sort(compare).map(choice),
suggested: offered.length >= entries.length ? [] : offered.flatMap(id => matches(byId.get(id)!) ? [choice(byId.get(id)!)] : []),
all: entries.filter(matches).sort(compare).map(choice),
total: entries.length,
matching: entries.filter(matches).length,
};
+5 -3
View File
@@ -108,6 +108,8 @@ export const architectureText = {
filter: 'Filter operations',
filterPlaceholder: 'Filter operations…',
suggested: 'Suggested',
/** The chosen start while the filter does not match it (review of K43): apart from the matches, so their count holds. */
current: 'Current start, not matching the filter',
all: (total: number) => `All operations · ${total.toLocaleString()}`,
matching: (shown: number, total: number) => `Matching operations · ${shown.toLocaleString()} of ${total.toLocaleString()}`,
none: (query: string) => `No operation matches "${query}"`,
@@ -117,14 +119,14 @@ export const architectureText = {
line: (place: string, line: number) => `${place}:${line}`,
ordinal: (index: number, count: number) => ` (${index} of ${count})`,
},
/** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when, and whether anything changed. */
/** The camera button of the maps: back to the whole map. */
fitMap: 'Fit map',
/** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when (shared words in ui/refresh/strings.ts). */
refreshFeedback: {
routes: { idle: 'Refresh connections', busy: 'Refreshing connections…', done: (time: string) => `Connections refreshed at ${time}` },
services: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Service map refreshed at ${time}` },
structure: { idle: 'Refresh analysis', busy: 'Refreshing analysis…', done: (time: string) => `Analysis refreshed at ${time}` },
behavior: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Journey refreshed at ${time}` },
unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`,
failed: (time: string, error: string) => `Refresh failed at ${time}: ${error}`,
},
};
@@ -1,12 +1,9 @@
.system-architecture { min-width: 0; }
.atlas-architecture[data-system-view] { padding: 16px; }
/* Review of K40: the view's frame and the row with Back, Forward and the subtabs are those of the other subtabs; with values of their own the row jumped by 16 px. */
.atlas-architecture[data-system-view] .atlas-arch-heading { align-items: center; margin-bottom: 12px; }
.atlas-architecture[data-system-view] .atlas-arch-heading h1 { font-size: 24px; line-height: 1.2; margin: 0 0 5px; }
.atlas-architecture[data-system-view] .atlas-arch-heading p { margin: 0; font-size: 12px; }
.atlas-architecture[data-system-view] .atlas-arch-project { display: none; }
.atlas-architecture[data-system-view] .atlas-arch-tabrow { margin-bottom: 10px; }
.atlas-architecture[data-system-view] .atlas-arch-tabs { gap: 5px; }
.atlas-architecture[data-system-view] .atlas-arch-tab { padding: 7px 10px; }
.atlas-architecture[data-system-view] .atlas-arch-toolbar { margin-bottom: 12px; gap: 8px; }
.system-toolbar, .system-reading-status, .system-map-caption { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; justify-content: space-between; }
.system-toolbar { margin-bottom: 10px; gap: 8px; }
@@ -0,0 +1,32 @@
/*
* 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.
*/
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 BOX = /^(padding|margin)(-|$)/;
it('K40: System structure and Behavior keep the subtab row where the other subtabs have it', () => {
const offending: string[] = [];
for (const { name, css } of SHEETS) {
for (const match of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
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.
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(', ')} }`);
}
}
}
expect(offending).toEqual([]);
});
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
/*
* Review zu K31: waehrend eine Ebene laedt, ist Expand +1 gesperrt
* (aria-disabled), nimmt Zeiger und Fokus, zeigte aber keinen Tooltip: der
* Text fuer diesen Zustand fehlte, und ein leerer Hinweis zeigt nichts. Jetzt
* sagt der Knopf, welche Ebene laedt und dass "−" sie abbricht.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import GalaxyPanel from './GalaxyPanel';
import { galaxyLayerText } from './galaxy-strings';
import { scopeFetch, scopeNode } from './test-scope-fetch';
import type { GraphEdge } from './types';
vi.mock('./GraphScene', async importOriginal => ({
...await importOriginal<typeof import('./GraphScene')>(),
GraphScene: () => <output data-testid="scene" />,
}));
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined;
window.localStorage.clear();
});
const settle = (check: () => void) => vi.waitFor(async () => {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
check();
});
const status = () => host.querySelector('.atlas-graph-scope-count');
const expand = () => [...host.querySelectorAll('button')].find(entry => entry.getAttribute('aria-label') === 'Expand +1')!;
const shown = () => document.querySelector<HTMLElement>('[data-testid="atlas-hint"][data-hint-for="galaxy-expand"]');
it('K31: while a layer loads, the blocked Expand +1 says which layer loads and that "−" cancels it, on hover and on focus', async () => {
const nodes = [1, 2, 3, 4, 5, 6].map(id => scopeNode(id));
const edges: GraphEdge[] = [{ id: 1, source: 1, target: 2, type: 'CALLS' }, { id: 2, source: 2, target: 3, type: 'CALLS' },
{ id: 3, source: 4, target: 2, type: 'CALLS' }, { id: 4, source: 3, target: 5, type: 'CALLS' }, { id: 5, source: 6, target: 1, type: 'TESTS' }];
let block = false, release: () => void = () => {}, queries = 0;
const { fetch } = scopeFetch({ nodes, edges, gate: async () => {
queries += 1;
if (block && queries > 1) await new Promise<void>(resolve => { release = resolve; });
} });
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={fetch} />));
await settle(() => expect(globalThis.__atlasGalaxy?.nodes).toBe(6));
await act(async () => { globalThis.__atlasGalaxy!.clickNode('sample.n1'); });
await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges'));
block = true; queries = 0;
await act(async () => expand().click());
await settle(() => expect(status()?.getAttribute('data-state')).toBe('loading'));
expect(expand().getAttribute('aria-disabled')).toBe('true');
expect(expand().getAttribute('data-hint')).toBe('Layer 2 is loading; "−" cancels it.');
expect(galaxyLayerText.expandLoading(2)).toBe('Layer 2 is loading; "−" cancels it.');
await act(async () => { expand().dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(shown()?.textContent).toBe('Layer 2 is loading; "−" cancels it.');
await act(async () => { expand().dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
expect(shown()).toBeNull();
await act(async () => { expand().focus(); });
expect(shown()?.textContent).toBe('Layer 2 is loading; "−" cancels it.');
// A press while it loads still does nothing.
await act(async () => expand().click());
expect([...host.querySelectorAll('.atlas-graph-exploration span')].some(entry => entry.textContent === '2 layers')).toBe(true);
await act(async () => { expand().blur(); release(); });
await settle(() => expect(status()?.getAttribute('data-state')).not.toBe('loading'));
// Loaded: the tooltip speaks about the next layer again.
expect(expand().getAttribute('data-hint') ?? '').not.toContain('is loading');
});
+9 -6
View File
@@ -135,6 +135,7 @@ import { GraphScene, computeCameraTarget, computeFitTarget, computeFrameTarget }
import type { CameraTarget } from './GraphScene';
import type { LabelBox } from './NodeLabels';
import { NodeTooltipCard } from './NodeTooltipCard';
import { HoverCardHtml } from './hover-layer';
import GalaxyNavigator from './GalaxyNavigator';
import { TraceEdgeFilter } from './TraceEdgeFilter';
import { PathPicker, PathSteps } from './ScopePathControls';
@@ -2112,7 +2113,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
const expandPast = expandOutlook && expandPastLimit(expandOutlook);
const expandWarning = Boolean(expandPast);
const expandBlocked = Boolean(scope.loading || scope.result?.exhausted || scope.result?.partial);
const expandTitle = partial ? galaxyLayerText.expandPartial : scope.result?.exhausted ? galaxyLayerText.expandEnd
// Review zu K31: gesperrt heisst immer mit Grund, auch waehrend eine Ebene laedt.
const expandTitle = scope.loading ? galaxyLayerText.expandLoading(scope.depth)
: partial ? galaxyLayerText.expandPartial : scope.result?.exhausted ? galaxyLayerText.expandEnd
: expandOutlook ? galaxyLayerText.expandHint(expandOutlook, expandPast) : undefined;
/*
@@ -2919,9 +2922,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
props.onSelectShadowNode?.(node);
setNote(`${node.file_path ?? node.name}: coverage shadow. Detailed indexing reasons are not included in this layout.`);
}}
renderShadowTooltip={(node) => <Html position={[node.x, node.y, node.z]} center style={{ pointerEvents: 'none' }}>
renderShadowTooltip={(node) => <HoverCardHtml position={[node.x, node.y, node.z]}>
<div className="atlas-coverage-tooltip"><b>{node.file_path ?? node.name}</b><p>Coverage shadow: not fully indexed.</p></div>
</Html>}
</HoverCardHtml>}
onBackgroundClick={handleBackgroundClick}
renderTooltip={(node) => <NodeTooltipCard node={node} />}
overlay={overlay}
@@ -2943,9 +2946,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
* Das Instrument liegt IM Kasten der Szene und nicht darunter:
* es erklaert, was auf dem Graphen zu sehen ist, und ein Kasten
* daneben waere eine zweite Flaeche, die man zwischen Bild und
* Text hin und her lesen muesste. Es ist halbtransparent und
* faengt seine eigenen Klicks ab; der Rest der Flaeche bleibt
* die Szene.
* Text hin und her lesen muesste. Es ist deckend (Review zu
* K29) und faengt seine eigenen Klicks ab; der Rest der Flaeche
* bleibt die Szene.
*/}
{liveOn && agentsView !== undefined && props.agents !== undefined && (
<AgentsHud
+13 -4
View File
@@ -112,6 +112,9 @@
* Wurzel und ihre direkten Nachbarn). Ohne die Prop wie vorher.
* 22. Neu (Handtest 2026-10-04, G1): die Huelle des Canvas bildet ihren
* eigenen Stapelkontext (`isolation: isolate`). Siehe `CANVAS_STYLE`.
* 23. Neu (Review zu K29): neben der Huelle steht die Ebene der Hover-Karten,
* damit eine Karte ueber den Flaechen der Galaxie steht und nicht wie die
* Namen darunter. Siehe hover-layer.tsx.
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
@@ -136,6 +139,7 @@ import { FRAME_WINDOW_MS, recordFrameWindow, recordSceneFacts } from './frame-ra
import { springStep } from '../agents/agent-motion';
import type { GraphData, GraphNode } from './types';
import { COVERAGE_SHADOW_COLOR, coverageShadowPositions, resolveCoverageShadowNode } from './coverage-shadow';
import { HOVER_LAYER_STYLE, HoverLayerContext } from './hover-layer';
import type { CoverageShadow, CoverageShadowNode } from './coverage-shadow';
import {
DEFAULT_DISPLAY_SETTINGS,
@@ -1106,8 +1110,10 @@ export function GraphScene({
* Beweislaeufe von W3 bis W9 gemessen haben.
*/
const frameloop = !active ? 'never' : frameCap > 0 ? 'never' : 'always';
/* Aenderung 23: die Hover-Karten zeichnen ausserhalb der Huelle in diese Ebene. */
const [hoverLayer, setHoverLayer] = useState<HTMLDivElement | null>(null);
return (
return (<>
<Canvas
{...background}
frameloop={frameloop}
@@ -1171,8 +1177,10 @@ export function GraphScene({
</group>}
{overlay}
{hovered && renderTooltip !== undefined && renderTooltip(hovered)}
{resolveCoverageShadowNode(renderedShadow, hoveredShadow) && hoveredShadow && renderShadowTooltip?.(hoveredShadow)}
<HoverLayerContext.Provider value={hoverLayer}>
{hovered && renderTooltip !== undefined && renderTooltip(hovered)}
{resolveCoverageShadowNode(renderedShadow, hoveredShadow) && hoveredShadow && renderShadowTooltip?.(hoveredShadow)}
</HoverLayerContext.Provider>
<CameraAnimator target={sceneTarget} controlsRef={controlsRef} flat={flat} />
<FitContainment nodes={renderedNodes} target={sceneTarget} controlsRef={controlsRef} moved={moved}
@@ -1229,7 +1237,8 @@ export function GraphScene({
/>
{flat && <FlatTarget box={flatBox} controlsRef={controlsRef} />}
</Canvas>
);
<div ref={setHoverLayer} data-testid="atlas-galaxy-hover-layer" style={HOVER_LAYER_STYLE} />
</>);
}
/*
+4 -7
View File
@@ -16,7 +16,7 @@
*/
import type { JSX } from 'react';
import { Html } from '@react-three/drei';
import { HoverCardHtml } from './hover-layer';
import type { GraphNode } from './types';
/** Der Zeilenbereich, wenn das Layout einen fuehrt. */
@@ -60,11 +60,8 @@ export function NodeTooltipCard({ node }: { node: GraphNode }): JSX.Element {
const rows = tooltipRows(node);
const lines = lineRangeOf(node);
return (
<Html
position={[node.x, node.y + node.size * 0.7, node.z]}
center
style={{ pointerEvents: 'none' }}
>
/* Review zu K29: in der Ebene der Hover-Karten, ueber den Flaechen der Galaxie (hover-layer.tsx). */
<HoverCardHtml position={[node.x, node.y + node.size * 0.7, node.z]}>
<div className="atlas-galaxy-card" data-testid="atlas-galaxy-card">
<div className="atlas-galaxy-card-head">
<span className="atlas-galaxy-card-dot" style={{ backgroundColor: node.color }} />
@@ -92,7 +89,7 @@ export function NodeTooltipCard({ node }: { node: GraphNode }): JSX.Element {
)}
<p className="atlas-galaxy-card-action">{tooltipAction(node)}</p>
</div>
</Html>
</HoverCardHtml>
);
}
+47 -9
View File
@@ -57,6 +57,31 @@ export function screenBlockers(host: ParentNode): ScreenRect[] {
/* Nur jedes dritte Bild: die Lage der Namen aendert sich mit der Kamera, nicht schneller. */
const PLACE_EVERY_FRAMES = 3;
/**
* Wo die Kantenlabels des Pfades stehen, in der Reihenfolge des Pfades, und
* welches keinen Platz bekommt.
*
* Review zu K30: fand ein Label keinen freien Platz, stand es dort, wo es am
* wenigsten verdeckte, und das konnte die Ueberschrift des Bandes der
* Hierarchie sein (ein breiter Titel ueber einer steilen Kante). Wie die
* Kantenschilder der Hierarchie (HierarchyEdgeLabels.tsx) faellt es jetzt
* weg, statt zwei Texte unlesbar zu machen: die Art der Kante steht weiter in
* der Schrittliste, und mit der naechsten Kamera kommt es wieder, sobald es
* frei steht. Ein spaeteres Label weicht den frueheren aus wie den Namen.
*/
export function pathLabelSpots(labels: readonly { index: number; from: { x: number; y: number }; to: { x: number; y: number }; size: { width: number; height: number } }[],
blockers: readonly ScreenRect[]): Map<number, { t: number; rect: ScreenRect }> {
const placed: ScreenRect[] = [];
const spots = new Map<number, { t: number; rect: ScreenRect }>();
for (const { index, from, to, size } of labels) {
const choice = placeAlongSegment(from, to, size, blockers, placed);
if (choice.overlap > 0) continue;
placed.push(choice.rect);
spots.set(index, { t: choice.t, rect: choice.rect });
}
return spots;
}
export function PathLayer({ nodes, path, namedRoots }: {
nodes: readonly GraphNode[];
path: ScenePath;
@@ -90,6 +115,8 @@ export function PathLayer({ nodes, path, namedRoots }: {
const groups = useRef(new Map<number, THREE.Group>());
const labels = useRef(new Map<number, HTMLElement>());
// Die Groesse eines Labels nach seinem Text: ein weggelassenes Label (display: none) hat keine, die man messen koennte.
const sizes = useRef(new Map<string, { width: number; height: number }>());
const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
// drei hangs Html into the element the events are connected to, not into the canvas parent.
@@ -106,17 +133,28 @@ export function PathLayer({ nodes, path, namedRoots }: {
scratch.point.set(node.x, node.y, node.z).project(camera);
return { x: box.left + ((scratch.point.x + 1) / 2) * box.width, y: box.top + ((1 - scratch.point.y) / 2) * box.height };
};
const placed: ScreenRect[] = [];
for (const { index, from, to } of labelled) {
const measured = labelled.flatMap(({ step, index, from, to }) => {
const group = groups.current.get(index), label = labels.current.get(index);
if (!group || !label || label.offsetWidth === 0) continue;
const a = toScreen(from), b = toScreen(to);
const choice = placeAlongSegment(a, b, { width: label.offsetWidth, height: label.offsetHeight }, blockers, placed);
placed.push(choice.rect);
group.position.lerpVectors(scratch.from.set(from.x, from.y, from.z), scratch.to.set(to.x, to.y, to.z), choice.t);
if (!group || !label) return [];
let size = sizes.current.get(step.edge.type);
if (!size) {
label.style.display = '';
size = { width: label.offsetWidth, height: label.offsetHeight };
if (size.width === 0) return [];
sizes.current.set(step.edge.type, size);
}
return [{ index, group, label, from, to, a: toScreen(from), b: toScreen(to), size }];
});
const spots = pathLabelSpots(measured.map(({ index, a, b, size }) => ({ index, from: a, to: b, size })), blockers);
for (const { index, group, label, from, to, a, b } of measured) {
const spot = spots.get(index);
const display = spot ? '' : 'none';
if (label.style.display !== display) label.style.display = display;
if (!spot) continue;
group.position.lerpVectors(scratch.from.set(from.x, from.y, from.z), scratch.to.set(to.x, to.y, to.z), spot.t);
// The side step from placeAlongSegment, applied in screen space around the point on the edge.
const dx = (choice.rect.left + choice.rect.right) / 2, dy = (choice.rect.top + choice.rect.bottom) / 2;
const onEdge = { x: a.x + (b.x - a.x) * choice.t, y: a.y + (b.y - a.y) * choice.t };
const dx = (spot.rect.left + spot.rect.right) / 2, dy = (spot.rect.top + spot.rect.bottom) / 2;
const onEdge = { x: a.x + (b.x - a.x) * spot.t, y: a.y + (b.y - a.y) * spot.t };
label.style.transform = `translate(${dx - onEdge.x}px, ${dy - onEdge.y}px)`;
}
});
+2
View File
@@ -171,6 +171,8 @@ export const galaxyLayerText = {
},
expandPartial: 'This layer stopped loading after it passed the render limit, so there is no complete edge to grow from. Raise the limit under Limits first.',
expandEnd: 'End of trace: no relationship leads further.',
/** Review of K31: Expand +1 is blocked while a layer loads, and says why. */
expandLoading: (layer: number) => `Layer ${layer} is loading; "−" cancels it.`,
};
/** Compact toolbar words, so the scoped toolbar keeps to one row at 1600 px. */
+10 -9
View File
@@ -2,7 +2,7 @@
.atlas-galaxy-search-container { position:relative; flex:1 1 12rem; min-width:9rem; max-width:32rem; }
.atlas-galaxy-search-container > input { box-sizing:border-box; width:100%; min-height:2rem; padding:.4rem .65rem; border:1px solid #29473a; border-radius:6px; background:rgba(7,17,12,.65); color:var(--atlas-fg,#cbdbd0); font:inherit; }
.atlas-galaxy-search-container > input:focus { outline:1px solid #6d9a84; border-color:#6d9a84; }
.atlas-galaxy-search-results { position:absolute; top:calc(100% + .4rem); left:0; z-index:35; width:min(34rem, calc(100vw - 2rem)); padding:.5rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-galaxy-search-results { position:absolute; top:calc(100% + .4rem); left:0; z-index:35; width:min(34rem, calc(100vw - 2rem)); padding:.5rem; border:1px solid #355246; border-radius:8px; background:rgb(13,25,19); box-shadow:0 8px 24px #0005; }
.atlas-graph-exploration label { display:flex; align-items:center; gap:.35rem; color:var(--muted, #a0b6aa); }
.atlas-graph-exploration select { padding:.2rem .35rem; background:#102019; color:#d4ded8; border:1px solid #29473a; border-radius:5px; }
.atlas-graph-coverage-filter { cursor:pointer; white-space:nowrap; }
@@ -60,11 +60,11 @@
/* Der Deckel im Scope: dieselbe Bauform wie die Kantenarten (Review-Befund G7). */
.atlas-graph-limits { position:relative; }
.atlas-graph-limits > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; }
.atlas-graph-limits-menu { position:absolute; top:calc(100% + .35rem); right:0; z-index:30; display:flex; flex-direction:column; align-items:flex-end; gap:.45rem; padding:.6rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-graph-limits-menu { position:absolute; top:calc(100% + .35rem); right:0; z-index:30; display:flex; flex-direction:column; align-items:flex-end; gap:.45rem; padding:.6rem; border:1px solid #355246; border-radius:8px; background:rgb(13,25,19); box-shadow:0 8px 24px #0005; }
.atlas-graph-scope-warning { width:100%; color:#c6aa82; }
.atlas-trace-edge-filter { position:relative; }
.atlas-trace-edge-filter > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; }
.atlas-trace-edge-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(20rem, calc(100vw - 3rem)); max-height:22rem; overflow:auto; padding:.65rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-trace-edge-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(20rem, calc(100vw - 3rem)); max-height:22rem; overflow:auto; padding:.65rem; border:1px solid #355246; border-radius:8px; background:rgb(13,25,19); box-shadow:0 8px 24px #0005; }
.atlas-trace-edge-actions { display:flex; gap:.4rem; margin-bottom:.4rem; }
.atlas-trace-edge-menu small { display:block; color:#95a89e; margin-bottom:.4rem; }
.atlas-trace-edge-row { display:flex; justify-content:space-between; align-items:center; gap:.5rem; padding:.2rem 0; }
@@ -78,7 +78,8 @@
.atlas-galaxy-node-picker button span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.75rem; }
.atlas-galaxy-node-picker .atlas-graph-result-kind { min-width:3rem; color:#91b6a3; }
.galaxy-selection-evidence.atlas-galaxy-selection-details { top:auto; bottom:12px; right:12px; width:auto; height:auto; max-width:calc(100% - 24px); max-height:55%; padding:0; border-radius:7px; background:rgba(13, 25, 19, .95); }
/* Review zu K29: jede Flaeche ueber der Szene ist deckend; durch .95 schienen der Name des Pfadziels und sein Ring hindurch. */
.galaxy-selection-evidence.atlas-galaxy-selection-details { top:auto; bottom:12px; right:12px; width:auto; height:auto; max-width:calc(100% - 24px); max-height:55%; padding:0; border-radius:7px; background:rgb(13, 25, 19); }
.galaxy-selection-evidence.atlas-galaxy-selection-details > summary { padding:.5rem .75rem; color:#b5c9bc; font-size:.8rem; cursor:pointer; }
.galaxy-selection-evidence.atlas-galaxy-selection-details[open] { width:min(420px, calc(100% - 24px)); max-height:min(24rem, 55%); }
.atlas-galaxy-selection-details-body { padding:0 .75rem .75rem; }
@@ -86,7 +87,7 @@
.galaxy-selection-evidence.atlas-galaxy-selection-details[open] { max-height:38%; }
}
.atlas-graph-render-progress { position:absolute; z-index:30; top:12px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:.5rem; max-width:calc(100% - 24px); box-sizing:border-box; padding:.45rem .75rem; border:1px solid #6c88764d; border-radius:999px; background:rgba(14,25,20,.9); color:#b9cdc1; box-shadow:0 3px 12px #0003; pointer-events:none; font:12px/1.4 var(--atlas-ui-font,system-ui,sans-serif); }
.atlas-graph-render-progress { position:absolute; z-index:30; top:12px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:.5rem; max-width:calc(100% - 24px); box-sizing:border-box; padding:.45rem .75rem; border:1px solid #6c88764d; border-radius:999px; background:rgb(14,25,20); color:#b9cdc1; box-shadow:0 3px 12px #0003; pointer-events:none; font:12px/1.4 var(--atlas-ui-font,system-ui,sans-serif); }
.atlas-graph-render-progress > span:last-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.atlas-graph-render-spinner { width:12px; height:12px; box-sizing:border-box; flex:none; border:1.5px solid #99b7a52e; border-top-color:#a8c4b3; border-radius:50%; animation:atlas-graph-render-spin .8s linear infinite; }
@keyframes atlas-graph-render-spin { to { transform:rotate(360deg); } }
@@ -95,14 +96,14 @@
/* Der Pfad: Auswahl in der Werkzeugleiste, Schrittliste in der Szene, Beschriftung und Ring an den Kanten. */
.atlas-graph-path-picker { position:relative; }
.atlas-graph-path-picker > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; }
.atlas-graph-path-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(22rem, calc(100vw - 3rem)); padding:.55rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-graph-path-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(22rem, calc(100vw - 3rem)); padding:.55rem; border:1px solid #355246; border-radius:8px; background:rgb(13,25,19); box-shadow:0 8px 24px #0005; }
.atlas-graph-path-menu input { box-sizing:border-box; width:100%; padding:.35rem .5rem; border:1px solid #29473a; border-radius:5px; background:rgba(7,17,12,.65); color:var(--atlas-fg,#cbdbd0); font:inherit; }
.atlas-graph-path-menu ul { max-height:18rem; overflow:auto; list-style:none; padding:0; margin:.45rem 0 0; }
.atlas-graph-path-menu button { width:100%; display:flex; gap:.5rem; align-items:baseline; text-align:left; border:0; background:transparent; }
.atlas-graph-path-menu button span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.72rem; color:#95a89e; }
.atlas-graph-path-menu small { display:block; color:#95a89e; margin-top:.4rem; }
.atlas-graph-exploration button[aria-pressed=true] { border-color:#80ac96; background:rgba(68, 112, 86, .3); }
.atlas-galaxy-path-panel { position:absolute; z-index:25; top:12px; left:12px; display:flex; flex-direction:column; gap:.35rem; width:min(26rem, calc(100% - 24px)); max-height:min(22rem, 55%); box-sizing:border-box; padding:.55rem .65rem; border:1px solid #355246; border-radius:8px; background:rgba(13, 25, 19, .94); color:#cbdbd0; font-size:.78rem; }
.atlas-galaxy-path-panel { position:absolute; z-index:25; top:12px; left:12px; display:flex; flex-direction:column; gap:.35rem; width:min(26rem, calc(100% - 24px)); max-height:min(22rem, 55%); box-sizing:border-box; padding:.55rem .65rem; border:1px solid #355246; border-radius:8px; background:rgb(13, 25, 19); color:#cbdbd0; font-size:.78rem; }
.atlas-galaxy-path-panel header, .atlas-galaxy-path-panel footer { display:flex; align-items:center; justify-content:space-between; gap:.5rem; }
.atlas-galaxy-path-panel p { margin:0; color:#c6aa82; }
.atlas-galaxy-path-panel ol { flex:1 1 auto; min-height:0; overflow:auto; margin:0; padding:0; list-style:none; }
@@ -129,14 +130,14 @@
/* Zweites Review zu K5: die Ueberschrift des Bandes unter den Spalten und die Notiz, wenn Namen fehlen. */
.atlas-hierarchy-band-label { display:flex; flex-direction:column; align-items:center; gap:1px; padding:2px 8px; border:1px dashed #6f8f80; border-radius:5px; background:rgba(13, 15, 18, .86); color:#d7e6dd; white-space:nowrap; font:600 11px/1.35 var(--atlas-ui-font, system-ui, sans-serif); }
.atlas-hierarchy-band-label small { color:#a9bdb2; font-weight:400; font-size:10px; }
.atlas-hierarchy-key { position:absolute; z-index:20; left:12px; bottom:12px; max-width:min(34rem, calc(100% - 24px)); margin:0; padding:.35rem .6rem; border:1px solid #355246; border-radius:6px; background:rgba(13, 25, 19, .9); color:#cbdbd0; font:12px/1.4 var(--atlas-ui-font, system-ui, sans-serif); pointer-events:none; }
.atlas-hierarchy-key { position:absolute; z-index:20; left:12px; bottom:12px; max-width:min(34rem, calc(100% - 24px)); margin:0; padding:.35rem .6rem; border:1px solid #355246; border-radius:6px; background:rgb(13, 25, 19); color:#cbdbd0; font:12px/1.4 var(--atlas-ui-font, system-ui, sans-serif); pointer-events:none; }
/* Zurueck, Vor und die letzten Wurzeln (Handtest K2): klein, damit die Leiste einzeilig bleibt (K3). */
.atlas-graph-history { display:flex; align-items:center; gap:.25rem; flex:none; }
.atlas-graph-history > button { min-width:1.9rem; padding:.25rem .45rem; }
.atlas-graph-recent { position:relative; }
.atlas-graph-recent > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; }
.atlas-graph-recent-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(24rem, calc(100vw - 3rem)); max-height:18rem; overflow:auto; margin:0; padding:.45rem; list-style:none; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-graph-recent-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(24rem, calc(100vw - 3rem)); max-height:18rem; overflow:auto; margin:0; padding:.45rem; list-style:none; border:1px solid #355246; border-radius:8px; background:rgb(13,25,19); box-shadow:0 8px 24px #0005; }
.atlas-graph-recent-menu button { width:100%; display:flex; flex-direction:column; align-items:flex-start; gap:.1rem; text-align:left; border:0; background:transparent; }
.atlas-graph-recent-menu button span { max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.72rem; color:#95a89e; }
.atlas-graph-recent-menu button[aria-current=true] { opacity:.6; }
+99
View File
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
/*
* Review zu K29: seit G1 bildet die Huelle des Canvas ihren eigenen
* Stapelkontext, damit die Namen der Szene unter den Bedienflaechen bleiben.
* Dieselbe Regel legte die Hover-Karte eines Knotens unter jede Flaeche: neben
* "Selection details" war sie abgeschnitten. Eine Hover-Karte ist fluechtig und
* muss lesbar sein. Hier steht, dass sie in einer eigenen Ebene ausserhalb der
* Huelle steht, ueber den Flaechen der Galaxie und unter ihren Menues und
* Tooltips.
*/
import { act, useState } from 'react';
import type { ReactNode } from 'react';
import { readFileSync } from 'node:fs';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const html = vi.hoisted(() => ({ props: [] as Record<string, unknown>[] }));
vi.mock('@react-three/drei', async importOriginal => ({
...await importOriginal<typeof import('@react-three/drei')>(),
Html: (props: Record<string, unknown> & { children?: ReactNode }) => { html.props.push(props); return <div data-testid="html">{props.children}</div>; },
}));
const { GraphScene } = await import('./GraphScene');
const { HoverLayerContext, HOVER_LAYER_Z_INDEX } = await import('./hover-layer');
const { NodeTooltipCard } = await import('./NodeTooltipCard');
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
(globalThis as unknown as Record<string, unknown>).ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} };
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
html.props = [];
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
const node = { id: 1, x: 0, y: 0, z: 0, name: 'n1', label: 'Function', size: 3, color: '#abcdef', file_path: 'a.py' };
/** The z-index of the last rule whose selector list names `selector` exactly, across the given sheets. */
function zIndex(selector: string, ...files: string[]): number {
let found: number | undefined;
for (const file of files) {
const css = readFileSync(new URL(file, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
for (const match of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
if (!match[1]!.split(',').map(item => item.trim()).includes(selector)) continue;
const value = /z-index:\s*(\d+)/.exec(match[2]!)?.[1];
if (value !== undefined) found = Number(value);
}
}
expect(found, selector).toBeDefined();
return found!;
}
const SHEETS = ['./graph-exploration.css', '../styles/terminal.css', '../why/selection-context.css'];
describe('K29: the hover card stands above the Galaxy panels', () => {
it('GraphScene keeps a hover layer beside the isolated canvas wrapper, not inside it', () => {
act(() => root.render(<GraphScene active={false} data={{ nodes: [node], edges: [], total_nodes: 1 }} highlightedIds={null} cameraTarget={null}
showLabels onNodeClick={() => {}} renderTooltip={() => null} />));
const wrapper = host.querySelector('canvas')!.parentElement!.parentElement!;
const layer = host.querySelector<HTMLElement>('[data-testid="atlas-galaxy-hover-layer"]');
expect(layer).not.toBeNull();
expect(layer!.parentElement).toBe(host);
expect(wrapper.contains(layer)).toBe(false);
expect(layer!.style.position).toBe('absolute');
expect(layer!.style.pointerEvents).toBe('none');
expect(Number(layer!.style.zIndex)).toBe(HOVER_LAYER_Z_INDEX);
});
it('lies over every Galaxy panel and under the toolbar menus and tooltips', () => {
const panels = {
'Selection details': zIndex('.galaxy-selection-evidence', ...SHEETS),
'path panel': zIndex('.atlas-galaxy-path-panel', ...SHEETS),
'hierarchy key': zIndex('.atlas-hierarchy-key', ...SHEETS),
'fit view': zIndex('.atlas-galaxy-fit', ...SHEETS),
'agents instrument': zIndex('.atlas-agents', ...SHEETS),
'agents strip': zIndex('.atlas-galaxy-bottom', ...SHEETS),
};
const above = {
'Path to menu': zIndex('.atlas-graph-path-menu', ...SHEETS),
'Edge types menu': zIndex('.atlas-trace-edge-menu', ...SHEETS),
'more menu': zIndex('.atlas-graph-limits-menu', ...SHEETS),
'Recent menu': zIndex('.atlas-graph-recent-menu', ...SHEETS),
'search results': zIndex('.atlas-galaxy-search-results', ...SHEETS),
tooltip: zIndex('.atlas-hint', ...SHEETS),
};
for (const [name, value] of Object.entries(panels)) expect(HOVER_LAYER_Z_INDEX, name).toBeGreaterThan(value);
for (const [name, value] of Object.entries(above)) expect(HOVER_LAYER_Z_INDEX, name).toBeLessThan(value);
});
it('the node card draws into that layer', () => {
function Probe() {
const [layer, setLayer] = useState<HTMLDivElement | null>(null);
return <><div ref={setLayer} /><HoverLayerContext.Provider value={layer}><NodeTooltipCard node={node} /></HoverLayerContext.Provider></>;
}
act(() => root.render(<Probe />));
const props = html.props.at(-1)!;
expect((props.portal as { current: HTMLElement }).current).toBe(host.firstElementChild);
expect(props.center).toBe(true);
});
});
+47
View File
@@ -0,0 +1,47 @@
/*
* Die Ebene der Hover-Karten (Review zu K29).
*
* Seit G1 bildet die Huelle des Canvas ihren eigenen Stapelkontext: jedes
* <Html> der Szene bleibt darin und damit unter den Bedienflaechen der
* Galaxie. Fuer Namen ist das richtig, fuer eine Hover-Karte nicht: sie
* erscheint nur, solange der Zeiger auf einem Knoten steht, und ist dann das,
* was der Leser lesen will. Neben "Selection details" war sie abgeschnitten.
*
* Darum steht neben der Huelle eine eigene Ebene, so gross wie die Szene, und
* die Karten haengen sich dort hinein (drei `portal`). drei rechnet ihre Lage
* weiter mit der Kamera der Szene; die Ebene liegt deckungsgleich ueber dem
* Canvas, also steht die Karte am selben Punkt wie vorher. Ihr z-index liegt
* ueber jeder Flaeche der Galaxie (Selection details 4, Hierarchie-Notiz 20,
* Pfadliste 25) und unter den Menues der Werkzeugleiste (30, die Suche 35) und
* den Tooltips (40): ein offenes Menue bleibt lesbar, auch wenn eine Karte
* darunter steht.
*/
import { createContext, useContext, useMemo } from 'react';
import type { CSSProperties, JSX, ReactNode, RefObject } from 'react';
import { Html } from '@react-three/drei';
export const HOVER_LAYER_Z_INDEX = 28;
export const HOVER_LAYER_STYLE: CSSProperties = {
position: 'absolute', inset: 0, zIndex: HOVER_LAYER_Z_INDEX, pointerEvents: 'none', overflow: 'hidden',
};
/*
* Die Ebene, in die eine Hover-Karte zeichnet; ohne sie bleibt die Karte, wie
* drei es tut, in der Huelle. Das Element selbst und kein Ref: drei liest sein
* Ziel beim Rendern, und erst ein neuer Wert sagt der Karte, dass die Ebene
* jetzt steht.
*/
export const HoverLayerContext = createContext<HTMLElement | null>(null);
/** Eine Karte an einem Punkt der Szene, in der Ebene der Hover-Karten. */
export function HoverCardHtml({ position, children }: { position: [number, number, number]; children: ReactNode }): JSX.Element {
const layer = useContext(HoverLayerContext);
const portal = useMemo(() => (layer ? { current: layer } : undefined), [layer]);
return (
<Html position={position} center style={{ pointerEvents: 'none' }}
{...(portal ? { portal: portal as RefObject<HTMLElement>, zIndexRange: [1, 0] } : {})}>
{children}
</Html>
);
}
@@ -0,0 +1,77 @@
/*
* Handtest 2026-10-04, Review zu K29: die Namen der Szene liegen seit G1 unter
* den Bedienflaechen der Galaxie, aber die Flaechen waren leicht durchsichtig
* (.9 bis .98, die Schaltflaeche "fit view" 82 %). Der Name des Pfadziels und
* sein Ring schienen darum durch "Selection details" hindurch. Hier steht, dass
* jede Flaeche, die ueber der Szene liegt, deckend gemalt wird.
*/
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
const SHEETS = [read('./graph-exploration.css'), read('../styles/terminal.css'), read('../styles/workspace.css')];
/** The background of the last rule, across the sheets, whose selector list names `selector` exactly. */
function background(selector: string): string | undefined {
let found: string | undefined;
for (const css of SHEETS) {
for (const match of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
if (!match[1]!.split(',').map(item => item.trim().replace(/\s+/g, ' ')).includes(selector)) continue;
for (const part of match[2]!.split(';')) {
const [name, ...value] = part.split(':');
if (name && ['background', 'background-color'].includes(name.trim())) found = value.join(':').trim();
}
}
}
return found;
}
/** True when the colour hides everything behind it: no alpha below one. Tokens of this project are plain hex colours. */
function opaque(value: string): boolean {
if (/^var\(--atlas-(bg|panel|panel-raised)\)$/.test(value)) return true;
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)) return true;
const rgb = /^rgba?\(([^)]*)\)$/.exec(value);
if (!rgb) return false;
const parts = rgb[1]!.split(/[\s,/]+/).filter(Boolean);
if (parts.length === 3) return true;
const alpha = parts[3]!;
return alpha.endsWith('%') ? Number.parseFloat(alpha) >= 100 : Number.parseFloat(alpha) >= 1;
}
const OVERLAYS = {
'Selection details': '.galaxy-selection-evidence.atlas-galaxy-selection-details',
'path panel': '.atlas-galaxy-path-panel',
'hierarchy key': '.atlas-hierarchy-key',
'fit view': '.atlas-galaxy-fit',
'render progress': '.atlas-graph-render-progress',
'search results': '.atlas-galaxy-search-results',
'Path to menu': '.atlas-graph-path-menu',
'Edge types menu': '.atlas-trace-edge-menu',
'more and Limits menu': '.atlas-graph-limits-menu',
'Recent menu': '.atlas-graph-recent-menu',
'node picker': '.atlas-galaxy-node-picker',
'agents instrument': '.atlas-agents',
'agents follow line': '.atlas-agents-followline',
'agents timeline': '.atlas-agents-timeline',
'placeholder': '.atlas-galaxy-placeholder',
'hover card': '.atlas-galaxy-card',
'coverage hover card': '.atlas-coverage-tooltip',
};
describe('K29: nothing of the scene shows through a Galaxy overlay', () => {
for (const [name, selector] of Object.entries(OVERLAYS)) {
it(`${name} is painted opaque`, () => {
const value = background(selector);
expect(value, `${selector} has no background`).toBeDefined();
expect(opaque(value!), `${selector}: ${value}`).toBe(true);
});
}
it('reads alpha in every notation the sheets use', () => {
expect(opaque('rgba(13, 25, 19, .95)')).toBe(false);
expect(opaque('rgb(10 14 13 / 82%)')).toBe(false);
expect(opaque('rgb(13, 25, 19)')).toBe(true);
expect(opaque('#0d1913')).toBe(true);
expect(opaque('#0d191380')).toBe(false);
});
});
@@ -0,0 +1,48 @@
/*
* Review zu K30: "ueberlappen nie" galt fuer die Kantenlabels des Pfades nicht.
* Fand ein Label keinen freien Platz, nahm placeAlongSegment den mit der
* kleinsten Ueberdeckung, und PathLayer zeichnete es dort, auch mitten auf der
* Ueberschrift des Bandes. Die Kantenschilder der Hierarchie fallen in diesem
* Fall weg; die Labels des Pfades jetzt auch. Die Art jeder Kante steht
* weiter in der Schrittliste.
*/
import { expect, it } from 'vitest';
import { pathLabelSpots } from './PathLayer';
import type { ScreenRect } from './path-frame';
const overlaps = (a: ScreenRect, b: ScreenRect) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
// Measured in the browser (CSS px): JSONBAgg <-DEFINES- general.py -DEFINES-> __init__ at two layers of the hierarchy.
const JSONBAGG = { x: 1050, y: 368 }, GENERAL = { x: 808, y: 572 }, INIT = { x: 1160, y: 757 };
const NAMES: ScreenRect[] = [{ left: 1018, right: 1082, top: 336, bottom: 353 }, { left: 778, right: 838, top: 540, bottom: 557 }, { left: 1135, right: 1185, top: 725, bottom: 742 }];
const LABEL = { width: 50, height: 14 };
const edges = [{ index: 0, from: GENERAL, to: JSONBAGG, size: LABEL }, { index: 1, from: GENERAL, to: INIT, size: LABEL }];
it('K30: a path label that finds no free spot on the band title is left out, not drawn on the title', () => {
// A band title that covers the edge from general.py to __init__ from end to end (the title of the browser run, made larger).
const title = { left: 512, right: 1590, top: 503, bottom: 835 };
const spots = pathLabelSpots(edges, [...NAMES, title]);
expect(spots.get(1)).toBeUndefined();
// The other edge runs above the title and keeps its label, clear of every name and of the title.
const kept = spots.get(0);
expect(kept).toBeDefined();
for (const blocker of [...NAMES, title]) expect(overlaps(kept!.rect, blocker)).toBe(false);
});
it('K30: with the title at its real size both labels stand, and none touches a name, the title or the other label', () => {
const title = { left: 834, right: 1268, top: 652, bottom: 687 };
const spots = pathLabelSpots(edges, [...NAMES, title]);
const rects = [spots.get(0)?.rect, spots.get(1)?.rect];
expect(rects.every(Boolean)).toBe(true);
for (const rect of rects) for (const blocker of [...NAMES, title]) expect(overlaps(rect!, blocker)).toBe(false);
expect(overlaps(rects[0]!, rects[1]!)).toBe(false);
});
it('K30: a later label also keeps clear of an earlier one, and gives way when only its place is left', () => {
// Two labels on the same short edge: the second has nowhere to go but onto the first.
const short = [{ index: 0, from: { x: 100, y: 100 }, to: { x: 130, y: 100 }, size: LABEL }, { index: 1, from: { x: 100, y: 100 }, to: { x: 130, y: 100 }, size: LABEL }];
const walls = [{ left: 0, right: 400, top: 0, bottom: 88 }, { left: 0, right: 400, top: 112, bottom: 300 }, { left: 0, right: 56, top: 0, bottom: 300 }, { left: 174, right: 400, top: 0, bottom: 300 }];
const spots = pathLabelSpots(short, walls);
expect(spots.get(0)).toBeDefined();
expect(spots.get(1)).toBeUndefined();
});
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
/*
* Review of K42 ("other Refresh buttons the same way"): Refresh in the file
* impact details of Explore collapsed the details into "Reading
* dependencies…" for about two seconds and then showed the same numbers
* (measured in the browser), with nothing left to say that it had run. The
* details now stay on screen, the button says that it runs, and a status
* beside it says when it ran and whether anything changed.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import FileImpactSummary from './FileImpactSummary';
import type { fetchSelectionImpact, SelectionImpact, SelectionImpactReply } from './selection-impact';
function impact(direct: number, computed_at: number): SelectionImpact {
const node = (id: number, file: string) => ({ id, name: `symbol${id}`, qualified_name: `fixture.symbol${id}`, file_path: file, line: id });
return {
status: 'ready', project: 'fixture', file_path: 'core.ts', scope: 'file', computed_at, cache_seconds: 30,
snapshot: { indexed_at: '2026-09-19T10:00:00Z', generation: 'generation-one', index_revision: 'abc', freshness: 'Current snapshot', coverage_recording: 'complete', coverage: [] },
structural: { available: true, basis: 'CALLS and IMPORTS', max_depth: 4, visit_cap: 600, result_cap: 60, seed_count: 1, reachable: direct, direct, test_candidates: 0, truncated: false,
findings: Array.from({ length: direct }, (_, at) => ({ ...node(at + 2, `src/caller${at}.ts`), distance: 1, test_candidate: false,
path: [{ edge_id: at + 2, type: 'CALLS', from: node(at + 2, `src/caller${at}.ts`), to: node(1, 'core.ts') }] })) },
history: { available: true, head: 'a'.repeat(40), shallow: false, worktree_status_known: true, selection_uncommitted: false, truncated: false, commit_cap: 200, window_days: 548,
mass_change_threshold: 30, commits_scanned: 20, commits_considered: 15, selection_commits: 3, merges_excluded: 0, mass_changes_excluded: 0, read_errors: 0, cochanges_omitted: 0,
commits: [], cochanges: [] },
};
}
let container: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((yes) => { resolve = yes; });
return { promise, resolve };
}
const refresh = () => container.querySelector<HTMLButtonElement>('.file-impact-summary-details footer .atlas-refresh button');
const status = () => container.querySelector('.file-impact-summary-details footer .atlas-refresh-status')?.textContent;
const TIME = '\\d\\d:\\d\\d:\\d\\d';
it('K42: Refresh in the file impact keeps the details, says that it runs, then when it ran and whether anything changed', async () => {
let next = deferred<SelectionImpactReply>();
const load = vi.fn<typeof fetchSelectionImpact>().mockResolvedValueOnce(impact(2, 1)).mockImplementation(() => next.promise);
await act(async () => root.render(<FileImpactSummary project="fixture" target={{ filePath: 'core.ts' }} load={load} onOpen={vi.fn()} />));
await act(async () => container.querySelector<HTMLButtonElement>('.file-impact-summary-toggle')!.click());
expect(refresh()?.textContent).toBe('Refresh');
expect(status()).toBe('');
await act(async () => refresh()!.click());
// While it runs the details stay, and the button says so.
expect(container.textContent).toContain('2 affected files');
expect(refresh()?.textContent).toBe('Refreshing…');
expect(refresh()?.getAttribute('aria-disabled')).toBe('true');
// A new computation of the same evidence is no change, although its time stamp is another.
await act(async () => next.resolve(impact(2, 99)));
expect(refresh()?.textContent).toBe('Refresh');
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
next = deferred<SelectionImpactReply>();
await act(async () => refresh()!.click());
await act(async () => next.resolve(impact(3, 120)));
expect(container.textContent).toContain('3 affected files');
expect(status()).toMatch(new RegExp(`^Impact refreshed at ${TIME}$`));
});
it('K42: another file never shows the evidence of the previous one while it loads', async () => {
const next = deferred<SelectionImpactReply>();
const load = vi.fn<typeof fetchSelectionImpact>().mockResolvedValueOnce(impact(2, 1)).mockImplementation(() => next.promise);
await act(async () => root.render(<FileImpactSummary project="fixture" target={{ filePath: 'core.ts' }} load={load} onOpen={vi.fn()} />));
expect(container.textContent).toContain('2 affected files');
await act(async () => root.render(<FileImpactSummary project="fixture" target={{ filePath: 'other.ts' }} load={load} onOpen={vi.fn()} />));
expect(container.textContent).not.toContain('affected files');
expect(container.textContent).toContain('Reading dependencies…');
});
+25 -6
View File
@@ -2,6 +2,8 @@ import { useEffect, useId, useMemo, useState } from 'react';
import type { JSX } from 'react';
import { evidenceTarget, fetchSelectionImpact } from './selection-impact';
import type { SelectionImpact, SelectionImpactFinding, SelectionImpactTarget } from './selection-impact';
import { FILE_IMPACT_REFRESH } from './impact-strings';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import './file-impact-summary.css';
export interface FileImpactSummaryProps {
@@ -63,6 +65,8 @@ function countLabel(count: number, noun: string, incomplete = false): string {
export default function FileImpactSummary({ project, target, onOpen, expectedGeneration,
load = fetchSelectionImpact }: FileImpactSummaryProps): JSX.Element {
const [reading, setReading] = useState<Reading>();
// The last ready evidence and the selection it belongs to: a refresh of the same selection shows it until the new one is there.
const [lastReady, setLastReady] = useState<{ key: string; data: SelectionImpact }>();
const [retry, setRetry] = useState({ key: '', count: 0 });
const [expandedKey, setExpandedKey] = useState('');
const [limits, setLimits] = useState({ key: '', files: 6, history: 4 });
@@ -74,12 +78,21 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
// Bind every state (including errors) before effects run; a newly selected
// file or symbol must never inherit the previous selection's evidence.
const current = reading?.key === requestKey ? reading : undefined;
const data = current?.status === 'ready' ? current.data : undefined;
const waiting = !current || ['loading', 'pending', 'busy'].includes(current.status);
/*
* Review of K42: Refresh folded the details into "Reading dependencies…"
* and then showed the same numbers, with nothing left to say that it had
* run. A refresh of the same selection keeps the last evidence on screen,
* the button says that it runs, and the status beside it says when and
* whether anything changed. Another selection never shows it.
*/
const kept = waiting && lastReady?.key === selectionKey ? lastReady.data : undefined;
const data = current?.status === 'ready' ? current.data : kept;
const aggregate = useMemo(() => data ? aggregateFileImpact(data) : undefined, [data]);
const expanded = expandedKey === selectionKey;
const fileLimit = limits.key === selectionKey ? limits.files : 6;
const historyLimit = limits.key === selectionKey ? limits.history : 4;
const refresh = (): void => setRetry(value => ({ key: selectionKey,
const retryNow = (): void => setRetry(value => ({ key: selectionKey,
count: value.key === selectionKey ? value.count + 1 : 1 }));
useEffect(() => {
@@ -98,6 +111,7 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
if (reply.project !== project || reply.file_path !== file)
throw new Error('The response belongs to another selection.');
setReading({ key: requestKey, status: 'ready', data: reply });
setLastReady({ key: selectionKey, data: reply });
} else if (reply.status === 'failed') {
setReading({ key: requestKey, status: 'failed', error: reply.error });
} else if (attempts < 80) {
@@ -111,7 +125,12 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
};
void read();
return () => { controller.abort(); if (timer !== undefined) clearTimeout(timer); };
}, [project, file, qualifiedName, id, load, requestKey, retryCount]);
}, [project, file, qualifiedName, id, load, requestKey, retryCount, selectionKey]);
// What counts as a change: the evidence shown, not the time stamp of its computation.
const refresh = useRefreshFeedback({ key: requestKey, settled: !waiting, error: current?.status === 'failed' ? '' : undefined,
value: data && aggregate ? { generation: data.snapshot.generation, revision: data.snapshot.index_revision, direct: data.structural.direct,
files: aggregate.files.map(row => [row.filePath, row.distance, row.symbols, row.testCandidate]), cochanges: aggregate.cochanges.map(row => [row.file_path, row.shared_commits]),
commits: data.history.selection_commits } : undefined });
const notes: string[] = [];
if (data && aggregate) {
@@ -130,10 +149,10 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
<div className="file-impact-summary-row">
<span className="file-impact-summary-label" title={file}>{scope} impact</span>
{!file && <span className="file-impact-summary-muted">Select a file to see its dependents.</span>}
{file && (!current || ['loading', 'pending', 'busy'].includes(current.status))
{file && waiting && !kept
&& <span role="status" className="file-impact-summary-muted">{current?.status === 'busy' ? 'Waiting for local analysis…' : 'Reading dependencies…'}</span>}
{current?.status === 'failed' && <><span role="alert" className="file-impact-summary-warning"
title={current.error}>Impact unavailable</span><button type="button" onClick={refresh}>Retry</button></>}
title={current.error}>Impact unavailable</span><button type="button" onClick={retryNow}>Retry</button></>}
{data && aggregate && <>
<div className="file-impact-summary-metrics">
{data.structural.available ? <>
@@ -174,7 +193,7 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
</section>
</div>
<footer><span>Indexed calls/imports · up to {data.structural.max_depth} hops. Test files are candidates; Git co-changes are associations.</span>
<button type="button" onClick={refresh}>Refresh</button></footer>
<RefreshControl labels={FILE_IMPACT_REFRESH} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); retryNow(); }} /></footer>
</div>}
</section>;
}
+5
View File
@@ -282,3 +282,8 @@ export function impactUntestedRule(count: number): string {
export function impactSymbolRule(name: string, level: 'low' | 'medium' | 'high', reasons: string[]): string {
return `${name} is ${RISK_LABELS[level]}: ${reasons.join('; ')}.`;
}
// Review zu K42: der Knopf Refresh der Dateiwirkung in Explore sagt, dass er laeuft, dann wann er lief ---
// (die gemeinsamen Saetze "Up to date at …" und "Refresh failed at …" stehen in ui/refresh/strings.ts).
export const FILE_IMPACT_REFRESH = { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Impact refreshed at ${time}` };
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
/*
* Review of K42 ("other Refresh buttons the same way"): "Refresh projects"
* in the project picker read the list again, showed "Loading projects..."
* for about 150 ms and then the same list (measured in the browser). It now
* says that it runs and then when it ran and whether the list changed.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import ProjectSwitcher from './ProjectSwitcher';
import type { ProjectEntry } from '../provider/rpc-schemas';
let container: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
const refresh = () => container.querySelector<HTMLButtonElement>('.atlas-project-picker footer .atlas-refresh button')!;
const status = () => container.querySelector('.atlas-project-picker footer .atlas-refresh-status')?.textContent;
const TIME = '\\d\\d:\\d\\d:\\d\\d';
const alpha: ProjectEntry = { name: 'alpha', root_path: '/repos/alpha' };
it('K42: Refresh projects says that it runs, then when it ran and whether the list changed, or that it failed', async () => {
let next = deferred<readonly ProjectEntry[]>();
const list = vi.fn().mockResolvedValueOnce([alpha]).mockImplementation(() => next.promise);
await act(async () => root.render(<ProjectSwitcher currentProject="alpha" listProjects={list} onSelectProject={vi.fn()} onAddProject={vi.fn()} />));
await act(async () => container.querySelector('summary')!.click());
expect(refresh().textContent).toBe('Refresh projects');
expect(status()).toBe('');
await act(async () => refresh().click());
expect(refresh().textContent).toBe('Refreshing projects…');
expect(refresh().getAttribute('aria-disabled')).toBe('true');
await act(async () => next.resolve([alpha]));
expect(refresh().textContent).toBe('Refresh projects');
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
next = deferred<readonly ProjectEntry[]>();
await act(async () => refresh().click());
await act(async () => next.resolve([alpha, { name: 'beta', root_path: '/repos/beta' }]));
expect(status()).toMatch(new RegExp(`^Projects refreshed at ${TIME}$`));
expect(container.querySelectorAll('.atlas-project-results li')).toHaveLength(2);
next = deferred<readonly ProjectEntry[]>();
await act(async () => refresh().click());
await act(async () => next.reject(new Error('offline')));
// The list area says what failed and offers Try again; the status beside the button says when.
expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`));
expect(container.querySelector('.atlas-project-results')?.textContent).toContain('Could not load projects.');
// Reopening the picker reads the list again by itself: that is no press of Refresh, and the status goes.
next = deferred<readonly ProjectEntry[]>();
await act(async () => container.querySelector('summary')!.click());
await act(async () => container.querySelector('summary')!.click());
await act(async () => next.resolve([alpha]));
expect(status()).toBe('');
});
+14 -1
View File
@@ -3,6 +3,7 @@ import type { JSX } from 'react';
import type { ProjectEntry } from '../provider/rpc-schemas';
import { projectSwitcherStrings as text } from './project-switcher-strings';
import { projectPickerPlacement } from './project-picker-placement';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import './project-switcher.css';
interface ProjectSwitcherProps {
@@ -18,6 +19,8 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
const [entries, setEntries] = useState<readonly ProjectEntry[]>([]);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [revision, setRevision] = useState(0);
// Every reading of the list counts, also the one an opening starts; the Refresh status speaks about one of them (review of K42).
const [reads, setReads] = useState(0);
const [placement, setPlacement] = useState({ top: 64, left: 12, width: 370, maxHeight: 520 });
const disclosure = useRef<HTMLDetailsElement>(null);
const trigger = useRef<HTMLElement>(null);
@@ -49,6 +52,7 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
if (!open) return;
let current = true;
setStatus('loading');
setReads(value => value + 1);
void Promise.resolve().then(props.listProjects).then(
(projects) => {
if (!current) return;
@@ -69,6 +73,15 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
return () => document.removeEventListener('pointerdown', outside);
}, [open]);
/*
* Review of K42: "Refresh projects" showed "Loading projects..." for about
* 150 ms and then the same list. Now it says that it runs, then when and
* whether the list changed; a failure only gets its time, the list area
* says what failed and offers Try again.
*/
const refresh = useRefreshFeedback({ key: String(reads), settled: status !== 'loading',
value: entries.map(entry => [entry.name, entry.root_path]), error: status === 'error' ? '' : undefined });
const projects = Array.from(new Map(entries.map(entry => [entry.name, entry])).values())
.filter(entry => entry.name.length > 0)
.sort((left, right) => Number(right.name === props.currentProject) - Number(left.name === props.currentProject)
@@ -120,7 +133,7 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen
</button>}
<footer>
<button type="button" onClick={() => { close(true); props.onAddProject(); }}>{text.add}</button>
<button type="button" disabled={status === 'loading'} onClick={() => setRevision(value => value + 1)}>{text.refresh}</button>
<RefreshControl labels={text.refreshFeedback} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); setRevision(value => value + 1); }} />
</footer>
</div>}
</details>;
@@ -10,7 +10,8 @@ export const projectSwitcherStrings = {
noMatches: 'No matching projects.',
current: 'Current',
retry: 'Try again',
refresh: 'Refresh projects',
/** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */
refreshFeedback: { idle: 'Refresh projects', busy: 'Refreshing projects…', done: (time: string) => `Projects refreshed at ${time}` },
add: 'Add project index',
indexActivity: (status: 'indexing' | 'done' | 'error') => status === 'indexing' ? 'Indexing…' : status === 'done' ? 'Index ready' : 'Index needs attention',
};
+6 -1
View File
@@ -18,8 +18,13 @@
.atlas-project-result-name { display: flex; justify-content: space-between; gap: 8px; overflow-wrap: anywhere; }
.atlas-project-result-name small { flex-shrink: 0; align-self: flex-start; color: var(--atlas-phosphor); font-size: 10px; }
.atlas-project-result-path { display: block; margin-top: 5px; color: var(--atlas-dim); font: 10px var(--atlas-mono); overflow-wrap: anywhere; }
.atlas-project-picker footer { display: flex; justify-content: space-between; flex-shrink: 0; gap: 8px; padding-top: 10px; border-top: 1px solid var(--atlas-line); }
.atlas-project-picker footer { display: flex; flex-wrap: wrap; justify-content: space-between; flex-shrink: 0; gap: 8px; padding-top: 10px; border-top: 1px solid var(--atlas-line); }
.atlas-project-picker footer button { padding: 6px 8px; color: var(--atlas-cyan); background: transparent; border: 1px solid var(--atlas-line); }
/* Review of K42: what Refresh projects found stands on a line of its own under the buttons; beside them it ran past the edge of the picker. */
.atlas-project-picker footer .atlas-refresh { display: contents; }
.atlas-project-picker footer .atlas-refresh-status { flex-basis: 100%; margin: 0; text-align: right; }
/* Empty, it leaves the row but not the tree: the live region has to be there before it speaks. */
.atlas-project-picker footer .atlas-refresh-status:empty { position: absolute; }
.atlas-project-picker button:disabled { opacity: .5; cursor: default; }
.atlas-project-index-activity { display: flex; justify-content: space-between; gap: 10px; padding: 10px; color: var(--atlas-fg); background: var(--atlas-select); border: 1px solid var(--atlas-line); text-align: left; overflow-wrap: anywhere; }
.atlas-project-index-activity span:first-child { color: var(--atlas-cyan); }
+9 -8
View File
@@ -2369,8 +2369,8 @@ button.atlas-galaxy-legend-swatch:hover {
/*
* Der Weg zurueck zur eingepassten Ansicht (W10b, AC5).
*
* Oben rechts in der Szene, halbtransparent, in derselben Schrift wie alles
* hier. Er liegt bewusst IM Bild und nicht im Kopf: die Kopfzeile ist bei 440
* Oben rechts in der Szene, deckend (Review zu K29: durch 82 % schien ein
* Name der Szene hindurch), in derselben Schrift wie alles hier. Er liegt bewusst IM Bild und nicht im Kopf: die Kopfzeile ist bei 440
* Pixeln Breite voll (siehe .atlas-galaxy-head-row), und ein Wort mehr darin
* haette den Zuklapper abgeschnitten. Die andere Ecke gehoert dem Instrument
* der Agentenebene, also stehen sich die beiden nie im Weg.
@@ -2382,7 +2382,7 @@ button.atlas-galaxy-legend-swatch:hover {
z-index: 3;
padding: 1px 6px;
border: 1px solid var(--atlas-line);
background: rgb(10 14 13 / 82%);
background: rgb(10 14 13);
color: var(--atlas-dim);
font: inherit;
font-size: 11px;
@@ -5881,8 +5881,9 @@ button.atlas-settings-model-pick:focus-visible {
/*
* Das Instrument unten rechts.
*
* Halbtransparent ueber dem Graphen, in derselben Schrift und mit denselben
* duennen Raendern wie der Rest der Oberflaeche. Kein Glassmorphism: keine
* Deckend ueber dem Graphen (Review zu K29: durch eine halbtransparente
* Flaeche schienen die Namen der Szene hindurch), in derselben Schrift und mit
* denselben duennen Raendern wie der Rest der Oberflaeche. Kein Glassmorphism: keine
* Weichzeichnung, kein Verlauf, kein runder Kasten. Was hier "fancy" ist, kommt
* aus dem Licht der Koerper daneben und nicht aus der Flaeche.
*/
@@ -5897,7 +5898,7 @@ button.atlas-settings-model-pick:focus-visible {
max-width: calc(100% - 16px);
max-height: calc(100% - 16px);
border: 1px solid var(--atlas-line);
background: rgb(10 14 13 / 88%);
background: rgb(10 14 13);
color: var(--atlas-fg);
font-family: var(--atlas-font);
font-size: 11px;
@@ -6342,7 +6343,7 @@ button.atlas-settings-model-pick:focus-visible {
margin: 0 0 2px;
padding: 2px 8px;
border: 1px solid var(--atlas-line);
background: rgb(10 14 13 / 88%);
background: rgb(10 14 13);
color: var(--atlas-agent-color, var(--atlas-fg));
font-size: 11px;
white-space: nowrap;
@@ -6366,7 +6367,7 @@ button.atlas-settings-model-pick:focus-visible {
gap: 2px;
padding: 3px 8px 4px;
border-top: 1px solid var(--atlas-line);
background: rgb(10 14 13 / 92%);
background: rgb(10 14 13);
font-family: var(--atlas-font);
font-size: 10px;
}
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useRef, useState, type JSX } from 'react';
import { architectureText } from './strings';
const text = architectureText.refreshFeedback;
import { refreshText as text } from './strings';
import './refresh.css';
/**
* What a Refresh button of Architecture says it did (hand test 2026-10-04, A3:
* "Refresh connections" fetched everything again and nothing on screen
* changed). While the reading runs the button reads "Refreshing …"; then a
* status beside it names the time and whether anything changed, or the error.
* What a Refresh button says it did (hand test 2026-10-04, A3: "Refresh
* connections" fetched everything again and nothing on screen changed). While
* the reading runs the button reads "Refreshing …"; then a status beside it
* names the time and whether anything changed, or the error. Made for the
* four Refresh buttons of Architecture and shared since the review of K42 with
* those of ADR, the project picker and the file impact in Explore.
*/
export type RefreshFeedback =
| { phase: 'running' }
@@ -74,9 +75,9 @@ export function RefreshControl({ labels, feedback, onRefresh, className }: {
feedback?: RefreshFeedback; onRefresh: () => void; className?: string;
}): JSX.Element {
const running = feedback?.phase === 'running';
return <span className="atlas-arch-refresh" data-phase={feedback?.phase}>
return <span className="atlas-refresh" data-phase={feedback?.phase}>
<button type="button" className={className} aria-disabled={running || undefined} aria-busy={running || undefined}
onClick={() => { if (!running) onRefresh(); }}>{running ? labels.busy : labels.idle}</button>
<span className="atlas-arch-refresh-status" role="status" data-phase={feedback?.phase}>{refreshStatus(feedback, labels.done)}</span>
<span className="atlas-refresh-status" role="status" data-phase={feedback?.phase}>{refreshStatus(feedback, labels.done)}</span>
</span>;
}
+6
View File
@@ -0,0 +1,6 @@
/* Hand test 2026-10-04 (A3, K42): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */
.atlas-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; }
.atlas-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; }
.atlas-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; }
.atlas-refresh-status:not(:empty) { margin-left: 8px; }
.atlas-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); }
+6
View File
@@ -0,0 +1,6 @@
/** What every Refresh button of the app says after it ran (hand test 2026-10-04, A3, and its review, K42). */
export const refreshText = {
unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`,
/** Without a reason when the view already shows the error in its own words beside the button. */
failed: (time: string, error: string) => (error ? `Refresh failed at ${time}: ${error}` : `Refresh failed at ${time}`),
};