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
32 changes: 32 additions & 0 deletions apps/web/e2e/mocked/browser-behavior.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,38 @@ test('dashboard stays available when the viewport changes to mobile', async ({ p
assertNoUnexpected()
})

test('recent journal cards fit narrow screens with long entry titles', async ({ page }) => {
const longTitle = 'database-migration-'.repeat(24)
const assertNoUnexpected = await mockApi(page, (request, url) => {
if (request.method() === 'GET' && url.pathname === '/api/technical-entry') {
return { body: { data: [{ ...entry, title: longTitle }], meta: { currentPage: 1, perPage: 5, lastPage: 1, total: 1 } } }
}
return authenticatedApi(request, url)
})

for (const width of [320, 390]) {
await page.setViewportSize({ width, height: 844 })
await page.goto('/dashboard')
const section = page.getByRole('region', { name: 'Recent journal entries' })
await expect(section.getByText(longTitle)).toBeVisible()

const layout = await section.evaluate((element) => ({
sectionWidth: element.clientWidth,
contentWidth: element.scrollWidth,
sectionRight: element.getBoundingClientRect().right,
headerWidth: element.querySelector('header')?.clientWidth ?? 0,
headerContentWidth: element.querySelector('header')?.scrollWidth ?? 0,
viewportWidth: document.documentElement.clientWidth,
documentWidth: document.documentElement.scrollWidth,
}))
expect(layout.contentWidth, JSON.stringify(layout)).toBeLessThanOrEqual(layout.sectionWidth)
expect(layout.headerContentWidth, JSON.stringify(layout)).toBeLessThanOrEqual(layout.headerWidth)
expect(layout.sectionRight, JSON.stringify(layout)).toBeLessThanOrEqual(layout.viewportWidth)
expect(layout.documentWidth, JSON.stringify(layout)).toBeLessThanOrEqual(layout.viewportWidth)
}
assertNoUnexpected()
})

test('desktop sidebar collapses and identifies active entry scope', async ({ page }) => {
const assertNoUnexpected = await mockApi(page, authenticatedApi)
await page.goto('/technical-entries/archived')
Expand Down
76 changes: 76 additions & 0 deletions apps/web/e2e/mocked/project-mobile-cards.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
import { authenticatedApi, entry, mockApi, project, projectId } from './mock-api'

const longWord = 'MobileLayoutWithoutSpaces'.repeat(12)

async function expectCardFitsViewport(page: Page, card: Locator) {
await page.evaluate(() => document.fonts.ready)
const layout = await card.evaluate((element) => {
const viewport = document.documentElement.clientWidth
const cardBounds = element.getBoundingClientRect()
const title = element.querySelector('h3')
const titleBounds = title?.getBoundingClientRect()
return {
viewport,
document: document.documentElement.scrollWidth,
cardLeft: cardBounds.left,
cardRight: cardBounds.right,
titleRight: titleBounds?.right,
titleScrollWidth: title?.scrollWidth,
titleClientWidth: title?.clientWidth,
}
})

// Measure real layout: an implicit grid track may grow to a long word's
// min-content width even though the card appears on a single mobile column.
expect(layout.document, JSON.stringify(layout)).toBeLessThanOrEqual(layout.viewport)
expect(layout.cardLeft, JSON.stringify(layout)).toBeGreaterThanOrEqual(0)
expect(layout.cardRight, JSON.stringify(layout)).toBeLessThanOrEqual(layout.viewport)
expect(layout.titleRight, JSON.stringify(layout)).toBeLessThanOrEqual(layout.cardRight)
expect(layout.titleScrollWidth, JSON.stringify(layout)).toBeLessThanOrEqual(layout.titleClientWidth! + 1)
}

for (const width of [320, 390]) {
test(`project list card fits a ${width}px viewport with long content`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 })
const assertNoUnexpected = await mockApi(page, (request, url) => {
if (request.method() === 'GET' && url.pathname === '/api/project') {
return {
body: {
data: [{ ...project, name: longWord, description: longWord }],
meta: { currentPage: 1, perPage: 10, lastPage: 1, total: 1 },
},
}
}
return authenticatedApi(request, url)
})

await page.goto('/projects')
const card = page.locator('section[aria-labelledby="projects-list-title"] article')
await expect(card.getByRole('heading', { name: longWord })).toBeVisible()
await expectCardFitsViewport(page, card)
assertNoUnexpected()
})

test(`project's linked entry card fits a ${width}px viewport with long content`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 })
const assertNoUnexpected = await mockApi(page, (request, url) => {
if (request.method() === 'GET' && url.pathname === `/api/project/${projectId}/technical-entries`) {
return {
body: {
data: [{ ...entry, title: longWord, context: longWord, tags: [{ id: 'tag-1', name: longWord }] }],
meta: { currentPage: 1, perPage: 6, lastPage: 1, total: 1 },
},
}
}
return authenticatedApi(request, url)
})

