Skip to content

Design system phases 1–3, and a core revision that reshaped phase 3 - #23

Open
guan4tou2 wants to merge 25 commits into
mainfrom
feat/design-system-phase3
Open

Design system phases 1–3, and a core revision that reshaped phase 3#23
guan4tou2 wants to merge 25 commits into
mainfrom
feat/design-system-phase3

Conversation

@guan4tou2

Copy link
Copy Markdown
Owner

Phases 1–3 of the UI/UX standard (docs/UIUX-STANDARD.md), plus a mid-stream
revision of the product's core that reshaped what phase 3 should even build.

The core moved, and the work followed

A design review (docs/DESIGN-core-and-capture.md, PRODUCT-POSITIONING.md)
moved the irreducible core from defensibility — a tamper-evident record a
third party can verify — to knowability: nothing missing, findable
afterwards, with the operator reconstructing and writing up their own
engagement as the primary consumer. Tamper-evidence is kept; it is no longer
what the product is for. Three subsystems stopped earning their place under
that test and were removed; one capture gap it exposed was filled.

Phase 1 — tokens & type

Palette repaint over a gray ramp with on-* tokens for text on fills; type
scale to 13/15/17px; every solid fill takes dark text (white fails contrast on
all of them — verified by computation). A Tailwind-class test bites on the two
dead classes that had shipped silently, and an i18n-key test on keys that fell
back to their own name.

Phase 2 — behaviour

Three-part errors, four Toast rules, two-tier status failure, one time
formatter, truncation-keeps-the-full-value, three-level dangerous-action
confirm with a focus trap, five undoable actions (three deferring the write,
since that log is evidence), one keyboard contract across every list, one ⌘K.

Phase 3 — structure, after the core revision

  • One export control (§10) replaces six scattered entry points; scope is
    an option, not a location.
  • Marketplace removed — distributing capture code is a different product
    from not missing anything. Installed-plugin management stays.
  • Cloud share removed — uploading the bundle to a backend with an expiry
    is distribution; the bundle itself still reaches the operator through the one
    export control.
  • Settings: 34 groups → 26, 13 pages → 11, each cut argued by one test —
    does the operator have to understand this to answer "what happened, and can I
    show it?" Two candidates failed that test because their names lied, not
    their contents; kept and renamed.
  • HTTP History reshaped to §3 — activities, not connections. 30 requests
    and 1 request are 2 rows, not 31; every flow still one disclosure down.

Capture gap the core exposed

Full-text search inside terminal recordings (cast-index.ts). Closes two
gaps with one mechanism: tool output was replayable but not queryable, and an
ssh into a jump host produced no structured commands at all. Search, not
parse — "which run mentioned 445", never "list every open port". A separate DB
file because it is derived and rebuildable and retention must be able to prune
it; token search not trigram for disk; index status surfaced because a
half-built index reading as "nothing there" is the one failure this product
cannot have.

Salvaged from the unmerged PR #8

That branch is 88 commits behind main and its central decisions are overtaken,
but shipped code on main cites docs that were never merged (src/core/alert/
names ALERT-ROLES.md three times). Recovered: 17 cross-linked decomposition
docs (as-argued, with DESIGN-PRINCIPLES §1 marked superseded rather than
rewritten), and the c2-tailers plugin pack — a C2 log is an event stream,
which is capture; its sibling scan-parsers is interpretation, and was left.

Verification

  • npm test806 passed (81 files; run under the Node ABI so the DB
    suites execute, then restored)
  • npm run e2e49 passed / 0 failed, including two new specs that verify
    the export menu, the selection boundary and the HTTP reshape in a running
    Electron app. The e2e caught a grouping bug the unit tests structurally could
    not — a response's _causes points at its own request, so reading it as
    parentage degenerated the §3 grouping into one-row-per-connection.

