Skip to content
Open
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
14 changes: 14 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# TODO — Streams dense layout toggle

- [ ] Add density state + toggle UI in `app/streams/StreamsPageContent.tsx` with localStorage persistence (`streampay.density`)
- [ ] Pass density to `app/components/StreamRow.tsx` and apply compact class
- [ ] Add compact CSS rules in `app/globals.css` (reduce padding/gaps for `.stream-row--compact` and related layouts)
- [ ] Update `app/streams/page.test.tsx` to cover:
- [ ] comfortable mode (default) -> non-compact
- [ ] compact mode -> compact class
- [ ] toggle interaction updates localStorage
- [ ] keyboard accessibility (role="switch" + aria-checked)
- [ ] Run `npm test -- app/streams/page.test.tsx` and ensure passing
- [ ] Commit changes with message: `feat: add dense-mode toggle to streams list`
- [ ] Provide manual visual testing notes for PR

13 changes: 10 additions & 3 deletions app/components/StreamRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ export type StreamRowData = {

type StreamRowProps = {
stream: StreamRowData;
density?: "compact" | "comfortable";
};

export function StreamRow({ stream }: StreamRowProps) {
export function StreamRow({ stream, density = "comfortable" }: StreamRowProps) {
// Density is controlled by StreamsPageContent and affects only layout spacing.
// Keeping it as a prop avoids extra re-renders beyond StreamRow itself.

const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<StreamPayError | null>(null);
const [isIncidentMode] = useState(false);
Expand Down Expand Up @@ -101,7 +105,10 @@ export function StreamRow({ stream }: StreamRowProps) {
};

return (
<article className="stream-row" aria-labelledby={`${stream.id}-recipient`}>
<article
className={`stream-row ${density === "compact" ? "stream-row--compact" : ""}`}
aria-labelledby={`${stream.id}-recipient`}
>
{/* Dynamic polite status messenger announcement node layer for assistive tech */}
<div className="sr-only" aria-live="polite" role="status">
{srAnnouncement}
Expand Down Expand Up @@ -158,7 +165,7 @@ export function StreamRow({ stream }: StreamRowProps) {
/>
)}

<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", alignItems: "flex-end" }}>
<div className="stream-row__action-wrap">
<button
ref={actionButtonRef}
className={`button button--secondary stream-row__action ${isProcessing ? "button--busy" : ""}`}
Expand Down
104 changes: 103 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,113 @@ a:hover {
line-height: 1.5;
}

.stream-list {
.stream-list {
display: grid;
gap: 1rem;
}

/* ─── Streams Dense Layout (density toggle) ─────────────────────────────── */
.stream-row--compact {
padding: 0.875rem;
gap: 0.75rem;
border-radius: 1.1rem;
}

.stream-list--compact {
gap: 0.75rem;
}

.stream-row--compact .stream-row__primary {
gap: 0.5rem;
}

.stream-row--compact .stream-row__recipient {
font-size: 1.0rem;
margin-bottom: 0.25rem;
}

.stream-row--compact .stream-row__schedule {
line-height: 1.35;
}

.stream-row--compact .stream-row__meta {
gap: 0.5rem;
}

.stream-row--compact .stream-row__meta dt {
font-size: 0.75rem;
margin-bottom: 0.25rem;
}

.stream-row--compact .stream-row__meta dd {
font-size: 0.9rem;
}

.stream-row--compact .stream-row__action-wrap {
gap: 0.25rem;
}

.density-toggle {
align-items: center;
display: inline-flex;
gap: 0.5rem;
}

.density-toggle__label {
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}

.density-toggle__switch {
background-color: var(--border);
border: none;
border-radius: 999px;
cursor: pointer;
height: 24px;
width: 44px;
padding: 0;
position: relative;
transition: background-color 160ms ease;
}

.density-toggle__switch:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
}

.density-toggle__switch--compact {
background-color: var(--accent);
}

.density-toggle__thumb {
background-color: white;
border-radius: 50%;
height: 20px;
left: 2px;
position: absolute;
top: 2px;
transition: transform 160ms ease-in-out;
width: 20px;
}

.density-toggle__switch--compact .density-toggle__thumb {
transform: translateX(20px);
background-color: var(--accent-strong);
border: 2px solid var(--accent);
}

@media (prefers-reduced-motion: reduce) {
.density-toggle__thumb,
.density-toggle__switch { transition: none !important; }
}

@media (max-width: 47.9375rem) {
.density-toggle__label { display: none; }
}


.stream-row {
background: linear-gradient(180deg, var(--panel-elevated), var(--panel));
border: 1px solid var(--border);
Expand Down
48 changes: 48 additions & 0 deletions app/streams/StreamsPageContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @jest-environment jsdom
*/

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StreamsPageContent } from "./StreamsPageContent";

const STORAGE_KEY = "streampay.density";

describe("StreamsPageContent density toggle", () => {
beforeEach(() => {
window.localStorage.clear();
});

it("defaults to comfortable when no localStorage value exists", () => {
render(<StreamsPageContent state="populated" />);

const compactSwitch = screen.getByRole("switch", { name: /streams list density/i });
expect(compactSwitch).toHaveAttribute("aria-checked", "false");

// Should not mark rows compact.
expect(document.querySelectorAll(".stream-row--compact").length).toBe(0);
});

it("uses compact mode when localStorage is set", () => {
window.localStorage.setItem(STORAGE_KEY, "compact");

render(<StreamsPageContent state="populated" />);

const compactSwitch = screen.getByRole("switch", { name: /streams list density/i });
expect(compactSwitch).toHaveAttribute("aria-checked", "true");

expect(document.querySelectorAll(".stream-row--compact").length).toBeGreaterThan(0);
});

it("toggle persists per device via localStorage", async () => {
const user = userEvent.setup();
render(<StreamsPageContent state="populated" />);

const compactSwitch = screen.getByRole("switch", { name: /streams list density/i });
await user.click(compactSwitch);

expect(window.localStorage.getItem(STORAGE_KEY)).toBe("compact");
expect(compactSwitch).toHaveAttribute("aria-checked", "true");
});
});

46 changes: 43 additions & 3 deletions app/streams/StreamsPageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"use client";

import { useEffect, useState } from "react";
import { EmptyState } from "../components/EmptyState";
import { PageError } from "../components/PageError";
import { StreamRow, type StreamRowData } from "../components/StreamRow";


export type StreamsViewState = "empty" | "loading" | "populated" | "error";

const streamListCopy = {
Expand Down Expand Up @@ -102,6 +106,29 @@ export function StreamsPageContent({
errorMessage,
onRetry,
}: StreamsPageContentProps) {
type DensityMode = "compact" | "comfortable";

const DENSITY_STORAGE_KEY = "streampay.density";

const [density, setDensity] = useState<DensityMode>(() => {
if (typeof window === "undefined") return "comfortable";

try {
const value = window.localStorage.getItem(DENSITY_STORAGE_KEY);
return value === "compact" || value === "comfortable" ? value : "comfortable";
} catch {
return "comfortable";
}
});

useEffect(() => {
// Keep localStorage in sync with state.
try {
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
} catch {
// Ignore storage errors (private mode, blocked storage, etc.)
}
}, [density]);
const isEmpty = state === "empty" || streams.length === 0;

return (
Expand All @@ -112,10 +139,23 @@ export function StreamsPageContent({
<h1 className="page-hero__title">Manage every stream from one list.</h1>
<p className="page-hero__description">{streamListCopy.description}</p>
</div>
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", alignItems: "center" }}>
<button className="button button--secondary" type="button">
Export History
</button>
<div className="density-toggle" aria-label="Streams list density">
<span className="density-toggle__label">Density</span>
<button
type="button"
className={`density-toggle__switch ${density === "compact" ? "density-toggle__switch--compact" : ""}`}
role="switch"
aria-checked={density === "compact"}
onClick={() => setDensity((d) => (d === "compact" ? "comfortable" : "compact"))}
>
<span className="density-toggle__thumb" aria-hidden="true" />
<span className="sr-only">{density === "compact" ? "Compact density" : "Comfortable density"}</span>
</button>
</div>
<button className="button button--primary" type="button">
{streamListCopy.primaryCta}
</button>
Expand Down Expand Up @@ -154,9 +194,9 @@ export function StreamsPageContent({
title={streamListCopy.empty.title}
/>
) : (
<section aria-label="Streams list" className="stream-list">
<section aria-label="Streams list" className={`stream-list ${density === "compact" ? "stream-list--compact" : ""}`}>
{streams.map((stream) => (
<StreamRow key={stream.id} stream={stream} />
<StreamRow key={stream.id} stream={stream} density={density} />
))}
</section>
)}
Expand Down
7 changes: 7 additions & 0 deletions app/streams/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import { render } from "@testing-library/react";
const { screen } = require("@testing-library/react") as any;
import { StreamsPageContent } from "./StreamsPageContent";

const STORAGE_KEY = "streampay.density";


describe("StreamsPageContent", () => {
beforeEach(() => {
window.localStorage.clear();
});

it("renders the empty state", () => {
render(<StreamsPageContent state="empty" streams={[]} />);

Expand Down