await page.goto(`/projects/${projectId}`)
await page.getByRole('tab', { name: 'Technical entries', exact: true }).click()
const card = page.getByRole('tabpanel', { name: 'Technical entries' }).locator('article')
await expect(card.getByRole('heading', { name: longWord })).toBeVisible()
await expectCardFitsViewport(page, card)
assertNoUnexpected()
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ function RecentEntryRow({ entry }: { entry: TechnicalEntry }) {
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{entry.title}</span>
<span className="min-w-0 line-clamp-2 wrap-anywhere font-medium">
{entry.title}
</span>
{status ? (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.className}`}
Expand All @@ -61,9 +63,9 @@ export function RecentEntriesSection({
return (
<section
aria-labelledby="recent-entries-title"
className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6"
className="min-w-0 rounded-2xl border bg-card p-4 shadow-sm sm:p-6"
>
<header className="mb-4 flex items-end justify-between gap-4 px-2">
<header className="mb-4 flex flex-col items-start gap-3 px-2 sm:flex-row sm:items-end sm:justify-between sm:gap-4">
<div>
<p className="text-xs font-medium tracking-widest text-emerald-700 uppercase dark:text-emerald-300">
Latest activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function RecentProjectsSection({
return (
<section
aria-labelledby="recent-projects-title"
className="rounded-2xl border bg-muted/30 p-4 shadow-sm sm:p-6"
className="min-w-0 rounded-2xl border bg-muted/30 p-4 shadow-sm sm:p-6"
>
<header className="mb-4 flex items-end justify-between gap-4">
<div>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/features/home/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ function HomePage() {
tags={{ isPending: tags.isPending, total: tags.data?.meta.total }}
/>

<div className="grid gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(18rem,1fr)]">
{/* An explicit zero-minimum mobile track lets long journal titles wrap
inside their card instead of widening the whole dashboard. */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(18rem,1fr)]">
<RecentEntriesSection
entries={entries.data?.data}
isError={entries.isError}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function TechnicalEntriesSection({

return (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{entries.map((entry) => {
const type = presentTechnicalEntryType(entry.type);
const TypeIcon = type.icon;
Expand All @@ -57,22 +57,22 @@ export function TechnicalEntriesSection({
// navigation target and avoids nesting the title link inside it.
<Link
aria-label={entry.title}
className="group block h-full rounded-2xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
className="group block h-full min-w-0 rounded-2xl focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
key={entry.id}
to={`/technical-entries/${entry.id}`}
>
<article className="flex h-full flex-col gap-4 rounded-2xl border border-border/60 bg-card/80 p-5 shadow-sm transition-colors group-hover:border-primary/40 group-hover:bg-card">
<header className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<article className="flex h-full min-w-0 flex-col gap-4 rounded-2xl border border-border/60 bg-card/80 p-5 shadow-sm transition-colors group-hover:border-primary/40 group-hover:bg-card">
<header className="flex min-w-0 items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 items-start gap-3">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<TypeIcon className="size-4 text-primary" />
</span>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">
{type.label}
</p>
<h3 className="mt-1 flex items-start gap-1 break-words font-semibold">
<span>{entry.title}</span>
<h3 className="mt-1 flex min-w-0 items-start gap-1 font-semibold">
<span className="min-w-0 wrap-anywhere">{entry.title}</span>
<ArrowUpRight
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
Expand All @@ -87,12 +87,12 @@ export function TechnicalEntriesSection({
) : null}
</header>

<p className="line-clamp-4 text-sm leading-6 text-muted-foreground">
<p className="line-clamp-4 wrap-anywhere text-sm leading-6 text-muted-foreground">
{entry.context}
</p>

{entry.conclusion ? (
<p className="line-clamp-2 text-sm leading-6 text-muted-foreground">
<p className="line-clamp-2 wrap-anywhere text-sm leading-6 text-muted-foreground">
{entry.conclusion}
</p>
) : null}
Expand All @@ -101,7 +101,7 @@ export function TechnicalEntriesSection({
<ul className="flex flex-wrap gap-1.5">
{entry.tags.map((tag) => (
<li
className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
className="max-w-full wrap-anywhere rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
key={tag.id}
>
#{tag.name}
Expand Down
14 changes: 8 additions & 6 deletions apps/web/src/features/projects/components/project-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ export function ProjectList({ projects }: ProjectListProps) {
</p>
</div>

<ul className="grid gap-4 md:grid-cols-2">
{/* A zero-minimum column and item let long project identifiers wrap
inside a phone-width card instead of sizing the entire grid. */}
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2">
{projects.map((project) => {
const status = presentProjectStatus(project.status);

return (
<li key={project.id}>
<li className="min-w-0" key={project.id}>
<Link
// A card contains an article and metadata; name its destination
// explicitly so browsers expose a short, stable link name.
Expand All @@ -35,9 +37,9 @@ export function ProjectList({ projects }: ProjectListProps) {
to={`/projects/${project.id}`}
>
<article className="flex h-full flex-col gap-5 rounded-xl border bg-card p-5 shadow-sm transition-colors group-hover:border-primary/40 group-hover:bg-card/80">
<header className="flex items-start justify-between gap-4">
<h3 className="flex min-w-0 items-start gap-2 text-lg font-semibold break-words">
<span>{project.name}</span>
<header className="flex min-w-0 items-start justify-between gap-4">
<h3 className="flex min-w-0 flex-1 items-start gap-2 text-lg font-semibold">
<span className="min-w-0 wrap-anywhere">{project.name}</span>
<ArrowUpRight className="mt-1 size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</h3>
<span
Expand All @@ -48,7 +50,7 @@ export function ProjectList({ projects }: ProjectListProps) {
</header>

{project.description ? (
<Markdown className="max-h-18 overflow-hidden text-sm leading-6 text-muted-foreground">
<Markdown className="max-h-18 min-w-0 overflow-hidden wrap-anywhere text-sm leading-6 text-muted-foreground">
{project.description}
</Markdown>
) : (
Expand Down
Binary file added docs/images/issue-13-dashboard-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/issue-13-linked-entries-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/issue-13-projects-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading