feat(ui): dead code and islands — what nothing reaches, and everything that could still reach it (CG-59)
A Dead code screen and a mark on the Map, both drawn from one derivation in src/graph/dead-code.ts so a second surface can never disagree with the first. The SQL half is four lines — no incoming edge but `contains`. It returns ~2 500 candidates on this repository and the shipped list is 20; everything in between is the feature. A candidate is dropped the moment there is any reason to believe something outside the graph reaches it: exported symbols and header declarations, test and generated files, abstract and interface members, anything carrying a `decorates` edge, overrides of an ancestor's member, names the language calls by itself, vendored directories, files nothing in the index reaches (those are islands, and the Map says so instead), names the resolver failed to resolve somewhere, and names shared with a symbol that IS referenced — the mis-resolution that leaves a used method with a self-edge and its twin with nothing. The last rule is the only one that is not a graph query: before a claim is made, the declaring file and every file that reaches it are read and the identifier counted, which is what catches the references the extractor never recorded (`this.handleMessage.bind(this)`, a call inside an object literal, a shorthand property). Every subtraction is counted and printed under the list with the scale it came from, and the caveat line above it never collapses: the claim is "no static reference in the index", not "unused". On the Map a module nothing depends on keeps its stroke and says so in its count line, and tool-generated files and modules recede to ink-4 there, in the map's file list, in search results and on the file screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2a0c6dc58f
commit
56dfdb0655
+16
-1
@@ -9,9 +9,18 @@
|
||||
import MapView from './views/MapView.svelte';
|
||||
import FlowView from './views/FlowView.svelte';
|
||||
import EntryView from './views/EntryView.svelte';
|
||||
import DeadCodeView from './views/DeadCodeView.svelte';
|
||||
import NotFoundView from './views/NotFoundView.svelte';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import { router, navigate, back, mapHref, flowHref, entryHref } from './lib/router.svelte';
|
||||
import {
|
||||
router,
|
||||
navigate,
|
||||
back,
|
||||
mapHref,
|
||||
flowHref,
|
||||
entryHref,
|
||||
deadHref,
|
||||
} from './lib/router.svelte';
|
||||
import { palette } from './lib/palette.svelte';
|
||||
import { trail, resolveTrailNames } from './lib/trail.svelte';
|
||||
import { project } from './lib/project.svelte';
|
||||
@@ -113,6 +122,10 @@
|
||||
event.preventDefault();
|
||||
navigate(entryHref());
|
||||
break;
|
||||
case 'd':
|
||||
event.preventDefault();
|
||||
navigate(deadHref());
|
||||
break;
|
||||
case 'Backspace':
|
||||
case '[':
|
||||
event.preventDefault();
|
||||
@@ -144,6 +157,8 @@
|
||||
/>
|
||||
{:else if route.view === 'entry'}
|
||||
<EntryView project={project.name} />
|
||||
{:else if route.view === 'dead'}
|
||||
<DeadCodeView exported={route.exported} />
|
||||
{:else if route.view === 'unknown'}
|
||||
<NotFoundView path={route.path} />
|
||||
{:else}
|
||||
|
||||
@@ -84,8 +84,10 @@
|
||||
</span>
|
||||
{:else}
|
||||
<KindGlyph kind={item.node.kind} />
|
||||
<span class="mid">
|
||||
<span class="nm">{item.name}</span>
|
||||
<span class="mid" title={item.node.generated ? `${item.name} — tool-generated` : undefined}>
|
||||
<!-- Generated code recedes here too: a `.pb.go` stub and the
|
||||
hand-written thing beside it must not read the same. -->
|
||||
<span class="nm" class:gen={item.node.generated}>{item.name}</span>
|
||||
{#if item.meta}<span class="sig">{item.meta}</span>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -145,6 +147,10 @@
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.nm.gen {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.sig {
|
||||
margin-left: 6px;
|
||||
color: var(--ink-3);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { router, mapHref, flowHref, entryHref, symbolHref } from '../lib/router.svelte';
|
||||
import { router, mapHref, flowHref, entryHref, deadHref, symbolHref } from '../lib/router.svelte';
|
||||
import { trail } from '../lib/trail.svelte';
|
||||
import SearchPalette from './SearchPalette.svelte';
|
||||
import { live } from '../lib/live.svelte';
|
||||
@@ -69,6 +69,7 @@
|
||||
<a href={mapHref()} class:active={view === 'map'}>Map</a>
|
||||
<a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
|
||||
<a href={flowHref()} class:active={view === 'flow'}>Flow</a>
|
||||
<a href={deadHref()} class:active={view === 'dead'}>Dead code</a>
|
||||
</nav>
|
||||
|
||||
<SearchPalette bind:this={search} />
|
||||
|
||||
@@ -45,9 +45,12 @@
|
||||
exportName,
|
||||
}: Props = $props();
|
||||
|
||||
const selectedModule = $derived(
|
||||
selected === null ? null : (layout.nodes.find((n) => n.id === selected)?.module ?? null)
|
||||
const selectedNode = $derived(
|
||||
selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null)
|
||||
);
|
||||
const selectedModule = $derived(selectedNode?.module ?? null);
|
||||
/** Which of the listed files are tool-generated — the rows drawn in ink-4. */
|
||||
const generatedFiles = $derived(new Set(selectedModule?.generatedFiles ?? []));
|
||||
|
||||
const dependencies = $derived(
|
||||
selected === null
|
||||
@@ -205,15 +208,32 @@
|
||||
{#if selectedModule.languages.length > 0}
|
||||
· {selectedModule.languages.map((l) => `${l.language} ${l.files}`).join(', ')}
|
||||
{/if}
|
||||
{#if selectedModule.generated > 0}
|
||||
· {selectedModule.generated === selectedModule.files
|
||||
? 'all tool-generated'
|
||||
: `${selectedModule.generated} tool-generated`}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if selectedNode?.island}
|
||||
<p class="island">
|
||||
Nothing in the index depends on this module — no import, call or reference crosses into
|
||||
it. It may be an entry point, or reached in a way the graph cannot see.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{@render linkList('depends on', dependencies, 'target')}
|
||||
{@render linkList('depended on by', dependents, 'source')}
|
||||
|
||||
<div class="pair label">files</div>
|
||||
{#if files.length > 0}
|
||||
{#each files as file (file)}
|
||||
<a class="filerow" href={fileHref(file)}>{file}</a>
|
||||
<a
|
||||
class="filerow"
|
||||
class:gen={generatedFiles.has(file)}
|
||||
href={fileHref(file)}
|
||||
title={generatedFiles.has(file) ? `${file} — tool-generated` : file}>{file}</a
|
||||
>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="pair dim">no files in the index for this module</div>
|
||||
@@ -381,4 +401,16 @@
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Generated code recedes wherever it appears (design spec §2.6). */
|
||||
.filerow.gen {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.island {
|
||||
margin: 6px 0 0;
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,14 +46,21 @@
|
||||
class:sel={node.selected}
|
||||
class:dimmed={node.dimmed}
|
||||
class:test={module.test}
|
||||
class:gen={layout.generated}
|
||||
style={`width:${layout.width}px;height:${layout.height}px`}
|
||||
onclick={() => node.onSelect(layout.id)}
|
||||
aria-pressed={node.selected}
|
||||
title={`${module.id} — ${module.symbols} symbols in ${module.files} file${module.files === 1 ? '' : 's'}`}
|
||||
title={`${module.id} — ${module.symbols} symbols in ${module.files} file${
|
||||
module.files === 1 ? '' : 's'
|
||||
}${layout.island ? '. Nothing in the index depends on it.' : ''}${
|
||||
layout.generated ? '. Every file in it is tool-generated.' : ''
|
||||
}`}
|
||||
>
|
||||
<span class="name">{module.id}</span>
|
||||
<!-- The same string nodeWidth() sized the box for; they must not drift. -->
|
||||
<span class="count">{moduleMetaLabel(module)}</span>
|
||||
<span class="count" class:island={layout.island}
|
||||
>{moduleMetaLabel(module, layout.island)}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
{#each layout.sourceHandles as handle, i (handle)}
|
||||
@@ -96,6 +103,19 @@
|
||||
.mnode.dimmed .count {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
/* Nothing depends on it — the stroke stays normal (it is not a lesser module,
|
||||
it is an unreached one); only the count line changes what it says. */
|
||||
.count.island {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
/* Generated code: nobody wrote it by hand and nobody deletes it by hand. */
|
||||
.mnode.gen {
|
||||
color: var(--ink-4);
|
||||
border-color: var(--rule-soft);
|
||||
}
|
||||
.mnode.gen .count {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
/* Test modules read as scaffolding, not as part of the program. */
|
||||
.mnode.test {
|
||||
border-style: dashed;
|
||||
|
||||
@@ -46,6 +46,7 @@ export {
|
||||
setGraphAdapter,
|
||||
} from './lib/adapter';
|
||||
export type {
|
||||
DeadCodeRequest,
|
||||
EntryPointsRequest,
|
||||
FlowRequest,
|
||||
GraphAdapter,
|
||||
@@ -59,6 +60,7 @@ export type {
|
||||
|
||||
export {
|
||||
back,
|
||||
deadHref,
|
||||
entryHref,
|
||||
fileHref,
|
||||
flowHref,
|
||||
@@ -70,6 +72,7 @@ export {
|
||||
symbolHref,
|
||||
} from './lib/navigation';
|
||||
export type {
|
||||
DeadCodeHrefOptions,
|
||||
FileHrefOptions,
|
||||
FlowHrefOptions,
|
||||
MapHrefOptions,
|
||||
@@ -94,6 +97,8 @@ export { default as FileView } from './views/FileView.svelte';
|
||||
export { default as FileSourceView } from './views/FileCodeView.svelte';
|
||||
/** Where a reader starts: routes, files that run something, tests, hubs. */
|
||||
export { default as EntryPointsView } from './views/EntryView.svelte';
|
||||
/** Symbols nothing reaches, grouped by file, with every exclusion printed. */
|
||||
export { default as DeadCodeView } from './views/DeadCodeView.svelte';
|
||||
|
||||
/* -------------------------------------------------------- the furniture -- */
|
||||
|
||||
@@ -215,6 +220,16 @@ export {
|
||||
} from './lib/filecode-model';
|
||||
export type { FileArc, FileCallRow, SourcePage } from './lib/filecode-model';
|
||||
|
||||
export {
|
||||
deadCodeHeadline,
|
||||
deadCodeRowMeta,
|
||||
deadCodeScale,
|
||||
emptyMessage as deadCodeEmptyMessage,
|
||||
exclusionPhrases,
|
||||
groupMeta as deadCodeGroupMeta,
|
||||
DEAD_CODE_CAVEAT,
|
||||
} from './lib/deadcode-model';
|
||||
|
||||
export { buildEntryPanel, flowPair, matchEntries } from './lib/entry-model';
|
||||
export type {
|
||||
EntryGroup,
|
||||
|
||||
+37
-5
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
|
||||
* the read-only JSON API over loopback. A host that already holds the graph —
|
||||
* CodeGraph Pro, which opens the index in-process — implements the same eleven
|
||||
* CodeGraph Pro, which opens the index in-process — implements the same twelve
|
||||
* methods against its own reads and never makes an HTTP request. The components
|
||||
* cannot tell the difference, which is the whole point: one implementation of
|
||||
* the Symbol view, the Flow strip and the Map, drawn from whichever side of the
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
WireDeadCode,
|
||||
WireEntryPoints,
|
||||
WireFilePayload,
|
||||
WireFileCodePayload,
|
||||
@@ -125,6 +126,24 @@ export interface RoutesRequest {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the dead code list should be allowed to claim.
|
||||
*
|
||||
* Every flag widens the list by switching one honesty rule off, so each one is
|
||||
* a thing the screen then has to say out loud. `exported` is the big one: a
|
||||
* symbol something outside the repository could import is not dead in any sense
|
||||
* the index can check, and turning it on also turns off the "this language
|
||||
* records no exports at all" guard.
|
||||
*/
|
||||
export interface DeadCodeRequest {
|
||||
limit?: number;
|
||||
/** Node kinds to consider. Omitted means callables and types. */
|
||||
kinds?: readonly string[];
|
||||
includeExported?: boolean;
|
||||
includeTests?: boolean;
|
||||
includeGenerated?: boolean;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- live -- */
|
||||
|
||||
/**
|
||||
@@ -149,10 +168,11 @@ export interface LiveHandlers {
|
||||
* Everything the components ask of a project.
|
||||
*
|
||||
* Seven of these are the reading surface named in the task — `search`, `node`,
|
||||
* `source`, `file`, `flow`, `map`, `routes` — and the other four are what the
|
||||
* screens around them need: `stats` (the blast bar's denominator and the top
|
||||
* bar's counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode`
|
||||
* (the whole-file view) and `entryPoints` (where a reader starts).
|
||||
* `source`, `file`, `flow`, `map`, `routes` — and the rest are what the screens
|
||||
* around them need: `stats` (the blast bar's denominator and the top bar's
|
||||
* counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode` (the
|
||||
* whole-file view), `entryPoints` (where a reader starts) and `deadCode` (where
|
||||
* nobody goes).
|
||||
*/
|
||||
export interface GraphAdapter {
|
||||
/** The index's own facts: counts, thresholds, the blast scale. */
|
||||
@@ -176,6 +196,8 @@ export interface GraphAdapter {
|
||||
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
|
||||
/** Where a reader starts: routes, files that run something, tests, hubs. */
|
||||
entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
|
||||
/** Symbols nothing reaches, grouped by file, with every exclusion counted. */
|
||||
deadCode(request?: DeadCodeRequest, signal?: AbortSignal): Promise<WireDeadCode>;
|
||||
/**
|
||||
* Subscribe to index/disk changes. Optional — a host without a live channel
|
||||
* omits it and nothing polls. Returns a function that closes the stream.
|
||||
@@ -314,6 +336,16 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
|
||||
return getJson<WireEntryPoints>(`api/entrypoints${query(params)}`, signal);
|
||||
},
|
||||
|
||||
deadCode(request = {}, signal) {
|
||||
const params = new URLSearchParams();
|
||||
if (request.limit) params.set('limit', String(request.limit));
|
||||
if (request.kinds?.length) params.set('kinds', request.kinds.join(','));
|
||||
if (request.includeExported) params.set('exported', '1');
|
||||
if (request.includeTests) params.set('tests', '1');
|
||||
if (request.includeGenerated) params.set('generated', '1');
|
||||
return getJson<WireDeadCode>(`api/deadcode${query(params)}`, signal);
|
||||
},
|
||||
|
||||
events(handlers) {
|
||||
if (typeof EventSource === 'undefined') return () => {};
|
||||
const stream = new EventSource(`${base}api/events`);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { getGraphAdapter } from './adapter';
|
||||
import type {
|
||||
WireDeadCode,
|
||||
WireEntryPoints,
|
||||
WireFilePayload,
|
||||
WireFileCodePayload,
|
||||
@@ -30,6 +31,7 @@ export * from './wire';
|
||||
export { ApiFailure } from './adapter';
|
||||
export type {
|
||||
GraphAdapter,
|
||||
DeadCodeRequest,
|
||||
EntryPointsRequest,
|
||||
FlowRequest,
|
||||
HttpAdapterOptions,
|
||||
@@ -68,6 +70,23 @@ export function fetchEntryPoints(
|
||||
return getGraphAdapter().entryPoints(opts, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbols nothing in the index reaches, grouped by file — and, just as
|
||||
* importantly, every reason a candidate was left off. The screen prints both.
|
||||
*/
|
||||
export function fetchDeadCode(
|
||||
opts: {
|
||||
limit?: number;
|
||||
kinds?: readonly string[];
|
||||
includeExported?: boolean;
|
||||
includeTests?: boolean;
|
||||
includeGenerated?: boolean;
|
||||
} = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<WireDeadCode> {
|
||||
return getGraphAdapter().deadCode(opts, signal);
|
||||
}
|
||||
|
||||
/** The URL → handler map. The palette reads routes through `fetchEntryPoints`. */
|
||||
export function fetchRoutes(
|
||||
opts: { limit?: number } = {},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The dead code list's arithmetic and its sentences (design spec §3.11).
|
||||
*
|
||||
* Pure functions over the `/api/deadcode` payload: no DOM, no fetch. The
|
||||
* screen's whole job is to be believed, and everything that decides whether it
|
||||
* should be lives here — the caveat that never goes away, the headline that
|
||||
* says how much of the index is behind the list, and the sentence that names
|
||||
* every reason a candidate was left off.
|
||||
*
|
||||
* The rule this file exists to enforce: **the list and its caveats are one
|
||||
* thing.** A screen that draws the rows and leaves the exclusions to a
|
||||
* collapsed panel is a screen that gets somebody to delete a route handler.
|
||||
*/
|
||||
|
||||
import { plural } from './symbol-model';
|
||||
import { kindWord } from './kinds';
|
||||
import type { WireDeadCode, WireDeadCodeGroup, WireDeadCodeRow } from './wire';
|
||||
|
||||
/**
|
||||
* The line that is always on screen, whatever the list says.
|
||||
*
|
||||
* Not a dismissible note and not a tooltip: the claim this screen makes is
|
||||
* "no static reference in the index", which is a strictly weaker claim than
|
||||
* "unused", and the difference is the whole risk of acting on it.
|
||||
*/
|
||||
export const DEAD_CODE_CAVEAT =
|
||||
'No static reference in the index — dynamic use is possible.';
|
||||
|
||||
/** "symbol" / "symbols" — the noun without its count, for sentences that count twice. */
|
||||
function noun(count: number, one: string): string {
|
||||
return count === 1 ? one : `${one}s`;
|
||||
}
|
||||
|
||||
/** The one-line summary above the list. */
|
||||
export function deadCodeHeadline(payload: WireDeadCode | null): string {
|
||||
if (!payload) return '';
|
||||
const { rows } = payload;
|
||||
if (rows.total === 0) return 'Nothing on this list.';
|
||||
const lines = payload.groups.reduce((sum, group) => sum + group.lines, 0);
|
||||
const shown = rows.truncated
|
||||
? `${rows.shown} of ${rows.total} ${noun(rows.total, 'symbol')}`
|
||||
: plural(rows.total, 'symbol');
|
||||
return `${shown} in ${plural(payload.groups.length, 'file')} · ${plural(lines, 'line')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "2 478 of 2 498 candidates were left off" — the number that gives the list
|
||||
* its scale.
|
||||
*
|
||||
* Twenty rows drawn from twenty candidates and twenty drawn from two and a half
|
||||
* thousand are different screens, and only this sentence tells them apart.
|
||||
*/
|
||||
export function deadCodeScale(payload: WireDeadCode | null): string {
|
||||
if (!payload || payload.candidates === 0) return '';
|
||||
return `${payload.candidates.toLocaleString()} ${noun(payload.candidates, 'symbol')} in this index carry no incoming reference at all; ${payload.excludedTotal.toLocaleString()} of them were left off this list.`;
|
||||
}
|
||||
|
||||
/** Each exclusion as "N <label>", biggest first — the sentence under the list. */
|
||||
export function exclusionPhrases(payload: WireDeadCode | null): string[] {
|
||||
if (!payload) return [];
|
||||
return payload.excluded.map((entry) => `${entry.count.toLocaleString()} ${entry.label}`);
|
||||
}
|
||||
|
||||
/** The row's second line: what it is, and what deleting it would remove. */
|
||||
export function deadCodeRowMeta(row: WireDeadCodeRow): string {
|
||||
const parts = [kindWord(row.kind), plural(row.lines, 'line')];
|
||||
if (row.members.total > 0) {
|
||||
parts.push(`${plural(row.members.total, 'member')} unreachable with it`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** The group header's right-hand count. */
|
||||
export function groupMeta(group: WireDeadCodeGroup): string {
|
||||
const parts = [plural(group.rows.length, 'symbol'), plural(group.lines, 'line')];
|
||||
if (group.generated) parts.push('generated');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen says when the list is empty.
|
||||
*
|
||||
* An empty list is a real answer and never an error — but "nothing found" and
|
||||
* "nothing survived the filters" are different answers, and the second one
|
||||
* points at the toggle that would widen it.
|
||||
*/
|
||||
export function emptyMessage(payload: WireDeadCode): string {
|
||||
if (payload.candidates === 0) {
|
||||
return 'Every symbol in this index is referenced by something. Nothing to show.';
|
||||
}
|
||||
if (payload.includeExported) {
|
||||
return `Every one of the ${payload.candidates.toLocaleString()} symbols with no incoming reference has a reason to be reachable anyway — see the list of exclusions below.`;
|
||||
}
|
||||
return `All ${payload.candidates.toLocaleString()} symbols with no incoming reference are either reachable from outside this repository or excluded for the reasons below. Turn on "including exported" to see the ones the index cannot check.`;
|
||||
}
|
||||
+34
-4
@@ -95,8 +95,17 @@ export function nodeWidth(label: string, meta = ''): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** The second line of a module box — and the string {@link nodeWidth} sizes for. */
|
||||
export function moduleMetaLabel(module: WireMapModule): string {
|
||||
/**
|
||||
* The second line of a module box — and the string {@link nodeWidth} sizes for.
|
||||
*
|
||||
* An island says so INSTEAD of counting itself. "Nothing depends on this" is
|
||||
* the only fact about such a module a reader needs from twenty boxes away, and
|
||||
* the counts are still one click away in the side panel. Both callers — the
|
||||
* width calculation and the box itself — must pass the same `island`, or the
|
||||
* text will not fit the box that was sized for it.
|
||||
*/
|
||||
export function moduleMetaLabel(module: WireMapModule, island = false): string {
|
||||
if (island) return 'nothing depends on this';
|
||||
const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
|
||||
const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
|
||||
return `${symbols} · ${files}`;
|
||||
@@ -105,6 +114,16 @@ export function moduleMetaLabel(module: WireMapModule): string {
|
||||
export interface MapNodeLayout {
|
||||
id: string;
|
||||
module: WireMapModule;
|
||||
/**
|
||||
* No link in the payload arrives here — an island (task CG-59).
|
||||
*
|
||||
* Computed from the WHOLE link set, not the filtered one, so hiding test
|
||||
* modules or raising the weight threshold cannot manufacture an island that
|
||||
* the index does not agree is one.
|
||||
*/
|
||||
island: boolean;
|
||||
/** Every file in it is tool-generated, so it draws in ink-4. */
|
||||
generated: boolean;
|
||||
layer: number;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -189,6 +208,9 @@ export function buildMapLayout(
|
||||
): MapLayout {
|
||||
const modules = payload.modules.filter((m) => options.includeTests || !m.test);
|
||||
const present = new Set(modules.map((m) => m.id));
|
||||
// Islands come off the UNFILTERED link set: a module a hidden test module
|
||||
// depends on is depended on, whatever this screen is currently showing.
|
||||
const depended = new Set(payload.links.map((l) => l.target));
|
||||
const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
|
||||
const minWeight = options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT;
|
||||
|
||||
@@ -258,7 +280,10 @@ export function buildMapLayout(
|
||||
}
|
||||
|
||||
// --- placement -----------------------------------------------------------
|
||||
const widths = new Map(modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m))]));
|
||||
const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
|
||||
const widths = new Map(
|
||||
modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m, islands.has(m.id)))])
|
||||
);
|
||||
const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
|
||||
// Natural span = the boxes shoulder to shoulder. The content width is the
|
||||
// widest of those, and NOTHING may exceed it — a row of forty leaf modules
|
||||
@@ -286,9 +311,14 @@ export function buildMapLayout(
|
||||
const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP);
|
||||
for (const id of row) {
|
||||
const w = widths.get(id) ?? MIN_NODE_WIDTH;
|
||||
const module = byId.get(id)!;
|
||||
nodesById.set(id, {
|
||||
id,
|
||||
module: byId.get(id)!,
|
||||
module,
|
||||
island: islands.has(id),
|
||||
// Every file generated, not merely some: a module with one `.pb.go` in
|
||||
// it is still a module somebody writes by hand.
|
||||
generated: module.files > 0 && module.generated === module.files,
|
||||
layer: index,
|
||||
x,
|
||||
y,
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface MapHrefOptions {
|
||||
tests?: boolean;
|
||||
}
|
||||
|
||||
export interface DeadCodeHrefOptions {
|
||||
/** Include symbols something outside the index could import. */
|
||||
exported?: boolean;
|
||||
}
|
||||
|
||||
export interface FlowHrefOptions {
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -63,6 +68,7 @@ export interface NavigationDriver {
|
||||
mapHref(opts?: MapHrefOptions): string;
|
||||
flowHref(opts?: FlowHrefOptions): string;
|
||||
entryHref(): string;
|
||||
deadHref(opts?: DeadCodeHrefOptions): string;
|
||||
/** Go to an href this driver built. */
|
||||
navigate(href: string, opts?: { replace?: boolean }): void;
|
||||
/** Back one entry in the host's history. */
|
||||
@@ -127,6 +133,12 @@ export const hashNavigation: NavigationDriver = {
|
||||
return '#/entry';
|
||||
},
|
||||
|
||||
deadHref(opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.exported) params.set('exported', '1');
|
||||
return `#/dead${query(params)}`;
|
||||
},
|
||||
|
||||
navigate(href, opts = {}) {
|
||||
const target = href.startsWith('#') ? href : `#${href}`;
|
||||
if (opts.replace) {
|
||||
@@ -200,6 +212,10 @@ export function entryHref(): string {
|
||||
return driver.entryHref();
|
||||
}
|
||||
|
||||
export function deadHref(opts: DeadCodeHrefOptions = {}): string {
|
||||
return driver.deadHref(opts);
|
||||
}
|
||||
|
||||
export function navigate(href: string, opts: { replace?: boolean } = {}): void {
|
||||
driver.navigate(href, opts);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* #/map module map (?root=&depth=&tests=1)
|
||||
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
|
||||
* #/entry entry points (where a flow starts)
|
||||
* #/dead dead code (?exported=1 widens the claim)
|
||||
*
|
||||
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
|
||||
* `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
|
||||
@@ -31,6 +32,7 @@ import { registerHashSync } from './navigation';
|
||||
|
||||
export {
|
||||
back,
|
||||
deadHref,
|
||||
entryHref,
|
||||
fileHref,
|
||||
flowHref,
|
||||
@@ -42,6 +44,7 @@ export {
|
||||
symbolHref,
|
||||
} from './navigation';
|
||||
export type {
|
||||
DeadCodeHrefOptions,
|
||||
FileHrefOptions,
|
||||
FlowHrefOptions,
|
||||
MapHrefOptions,
|
||||
@@ -71,6 +74,11 @@ export type Route =
|
||||
trail: string | null;
|
||||
}
|
||||
| { view: 'entry' }
|
||||
| {
|
||||
view: 'dead';
|
||||
/** Symbols reachable from outside the index are on the list. */
|
||||
exported: boolean;
|
||||
}
|
||||
| { view: 'unknown'; path: string };
|
||||
|
||||
export type ViewName = Route['view'];
|
||||
@@ -128,6 +136,10 @@ export function parseHash(hash: string): RouterLocation {
|
||||
};
|
||||
} else if (head === 'entry' && rest.length === 0) {
|
||||
route = { view: 'entry' };
|
||||
} else if (head === 'dead' && rest.length === 0) {
|
||||
// The widening travels in the URL like the map's shape does: a link to
|
||||
// "including exported symbols" has to reopen the same list.
|
||||
route = { view: 'dead', exported: params.get('exported') === '1' };
|
||||
} else if (head === 'flow' && rest.length === 0) {
|
||||
// The question travels in the URL exactly as it was asked, so a flow can be
|
||||
// linked in a review and reopen as the same path.
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface WireNodeRef {
|
||||
exported?: boolean;
|
||||
/** Lives in a file that looks like test or fixture code. */
|
||||
test: boolean;
|
||||
/**
|
||||
* Lives in a tool-generated file, so the row draws in ink-4. Optional: only
|
||||
* the endpoints that show it pay for the lookup, so `undefined` means "not
|
||||
* asked", never "no".
|
||||
*/
|
||||
generated?: boolean;
|
||||
}
|
||||
|
||||
export interface WireNodeDetail extends WireNodeRef {
|
||||
@@ -594,6 +600,10 @@ export interface WireMapModule {
|
||||
languages: Array<{ language: string; files: number }>;
|
||||
/** More than half its files are tests. */
|
||||
test: boolean;
|
||||
/** How many of its files are tool-generated. All of them → drawn in ink-4. */
|
||||
generated: number;
|
||||
/** Which of `fileList.items` are generated, so a row in the panel can dim too. */
|
||||
generatedFiles: string[];
|
||||
/** A single file kept out of the root bucket because it is the façade. */
|
||||
facade: boolean;
|
||||
/** Its files, capped — the side panel's list when the module is selected. */
|
||||
@@ -631,3 +641,48 @@ export interface WireMapPayload {
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number; cached: boolean };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- dead code -- */
|
||||
|
||||
/** One symbol nothing in the index reaches. */
|
||||
export interface WireDeadCodeRow extends WireNodeRef {
|
||||
/** Source lines it spans — the rank, and what deleting it would remove. */
|
||||
lines: number;
|
||||
/** Unreferenced members inside it: a dead class takes its methods with it. */
|
||||
members: WireList<WireNodeRef>;
|
||||
}
|
||||
|
||||
/** The rows of one file, in source order. */
|
||||
export interface WireDeadCodeGroup {
|
||||
file: string;
|
||||
/** Tool-generated — drawn dimmed wherever it appears. */
|
||||
generated: boolean;
|
||||
test: boolean;
|
||||
lines: number;
|
||||
rows: WireDeadCodeRow[];
|
||||
}
|
||||
|
||||
/** One reason candidates were dropped, already worded for the screen. */
|
||||
export interface WireDeadCodeExclusion {
|
||||
reason: string;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface WireDeadCode {
|
||||
rows: WireList<WireDeadCodeRow>;
|
||||
/** The SHOWN rows, grouped by file — group order follows the best row. */
|
||||
groups: WireDeadCodeGroup[];
|
||||
/** Symbols with no incoming reference at all, before any exclusion ran. */
|
||||
candidates: number;
|
||||
excluded: WireDeadCodeExclusion[];
|
||||
excludedTotal: number;
|
||||
kinds: string[];
|
||||
includeExported: boolean;
|
||||
includeTests: boolean;
|
||||
includeGenerated: boolean;
|
||||
bounded: boolean;
|
||||
/** Every row was checked against the text of the files that can reach it. */
|
||||
corroborated: boolean;
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Dead code — symbols nothing in this repository reaches, grouped by file
|
||||
* (design spec §3.11).
|
||||
*
|
||||
* The screen is a list and a disclaimer, deliberately in that order and
|
||||
* deliberately inseparable. The caveat line sits above the rows and never
|
||||
* goes away, because the claim behind every row is "no static reference in
|
||||
* the index" and not "unused": reflection, a framework registry and a
|
||||
* template can all reach code the graph cannot follow. Underneath, every
|
||||
* reason a candidate was left off is printed with its count — a list of
|
||||
* twenty drawn from two and a half thousand candidates means something very
|
||||
* different from a list of twenty drawn from twenty-one.
|
||||
*
|
||||
* The one switch is "including exported". Off (the default) the list is only
|
||||
* symbols nothing outside this repository could import either; on, it widens
|
||||
* to symbols the index has no way to check, and says so. It travels in the
|
||||
* URL like the map's shape does, so a link reopens the same list.
|
||||
*
|
||||
* The other half of this task lives on the Map: a module nothing depends on
|
||||
* says so in its own count line. See `lib/map-model.ts`.
|
||||
*/
|
||||
import KindGlyph from '../components/KindGlyph.svelte';
|
||||
import { fetchDeadCode, ApiFailure, type WireDeadCode, type WireDeadCodeRow } from '../lib/api';
|
||||
import { deadHref, fileHref, navigate, symbolHref } from '../lib/navigation';
|
||||
import { live } from '../lib/live.svelte';
|
||||
import {
|
||||
DEAD_CODE_CAVEAT,
|
||||
deadCodeHeadline,
|
||||
deadCodeRowMeta,
|
||||
deadCodeScale,
|
||||
emptyMessage,
|
||||
exclusionPhrases,
|
||||
groupMeta,
|
||||
} from '../lib/deadcode-model';
|
||||
|
||||
interface Props {
|
||||
/** Include symbols something outside the index could import. */
|
||||
exported?: boolean;
|
||||
}
|
||||
|
||||
let { exported = false }: Props = $props();
|
||||
|
||||
let payload = $state<WireDeadCode | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const includeExported = exported;
|
||||
// The index moving invalidates every row: a symbol is on this list because
|
||||
// of what the graph does NOT contain, which is exactly what a sync changes.
|
||||
void live.indexTick;
|
||||
const controller = new AbortController();
|
||||
loading = true;
|
||||
failure = null;
|
||||
fetchDeadCode({ includeExported }, controller.signal)
|
||||
.then((next) => {
|
||||
payload = next;
|
||||
loading = false;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
failure = error instanceof ApiFailure ? error.message : 'The list could not be read.';
|
||||
loading = false;
|
||||
});
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
let headline = $derived(deadCodeHeadline(payload));
|
||||
let scale = $derived(deadCodeScale(payload));
|
||||
let phrases = $derived(exclusionPhrases(payload));
|
||||
|
||||
function open(row: WireDeadCodeRow): void {
|
||||
navigate(symbolHref(row.id, { line: row.line }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="head">
|
||||
<h2>Dead code</h2>
|
||||
<p>
|
||||
Symbols no import, call or reference in this index reaches, largest first. Everything below
|
||||
is what the graph can see; the notes under the list are what it cannot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
<p class="caveat">{DEAD_CODE_CAVEAT}</p>
|
||||
<a
|
||||
class="toggle"
|
||||
class:on={exported}
|
||||
href={deadHref({ exported: !exported })}
|
||||
title={exported
|
||||
? 'Back to symbols nothing outside this repository could import either'
|
||||
: 'Also list symbols something outside this repository could import — the index cannot check those'}
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
navigate(deadHref({ exported: !exported }));
|
||||
}}>{exported ? 'Including exported' : 'Internal only'}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if failure}
|
||||
<p class="state">Could not read the list — {failure}</p>
|
||||
{:else if loading && payload === null}
|
||||
<p class="state">Reading the graph…</p>
|
||||
{:else if payload}
|
||||
{#if exported}
|
||||
<p class="warn">
|
||||
Exported symbols are on this list. Nothing in this repository references them, but anything
|
||||
outside it can — a published package, another service, a script. Read each one before you
|
||||
believe it.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if payload.groups.length === 0}
|
||||
<p class="state">{emptyMessage(payload)}</p>
|
||||
{:else}
|
||||
<p class="headline">{headline}</p>
|
||||
<div class="groups">
|
||||
{#each payload.groups as group (group.file)}
|
||||
<div class="filegroup" class:gen={group.generated}>
|
||||
<div class="fpath">
|
||||
<a href={fileHref(group.file)} title={group.file}>{group.file}</a>
|
||||
<b>{groupMeta(group)}</b>
|
||||
</div>
|
||||
{#each group.rows as row (row.id)}
|
||||
<div class="row">
|
||||
<KindGlyph kind={row.kind} />
|
||||
<div class="body">
|
||||
<div class="line">
|
||||
<button
|
||||
type="button"
|
||||
class="nm"
|
||||
title={row.qualifiedName}
|
||||
data-dead-row={row.id}
|
||||
onclick={() => open(row)}>{row.name}</button
|
||||
>
|
||||
<a class="ln" href={fileHref(group.file, { source: true, line: row.line })}
|
||||
>{row.file}:{row.line}</a
|
||||
>
|
||||
{#if row.exported}<span class="chip">exported</span>{/if}
|
||||
</div>
|
||||
<div class="meta">{deadCodeRowMeta(row)}</div>
|
||||
{#if row.members.items.length > 0}
|
||||
<div class="members">
|
||||
{#each row.members.items as member (member.id)}
|
||||
<a class="member" href={symbolHref(member.id)}>{member.name}</a>
|
||||
{/each}
|
||||
{#if row.members.truncated}
|
||||
<span class="member more"
|
||||
>+{row.members.total - row.members.shown} more</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="notes">
|
||||
<p>{scale}</p>
|
||||
{#if phrases.length > 0}
|
||||
<ul>
|
||||
{#each phrases as phrase (phrase)}
|
||||
<li>{phrase}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if !payload.corroborated}
|
||||
<p>
|
||||
The rows were not checked against the text of the files that can reach them, so a
|
||||
reference the extractor did not record would not have been caught.
|
||||
</p>
|
||||
{/if}
|
||||
{#if payload.bounded}
|
||||
<p>
|
||||
The scan stopped at its cap — this index holds more unreferenced symbols than were
|
||||
considered.
|
||||
</p>
|
||||
{/if}
|
||||
{#if payload.rows.truncated}
|
||||
<p>
|
||||
Showing {payload.rows.shown} of {payload.rows.total} — the rest are in the index, not on
|
||||
this list.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.head {
|
||||
max-width: 760px;
|
||||
padding: 26px 40px 6px;
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 0;
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
max-width: 760px;
|
||||
margin: 14px 40px 0;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--rule-soft);
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
}
|
||||
|
||||
/* The caveat is never dismissible and never collapsed: it is the difference
|
||||
between "nothing references this" and "nobody uses this". */
|
||||
.caveat {
|
||||
margin: 0;
|
||||
color: var(--ink-3);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--rule-soft);
|
||||
color: var(--ink-2);
|
||||
font: 11px var(--mono);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
border-color: var(--ink);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.toggle.on {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.warn {
|
||||
max-width: 760px;
|
||||
margin: 12px 40px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.headline {
|
||||
max-width: 760px;
|
||||
margin: 14px 40px 0;
|
||||
color: var(--ink-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.state {
|
||||
max-width: 760px;
|
||||
padding: 16px 40px 40px;
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.groups {
|
||||
max-width: 760px;
|
||||
margin: 8px 40px 0;
|
||||
border: 1px solid var(--rule-soft);
|
||||
}
|
||||
|
||||
.filegroup {
|
||||
padding: 10px 14px 6px;
|
||||
border-bottom: 1px solid var(--rule-faint);
|
||||
}
|
||||
|
||||
.filegroup:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.fpath {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--ink-3);
|
||||
font: 11px var(--mono);
|
||||
}
|
||||
|
||||
.fpath a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fpath a:hover {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fpath b {
|
||||
flex: none;
|
||||
color: var(--ink-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Generated code recedes wherever it appears (design spec §2.6). */
|
||||
.filegroup.gen .fpath,
|
||||
.filegroup.gen .fpath b,
|
||||
.filegroup.gen .nm,
|
||||
.filegroup.gen .meta {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
margin: 0 -6px;
|
||||
padding: 5px 6px 5px 4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--press);
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nm {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font: 12.5px var(--mono);
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ln {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
font: 11px var(--mono);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ln:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font: 11px var(--mono);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.member {
|
||||
color: var(--ink-3);
|
||||
font: 11px var(--mono);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.member:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.member.more {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.notes {
|
||||
max-width: 760px;
|
||||
margin: 14px 40px 48px;
|
||||
color: var(--ink-3);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notes p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.notes ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -276,7 +276,8 @@
|
||||
<section class="center" bind:this={centerEl}>
|
||||
<div class="card-h">
|
||||
<KindGlyph kind="file" />
|
||||
<h1>{basename(payload.file.path)}</h1>
|
||||
<!-- Generated code recedes wherever it appears (design spec §2.6). -->
|
||||
<h1 class:gen={payload.file.generated}>{basename(payload.file.path)}</h1>
|
||||
<span class="kindword">{fileMetaLine(payload)}</span>
|
||||
<span class="loc">{payload.file.path}</span>
|
||||
<div class="spacer"></div>
|
||||
@@ -381,6 +382,10 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card-h h1.gen {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user