Notes for review

  • Two ! commits (feat(plugins)!, feat(share)!): marketplace and cloud
    share are removed, including their config keys and IPC. No migration — both
    were UI-reachable features, not data formats.
  • Left for a ruling, not lifted from PR Timeline reconstruction axis, I/O sidecar lifecycle, alerting correctness + test matrix #8: size-pressure eviction for the body
    store (main's retention is time-only — a real gap), and the target-lane
    timeline axis (its branch deletes TargetView, which this branch keeps).

🤖 Generated with Claude Code

guan4tou2 and others added 23 commits August 21, 2026 12:37
Starting phase 3 with the primitives, so the eighteen items that follow
do not each invent their own.

**Empty states get their third part.** Every empty view was a grey circle
and two lines of grey text with no way out — "Screenshots will appear
here when captured" tells an operator exactly what they had already
worked out from the empty screen. §5.4 wants what will appear, *why there
is nothing yet*, and one button. The middle one is where the work is:
"no screenshots yet" is the observation they just made; "periodic capture
is off and you have not taken one by hand" is the reason, and it implies
the fix. Screenshots, Loot, Targets, Marks and Transcript convert here.

**Cards move their colour to the left.** The 2px bar across the top made
the card itself the status indicator, so a screen of cards read as a wall
of colour with nothing standing out — including the one that was actually
shouting. Colour goes into a block on the left wrapped around the icon,
12% tint like the badges, solid only for danger. The card's background
stays `surface` and never changes with state.

Also fixes a live `ReferenceError` that predates this branch: the
transcript's ↗ button called `onNavigate` inside App, where only `setView`
exists. That is §7's transcript ↔ timeline link — one of the five
cross-view routes phase 3 is here to complete — and it threw on every
click.

`test/tailwind-classes.test.ts` is new because I shipped two dead classes
within an hour writing the card: `border-redlog-border-strong` (a token
the phase 1 palette collapsed away) and `text-wrap-pretty` (the CSS
property name; Tailwind spells it `text-pretty`). Tailwind drops what it
does not recognise without a warning, so both looked right in review and
did nothing on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…button

**The sidebar order is fixed and the numbers are printed** (§5.3). Those
two changes depend on each other: while rows could be dragged, the number
was a property of the operator's current arrangement rather than of the
view, so printing it would have taught the wrong thing — and ⌘2 had
already drifted off the sidebar once, which is why the shared order
module was written in the first place. Fixing the order is what lets the
number be shown, and showing it is what makes the shortcut learnable
without opening the ? panel.

That deletes the drag gesture, the localStorage persistence, the
change-subscription, and the state and effect in App that consumed it —
about seventy lines, and with them a class of bug where what ⌘2 did and
what the sidebar showed were maintained separately.

**The status bar's ⏱ button goes** (§10). It wrote to the same table as a
marker with neither a title nor a severity, so what it produced was a
marker that says nothing — the operator has to remember what they meant
by that timestamp, which is the job the marker dialog already does
properly. ⌘⇧M reaches that from anywhere, including from another
application.

One note on verification: a full e2e run failed on `cli-smoke` after
these changes, taking 7.4 minutes for a spec that takes 6 seconds. It
passed in isolation and in two subsequent full runs, and nothing in this
diff touches the CLI or the API server. I could not reproduce it and am
not claiming to have explained it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
⌘K meant two different things depending on where you were: the Timeline's
own fuzzy palette there, and a jump to the Search sidebar page everywhere
else. So an operator had to know which surface they were on to know what
the key would do — and the Search page itself occupied a whole navigation
slot to provide full-text over events and nothing more, while navigation,
project switching, density and every app-level action had no keyboard
route at all.

§10 collapses that. ⌘K is the same palette on every view and covers
navigation, actions, density, project switching and event search. ⌘F
takes in-page filtering, which is the convention every other desktop app
has already taught; the Timeline's `/` still works, since there is no
reason to take away the chord that view taught first. ⌘/ stays bound as
an alias to the palette rather than being deleted, so the muscle memory
lands somewhere useful instead of nowhere.

The Search page and its component are gone — a view, nine strings and a
sidebar slot.

Two things the tests caught in my own work. The palette advertised ⌘9
against the ninth sidebar row, but ⌘9 is Settings' — pinned outside the
numbered run — so it was offering a key that does something else, which
is worse than offering none. And the chord was hardcoded to `⌘`
regardless of platform, so every Windows operator would have been taught
the wrong key by the surface built to teach them.

The Timeline's scoped palette knows about lanes, operators and hosts in a
way a database search does not, and it lost its chord when ⌘K went
global. Rather than leave a working feature with no way in, the palette
offers it as an action. Folding those three into the global search
properly is still owed — §10 lists operator and host among what ⌘K should
cover, and right now it reaches them only by that indirection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five modules had grown their own copy of the platform check and four
wrote it as `platform !== 'win32'`. On Linux that is true, so every Linux
operator was shown `⌘` — a key their keyboard does not have — in the
sidebar tooltips, the status bar, the Timeline and the `?` panel.

`shortcuts.ts` was never wrong: it takes `isMac` as a parameter, which is
exactly why its tests passed throughout. The error was in what every
caller handed it, and no test looked there.

The bug also survives being fixed in one place. I wrote the palette's
version an hour ago as `=== 'win32' ? 'Ctrl+' : '⌘'` while removing a
hardcoded `⌘`, which moves the mistake to the other side of the same
check and still shows Linux a Command key. The question is not "is this
Windows" — it is "is this a Mac", and only `darwin` answers yes. One
module answers it now, and a test asserts nobody re-derives it.

Operator search lands in the palette with it. §10 lists operator among
what ⌘K covers, and unlike host it needs no aggregation and no loaded
timeline — `operators:list` is a plain registry read, so this half had no
reason to wait for the other. Choosing one filters the Timeline to what
that person did. Revoked operators are left out: a revoked key cannot
produce new events, so offering it as a filter promises a view that can
only ever shrink.

Host stays owed, and belongs with the SQL aggregation rather than with
the palette — `data.host` lives inside event JSON, so only the Timeline
can currently count it, and the global answer is the same `GROUP BY` the
Targets page needs. They are one query with two consumers.

Also drops a comment in `shortcuts.ts` still describing ⌘1..8 as
following a drag-reorder that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l left

Five things from the audit against 1c.

**The sidebar is 186px.** It was 140px when it held an icon and a label;
printing the number left a few pixels between label and number, and a
loot badge squeezed all three. The whole value of printing the number is
that it can be read, so the column has to fit.

**Count badges stop filling.** Loot was `bg-amber-500 text-white` and
scope violations solid too, so the sidebar carried two solid blocks —
exactly the competition "one solid red per screen" exists to prevent.
§4's badge rule is 12% tint plus same-colour text, and only danger fills.
A count is not danger.

**One red per state.** The active row drew its text as `red-400`
(#d75f63) and its indicator bar as `red-500` (#cf5459) — two reds for one
state, five units apart, which reads as a rendering artefact rather than
a decision. Both are `redlog-accent` now. The raw `red-*` classes are
legitimate elsewhere (the `soften` map remaps them), so this is about the
inconsistency, not the namespace.

**Empty states take screen-title weight** — 19px/15px rather than
15px/13px. Sized down they read as a caption for content that is not
there. The action becomes §4's primary: solid accent, 34px, 8px radius.
An empty state has exactly one button, which makes it the primary one by
definition.

**Residue from deleting the drag gesture**: `DRAG_THRESHOLD_PX`,
`ITEM_STRIDE_PX` and a comment claiming persistence still lives in the
shared module, none of which had any remaining referent. The Settings row
also gains the `title`, `aria-label` and `aria-current` every other row
already had — it had been the one row a screen reader could not place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…again

Implements the §1 ruling. The old rule — brand red may only draw text or
a hairline — drew the line on "does it have a background", which made the
strongest control on a screen look like the weakest. "Install the shell
hook" is the only way out of the dashboard's main question and an outline
button cannot carry it. The line that works is verb versus state: a
filled #d75f63 "Create project" reads as something you press, a filled
#ff4d4f "IP exposed" reads as something being reported.

So the four buttons I converted to outlines in phase 1 go back to solid
accent — correctly, at the time, against a rule that was itself wrong.

They land in a `Button` component rather than four more class strings.
Two of those four were still carrying dead classes from before that
conversion, fighting the ones beside them: a `text-white` under a
`text-redlog-accent`, a `hover:bg-red-700` under an accent hover. Neither
did anything, both looked deliberate, and one definition is what stops it
recurring.

Primary takes dark text (#16090a), which the synced §4 now specifies:
white on #d75f63 is 3.68:1 and fails AA, #16090a is 5.3:1. That is a
contrast decision rather than a taste one, so the exact value is written
in rather than approximated with a Tailwind shade.

The hard constraint from the ruling — never a filled primary beside a
filled danger — is asserted against `ConfirmDialog`, the only surface
that fills a button with danger and therefore the only place it could be
broken. The 2px active-indicator bars stay accent-filled: a hairline is
what §1 still permits, and the test distinguishes a line from a control
by whether it has padding.

Also syncs §1, §4 and §10 from the design project, including the new
wordmark spec and the operator/host split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The danger button was the case I flagged, but it was never the case. Every
accent in this palette is mid-luminance, so white fails AA on all of them
— and the two I had not measured are the worst:

    #d75f63 + white  3.68:1      #16090a  5.3:1
    #ff4d4f + white  3.27:1      #1a0303  6.1:1
    #d4ac5a + white  2.59:1      #1a1203  7.2:1
    #5ecf9c + white  1.93:1      #03150d  9.7:1
    #3fc7d6 + white  2.03:1      #031215  9.4:1

At 1.9:1 that is not low-contrast text, it is barely text. The rule that
follows is simpler than the one it replaces and needs no per-component
judgement: fill anything, and the text on it goes dark. Buttons, badges,
chips, progress marks — one rule.

Seventeen fills convert, and five `on-*` tokens land so the value is
named rather than repeated. The remaining four `text-white` uses were
plain text on a dark surface, where white was only ever an approximation
of `text` (#ececf0) from outside the token namespace; they take the token
too, which leaves the renderer with no `text-white` at all — an easier
invariant to hold than "no white on a fill".

Deliberately not: darkening #ff4d4f, whose luminance is exactly what
separates it from the brand red, and not a third red, which is the
problem §1 was written to end.

The test asserts both halves — that each dark clears 4.5:1, and that the
white it replaced did not — so the rule keeps its justification attached
rather than becoming a number someone later rounds off.

Phase 3 gains the wordmark as item 20; the app icon needs a vector
original and is not in any phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight tabs in a single 13px row was already hard to scan, and two of them
— "Integrations" and "Data" — had drifted into meaning roughly the same
thing. Underneath, Plugins held sub-tabs and those held publisher and
revocation lists: three levels below the second level, when §10 allows
two.

The container had to come first. Thirteen categories fit down a side and
do not fit across a top, so the content could not be split until there
was somewhere to put it — which is why this lands before the pages
themselves are filled out. A left list also does what a tab row cannot:
the categories stay visible while a page is read, it takes group
headings, and it has somewhere to put the search box §10 asks for.

The eight blocks route onto the thirteen pages without being rewritten —
the content was already separated into named panels, so this is wiring.
"Data" becomes retention, chain, sharing and appearance, which is the
split it had needed for a while.

Plugins loses its sub-tabs. "What is installed" and "where to get more"
are one task, so they are two sections of one page rather than two
destinations.

Two things caught in the process. The e2e specs reached settings by
visible tab text (`/^data$/i`, `/^plugins$/i`), which cannot survive a
category being split — they use the `data-settings-page` hook and an
`openSettingsPage` helper now, the same shape as `openView`. And making
the root a flex row turned the save bar, previously a sibling below the
content, into a third column: it took 822px and squeezed the content pane
to 35px. The right pane is a column of its own now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…quence

`CORE_SOURCE_ORDER` was an ordered triple — shell hook, agent tailer,
built-in terminal — with everything else declared "enrichment" and
excluded from the dark→recording path outright. Two things were wrong,
and the second is a bug rather than a modelling preference.

The ordering claimed a sequence that does not exist. An operator running
a proxied web assessment wires mitmproxy first and may never install a
shell hook.

And because mitmproxy was outside the path, the model reported `dark`
while HTTP and DNS events were landing on the timeline. "Dark" means the
timeline will stay empty; it was saying that about an app that was
recording. A source that produces events is a source that produces
events, whatever the onboarding copy calls it.

So: three groups — commands, traffic, artefacts — with no order inside
one, because the useful distinction was never rank but *what each source
captures*, which is the choice an operator is actually making. All nine
sources are in the model now, and `recording` means any of them is live.

`nextStep` follows from state rather than position, since "first in the
array" would be an arbitrary answer dressed as a recommendation. A
`wired` source needs an event; a `todo` source needs an installation
first. The wired one is the shorter route out of dark, which is the only
thing this model is for — so it now points at the installed-but-quiet
hook rather than skipping past it to the next unset source.

The Dashboard drops the numbered `<ol>`. Numbering was the visual claim
that the sequence existed.

Three readiness tests and two render tests change to assert the new
contract rather than being deleted — the old behaviour was real and
tested, and the diff should show which properties were traded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The toolbar carried eight independent switches — collapse agent turns,
compress idle gaps, session dividers, anomaly filter, auditor view,
follow, timezone, lane visibility — with equal weight and no stated
relationship between them. That is 256 states, and an operator who
arrived at one had no way to tell whether it was a sensible place to be.

They were never independent. They fall into three postures §6 names:
working (collapsed, compressed, local time), audit (chained events only,
UTC, nothing folded or squeezed), debug (everything, plus session
boundaries and raw time).

Audit does not compress time, and that is the substantive part. A gap is
evidence about when nothing happened; squeezing it is editing the record.
Audit is also locked — every other mode is a convenience and can be
adjusted, but audit is a claim about what is being shown, and a claim you
can quietly tweak is not a claim.

The mode is derived from the settings rather than stored alongside them,
so the label cannot disagree with the screen. Adjusting anything drops
the view to "custom" rather than leaving it wearing a name it no longer
earns — which is the failure that would make an audit view worthless.

§7's other half comes with it: the status bar's tier counter now enters
audit mode rather than flipping `auditorView` alone. That flag on its own
produced a view that hid the logged tier while still compressing time and
folding agent turns — something that looks like an audit view and is not
one.

The mapping is a pure module with its own tests, including the one that
matters: an adjusted audit no longer reports itself as audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A design review that started from "what is this project for" ended up
revising the core, so this lands before any code does.

RedLog records what actually happened in an engagement — every command,
connection, file and screen, whoever or whatever produced it — so that
afterwards you can replay it, hand a purple team something they can judge
traffic from, and find evidence of the things you were not watching when
they happened.

**Tamper-evidence is demoted to a property of how the record is kept.**
The chain, the anchoring, the signing, the bundle and the verifier all
keep running and stay fully supported. They stop being the design centre,
and they stop earning prominent UI. The load-bearing property is now
knowability: completeness matters more than before, but it serves "so you
can find out what happened" rather than "so it survives a challenge".

That inverts several conclusions reached earlier in this cycle. Gap
compression stopped being a contradiction of "a 20-minute gap is right" —
the record still holds the gap; the timeline may draw it compressed, and
nobody reviewing eight hours wants to scroll twenty idle minutes.
A reading posture in the app became core rather than scope creep, since
the argument against it rested on an evidence consumer who never opens
the app and is no longer the design centre. Search became core outright:
"find the evidence later" *is* search.

An AI agent is one producer among several, not half the product. It is
the sharpest case of the problem — you delegate, it does three hundred
things, you know none of them — but the same gap opens with no agent
involved, and the model already treats `agent` as one lane of eighteen.

`DESIGN-core-and-capture.md` carries the review: four capture gaps and
what was decided for each, the point/span timeline model, the
purple-team activity record, and what is still open. `ROADMAP.md`'s v1.0
gate is flagged rather than rewritten — it is written entirely around
bundle verification and is arguably now missing criteria of its own, but
that is not a call to make unilaterally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting the previous commit. It promoted the purple team to primary
stakeholder, which was wrong in the same way the original doc was wrong
about the evidence consumer: both put the everyday output in someone
else's hands.

The everyday consumer is the operator themselves — P1, P2 or P3 a day
later, writing the engagement up. That is not a fourth persona, it is the
second half of the same person's job, and it is the phase the whole
record exists to serve.

Which sharpens what the record has to carry. Writing a report means going
back for the screenshot of a finding and the exact command with its
output, its timestamp and its target, in a form that can be pasted into a
document. RedLog still does not write the report — that stays downstream
— but everything captured is judged by whether it survives that trip.

Two things get re-weighted by this, and both were under-weighted before:
screenshots are report material rather than incidental artefacts, and a
command record is only as useful as its output. "I ran `nmap -sV`" is not
evidence of anything. That is why the two output-capture gaps matter more
than their size suggests — Windows only captures output behind a manual
`Redlog-Run` prefix, and tool output is replayable but not searchable.

The purple team and the evidence consumer stay, demoted to secondary
stakeholders where they belong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were built earlier in this phase and both are wrong under the
revised core (`DESIGN-core-and-capture.md`).

**The three view modes go.** They invented three personas for one
surface, and two of them do not use it: the evidence consumer never opens
the app, and "why is capture broken" is answered better by the capture
health card and the `Needs attention` counter than by a timeline preset.
The audit mode's ingredients — chained-only, UTC, locked — are all
tamper-evidence concerns, which is no longer the design centre.

There was also a shipped bug that made the point better than the
argument: the existing toggle defaults happen to match the debug preset
exactly, so a fresh timeline told every operator they were in Debug mode.
The three categories were reverse-engineered onto flag combinations, and
when the default state lands on "debug" the categories were describing
combinations rather than postures.

**The Search view comes back**, recovered from history rather than
rewritten. Folding it into ⌘K was justified by §10, but §10 was never
checked against what it served. Finding evidence afterwards is now a core
use, and a dropdown is the wrong shape for it: after-action review asks
"which files did the agent touch", "what happened on this host", "which
commands failed in those two hours" — none of which are jumps. ⌘K stays
the jumper and reaches the view like anything else.

Search sits at ⌘3, next to the timeline. Ten sidebar entries into eight
numbered slots means `loot` and `marks` now carry no chord. That is a
real cost, taken deliberately and written down where the order is
defined — both stay reachable from the sidebar and from ⌘K, and neither
is somewhere an operator jumps mid-keystroke.

Not touched: the eight toolbar toggles. Two of the three deletions agreed
earlier rested on reasoning the core revision then retracted — gap
compression is a core convenience now, not a contradiction, and agent-turn
folding cannot be dropped in favour of the >50 event-stream threshold
before that threshold exists. Deleting them now would be applying a
decision whose grounds are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ttings

Exporting was reachable from six places — the dashboard, three separate
groups in Settings, the timeline's slice button, the transcript's — each
worded its own way. An operator writing an engagement up had to already know
which one produced the thing they wanted, and the labels did not say.

The fix is not to pick a winner. The scope of an export is a property of the
export, not of where the button sits; once scope is an option, six locations
collapse to one control offering the visible range, this view, everything,
and the evidence bundle separated below. Each option says what it will
produce first — a wrong export is cheap, but finding out after opening the
file is a wasted round trip during the one task this record exists for.

A view still contributes the part it alone knows. The timeline knows what
"the visible range" means and registers it while mounted; the registration
clears on unmount, because an option labelled "the visible time range" that
quietly means the previous view's range is worse than no option at all.

Two things that looked like the seventh and eighth entry points are not:

The transcript's Markdown button writes the clipboard, not a file. Folding
it in would have made the count tidier and the action worse — a file dialog
is not what you want when you are pasting into the report you have open.
Renamed to say what it does.

The Settings "Retention & export" page had one setting left after the three
export groups moved out, and that setting was the screenshot interval, which
is a capture setting that had been filed under export. It moves to capture
and the page disappears: thirteen pages become twelve, thirty-four groups
become thirty-one.

test/export-single-control.test.ts walks every renderer file rather than the
ones I happened to touch, so a seventh entry point cannot appear quietly.

npm test 800 passed · npm run e2e 47 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Browsing a registry, pinning publishers by Ed25519 fingerprint, and reading
revocation lists are the machinery of distributing capture code. The core is
"nothing missing, findable afterwards" (docs/DESIGN-core-and-capture.md §1);
distribution serves extensibility, which is a different product.

What stays is the installed list, because "is anything capturing that I did
not put there" is a question §1 does have to answer.

Removed: MarketplacePanel and PublisherEditor (381 lines of Settings), the
core modules marketplace.ts and publisher-trust.ts, eleven IPC handlers and
their preload surface, config.marketplace.defaultRegistryUrl, 46 i18n keys
per locale, and examples/registry.

The redlog-sign CLI goes too. It existed to produce signed registry entries;
with no registry there are no entries, and a redlog-sign on someone's PATH
that signs for nothing is worse than its absence.

Two handlers were exposed and never consumed: marketplace:listVersions and
marketplace:rollback. Versions only exist for registry-installed plugins, so
they had no reachable caller either way.

Revocation lists were only ever displayed in the marketplace panel — the
plugin loader does not consult them. Removing the panel removes the whole
feature rather than orphaning a check, which is the outcome to want here but
worth stating rather than discovering later.

Settings: 31 field groups to 30, 12 pages unchanged. One group understates
it — that group held a registry browser, a trust editor, and a revocation
viewer stacked inside it. Noted in the design doc, because the group count
is the metric positioning risk #2 is measured by.

npm test 771 passed · npm run e2e 43 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/DESIGN-core-and-capture.md §2.4 calls this the highest-leverage single
capability in that note, because one mechanism closes two gaps. Tool output
was replayable but not queryable — you could watch the nmap run back but not
ask which run mentioned 445. And an `ssh` into a jump host produces no
structured commands at all (§2.2), only bytes; searching those bytes is the
only thing that makes such a session findable.

It does not parse. "Which runs mention 445" is answerable from what was on
screen; "list every open port" needs nmap's XML read into findings, and what
counts as a finding is per-shop opinion — the same interpretation the
positioning lists as a non-goal.

Three decisions had plausible alternatives:

A separate database file rather than a table in events.db. The index is
derived, mutable and rebuildable; the evidence DB is none of those, carries
append-only triggers, and is copied verbatim into evidence bundles. The
deciding argument was retention: recordings are swept after N days, and text
surviving inside the evidence DB would make the policy a lie — the operator
is told the recording aged out while its contents stay searchable.
retention.ts now calls pruneCast, and test/retention.test.ts fails if that
link is cut. A second test keeps the index out of evidence bundles, where it
stays out today only because the export copies named subdirectories.

Token search rather than trigram. Trigram gives grep semantics at roughly 3x
the indexed text on disk, which across an engagement of 50 MB recordings is
the difference between an index that ships and one that fills the disk.
unicode61 with prefix indexes covers what operators type — an IP, a port, a
hostname, a tool name. The UI states the limit rather than letting a miss
read as absent bytes.

The index status is surfaced rather than hidden. A project still being
indexed returns fewer hits than it will in a minute, and for this product
that is the one failure mode that must never be silent — so the count of
unread recordings shows in the results header and on the no-results screen,
which is exactly where a silent partial index would do its damage.

Hits carry line-aligned byte offsets, so a result expands in place through
readCastRange — the same path the timeline uses for per-command replay.
test/cast-index.test.ts proves the round trip rather than just that rows
land; a hit whose range does not resolve claims the evidence exists and then
fails to produce it.

Indexed at session close (the bytes are final, and the file is read once) and
backfilled in the background on project open, so recordings written by an
older build or restored from a backup become searchable without anyone
knowing to ask. Both paths are fire-and-forget: the exit path is racing the
DB close, and session_end is evidence while the index is rebuildable.

npm test 787 passed (81 files, incl. the DB suites) · npm run e2e 43 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #17 made the app's chrome behave like a desktop app rather than a web
page — dragging across the sidebar or status bar no longer paints everything
blue, while content an operator needs to copy stays selectable. It shipped
with the rule written down and never checked in a running app: the session
that built it ended blocked on being able to launch one, listing three
behaviours as outstanding.

One of the three is objectively measurable — the selection boundary is a
computed style — so it is a test rather than a squint. The other two are
native OS context menus, which Playwright cannot see; they still need a human
on each platform, and saying so beats a test that pretends otherwise.

A third concern from that session resolves by reading rather than running:
the terminal's "clear screen" calls term.clear() instead of writing `clear`
down the pty, so it cannot leave a command on the timeline that nobody typed.

The spec also guards a boundary that moved afterwards. The export control
(§10) now sits in the title bar, which is both select-none chrome and a
-webkit-app-region: drag surface, and a button inside a drag region is
decoration — the OS takes the mousedown to move the window and the click
never lands. It is in a no-drag ancestor and the menu opens; both are now
asserted.

Writing that assertion surfaced a wrong belief of mine: -webkit-app-region
does not inherit. Its initial value is `none`, and the drag surface is
resolved by hit-testing region boxes, not by reading the computed style under
the cursor — so the button reads `none` while sitting inside a no-drag
region and working correctly. The test walks up to the nearest explicit
region, which is the mechanism rather than a proxy for it.

npm run e2e 49 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ontents

Third pass with the same test: does the operator in §1 have to understand
this to answer "what happened, and can I show it"?

Two of the five candidates failed on inspection, both the same way — the
group's name did not describe its contents, and I had judged the name.

"Polling" is not a tuning knob. Every field in it decides what RedLog itself
sends to the network and to whom: which resolver or third-party echo service
learns the operator's address, how often, from where. During an engagement
that is OPSEC surface and belongs in front of the operator rather than in a
packet capture. Kept, renamed "What RedLog itself sends out" — the bad name
is what nearly deleted it.

"Team Profile Sync" was one useful button and one duplicate. Import
duplicated the project picker's, which is where it belongs, because you seed
a config when creating a project rather than after. Export could not go with
it: it carries views.json as well as config.yaml, so it is not a button for
`cp`, and deleting it would strand an import that consumes files nothing
produces. Kept as one button, renamed "Hand-off profile".

The three that survived:

Screenshot quality folded into Screenshots — one subject reading as two
settings. VPN adapter detection folded into Safe / Exposed IPs, since it
exists only to answer that group's question and standing apart made "am I
exposed" look like two unrelated concerns. "Check for updates" moved to the
title bar, beside the version string it already sat next to a copy of; it is
an action about the version, and nobody was looking for it under Settings.

test/settings-ia.test.ts now caps the group count. The number is a proxy, but
it is the proxy positioning risk #2 measured, so a group added without one
removed should have to argue for itself in a diff.

Four groups still need a scope ruling rather than a merge: cloud share, the
deconfliction webhook, operator tokens, the MCP server.

npm test 788 passed · npm run e2e 49 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merges origin/main (full HTTP body capture, JA3, cookie tracking, HTTP
History panel, proxy-bypass detector) into this branch. Merged rather than
rebased: 18 commits against heavily-edited i18n files would have meant
resolving the same conflict eighteen times.

The i18n merge is a union minus what this branch deleted, so the 61 keys
removed with the marketplace and the export groups do not come back.

Two conflicts resolved against main on purpose: the sidebar badge colours
return to the 12% tints (main had them solid, which the design ruling
reversed), and the sidebar keeps both the Search entry and the new HTTP
History one.

The four breakages, all found by tests rather than by reading:

The external-API allowlist (9a97bd0) blocked `terminal`, which is the type
in redlog-cli's own help text, so `redlog-cli log` 403'd. Added, along with
clipboard/screenshot/browser/http_navigation. The derived types stay out —
forging `system` or `pivot` forges RedLog's conclusions about the engagement,
which is the whole point of that commit. A rejection now also records to
capture-health: a 403 an integration ignores is capture silently stopping,
and that is the one failure §1 does not allow.

The same allowlist broke three specs that seed derived rows directly. They
now use `/api/events/seed`, which skips the allowlist and does not exist
unless REDLOG_E2E=1. A flag on `/api/events` would have been fewer lines and
wrong: the production route's refusal is itself under test, and a bypass on
the same route would have made that test pass with the door open.

HttpHistoryPanel arrived outside the design system: 48 zinc classes and zero
redlog tokens, nine hardcoded English strings, its own toLocaleTimeString,
truncation with no route to the full value, and no list-keyboard wiring.
The time and truncation guards caught themselves; the palette and the strings
I found by reading.

The list-keyboard contract did not catch the missing hook because it named
its five files by hand — the exact failure its own comment describes, one
level up. It now derives the list from the sidebar order. Writing that
surfaced a second silent pass: the first version matched the wrong constant
name, found nothing, and `it.each([])` generated zero tests and reported
success. It now asserts a floor.

npm test 790 passed · npm run e2e 50 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uploading an evidence bundle to a backend with an expiry picker and a
stub/HTTPS switch is distribution infrastructure. It is the same argument
that removed the marketplace and it lands harder: §1 makes the operator
writing the engagement up the consumer of the output, and the bundle already
reaches them through the one export control.

How the bundle then gets to a client, a reviewer or a court is the operator's
business. A product that carries a record of an engagement should not also be
the thing that ships it somewhere with a link and a timer.

Removed: CloudSharePanel and its two orphaned manifest types (304 lines of
Settings), src/core/cloud-share.ts and cloud-share-uploader.ts, five IPC
handlers with their preload surface, config.cloudShare, 42 i18n keys per
locale, two unit specs and one e2e spec, and the README screenshot.

Settings: 12 pages to 11, 27 field groups to 26.

The hard review-gate checkbox goes with it, which is worth naming rather
than losing quietly: it existed because uploading raw capture to a remote
endpoint needed a deliberate stop. Nothing in the remaining path uploads, so
the gate has nothing left to guard — the evidence bundle is written to disk
and stays there.

npm test 782 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The HTTP History panel arrived rendering one row per connection, with a
5000-row page and an IntersectionObserver to survive it. That is Burp's
information architecture: right for a tool you drive traffic with, where each
request is a thing you are about to modify and resend; wrong for a record of
an engagement, where "I ran a directory brute-force against x" is one action
whether it made four requests or forty thousand.

docs/DESIGN-core-and-capture.md §3 already ruled on this — a single connection
is a point, a bulk operation is a span, and "only the command is listed; the
connections it produced are its contents, reachable by reference, never
rendered individually."

src/renderer/src/lib/httpActivity.ts groups flows into that shape: a parent
command when the record links one, otherwise same host within a 4s idle gap.
The gap is chosen against the failure that matters — a scanner's own pacing
must not shatter one run into a dozen rows, since that recreates the wall this
removes. Merging two separate actions is the cheaper mistake; both are still
there one level down with visible timestamps.

Nothing is dropped. Expanding a row lists every flow, and "Every request" is
still one click away with the original table intact. What changed is the level
the eye lands on: two rows instead of thirty-one, each carrying its method
mix, request count, status spread and duration without being opened.

The e2e caught a bug the unit tests structurally could not. I read a flow's
parent command from `_causes` on either half of the pair — but a response's
`_causes` points at its own request, because that is how the api-server pairs
them by flow_id. Every flow got a unique "parent" and grouping degenerated to
one group per connection: exactly the shape being removed, arrived at through
the fix for it. The unit tests hand `causeEventId` in directly, so they were
blind to it by construction.

Two further test lessons, both about selecting by the wrong thing:
`[aria-expanded]` also matches the title bar's export menu, which is how the
row count first read 3; and clicking a view toggle by its zh-TW label fails in
whatever locale the fixture boots. Both now use explicit hooks.

npm test 794 passed · npm run e2e 49 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #8 (opened 2026-08-12, 47 commits, 162 files, +19k lines) is 88 commits
behind main and its central decisions have been overtaken. What is still worth
having is the documentation, and one piece of it is not optional: shipped code
on main names `docs/ALERT-ROLES.md` three times and the file was never merged.
`src/core/alert/` was built later, from a spec that only ever existed on an
unmerged branch.

Seventeen documents, no code. The suite is tightly cross-linked by design —
salvaging the one that main cites pulls the framework it belongs to.

DESIGN-PRINCIPLES.md needed a header rather than a quiet import. Its §1 says
the irreducible core is a tamper-evident record a third party can verify, which
the 2026-08-21 revision replaced with knowability; and the doc asserts that when
another doc disagrees with a principle, the principle wins and the doc is the
bug. Importing that verbatim would have made DESIGN-core-and-capture.md the bug.
The header inverts that rule for §1 and only §1, and says so.

It is salvaged as argued rather than rewritten to agree. Editing the old
reasoning into the new conclusion hides that a decision was made.

Also removes docs/CLOUD_SHARE_BUNDLE.md, which the cloud-share removal left
behind, and the reference to it from the salvaged DELIVERY-TARGETS.md.

Deliberately NOT salvaged, pending a ruling:
  - plugins/scan-parsers — parses nmap/nuclei output into events, which §2.4
    ruled against by name: what counts as a finding is per-shop opinion.
  - plugins/c2-tailers — following a Sliver or generic C2 log is a real capture
    gap and fits the existing tailer host. This one is worth building.
  - the target-lane axis and phase ribbon — the axis is a real idea; the branch
    also deletes TargetView, which this branch keeps and the list-keyboard
    contract names.
  - src/core/io-store.ts and the artifact GC/pin pair — likely superseded by
    http-body-store.ts and cast-index.ts, but not verified.

npm test 794 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second of PR #8's two bundled plugin packs, and the one that survives the
core review. A C2 framework (Sliver, or anything via a documented JSONL
contract) writes beacon check-ins, task results and pivots to its own log —
an out-of-band channel the substrate capture never sees. This follows that log
shell-side and lands each line on the timeline, so C2 activity sits beside
everything else in the record.

Its sibling pack, scan-parsers, is deliberately NOT salvaged. The distinction
is the same one DESIGN-core-and-capture.md §2.4 draws: a C2 log is an event
stream — things that happened, recorded verbatim (DESIGN-PRINCIPLES §3). Scan
output is a result set — claims about the world, and turning nmap XML into
structured findings is interpretation, which is per-shop opinion and a stated
non-goal. The recording is searchable instead; that closes "which run
mentioned 445" without pretending to answer "list every open port".

Nothing runs inside RedLog: the pack is declarative (🟢) tier, follows a file
and POSTs to the local API. test/c2-tailers.test.ts carries the original 8
parser tests plus 4 that load the bundled manifest against the *current*
schema — because a bundled plugin that sits in the directory but fails
validation is worse than an absent one: the capture source shows up in the
roadmap and never fires. The schema moved while the branch sat unmerged; the
pack validates against it today.

Backlog updated: Q1 (scan-parsers) marked ruled-against with the reason, the
c2 pack marked built.

Not salvaged, with reasons recorded for next time:
  - io-store.ts + artifact-gc/pin: io-store is the generalised sidecar the
    SPEC-IO-SIDECAR proposal describes; http-body-store.ts on main already
    does content-addressed dedup sidecar storage for HTTP bodies, and
    cast-index does it for recordings. The artifact GC/pin pair adds
    size-pressure eviction, which main has NOT built (retention is time-only)
    — a real gap, but a design question, not a copy. Left for a decision.
  - target-lane axis + phase ribbon: the axis is a real idea, but the branch
    deletes TargetView, which this branch keeps and the list-keyboard contract
    names. Needs reconciling rather than lifting.

npm test 806 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 82006e93-45b9-4a29-8a37-454e5a6d55ee


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

guan4tou2 and others added 2 commits August 22, 2026 23:15
… traps

CI on #23 was green on ubuntu and red on windows-latest — two tests I added
this branch, each making a Unix assumption.

settings-ia: `/type SettingsPage =([\s\S]*?)\n\n/` needs a blank line to close
the match, but .gitattributes only pins .sh/.py to LF, so .tsx checks out CRLF
on Windows and the two newlines have a \r between them. Now `\r?\n\r?\n`. Same
family as the path.sep trap this repo hit before.

c2-tailers: asserted the hooks' exec bit via fs.statSync().mode, which Windows
checkouts drop to 0 — so a correctly-tracked plugin failed there. Git's index
mode is the cross-platform truth and is what a Unix clone actually gets; the
test now reads `git ls-files -s` and asserts 100755, which is also the bit that
matters (a clone's hooks must be runnable, the CI runner's filesystem need not
be).

Both green locally; scanned the rest of this branch's new tests for other
bare-\n\n or LF-split regexes and found none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/DESIGN-core-and-capture.md §2.1's first capture gap. mitmproxy only sees
what is proxied, so the timeline goes dark the moment a tool talks to a host
over something else — SMB, LDAP, RDP, a reverse shell, a C2 beacon. `nmap -sV
10.10.11.24` would show the command and then nothing about what it reached.

Same shape as process-monitor: poll a table the OS already keeps (the socket
table), diff against the last snapshot, emit on the change. It records who
connected to which IP:port, over what protocol, for how long — no payload, no
root. Two events per connection, `connection` on open and `connection_end` on
close carrying the duration, so a reverse shell held open across a hundred
polls is one span (§3), not a hundred rows.

The parsing is the risk — BSD's `addr.port` vs `:port`, IPv6 zones, the pid
column landing in a different column per OS — so it is split into a pure
`connection-table.ts` (three platform parsers + diff + capturability) with 19
fixture tests, away from the shell-out. `ss -tunpH` on Linux, `netstat -n`
per-proto on macOS, `netstat -no` on Windows.

The blind spot is stated, not hidden: established-connection polling cannot
see a SYN scan, which completes no handshake. `nmap -sS` leaves a command and
no connections. The limitation is surfaced twice — a `connection_capture_started`
system event when it turns on, and a line next to the settings toggle — because
a capture that silently misses a class of activity is worse than one that
admits its edge, the same confident-green failure the alerting model exists to
prevent.

Off by default (it shells out every 2s and is capture the operator opts into),
its own capture-health source in the traffic group split from mitmproxy by
subtype so neither lights the other's indicator, and the app's own API port is
filtered so RedLog does not record its hooks talking to itself.

Settings: 26 field groups → 27 (the cap in settings-ia.test.ts moves with it —
a real capture feature, argued in this diff).

Verified in a running app (e2e/connection-capture.spec.ts): a seeded connection
renders on the timeline reading as a connection, the close carries its
duration, the toggle round-trips through autosave, and the SYN-scan note is
visible where the switch is.

npm test 825 passed · npm run e2e 53 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant