feat(ui): the Steps tab lays a screen out in clusters and says a far link in words; a <Card/> is the Card its file imports (#1817)
* fix(ui): a screen's steps read as clusters, and a link too far to follow is said in words The mobile app's /capture came back as a web: 100 boxes in a 1,227x5,588 ribbon, 113 lines drawn at rest crossing each other 652 times, each one running over about five other boxes' names. Measured, not guessed — three separate causes, very unequal. The region grouping had nothing to divide there (98 of 100 boxes take their region from one memoized component), so the picture fell back to a single 719px column. But the region dimension was not the lever. The lever was that `packRegions` packed every step of one distance onto shared rows and wrapped those rows at a fixed 720px, so a box and the thing it fires landed seven lines apart: 70 of the 113 lines joined boxes ONE step apart. That is what the crossings were made of. So a region is now packed as CLUSTERS — a step, then the steps it sets in motion on the line under it, stepped in — while the starting points that fire nothing still share a line, because a screen's handlers are siblings and giving each its own line turned a flat region into a column. A region's line width is earned rather than fixed (sqrt(total * pitch), clamped 720..2600), so a big screen comes out about as wide as it is tall. Clustering makes most links local but not all: a step reached from two places is drawn under whichever reached it first, so the other way in still crosses the picture. Those are now said in WORDS at both ends — `-> resumeInference` under the box that leads there, `<- CaptureView` under the box it arrives at, capped at three with `+N more` — rather than drawn. This is not a hiding: the link is stated, which says more than a line vanishing off the edge of the screen does, and selecting the box draws every one of its real lines exactly as before. It is the one at-rest cut that does not produce the "box that leads somewhere and draws nothing" every earlier cut produced. Also fixed while here, and predicted by the earlier region work: the in-region row relaxation had no cycle guard, so a region holding one loop pushed 65 of its boxes to rows 294-301 while the rest sat at 0-2. `forwardLinks` sets cycle-closing links aside first, as the order reading's `withoutBackEdges` already did. /capture: 1,227x5,588 -> 2,279x4,356, at-rest crossings 652 -> 1, lines-over-boxes 553 -> 26, with half the links still drawn as real lines and every quiet box still one the screen itself fires directly. The order reading is untouched (it keeps every line). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): a stub names its box without the mark the box wears for its kind `← ⇠ onCaptureProgress +2` reads as two arrows arguing: the stub already leads with a direction, and the box's own kind mark was competing with it. Verified in the live canvas. The box keeps its mark, where nothing competes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): the screen's line into each of its parts stops sweeping the picture Audited all 51 screens of the mobile app on this branch. 96 lines — the screen's own stand-in line into each region — were 17% of everything drawn and caused 79% of every crossing left. A screen with ten regions tiles them into bands, so the line into a region two bands down travelled the height of the whole picture. Two causes, both fixed. The entry the line lands on was the walk's first member of the region; clustering moves a step that fires something BELOW the ones that fire nothing, so that box could sit lines down inside the region and the line had to reach past everything above it. It now lands on the box nearest the region's top-left that the screen actually leads to. And the stand-in line is no longer exempt from the stub rule — when the region is still too far to follow, the link is said in words like any other. The rest of the anchor's fan stays quiet as before: it is already stood in for. Across the 51 screens: crossings 47 -> 10, no screen above 10 (worst was 19, now 2); lines-over-boxes 236 -> 182; boxes with neither a line nor a word 150 -> 135. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): a screen's parts fill the canvas instead of squaring off into rows Audited the app's 28 regioned screens: the median canvas was 55% region and 45% nothing, and /home was 44% — 4,860px tall to hold about 2,160px of picture. The cause is that regions were tiled a row at a time with each row as tall as its tallest member, so one short region beside a tall one left the rest of that row blank, and a reader scrolls through the blank. Each region now goes as high as it can and then as far left as it can, over a skyline of what is already placed. Reading order is untouched: regions are still walked in the screen's own source order, so an earlier one is never pushed below a later one — a short one just tucks under another short one rather than waiting for the tall one beside it. Layering now comes from the finished geometry rather than a band counter, since once regions drop independently what a reader sees as one row IS one row. /home 4,860px -> 3,584px, aspect 0.59 -> 0.94. Tallest screen in the app 4,860 -> 4,356. Crossings 10 -> 13 across all 51 screens, still none above 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): the width a picture wraps at is tried, not estimated A region's line width came from sqrt(total * pitch) — the width at which total/width lines come out square. That estimate is wrong for how these pictures are drawn: a cluster spends lines on its own structure (a hub gets a line to itself, and what it fires starts another), so it undercounts a region's lines badly and wrapped /capture's 98 boxes into a 4,356px column. Laying a picture out is cheap and exact, so the widths are tried instead: layoutAt runs the whole pack at each of eight widths and the best finished canvas wins (~2ms for the model, all eight included). It has to be scored on the CANVAS, not per region — squaring each region off individually leaves fewer of them side by side, which took /home from 3,584px to 5,624px while every region looked better on its own. Also measured and rejected while here: dropping a region's CLUSTERS side by side the way the regions drop onto the canvas. Total height 42,084 -> 39,756px (-6%), but lines-over-boxes 120 -> 134 and crossings 5 -> 8, because two clusters side by side put each one's lines through the other. Height is cheap to scroll; a crossed line is what made this picture unreadable. The reasoning is recorded in the code so it is not re-tried blindly. Regions differ — they sit far enough apart that few lines run between them. Across the app's 51 screens: tallest picture 4,356 -> 3,796px, total height 45,744 -> 42,084px, lines-over-boxes 184 -> 120, crossings 13 -> 5, and no screen is a tall ribbon any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * stuff --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a23648db9
commit
3ed73bc127
@@ -21,7 +21,7 @@
|
||||
* need receiver-type matching, deferred to Phase 3). All synthesized edges are
|
||||
* tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
|
||||
*/
|
||||
import type { Edge, Node, NodeKind } from '../types';
|
||||
import type { Edge, Language, Node, NodeKind } from '../types';
|
||||
import type { QueryBuilder } from '../db/queries';
|
||||
import type { ResolutionContext } from './types';
|
||||
import { isGeneratedFile } from '../extraction/generated-detection';
|
||||
@@ -37,6 +37,7 @@ import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-syn
|
||||
import { createYielder, type MaybeYield } from './cooperative-yield';
|
||||
import { crossTierEdges } from './tier-synthesizer';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
import { resolveImportPath } from './import-resolver';
|
||||
|
||||
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
|
||||
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
|
||||
@@ -47,6 +48,7 @@ const ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\
|
||||
const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
|
||||
const SETSTATE_RE = /this\.setState\s*\(/;
|
||||
const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
|
||||
const JS_FAMILY = ['typescript', 'javascript', 'tsx', 'jsx'];
|
||||
const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
|
||||
const MAX_JSX_CHILDREN = 30;
|
||||
// Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
|
||||
@@ -1187,6 +1189,65 @@ async function goGrpcStubImplEdges(queries: QueryBuilder, onYield: MaybeYield):
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Kinds a JSX tag can name. A tag that resolves only to a type is markup we drop. */
|
||||
const JSX_CHILD_KINDS = new Set<NodeKind>(['component', 'function', 'class']);
|
||||
|
||||
/**
|
||||
* The languages a JSX tag can plausibly name a component in. Preferred over a
|
||||
* same-named symbol in another language, never required — a React Native tag
|
||||
* whose only match is the native class it bridges to (`requireNativeComponent`)
|
||||
* still links there.
|
||||
*/
|
||||
const JSX_CHILD_LANGUAGES = [...JS_FAMILY, 'vue', 'svelte'];
|
||||
|
||||
/** `localName` → the project file it is imported from, for one file's imports. */
|
||||
function importedFrom(ctx: ResolutionContext, file: string, language: Language): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const im of ctx.getImportMappings(file, language)) {
|
||||
// The mappings name the module as written; the file it is comes from the
|
||||
// same resolution the import resolver uses (aliases, extensions, index files).
|
||||
const resolved = im.resolvedPath ?? resolveImportPath(im.source, file, language, ctx);
|
||||
if (resolved) out.set(im.localName, resolved);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The component a JSX tag names, among every node that shares the name.
|
||||
*
|
||||
* A tag is written in one file, and that file already says which `FrameCard` it
|
||||
* means: the one it declares, or the one it imports. Taking the FIRST node of
|
||||
* that name — which is what this did — is a coin flip once a name repeats, and
|
||||
* it costs twice over. The parent gets an edge to a component it never renders,
|
||||
* and the component it does render is left with no caller at all, so every walk
|
||||
* back from that subtree dead-ends: on an Expo app whose `<FrameCard/>` was one
|
||||
* of two, the folder sheet's `openBackgroundNoiseDetail` stood alone on Screens
|
||||
* with no screen behind it, while the edge pointed at an unrelated card in
|
||||
* another sheet.
|
||||
*
|
||||
* Same file first — a small component declared beside its use is the commonest
|
||||
* shape, and the one an import can never disambiguate. Then the file the name
|
||||
* is imported from. Then the language, which only decides a tie: a `.tsx` tag
|
||||
* naming both a TS component and a same-named Swift class means the TS one.
|
||||
*/
|
||||
function jsxChild(
|
||||
ctx: ResolutionContext,
|
||||
name: string,
|
||||
file: string,
|
||||
importsOf: () => Map<string, string>
|
||||
): Node | undefined {
|
||||
const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind));
|
||||
if (candidates.length <= 1) return candidates[0];
|
||||
const local = candidates.find((n) => n.filePath === file);
|
||||
if (local) return local;
|
||||
const from = importsOf().get(name);
|
||||
if (from) {
|
||||
const imported = candidates.find((n) => n.filePath === from);
|
||||
if (imported) return imported;
|
||||
}
|
||||
return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 5: React JSX child rendering. A component that returns `<Child .../>`
|
||||
* mounts Child — React calls it — but JSX instantiation isn't a static call edge,
|
||||
@@ -1213,6 +1274,10 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
(n) => PARENT_KINDS.has(n.kind) && JS_FAMILY.includes(n.language)
|
||||
);
|
||||
if (parents.length === 0) continue;
|
||||
// Read once per file, and only when a name actually turns out ambiguous.
|
||||
let imports: Map<string, string> | null = null;
|
||||
const importsOf = () =>
|
||||
(imports ??= importedFrom(ctx, file, parents[0]!.language));
|
||||
for (const parent of parents) {
|
||||
const src = sliceLines(content, parent.startLine, parent.endLine);
|
||||
if (!src || (!src.includes('</') && !src.includes('/>'))) continue;
|
||||
@@ -1223,9 +1288,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
let added = 0;
|
||||
for (const name of names) {
|
||||
if (added >= MAX_JSX_CHILDREN) break;
|
||||
const child = ctx.getNodesByName(name).find(
|
||||
(n) => n.kind === 'component' || n.kind === 'function' || n.kind === 'class'
|
||||
);
|
||||
const child = jsxChild(ctx, name, file, importsOf);
|
||||
if (!child || child.id === parent.id) continue;
|
||||
const key = `${parent.id}>${child.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
@@ -3512,8 +3575,6 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P
|
||||
* pre/post marks) so adding a pass without bumping this fails loudly instead
|
||||
* of silently skewing the bar.
|
||||
*/
|
||||
const JS_FAMILY = ['typescript', 'javascript', 'tsx', 'jsx'];
|
||||
|
||||
/** `has(...)` shape passed to pass gates — true when the project contains any of the languages. */
|
||||
type HasLang = (...ls: string[]) => boolean;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user