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
3 changes: 3 additions & 0 deletions src/components/GlassyRecordCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@

.glassy-record-card__button {
display: grid;
align-items: start;
width: 100%;
grid-template-columns: minmax(10rem, 1fr) minmax(36rem, 42rem);
gap: 1rem;
Expand Down Expand Up @@ -118,6 +119,7 @@
.glassy-record-card__aside {
display: grid;
align-self: start;
margin-top: var(--record-aside-offset, 0px);
grid-template-columns:
minmax(15rem, 1fr) minmax(8.75rem, 9.5rem)
7.75rem 1.25rem;
Expand Down Expand Up @@ -225,6 +227,7 @@

.glassy-record-card__aside {
display: flex;
margin-top: 0;
flex-wrap: wrap;
justify-content: flex-start;
}
Expand Down
39 changes: 38 additions & 1 deletion src/components/GlassyRecordCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useLayoutEffect, useRef, type ReactNode } from "react";

import { ChevronDown } from "lucide-react";

Expand Down Expand Up @@ -40,6 +40,42 @@ export function GlassyRecordCard({
summary,
title,
}: GlassyRecordCardProps) {
const headerRef = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => {
const header = headerRef.current;
if (!header) return;
const copy = header.querySelector<HTMLElement>(".glassy-record-card__copy");
const summary = header.querySelector<HTMLElement>(
".glassy-record-card__summary",
);
const aside = header.querySelector<HTMLElement>(
".glassy-record-card__aside",
);
if (!copy || !summary || !aside) return;

const alignMetadata = () => {
// Anchor to the collapsed header even while the full summary is visible.
const summaryLineHeight = Number.parseFloat(
getComputedStyle(summary).minHeight,
);
const expandedHeight = Math.max(
0,
summary.getBoundingClientRect().height - summaryLineHeight,
);
const collapsedHeight =
copy.getBoundingClientRect().height - expandedHeight;
const offset = Math.max(
0,
(collapsedHeight - aside.getBoundingClientRect().height) / 2,
);
header.style.setProperty("--record-aside-offset", `${offset}px`);
};
alignMetadata();
const observer = new ResizeObserver(alignMetadata);
for (const element of [copy, summary, aside]) observer.observe(element);
return () => observer.disconnect();
}, []);

const renderedSummary =
typeof summary === "string" ? normalizePreviewText(summary) : summary;

Expand All @@ -55,6 +91,7 @@ export function GlassyRecordCard({
)}
>
<Button
ref={headerRef}
type="button"
size="content"
variant="bare"
Expand Down
112 changes: 112 additions & 0 deletions tests/e2e/proposal-card-alignment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { expect, test, type Locator } from "@playwright/test";
import type { ProposalListItemDto } from "../../src/types/api";

const proposals: ProposalListItemDto[] = [
{ title: "Short title", summary: "A short summary." },
{
title: "A longer proposal title that wraps across several lines",
summary: "Full proposal context remains readable when expanded. ".repeat(
20,
),
},
{ title: "No summary", summary: "" },
{
title: "An initiative proposal",
summary: "Supporting proposal details. ".repeat(12),
initiative: { id: "test-initiative", title: "Community research" },
},
].map((content, index) => ({
id: `alignment-${index}`,
meta: "General chamber",
chamber: index === 2 ? "Long chamber name ".repeat(6) : "General chamber",
stage: "citizen_veto",
summaryPill: "Citizen veto",
stageData: [],
stats: [],
proposer: "Test author",
proposerId: "test-author",
tier: "Citizen",
proofFocus: "pog",
tags: [],
keywords: [],
date: "2026-09-08",
votes: 0,
activityScore: 0,
ctaPrimary: "Open proposal",
ctaSecondary: "",
...content,
}));

async function geometry(card: Locator) {
return card.evaluate((element) => {
const header = element.querySelector(".glassy-record-card__button")!;
const bounds = header.getBoundingClientRect();
const selectors = ["metaPill", "stage", "time", "chevron"];
return selectors.map((suffix) => {
const item = header.querySelector(`.glassy-record-card__${suffix}`)!;
const rect = item.getBoundingClientRect();
return {
x: rect.x - bounds.x,
y: rect.y - bounds.y,
centerOffset: rect.y + rect.height / 2 - bounds.y - bounds.height / 2,
};
});
});
}

test("proposal metadata centers on collapsed cards and stays fixed on expansion", async ({
page,
}) => {
await page.route("**/api/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === "/api/proposals") {
await route.fulfill({ json: { items: proposals } });
} else if (path.endsWith("/citizen-veto")) {
await route.fulfill({
json: {
stats: [],
stageData: [],
attemptsUsed: 0,
attemptsRemaining: 1,
},
});
} else {
await route.fulfill({ json: { authenticated: false } });
}
});
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto("/app/proposals");
const cards = page.locator(".glassy-record-card");
await expect(cards).toHaveCount(proposals.length);

for (const width of [1440, 1920, 1280]) {
await page.setViewportSize({ width, height: 1000 });
for (const card of await cards.all()) {
await expect
.poll(async () =>
Math.max(
...(await geometry(card)).map((g) => Math.abs(g.centerOffset)),
),
)
.toBeLessThan(1);
const before = await geometry(card);
await card.locator(".glassy-record-card__button").click();
await expect(card.locator(".glassy-record-card__details")).toBeVisible();
const after = await geometry(card);
for (let i = 0; i < before.length; i++) {
expect(after[i].x).toBeCloseTo(before[i].x, 0);
expect(after[i].y).toBeCloseTo(before[i].y, 0);
}
await card.locator(".glassy-record-card__button").click();
}
}

for (const width of [390, 768, 1024]) {
await page.setViewportSize({ width, height: 1000 });
await cards.first().locator(".glassy-record-card__button").click();
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(width);
await cards.first().locator(".glassy-record-card__button").click();
}
});
Loading