Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# ShadowBrain

[![Version](https://img.shields.io/badge/version-0.10.0-yellow)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-0.11.0-yellow)](CHANGELOG.md)

</div>

Expand Down
16 changes: 8 additions & 8 deletions docs/design-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ content).

## Cool accents (brand)

| Token | Hex | CSS variable | Usage |
| ------------------ | -------------------------- | ---------------------- | ----------------------------------------------------------- |
| Primary | `#3D6BFF` | `--primary` | Dominant brand. Primary actions, focus, links, selected nav |
| Primary foreground | `#E4DCC8` | `--primary-foreground` | Text on primary |
| Primary muted | `rgba(61, 111, 255, 0.15)` | `--primary-muted` | Subtle primary backgrounds |
| Accent cyan | `#4FCFFF` | `--accent-cyan` | Live/active indicators, type badge border |
| Accent violet | `#7B6AFF` | `--accent-violet` | Tag pills, secondary affordances |
| Token | Hex | CSS variable | Usage |
| ------------------ | -------------------------- | ---------------------- | ---------------------------------------------------- |
| Primary | `#3D6BFF` | `--primary` | Dominant brand. Primary actions, links, selected nav |
| Primary foreground | `#E4DCC8` | `--primary-foreground` | Text on primary |
| Primary muted | `rgba(61, 111, 255, 0.15)` | `--primary-muted` | Subtle primary backgrounds |
| Accent cyan | `#4FCFFF` | `--accent-cyan` | Live/active indicators, type badge border |
| Accent violet | `#7B6AFF` | `--accent-violet` | Tag pills, secondary affordances |

`--primary` is the anchor — most prominent. `--accent-cyan` and
`--accent-violet` are supporting, used sparingly.
Expand Down Expand Up @@ -109,7 +109,7 @@ accent on detail view. See the design system spec for the full rule.
| Spacing base | 4px (Tailwind default) |
| Type dots | 6px filled circle (1.5px inset from badge edge) |
| Detail edge accent | 2px solid, 16px left padding |
| Focus outline | 1px solid `--primary`, 2px offset |
| Focus outline | 1px solid `--foreground`, 2px offset |
| Transition timing | 150ms ease-out, color/opacity only |

---
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shadowbrain",
"version": "0.10.0",
"version": "0.11.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
36 changes: 33 additions & 3 deletions src/app/browse/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,45 @@ describe("fetchBrowseItems", () => {
const call = calls[0];
expect(call.url).toMatch(/^\/api\/search\?/);
expect(call.url).toMatch(/q=docker/);
// The search-only fields (rank, snippet) must not leak into the
// Browse response shape.
// `rank` (BM25 score) is search-only and must not leak into the
// Browse response shape, but `snippet` is preserved so the card
// can render highlighted matches (issue #24).
expect(result.items).toHaveLength(1);
const item = result.items[0];
expect(item).not.toHaveProperty("rank");
expect(item).not.toHaveProperty("snippet");
expect(item.snippet).toBe("docker <mark>networking</mark>");
expect(item.id).toBe("x");
});

it("preserves the snippet as null when the search row omits it", async () => {
nextResponse = () =>
new Response(
JSON.stringify({
query: "docker",
results: [
{
id: "x",
type: "note",
title: null,
content: "docker networking",
source: "manual",
source_url: null,
created_at: "2026-06-21T00:00:00.000Z",
updated_at: "2026-06-21T00:00:00.000Z",
rank: 1.23,
},
],
total: 1,
page: 1,
limit: 20,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);

const result = await fetchBrowseItems({ q: "docker" });
expect(result.items[0].snippet).toBeNull();
});

it("drops empty filter values from the query string", async () => {
nextResponse = () =>
new Response(
Expand Down
32 changes: 20 additions & 12 deletions src/app/browse/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ function endpointFor(filters: BrowseFilters): {
return {
url: "/api/search",
// The search endpoint returns `results` (with a `rank` and a
// FTS5 `snippet`); we strip those to the canonical BrowseItem
// shape so the feed component never has to branch.
// FTS5 `snippet`); we keep the `snippet` (the card renders it
// with highlighted matches) but drop `rank` to the canonical
// BrowseItem shape so the feed component never branches on it.
mapResults: (body) =>
Array.isArray(body.results)
? (body.results as Record<string, unknown>[]).map(stripSearchOnly)
Expand Down Expand Up @@ -127,12 +128,12 @@ function coerceTags(raw: unknown): string[] {
}

/** The search endpoint carries a `rank` and a `snippet` per row;
* the items endpoint does not. Both are valid for the Browse feed,
* but the type only declares the shared columns. This normaliser
* maps a raw DB row to the canonical `BrowseItem` shape — it
* prefixes the `image_path` with `/api/images/`, picks up the
* batched `tags`, and drops any search-only fields (`rank`,
* `snippet`). */
* the items endpoint does not. This normaliser maps a raw DB row to
* the canonical `BrowseItem` shape — it prefixes the `image_path`
* with `/api/images/`, picks up the batched `tags`, and omits the
* search-only fields. `stripSearchOnly` layers the `snippet` back on
* for search results (it renders as highlighted matches in the card);
* `rank` (BM25 score) is never surfaced to the client. */
function normaliseItem(row: Record<string, unknown>): BrowseItem {
return {
id: String(row.id),
Expand All @@ -149,11 +150,18 @@ function normaliseItem(row: Record<string, unknown>): BrowseItem {
};
}

/** The search endpoint returns `results` (with a `rank` and a
* FTS5 `snippet`); we strip those to the canonical BrowseItem
* shape so the feed component never has to branch. */
/** The search endpoint carries a `rank` (BM25 score) and a `snippet`
* (FTS5 highlight) per row. We keep the `snippet` — the card renders
* it with highlighted matches — but drop `rank` so the canonical
* `BrowseItem` shape stays free of search-only scoring metadata. */
function stripSearchOnly(row: Record<string, unknown>): BrowseItem {
return normaliseItem(row);
return { ...normaliseItem(row), snippet: asStringOrNull(row.snippet) };
}

/** Coerce an unknown value to `string | null` for snippet passthrough.
* Defensive: a missing / non-string `snippet` collapses to `null`. */
function asStringOrNull(value: unknown): string | null {
return typeof value === "string" ? value : null;
}

export async function fetchBrowseItems(
Expand Down
5 changes: 5 additions & 0 deletions src/app/browse/browse-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ export function BrowsePage() {
hasMore={hasMore}
onLoadMore={loadMore}
cardVariant={cardVariant}
// Issue #24: while a search query is active, the feed shows
// search results as a finite set — the scroll-triggered
// auto-load is suspended (a manual "Load more" still
// paginates). Clearing `q` re-enables infinite scroll.
infiniteScroll={!filters.q}
// Clicking a card's tag pill narrows the feed to that tag.
// `setFilters({ tag })` updates the URL (`?tag=…`) and resets
// to page 1, exactly like picking the tag from the advanced
Expand Down
47 changes: 47 additions & 0 deletions src/app/browse/content-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,53 @@ describe("ContentCard", () => {
screen.queryByTestId("content-card-metadata-summary")
).not.toBeInTheDocument();
});

it("renders the FTS5 snippet with <mark> highlighting matched terms", () => {
const item: BrowseItem = {
...baseItem,
snippet: "…bridge <mark>networks</mark> are the default…",
};
render(<ContentCard item={item} />);
const snippet = screen.getByTestId("content-card-snippet");
const mark = snippet.querySelector("mark");
expect(mark).not.toBeNull();
expect(mark).toHaveTextContent("networks");
});

it("keeps the line-clamped styling on the snippet paragraph", () => {
const item: BrowseItem = { ...baseItem, snippet: "a <mark>b</mark> c" };
render(<ContentCard item={item} />);
expect(screen.getByTestId("content-card-snippet").className).toMatch(
/line-clamp-3/
);
});

it("falls back to the plain content preview when there is no snippet", () => {
render(<ContentCard item={baseItem} />);
// No snippet test id, and the plain preview text is shown.
expect(
screen.queryByTestId("content-card-snippet")
).not.toBeInTheDocument();
expect(screen.getByText(/Bridge networks/)).toBeInTheDocument();
});

it("treats markup inside the snippet as inert text (XSS-safe)", () => {
// FTS5's snippet() does not escape source content, so a note
// containing a <script> tag would surface in the snippet. We
// render segments as React text children, which escape markup —
// so no executable element is ever created.
const item: BrowseItem = {
...baseItem,
snippet: "…<mark>docker</mark><script>alert(1)</script>…",
};
const { container } = render(<ContentCard item={item} />);
expect(container.querySelector("script")).toBeNull();
// The matched term still highlights.
const mark = screen
.getByTestId("content-card-snippet")
.querySelector("mark");
expect(mark).toHaveTextContent("docker");
});
});

describe("metadataSummary", () => {
Expand Down
30 changes: 27 additions & 3 deletions src/app/browse/content-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";

import { cn } from "@/lib/utils";
import { parseSnippet } from "@/lib/snippet";
import {
Tooltip,
TooltipContent,
Expand Down Expand Up @@ -222,6 +223,10 @@ export function ContentCard({
[item.created_at]
);
const summary = metadataSummary(item.type, item.metadata);
// Parse the FTS5 snippet (search results only) into highlighted
// segments. `null` when there is no snippet (the regular list view)
// so the card falls back to the plain content preview.
const snippetParts = item.snippet ? parseSnippet(item.snippet) : null;
// When the image 404s, show a text placeholder instead of the
// browser's default broken-image glyph. The file may genuinely
// not exist (the image capture pipeline hasn't created it yet),
Expand Down Expand Up @@ -325,9 +330,28 @@ export function ContentCard({
</h3>
) : null}

<p className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed break-words">
{previewText(item.content)}
</p>
{snippetParts ? (
// Search result: render the FTS5 snippet with `<mark>`
// highlighting the matched terms. Segments are React text
// children (auto-escaped), so markup in the source content
// is neutralised — see `parseSnippet`.
<p
data-testid="content-card-snippet"
className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed break-words"
>
{snippetParts.map((part, i) =>
part.highlight ? (
<mark key={i}>{part.text}</mark>
) : (
<span key={i}>{part.text}</span>
)
)}
</p>
) : (
<p className="text-muted-foreground line-clamp-3 font-sans text-sm leading-relaxed break-words">
{previewText(item.content)}
</p>
)}

{summary ? (
<p
Expand Down
92 changes: 92 additions & 0 deletions src/app/browse/content-feed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,98 @@ describe("ContentFeed", () => {
expect(onLoadMore).toHaveBeenCalledOnce();
});

it("attaches the scroll observer by default (infinite scroll on)", () => {
const observeSpy = vi.spyOn(IntersectionObserver.prototype, "observe");
render(
<ContentFeed
items={[item]}
status="success"
error={null}
onRetry={() => undefined}
hasActiveFilters={false}
{...defaultProps}
hasMore
/>
);
expect(observeSpy).toHaveBeenCalled();
});

it("does not attach the scroll observer during an active search (infiniteScroll off)", () => {
// Issue #24: while searching, the feed shows results as a finite
// set — the IntersectionObserver sentinel must not auto-load.
const observeSpy = vi.spyOn(IntersectionObserver.prototype, "observe");
render(
<ContentFeed
items={[item]}
status="success"
error={null}
onRetry={() => undefined}
hasActiveFilters={false}
{...defaultProps}
hasMore
infiniteScroll={false}
/>
);
expect(observeSpy).not.toHaveBeenCalled();
// The sentinel element is still rendered (it doubles as a layout
// spacer), but it has no observer attached.
expect(screen.getByTestId("feed-sentinel")).toBeInTheDocument();
});

it("detaches the observer when infiniteScroll flips from on to off (typing a query)", () => {
// The real user flow: browse (infinite scroll on) → type a query
// (infinite scroll off). The effect cleanup must disconnect the
// observer so scrolling no longer auto-loads mid-search.
const disconnectSpy = vi.spyOn(
IntersectionObserver.prototype,
"disconnect"
);
const { rerender } = render(
<ContentFeed
items={[item]}
status="success"
error={null}
onRetry={() => undefined}
hasActiveFilters={false}
{...defaultProps}
hasMore
infiniteScroll
/>
);
expect(disconnectSpy).not.toHaveBeenCalled();
rerender(
<ContentFeed
items={[item]}
status="success"
error={null}
onRetry={() => undefined}
hasActiveFilters={false}
{...defaultProps}
hasMore
infiniteScroll={false}
/>
);
expect(disconnectSpy).toHaveBeenCalled();
});

it("still shows the manual Load more button during search when hasMore", () => {
// Infinite scroll is off, but manual pagination stays available so
// search results are never cut off.
render(
<ContentFeed
items={[item]}
status="success"
error={null}
onRetry={() => undefined}
hasActiveFilters={false}
{...defaultProps}
hasMore
infiniteScroll={false}
/>
);
expect(screen.getByTestId("feed-load-more-button")).toBeInTheDocument();
});

it("threads onTagClick through to each card's tag pill", async () => {
const onTagClick = vi.fn();
const user = userEvent.setup();
Expand Down
Loading
Loading