Skip to content

UI search - #21

Merged
madnh merged 4 commits into
mainfrom
ui-search
Aug 5, 2026
Merged

madnh merged 4 commits into
mainfrom
ui-search

Conversation

@madnh

@madnh madnh commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Content search reaches the Web UI: the store, a project, or one pad — and a hit you can follow.

pad search has been a CLI-only capability since it landed. DESIGN.md said at the time that
it could be added to the Web UI later from the same store.Search; this is that.

What it adds

Three ways of asking one question, so three entry points and one endpoint (GET /api/search).
Neither project nor ref set means the whole store; project narrows to one project; ref to
one pad.

Where What it answers
#/search "which pad talked about this" — across the store
"Search this project" on a project page the same, scoped, pre-filled
A third tab on a pad's rail "where in HERE was that said", beside the transcript it jumps into

The rail now holds three indexes of the same pad: by section (outline), by work (tasks), by what
was written (search).

A hit on the store-wide page links to #/pads/<ref>?section=<n>&q=<query>, so following one lands
on the section and opens that pad's rail on the same search. Without that, a result is
something you then have to go and find by hand.

Design decisions worth a reviewer's attention

A protected pad is reached the way the CLI reaches one — by being named. A store-wide search
skips protected pads before reading a single body byte, and reports which. Only a search that names
one pad can open it, and the password comes from the session that already unlocked it, so it never
travels in a URL that could end up in history or in a copied link. This deliberately does not
widen to "search every pad this session has unlocked": that would need store.Search to accept
several ref–password pairs, and reading through protection one noun at a time is the thing the
store refuses to do.

What was NOT searched is on the summary line itself, not in a paragraph below it. A person who
reads one line must not read the wrong answer, and "0 matches" and "0 matches, 3 pads skipped" are
different answers to the same question.

limit has a ceiling here that the CLI does not need. --limit 0 at a terminal is a person
waiting for their own command; over HTTP it is a request to serialise the store into one response.

No search-as-you-type. There is no index — the store reads the pads it looks at — so ten
keystrokes would be ten walks over every pad.

Results are never repainted under the reader. The default order is by pad activity, so a silent
refresh slides a whole group to the top of what someone is reading. When a pad moves, the page says
the results are stale and offers to run it again. Inside a pad the rule is stronger: a search there
reads the whole file, and re-running it per arriving section would turn a busy pad into a poll.

The match is highlighted from offsets the SERVER computed. Nothing re-runs the pattern in
JavaScript, because a second matcher disagrees with the first: --word's boundaries are Unicode
classes that \b does not mean, and folding is on by default here and off in most regex dialects.

Two fixes that came along, neither caused by this feature

A hit was cut from the front of the line, not around its match. Agent prose arrives as one long
line, so a word matched at character 500 was not in the row the search returned — a wrong answer
wearing the shape of a truncated one. This affected the CLI as much as the new UI, and is fixed for
both in the first commit.

The rail's tab strip was covered by the sticky toolbar. top: 3.25rem was a guess at the
toolbar's height; it wraps to two lines and measures 88px in practice. It was hiding Outline and
Tasks already, and would have hidden the only way into the new tab. The offset is now measured and
published as --pad-toolbar-h.

Vendor bump

puredashboard → 5d9d4f9, for a fix this PR needed. Every form-associated component mirrors a
wrapping <label> onto its inner control as aria-labelledby and mints an id when the label has
none — from a counter declared in each component file, all sharing one pd-label- prefix. So
the first <label> around a select and the first around an input were both pd-label-1, and the
second control announced the first one's name; aria-labelledby outranks aria-label, so it could
not be overridden from here. The search form's four-field row is exactly that shape: its Agent
field called itself "Project".

Reported upstream and fixed there (one counter in reactive.js, which now also steps past ids
already present in the document). The workaround this repo carried is removed in the same PR rather
than left to rot.

Note that make vendor-ui syncs the whole directory, so the bump also brings <puredashboard-copy>
along. Nothing here uses it yet.

Reviewing this

Four commits, each of which builds and passes tests on its own (checked by walking them):

fix(search) match offsets in the store, the cut-around-the-match window, the CLI fix
feat(ui) the endpoint
chore(vendor) the bump — mechanical, skim it
feat(ui) the pages, the rail tab, deep-linking, docs, demo

The order is load-bearing: the vendor bump has to precede the UI commit, which removes the
workaround.

Verification

  • make check green.
  • New store tests cover the excerpt window, rune offsets on non-ASCII, --word submatch boundaries,
    title hits, and TextWidth: 0.
  • New endpoint tests cover the three scopes, offsets surviving the wire, a protected pad before and
    after unlock (403 → hits), the limit ceiling, and a misspelled flag being refused.
  • /security-review on the branch: no findings at or above the bar.
  • Measured in Chromium against a real store, not only jsdom: 100 hits / 100 highlights on the
    store-wide page; deep-link opening the rail on the same query and landing on §44; the stale notice
    appearing on a post and clearing on re-run; the rail tab strip clear of the toolbar (88px measured,
    8px gap).

Demo scenario

mobile-budget2v — five weeks old, holds the definition of a term the flagship restates this week.
Neither "oldest first" nor cutting a hit around its match can be demonstrated on a store built five
minutes ago: one needs two pads separated in time, the other a line long enough to have a late
match. tools/gendemo/README.md is updated in the same commit, as that file asks.

Follow-up, not in this PR

The transcript rebuilds every section node on each render (body.replaceChildren()), which measured
as 0 of 20 nodes surviving one render, parsed markdown thrown away, and — visibly — a section the
reader had expanded snapping shut whenever an agent posts. It is a keyed list (key = section number)
that changes constantly, i.e. the case repeat() exists for. Out of scope here: it touches the most
intricate part of that page and deserves its own before/after measurement.

