feat(paperless): Document Browser & Search UI (Story #356)#364
Conversation
Implements the Document Browser page for Paperless-ngx integration: - `client/src/lib/paperlessApi.ts`: API client for status, list, get, tags, thumbnail URL, preview URL - `client/src/hooks/usePaperless.ts`: React hook managing status/documents/tags/pagination/search/filter state - `client/src/components/documents/DocumentBrowser.tsx`: Main reusable component with page/modal modes, search bar, tag filter strip, document grid, detail panel, and pagination - `client/src/components/documents/DocumentCard.tsx`: Individual card with thumbnail, title, date, tags, accessible button role - `client/src/components/documents/DocumentDetailPanel.tsx`: Expanded detail panel with metadata, content snippet, external link - `client/src/components/documents/DocumentSkeleton.tsx`: Loading skeleton cards with shimmer animation - All CSS modules use design tokens, no hardcoded hex values, responsive grid (3/2/1 col) - Replaces stub DocumentsPage with full implementation delegating to DocumentBrowser - Tests for all new modules (95%+ coverage target) Fixes #356 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
Replace inline import() type annotations with top-level import type * patterns to satisfy @typescript-eslint/consistent-type-imports rule. Also fix TypeScript type assertion for mock call args in usePaperless.test.tsx. Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
The test `does not render date when created is null` used `/2025/` which also matched the document title "Test Invoice 2025". Narrowed to a pattern matching formatted date strings (e.g. "Mar 15, 2025") only. Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
Architecture Review: Story 8.3 -- Document Browser & Search UI
Verdict: APPROVED (posted as comment due to same-account limitation)
I have reviewed all 19 changed files (2,503 additions / 18 deletions) against the Wiki Architecture page, API Contract (EPIC-08 Paperless section), Schema, and shared type definitions. This is a well-structured frontend implementation.
What was verified
Architecture compliance
- Component hierarchy (
DocumentsPage->DocumentBrowser->DocumentCard/DocumentDetailPanel/DocumentSkeleton) follows the established pattern of pages delegating to reusable components. - All Paperless-ngx API calls go through
paperlessApi.ts, which uses the sharedapiClient.ts-- maintaining the single HTTP abstraction layer pattern. - The
usePaperlesshook correctly implements the two-phase data loading pattern (status check -> conditional data fetch) documented in the API Contract's status endpoint notes. DocumentBrowsersupports bothmode="page"andmode="modal"with anonSelectcallback, enabling future reuse as a document picker in work item/household item modals, as the architecture anticipates for Stories 8.4/8.5.
Shared type usage
- All API types are imported from
@cornerstone/shared(PaperlessDocumentSearchResult,PaperlessTag,PaperlessStatusResponse,PaperlessDocumentListResponse,PaperlessDocumentDetailResponse,PaperlessTagListResponse,PaperlessDocumentListQuery,PaginationMeta). - The
paperlessApi.tsclient correctly maps all query parameters defined inPaperlessDocumentListQuery(query, tags, correspondent, documentType, page, pageSize, sortBy, sortOrder) to URL search params usingURLSearchParams. - The hook's
UsePaperlessResultinterface is properly exported and consumed by test files.
API Contract adherence
- Endpoint paths match the contract exactly:
/paperless/status,/paperless/documents,/paperless/documents/:id,/paperless/documents/:id/thumb,/paperless/documents/:id/preview,/paperless/tags. - Thumbnail and preview URLs are constructed as
<baseUrl>/paperless/documents/<id>/thumb|preview, matching the binary passthrough endpoints in the contract. - Error handling distinguishes
ApiClientError(server-returned errors) fromNetworkError(connectivity failures), mapping to appropriate user-facing messages.
CSS Modules patterns
- All CSS files use the project's design tokens (
var(--color-*),var(--shadow-*)) consistently -- no hardcoded colors or box shadows. - Responsive breakpoints use the established 768px/1024px pattern with mobile-first grid adjustments (3 -> 2 -> 1 columns for page mode, 2 -> 1 for modal mode).
- Dark mode works automatically through CSS custom properties (tokens define dark mode values via
[data-theme="dark"]).
Accessibility
- Tag chips use
role="checkbox"witharia-checkedand keyboard support (Enter + Space). - Document cards use
role="button"witharia-pressed,aria-label, and keyboard support. - Detail panel uses
role="region"witharia-label. - Pagination nav uses
aria-label="Document pagination"with labeled Previous/Next buttons. - Skeleton cards are
aria-hidden="true". - Error states use
role="alert". - Loading state uses
aria-busy="true".
Test coverage
paperlessApi.test.ts: 213 lines covering all 6 exported functions, including URL construction edge cases.usePaperless.test.tsx: 296 lines covering two-phase loading, error classification (ApiClientError/NetworkError/unknown), search, toggleTag, setPage, refresh.DocumentBrowser.test.tsx: 349 lines covering all status states, search debounce, tag filter strip (with ARIA assertions), loading/error/empty/populated states, detail panel toggle, modal mode delegation, pagination controls.DocumentCard.test.tsx: 160 lines covering rendering, date formatting, tags truncation (3+N), ARIA attributes, click/keyboard handlers.DocumentDetailPanel.test.tsx: 163 lines covering metadata display, content truncation, external link rendering, close button.DocumentSkeleton.test.tsx: 30 lines covering count rendering and accessibility hiding.DocumentsPage.test.tsx: 95 lines covering integration with DocumentBrowser across all status states.
Minor observations (informational, not blocking)
-
Missing
prefers-reduced-motionfor shimmer animation (DocumentSkeleton.module.css): The Gantt chart components (GanttBar.module.css,GanttMilestones.module.css,GanttArrows.module.css) all include@media (prefers-reduced-motion: reduce) { animation: none; }for their animations. The skeleton shimmer animation should follow the same pattern for consistency and accessibility. This is a minor polish item that can be addressed in a follow-up. -
NetworkErrormock constructor arity (usePaperless.test.tsxline ~1785-1789): TheMockNetworkErrorclass accepts a single argument, but the realNetworkErrorclass requires two (message,cause). The tests work because JS silently ignores extra arguments, but it is a minor fidelity gap in the mock. Not blocking.
Summary
The PR demonstrates strong adherence to the established architecture patterns. The paperlessApi.ts API client correctly consumes all shared types from @cornerstone/shared. The usePaperless hook properly orchestrates the two-phase loading lifecycle. The DocumentBrowser component is well-designed for reuse (page vs. modal modes with onSelect callback). CSS Modules use design tokens throughout with no hardcoded values. Test coverage is thorough across all layers (API client, hook, components, page). CI is green (Quality Gates + Docker + E2E Smoke pass).
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer]
Security review of PR #364 — Story 8.3: Document Browser & Search UI
This is a frontend-only PR adding the Document Browser, DocumentCard, DocumentDetailPanel, DocumentSkeleton components, the usePaperless hook, and paperlessApi.ts client helper. All data flows through the /api/paperless/* proxy endpoints established in earlier stories.
Verdict: No security issues found. Approved from a security standpoint.
Review Checklist
XSS: dangerouslySetInnerHTML scan — PASS
A full scan of all new .tsx files confirms zero use of dangerouslySetInnerHTML, innerHTML, or eval(). All user-facing strings from Paperless-ngx are rendered as React text content, which benefits from React's automatic HTML escaping.
Specific rendering paths verified:
document.title— rendered as{document.title}in<h3>andaria-label. React escapes this. Safe.document.content— sliced to 300 characters and rendered as{document.content.slice(0, 300)}inside a<p>tag. Plain text rendering only. Safe.tag.name— rendered as{tag.name}in<span>elements throughout. Safe.document.correspondent,document.documentType— plain text in<dd>elements. Safe.hook.error— error string fromusePaperless, rendered as{hook.error}in a<p>tag. Originates from the backend error handler (already sanitized at the server layer). Safe.
XSS: searchHit.highlights HTML field — PASS (not rendered)
The PaperlessSearchHit.highlights field is typed as string and documented to contain HTML <em> highlight markup from Paperless-ngx. This is a known XSS risk surface.
Finding: searchHit is carried through the data model (PaperlessDocumentSearchResult) but is never rendered in any component in this PR. DocumentCard.tsx and DocumentDetailPanel.tsx do not access searchHit at all. The risk surface that was flagged in the review brief does not exist in the current implementation.
Note for future stories: If a future story adds rendering of searchHit.highlights, it will require either (a) a strict allowlist-based HTML sanitizer (e.g., DOMPurify allowing only <em> tags), or (b) stripping the HTML tags server-side in the proxy before forwarding to the client. Rendering this field with dangerouslySetInnerHTML without sanitization would be a Critical XSS finding. I will flag this proactively in the Security Audit wiki page.
External links: rel="noopener noreferrer" — PASS
DocumentDetailPanel.tsx at line 1594 of the diff:
<a
href={paperlessDocUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.externalLink}
>
View in Paperless-ngx ↗
</a>Correct. noopener prevents the opened tab from accessing window.opener, and noreferrer suppresses the Referer header.
Additional observation: The paperlessBaseUrl prop that populates this link is optional and is never passed from DocumentsPage or DocumentBrowser in this PR, so the "View in Paperless-ngx" link is never rendered in practice at this time. No security impact — informational only.
Thumbnail image loading — PASS
URLs are constructed in paperlessApi.ts:
export function getDocumentThumbnailUrl(id: number): string {
return `${getBaseUrl()}/paperless/documents/${id}/thumb`;
}The id is typed as number (integer from the Paperless-ngx API response). The base URL comes from the application's own API base (/api). There is no user-controlled input in the constructed URL. Thumbnail requests go through the Cornerstone proxy, not directly to Paperless-ngx, so the API token is never exposed to the browser. Safe.
Search input handling — PASS
The search input is a standard controlled React input:
<input
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>The value is debounced and passed to hook.search(q) → listPaperlessDocuments({ query }) → URLSearchParams.set('query', query) → the backend proxy, where the query parameter is validated by AJV schema. No injection risk.
Client-side storage of sensitive data — PASS
No tokens, credentials, or sensitive data are stored in localStorage, sessionStorage, or cookies by any new code. The Paperless-ngx API token remains server-side only.
Authentication / authorization on new API surface — PASS (covered by proxy layer)
The new frontend code calls /api/paperless/* endpoints. Per the existing auth plugin (server/src/plugins/auth.ts), the global preValidation hook enforces authentication on all /api/* routes. The proxy routes were audited in Story 8.1/8.2 and covered by the backend's auth enforcement. No new auth surface introduced by this frontend-only PR.
New dependencies — PASS
No new npm dependencies introduced in this PR.
Summary
This PR is well-implemented from a security perspective. React's auto-escaping protects all rendered content from Paperless-ngx. The one latent risk — searchHit.highlights containing HTML — is not rendered in the current implementation. I will document a proactive advisory in the Security Audit wiki to ensure any future implementation of that feature is flagged for mandatory sanitization review.
steilerDev
left a comment
There was a problem hiding this comment.
[product-owner]
Story 8.3: Document Browser & Search UI (#356) -- Requirements Review
Verdict: REQUEST CHANGES (cannot use --request-changes on own PR, posting as comment)
Acceptance Criteria Assessment
| # | Criterion | Verdict |
|---|---|---|
| 1 | Documents page replaced from stub with functional browser | PASS |
| 2 | Document list displays thumbnail, title, created date, correspondent, tags | PASS |
| 3 | Search input for filtering by title/content (Paperless-ngx search API) | PASS |
| 4 | Tag chips clickable to filter document list by tag | PASS |
| 5 | Pagination supported | PASS |
| 6 | Document detail view with full metadata | PASS |
| 7 | "View in Paperless-ngx" link opens document in new tab | FAIL |
| 8 | Not configured state with setup message | PASS |
| 9 | Unreachable state with retry option | PASS |
| 10 | Loading states shown | PASS |
| 11 | Empty state shown | PASS |
| 12 | Reusable component for modal embedding | PASS |
Result: 11/12 criteria pass. 1 criterion fails.
AC #7 Failure: "View in Paperless-ngx" link is unreachable in practice
The DocumentDetailPanel component correctly implements the paperlessBaseUrl optional prop and generates the correct external link ({baseUrl}/documents/{id}/details, target="_blank", rel="noopener noreferrer"). The component-level tests pass for this feature.
However, DocumentBrowser never passes paperlessBaseUrl to DocumentDetailPanel.
In client/src/components/documents/DocumentBrowser.tsx (approximately line 163 of the new file):
<DocumentDetailPanel document={selectedDoc} onClose={() => setSelectedDoc(null)} />The paperlessBaseUrl prop is omitted, so it defaults to undefined, and the "View in Paperless-ngx" link is never rendered to the user. The full chain is broken:
- The
PaperlessStatusResponsetype (shared/src/types/document.ts) does not include a URL field -- onlyconfigured,reachable,error. - The API contract (
GET /api/paperless/status) confirms no URL is returned. - The
usePaperlesshook has no mechanism to provide the base URL. - Therefore,
DocumentBrowserhas no source for the URL and cannot pass it through.
To fix this, the implementation must make the Paperless-ngx web UI base URL available to the client. The recommended approach:
Add a paperlessUrl field to the status response -- the server already has PAPERLESS_URL in config and could include it in the status payload when configured === true. Then usePaperless exposes it, and DocumentBrowser passes it to DocumentDetailPanel.
The full fix chain would be:
- Shared types: Add
paperlessUrl?: stringtoPaperlessStatusResponse - Server: Include
paperlessUrlin the status response when configured (the value is already available fromconfig.paperlessUrl) - Hook: Expose the URL from
usePaperless(it's already instatus.paperlessUrl) - DocumentBrowser: Pass
hook.status.paperlessUrltoDocumentDetailPanel'spaperlessBaseUrlprop
Note: If the internal PAPERLESS_URL (e.g., http://paperless:8000) differs from the user-facing URL, a separate env var may be needed. This is an architectural question -- but the simplest first step is to expose the existing PAPERLESS_URL since it works for many self-hosted setups where internal and external URLs are the same or the user configures the external URL.
Passing Criteria -- Summary of Strong Points
- Architecture: Clean separation of concerns --
paperlessApi.ts(API client),usePaperless.ts(state management hook),DocumentBrowser(orchestration),DocumentCard/DocumentDetailPanel/DocumentSkeleton(presentation). - Accessibility: Excellent ARIA support throughout --
role="checkbox"witharia-checkedfor tag filters,role="button"witharia-pressedfor cards,role="region"witharia-labelfor detail panel,role="alert"for error states,aria-hiddenfor skeleton placeholders, keyboard navigation (Enter/Space). - State handling: All five UI states (checking connection, not configured, unreachable, loading/error/empty/populated) are implemented and tested.
- Reusability: Modal mode is well-designed with
onSelectcallback, different grid layout, and suppressed detail panel. - Test coverage: Comprehensive unit tests for all components, the hook, and the API module.
Scope Assessment
No scope creep detected. All changes are within the boundaries of Story 8.3.
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer]
Design Review — PR #364: Document Browser & Search UI
I reviewed this PR against the visual specification posted on issue #356 and the design system defined in wiki/Style-Guide.md / client/src/styles/tokens.css. Overall the implementation is solid — token usage is mostly correct and dark mode will work automatically. However there are several deviations from the spec that need to be fixed before this merges.
Summary
| Category | Status |
|---|---|
| Token adherence | PASS (minor gaps) |
| Dark mode | PASS |
| Visual consistency | PARTIAL — deviations from spec |
| Responsive behavior | PARTIAL — tablet breakpoint wrong |
| Accessibility | PARTIAL — several issues |
| Component patterns | PARTIAL — missing prefers-reduced-motion |
MEDIUM — Hardcoded spacing/size values not using design tokens
Files: DocumentBrowser.module.css, DocumentCard.module.css, DocumentDetailPanel.module.css, DocumentSkeleton.module.css
Multiple spacing, size, padding, and border-radius values are hardcoded rather than referencing token variables. The design system requires all visual properties to use tokens (Style Guide: "Tokens Over Hardcoded Values").
Specific instances:
DocumentBrowser.module.css
.browsergap: 1rem→gap: var(--spacing-4).searchRowgap: 0.75rem→gap: var(--spacing-3).searchInputpadding: 0.5rem 0.75rem→padding: var(--spacing-2) var(--spacing-3)(and see search bar spec below).searchInputborder-radius: 0.375rem→border-radius: var(--radius-md).tagStripgap: 0.5rem→gap: var(--spacing-2).tagStrippadding-bottom: 0.25rem→padding-bottom: var(--spacing-1)(spec:var(--spacing-2)).tagChipgap: 0.25rem,padding: 0.25rem 0.625rem→gap: var(--spacing-1),padding: var(--spacing-1) var(--spacing-3)(spec).tagChipborder-radius: 1rem→border-radius: var(--radius-full).infoState,.errorState,.emptyStatepadding: 2rem→padding: var(--spacing-8).infoState,.errorStateborder-radius: 0.5rem→border-radius: var(--radius-lg).infoTitle,.errorTitlefont-size: 1.125rem→font-size: var(--font-size-lg).infoTitle,.errorTitlemargin: 0 0 0.75rem→margin: 0 0 var(--spacing-3).infoText,.errorTextfont-size: 0.875rem→font-size: var(--font-size-sm).retryButton,.pageButtonpadding: 0.5rem 1rem→padding: var(--spacing-2) var(--spacing-4).retryButton,.pageButtonfont-size: 0.875rem→font-size: var(--font-size-sm).retryButton,.pageButtonborder-radius: 0.375rem→border-radius: var(--radius-md).paginationgap: 1rem,padding: 0.75rem 0→gap: var(--spacing-4),padding: var(--spacing-3) 0.gridgap: 1rem→ should bevar(--spacing-6)on desktop (see breakpoint findings below).tagCountfont-size: 0.625rem→font-size: var(--font-size-xs)
DocumentCard.module.css
.cardborder-radius: 0.5rem→border-radius: var(--radius-lg).bodypadding: 0.75rem→padding: var(--spacing-4)(spec:var(--spacing-4)).bodygap: 0.25rem→gap: var(--spacing-2)(spec:var(--spacing-2)).titlefont-size: 0.875rem→font-size: var(--font-size-sm).meta,.correspondentfont-size: 0.75rem→font-size: var(--font-size-xs).tagsgap: 0.25rem,margin-top: 0.25rem→gap: var(--spacing-1),margin-top: var(--spacing-1).tagChip,.tagChipMorepadding: 0.125rem 0.375rem,border-radius: 0.25rem→border-radius: var(--radius-full)(spec)
DocumentDetailPanel.module.css
.panelborder-radius: 0.5rem,padding: 1.25rem→border-radius: var(--radius-lg),padding: var(--spacing-6).headergap: 1rem,margin-bottom: 1rem→gap: var(--spacing-4),margin-bottom: var(--spacing-4).panelTitlefont-size: 1.125rem→font-size: var(--font-size-lg).closeButtonpadding: 0.25rem,border-radius: 0.25rem→padding: var(--spacing-1),border-radius: var(--radius-sm).contentgap: 1.5rem→gap: var(--spacing-6).thumbborder-radius: 0.375rem→border-radius: var(--radius-md).metaListgap: 0.375rem 1rem→gap: var(--spacing-1-5) var(--spacing-4).metaLabelfont-size: 0.75rem→font-size: var(--font-size-xs).metaValue,.snippetText,.externalLinkfont-size: 0.875rem→font-size: var(--font-size-sm).tagsgap: 0.25rem→gap: var(--spacing-1).contentSnippetpadding-top: 0.75rem→padding-top: var(--spacing-3).snippetLabelfont-size: 0.75rem,margin: 0 0 0.375rem→font-size: var(--font-size-xs),margin: 0 0 var(--spacing-1-5)
DocumentSkeleton.module.css
.skeletonCardborder-radius: 0.5rem→border-radius: var(--radius-lg).skeletonBodypadding: 0.75rem,gap: 0.5rem→padding: var(--spacing-3),gap: var(--spacing-2).skeletonTitle,.skeletonMeta,.skeletonTagsborder-radius: 0.25rem→border-radius: var(--radius-sm)
MEDIUM — Grid breakpoint values deviate from spec and have an edge-case overlap
File: DocumentBrowser.module.css lines 91–108
The grid gap values do not match the spec at any breakpoint, and the tablet upper bound max-width: 1024px overlaps with desktop min-width: 1024px — a 1024px viewport matches both media queries.
Spec (§5):
Desktop (≥ 1024px): 3 cols, gap: var(--spacing-6) [24px]
Tablet (768px–1023px): 2 cols, gap: var(--spacing-5) [20px]
Mobile (< 768px): 1 col, gap: var(--spacing-4) [16px]
Correct CSS:
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-6);
}
@media (min-width: 768px) and (max-width: 1023px) {
.grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-5);
}
}
@media (max-width: 767px) {
.grid {
grid-template-columns: 1fr;
gap: var(--spacing-4);
}
}MEDIUM — Card border thickness deviates from spec
File: DocumentCard.module.css line 3
The card uses border: 2px solid var(--color-border). The spec (§6) specifies border: 1px solid var(--color-border). The 2px border makes unselected cards visually over-weighted relative to all other cards in the codebase (WorkItemCard, budget cards all use 1px). The heavier border is reserved for selected/focused states via box-shadow and border-color changes.
Change to:
border: 1px solid var(--color-border);MEDIUM — Card selected state does not match spec
File: DocumentCard.module.css lines 28–31
/* Current */
.cardSelected {
border-color: var(--color-primary);
box-shadow: var(--shadow-focus);
}The spec (§6) specifies the selected glow as:
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg);var(--shadow-focus) is the full blue keyboard focus ring. Using it for the persistent selected state creates an accessibility ambiguity — screen reader users and keyboard users will not be able to distinguish "keyboard focus" from "detail panel open". The subtle primary-bg glow from the spec resolves this.
MEDIUM — Missing prefers-reduced-motion support
File: DocumentSkeleton.module.css
The shimmer animation runs continuously. Per the spec (§8) and the Style Guide's progressive enhancement principle, this must be suppressed:
@media (prefers-reduced-motion: reduce) {
.skeletonThumb,
.skeletonTitle,
.skeletonMeta,
.skeletonTags {
animation: none;
background: var(--color-bg-tertiary);
background-size: unset;
}
}Also add to DocumentCard.module.css:
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}MEDIUM — Tag chip accessibility: aria-label missing on individual chips
File: DocumentBrowser.tsx lines 128–141
The spec (§4) requires each tag chip to carry aria-label="Filter by tag: {name}". The implementation uses role="checkbox" and aria-checked correctly, but the accessible name is only the visible text. Screen reader users will hear "Invoice 5 checkbox" (where 5 is the count rendered as a sibling <span>) rather than understanding the count as "5 documents".
Required change:
<span
aria-label={`Filter by tag: ${tag.name}${tag.documentCount > 0 ? ` (${tag.documentCount} documents)` : ''}`}
...
>MEDIUM — Card ARIA: aria-pressed should be aria-expanded
File: DocumentCard.tsx lines 30–31
aria-pressed is for toggle buttons (bold/italic). aria-expanded is the correct attribute when pressing a control opens an associated region — which is exactly what selecting a card does (opens the detail panel). Screen readers announce aria-expanded as "expanded/collapsed" which is semantically correct.
Change to:
aria-expanded={isSelected}Also, the aria-label pattern should follow the spec: "{title} — {correspondent}, {date}" rather than "Document: {title}" to surface key metadata to screen reader users without requiring them to navigate into the card content.
LOW — Search input missing aria-controls linking to results region
File: DocumentBrowser.tsx line 114
The spec (§3) requires aria-controls="document-results" on the search input and a matching id="document-results" on the results grid. This relationship helps AT announce that typing controls the document list.
<input aria-controls="document-results" ... />
<div id="document-results" className={gridClass}>LOW — Search bar missing leading icon per spec
Files: DocumentBrowser.module.css, DocumentBrowser.tsx
The spec (§3) shows a decorative magnifying-glass icon positioned absolutely at left: var(--spacing-3) with the input's left padding set to var(--spacing-10). The implementation uses plain symmetric padding and no icon. Low severity (functional) but a spec deviation affecting visual polish.
LOW — Tag filter strip missing trailing mask gradient
File: DocumentBrowser.module.css
The spec (§4) includes a CSS mask-image fade at the trailing edge as a scroll affordance:
.tagStrip {
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent);
-webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent);
}LOW — Tag chip mobile touch target not met
File: DocumentBrowser.module.css
The spec (§4) and Style Guide require 44px minimum touch target on mobile. Tag chips render at ~26px tall. Add:
@media (max-width: 767px) {
.tagChip {
min-height: 44px;
}
}LOW — closeButton and retryButton missing :focus-visible styles
Files: DocumentDetailPanel.module.css, DocumentBrowser.module.css
Both buttons have hover styles but no :focus-visible rule. All interactive elements must show a focus ring per the Style Guide.
.closeButton:focus-visible,
.retryButton:focus-visible,
.pageButton:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}INFORMATIONAL — Transition values should use composite transition tokens
Files: DocumentBrowser.module.css, DocumentCard.module.css
Several transitions use inline durations (0.1s ease, 0.15s ease) instead of the composite tokens defined in tokens.css. Prefer:
- Tag chips:
transition: var(--transition-button-border)(covers background + border) - Cards:
transition: box-shadow var(--transition-normal), border-color var(--transition-normal) - Retry/page buttons:
transition: var(--transition-button)
INFORMATIONAL — Tag pill border-radius should be --radius-full not 0.25rem
Files: DocumentCard.module.css, DocumentDetailPanel.module.css
Tag chips in the card body and detail panel use border-radius: 0.25rem (square-ish badge). The spec (§6) and Style Guide badge pattern both call for border-radius: var(--radius-full) (pill). This makes inline tag chips visually inconsistent with the filter strip chips.
What Passes
- All color properties use semantic CSS custom properties — no hardcoded hex values in any
.module.cssfile. Dark mode will work automatically via the 3-layer token system. - Skeleton shimmer uses design tokens for gradient colors (
--color-bg-tertiary,--color-bg-secondary). DocumentCard.module.css:focus-visibleusesvar(--shadow-focus)correctly.DocumentDetailPanelusesrole="region"witharia-label— correct.- Pagination
<nav aria-label="Document pagination">— correct. - Tag strip
role="group" aria-label="Filter by tag"— correct pattern. role="checkbox"+aria-checkedon tag chips — correct semantic model.aria-label="Close document details"on close button — correct.loading="lazy"on card thumbnails — correct.aria-busy="true"on connection-checking state — correct.role="alert"on error states — correct.
Please address the Medium and Low findings before merging. The Informational items can be follow-up polish.
|
🎉 This PR is included in version 1.10.0-beta.74 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
…advisory (PR #364) Advances wiki submodule pointer to include the proactive security advisory for searchHit.highlights HTML rendering risk, added during Story 8.3 security review. Co-Authored-By: Claude security-engineer (Sonnet 4.6) <noreply@anthropic.com>
|
🎉 This PR is included in version 1.11.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
paperlessApi.tsAPI client module with functions for status, document listing, single-document fetch, tags, and URL helpers for thumbnails/previewsusePaperless.tsReact hook that manages two-phase fetching (status check then documents+tags), with search debounce, tag toggle, pagination, and refreshDocumentBrowser,DocumentCard,DocumentDetailPanel, andDocumentSkeletoncomponents inclient/src/components/documents/, with full CSS Modules using design tokens, 3/2/1 column responsive grid, accessible tag filter strip (role="checkbox",aria-checked), and inline detail panel accordionDocumentsPagewith a full implementation delegating toDocumentBrowser, handling not-configured, unreachable, loading, empty, and error statesFixes #356
Test plan
paperlessApi.ts— all 6 functions covered (status, list with query string building, get, tags, thumbnail URL, preview URL)usePaperless.ts— status fetch, conditional data fetch, error handling (API/network/unknown), search/toggleTag/setPage/refresh mutationsDocumentCard— title, date, tags, accessible attributes, click/keyboard handlersDocumentDetailPanel— metadata display, content truncation, external link, close buttonDocumentSkeleton— count renderingDocumentBrowser— all status states, search bar, tag filter strip, loading/error/empty/populated grid, detail panel toggle, modal mode, paginationDocumentsPage— heading, search input, all status statesCo-Authored-By: Claude Opus 4.6 noreply@anthropic.com