madnh and others added 4 commits August 6, 2026 00:06
…ins the word it reports

A hit's text was cut to 140 runes from the FRONT of the line. Agent prose arrives as one
long line, so a word matched at character 500 was not in the row the search returned — a
wrong answer wearing the shape of a truncated one. The window now centres on the match,
and an ellipsis at either end says the line goes on.

Getting there means the store has to know WHERE it matched, so `matcher` grew `find` and
`locate`, and `Hit` carries the match's rune offsets. They are published rather than left
to each surface to recompute, because recomputing means a second matcher, and a second
one disagrees: `--word`'s boundaries are Unicode classes that `\b` does not mean, and
folding is on by default here and off in most regex dialects. A caller that highlighted
its own idea of the match would draw the box around the wrong characters.

Two details that are easy to get wrong, and are tested:

  - `--word` spells its boundaries as CONSUMING character classes (RE2 has no lookaround),
    so the reported range comes from submatch 1. Reporting the whole match would hand
    every caller the spaces either side of the word.
  - `locate` re-finds against the CLEANED text rather than reusing an offset from the raw
    line: cleanLine turns a tab into four spaces, which moves every byte after it. It may
    legitimately fail, and -1 then means "cannot say", not "no match" — the hit stands and
    only the highlight is dropped.

The CLI passes its own 140 as TextWidth instead of truncating afterwards, so `truncateRunes`
is gone.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
…ver HTTP

The three scopes a person asks for are not three endpoints: neither `project` nor `ref`
is the whole store, `project` is one project, `ref` is one pad. `ref` is also the only
form that carries a password, exactly as `--pad` is in the CLI.

What differs from the CLI, and why:

  - The password comes from the SESSION that already unlocked that pad, never from the
    request, so it cannot end up in history or in a link someone copies. `sess.unlocked`
    is an exact-ref lookup, so anything else fails closed.
  - `limit` has a ceiling. `--limit 0` at a terminal is a person waiting for their own
    command; over HTTP it is a request to serialise the store into one response.
  - `before`/`after` are unix seconds, not "30d". The age spelling belongs where a person
    types the bound; a browser knows the clock, and a wire format that parses prose is one
    more place for two surfaces to disagree about what "a month ago" means.

A store-wide search still skips protected pads and still says which — that list is handed
straight through, because an empty result must never be readable as "the word is nowhere
in the store".

A misspelled flag is refused rather than read as false: a filter that silently turns
itself off answers a question nobody asked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
…fix a form here needs

Every form-associated component mirrors a wrapping <label> onto its inner control as
aria-labelledby, minting an id for the label when it has none. That id came from a
counter declared in EACH component file, all sharing one `pd-label-` prefix — so the
first <label> around a select and the first around an input were both `pd-label-1`,
getElementById returned whichever came first in the DOM, and the second control announced
the first one's name. aria-labelledby outranks aria-label, so it could not be overridden
from here.

The search page's four-field row is exactly that shape: its Agent field called itself
"Project". Reported upstream, fixed there (labelIdFor in reactive.js, one counter, and it
now steps past ids already present in the document), so the workaround this repo was
carrying comes out in the next commit rather than being left to rot.

Vendored from main, which also brings PR #13 (<puredashboard-copy>) along: `make vendor-ui`
syncs the whole directory, and nothing here uses that component yet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
…the hit as a place to go

Three ways of asking one question, so three entry points and one endpoint:

  - #/search, with the question IN THE URL. A search is then a link, and the thing worth
    sending a colleague is the question rather than a screenshot of the answer. Back and
    reload work because the address bar is the state.
  - "Search this project" on a project page, which is that page's scope pre-filled.
  - A third tab on a pad's rail, beside the outline and the task board — three indexes of
    the same pad: by section, by work, by what was written. A hit there moves the
    transcript next to it, through the same jumpTo an outline row uses.

A hit from the store-wide page carries `?section=&q=` into the pad, so following one lands
on the section AND opens the rail on the same search. Without that, a result is something
you then have to go and find by hand.

What was NOT searched is on the summary line itself, not in a paragraph below it: a person
who reads one line must not read the wrong answer. Three empty states stay distinguishable —
nothing asked yet, asked and absent, asked and absent but pads were skipped.

Results are never repainted under the reader. When a pad moves, the page says so and offers
to run it again; the default order is by pad activity, so a silent refresh would slide a
whole group to the top of what someone is reading. In a pad the same rule is stronger — a
search there reads the whole file, and re-running it per arriving section turns a busy pad
into a poll.

The match is painted from the offsets the SERVER computed; nothing re-runs the pattern in
JavaScript. safeInline exists for that: it is length-preserving, where safeText collapses
whitespace and would shift every character after a double space, landing the highlight
beside the word instead of on it.

Two things fixed on the way, neither caused by this feature:

  - The rail's tab strip was covered by the sticky toolbar once you scrolled. `top: 3.25rem`
    was a guess at the toolbar's height; it wraps to two lines and is 88px in practice, so
    the offset is now measured and published as --pad-toolbar-h. It hid Outline and Tasks
    too, and would have hidden the only way into the new tab.
  - The label workaround for the vendored id clash is gone, now that the fix is vendored.

Demo: mobile-budget2v, five weeks old, holds the definition of a term the flagship restates
this week. Neither "oldest first" nor cutting a hit around its match can be shown on a store
built five minutes ago — one needs two pads separated in time, the other a line long enough
to have a late match.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
@madnh
madnh merged commit d6b656e into main Aug 5, 2026
1 check passed
@madnh
madnh deleted the ui-search branch August 5, 2026 17:13
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