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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to Patternflow will be documented in this file, newest first

### Web

- **Journal archive dates stay on one line on a desktop.** The index's date column was a fixed 120px, so above 720px 27 of the 37 Korean dates and 17 of the 37 English ones broke onto a second line. The column now grows to fit its date.
- **Journal dates are the Seoul day a post was written, wherever the site is built.** The formatter used the build machine's time zone, so a UTC build printed the four posts written between midnight and 9 a.m. in Seoul a day early, 60 of the 520 dates across /journal.
- **The journal's language switch and Archive label are readable.** The language a page is not in and the index's "Archive" label were #A69F90, 2.3:1 on the cream and 2.6:1 on the white panel, and axe flagged them on all 76 journal pages. They now use the journal's muted ink, 5.0:1 and 5.8:1.
- **A journal post's language switch no longer sits over the text on a phone.** Below 721px it was fixed 22px from the right edge, inside the column of text, and at 390px the 74 posts scrolled 8,801 lines of text under it. It now starts in the same place and scrolls away with the page.
- **Dates in the list under a journal post stay clear of the post numbers.** Its rows kept 56px for a 64px number, so above 760px the end of 350 of the 370 dates across the 74 posts ran under it. They now keep the index's 104px.
- **The footers under a journal post and the journal index line up with the text above them.** As `<footer>`s they took the front page's 60px sides from 901px and none below, so the list under a post started 4px left of the post's text on a desktop and 64px left of it from 721 to 900px. Each now keeps its own panel's side padding; phones are unchanged.
- **A journal post is one `<main>`, as the journal index is.** A screen reader's jump to the main content found nothing on a post, and axe flagged landmark-one-main on all 74 posts.
- **A journal post's share card prints a date you can read.** The card printed the frontmatter timestamp, `2026-05-14T01:30:00+09:00`, on all 37 posts. It now prints the Seoul day the post page shows, in the card's English capitals: `MAY 14, 2026`.
- **The journal's picture window keeps the focus.** Opening a picture left the focus on the post, so Tab went to the links under the backdrop and scrolled the post away from the picture. The focus now starts on Close and stays in the window, and closing it puts the focus back on the picture.
- **The journal's stylesheet drops 61 lines that matched nothing.** No component or post used `.journal-list-number` or `.journal-figure` with its `-wide` and `-full`, the cover slots never held the `span` two rules styled, and two rules switched off a `::before` nothing switched on. Every journal page renders as before.
- **The site nav links the shop.** Shop sits between Community and Instagram at the top right of the home page, boxed in ink like the hero's outline button, and opens shop.patternflow.work in a new tab. The Inside panel's last row of links carries it too.

## [3.12.0] - 2026-10-27
Expand Down
101 changes: 36 additions & 65 deletions web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,9 @@
align-items: flex-start;
gap: 14px;
}
/* The language a page is not in: 5.0:1 on the cream, 5.8:1 on the white
panel, and 3.2:1 from the current one in --ink. --ink-faint was 2.3:1
and 2.6:1. */
.journal-lang-switch {
position: fixed;
top: 28px;
Expand All @@ -1092,7 +1095,7 @@
font-size: var(--pf-fs-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-faint);
color: var(--ink-muted);
}
.journal-lang-switch a {
color: inherit;
Expand Down Expand Up @@ -1138,7 +1141,6 @@
.journal-lockup,
.journal-featured-meta,
.journal-archive-label,
.journal-list-number,
.journal-list-meta,
.journal-index-footer,
.journal-back-link,
Expand Down Expand Up @@ -1236,14 +1238,6 @@
rgba(20, 20, 20, 0.035) 15px
);
}
.journal-index-thumb span {
position: relative;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-muted);
}
.journal-index-thumb img {
position: relative;
z-index: 1;
Expand All @@ -1262,17 +1256,6 @@
border: 1px solid var(--rule);
background: var(--cream-2);
}
.journal-cover-slot::before {
content: none;
}
.journal-cover-slot span {
position: relative;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-muted);
}
.journal-cover-slot img {
position: relative;
z-index: 1;
Expand All @@ -1285,7 +1268,7 @@
}
.journal-archive-label {
margin-bottom: 16px;
color: var(--ink-faint);
color: var(--ink-muted);
}
.journal-post-list {
list-style: none;
Expand All @@ -1294,9 +1277,12 @@
.journal-post-list li {
border-bottom: 0;
}
/* The date column is at least 120px and as wide as its date. At 120px a
two-digit Korean date (130px, "2026년 10월 29일") and a long English
one (159px, "SEPTEMBER 29, 2026") broke onto a second line. */
.journal-post-list a {
display: grid;
grid-template-columns: 1fr 120px 80px;
grid-template-columns: minmax(0, 1fr) minmax(120px, max-content) 80px;
gap: 24px;
align-items: baseline;
padding-right: 104px;
Expand All @@ -1312,9 +1298,6 @@
.journal-post-list a .pf-ghost {
right: 0;
}
.journal-list-number {
color: var(--ink-faint);
}
.journal-list-body {
display: grid;
gap: 4px;
Expand Down Expand Up @@ -1346,6 +1329,7 @@
}
.journal-list-meta {
text-align: right;
white-space: nowrap;
}
.journal-list-body .pf-row-d {
max-width: none;
Expand All @@ -1357,9 +1341,16 @@
.journal-post-list a.journal-anchor-post .journal-list-meta:last-child {
color: var(--pf-led);
}
/* Both journal footers are <footer>s, so the front page's footer rule
gave them 60px a side from 901px and 0 below it. Each now keeps its
own panel's side padding, and its text starts where the panel's does:
the list under a post started 4px left of the post's text from 901px
and 64px left of it from 721 to 900. */
.journal-index-footer {
margin-top: 32px;
padding-top: 14px;
padding-left: 60px;
padding-right: 60px;
border-top: 1px solid var(--pf-rule);
display: flex;
justify-content: flex-start;
Expand Down Expand Up @@ -1441,10 +1432,6 @@
overflow: visible;
text-align: center;
}
.journal-hero-cover .journal-cover-slot::before {
content: none;
display: none;
}
.journal-hero-cover .journal-cover-slot img {
display: block;
width: auto;
Expand Down Expand Up @@ -1621,27 +1608,6 @@
border: 0;
background: var(--rule);
}
.journal-figure {
margin: 36px 0;
}
.journal-figure-wide {
width: min(82vw, 1312px);
margin-left: 50%;
transform: translateX(-50%);
}
.journal-figure-full {
width: 100vw;
margin-left: 50%;
transform: translateX(-50%);
}
.journal-figure img,
.journal-figure video {
width: 100%;
height: auto;
display: block;
border: 1px solid var(--rule);
background: var(--cream-2);
}
.journal-lightbox {
position: fixed;
inset: 0;
Expand Down Expand Up @@ -1690,13 +1656,6 @@
text-transform: uppercase;
cursor: pointer;
}
.journal-figure figcaption {
margin-top: 10px;
font-family: var(--mono);
font-size: var(--pf-fs-mono);
letter-spacing: 0.06em;
color: var(--ink-muted);
}
.journal-end-mark {
margin-top: 80px;
display: flex;
Expand All @@ -1718,6 +1677,9 @@
.journal-article-footer {
width: min(100%, 948px);
margin: 34px auto 0;
/* .journal-article's side padding; see .journal-index-footer. */
padding-left: 64px;
padding-right: 64px;
display: grid;
gap: 18px;
color: var(--ink-muted);
Expand All @@ -1731,12 +1693,15 @@
letter-spacing: normal;
text-transform: none;
}
/* 104px clear for the post number, as the index rows keep. At 56px the
end of a date ran under the 64px number: 350 of the 370 rows on the
74 posts, from 761px up. */
.journal-context-list .pf-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: baseline;
padding-right: 56px;
padding-right: 104px;
border-top: 0;
color: var(--ink-muted);
text-decoration: none;
Expand Down Expand Up @@ -1851,6 +1816,12 @@
top: 18px;
right: 22px;
}
/* A post's switch scrolls away with the page. Fixed, it sat over the
right end of each line of text that scrolled under it: 8,801 lines
across the 74 posts at 390px. It starts in the same place. */
.journal-article .journal-lang-switch {
position: absolute;
}
.journal-shell {
padding: 36px 24px 96px;
}
Expand All @@ -1871,6 +1842,12 @@
align-items: flex-start;
gap: 12px;
}
/* On a phone both footers stay at the panel's edge, as they were. */
.journal-index-footer,
.journal-article-footer {
padding-left: 0;
padding-right: 0;
}
.journal-lockup {
font-size: 50px;
}
Expand Down Expand Up @@ -1963,12 +1940,6 @@
margin-right: 12px;
color: var(--pf-ink-muted);
}
.journal-figure-wide,
.journal-figure-full {
width: 100%;
margin-left: 0;
transform: none;
}
.journal-md-figure,
.journal-md-figure:has(+ .journal-md-figure),
.journal-md-figure:has(+ .journal-md-figure) + .journal-md-figure {
Expand Down
57 changes: 57 additions & 0 deletions web/src/app/journal/[slug]/opengraph-image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// The card a post shows when its link is shared printed the frontmatter date
// as it is written: "2026-05-14T01:30:00+09:00" across the top right of all
// 37 cards, on 27 October.

import { isValidElement, type ReactElement, type ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAllJournalPosts } from "@/lib/journal";
import OpenGraphImage from "./opengraph-image";

vi.mock("next/og", () => ({
ImageResponse: class {
element: ReactElement;
constructor(element: ReactElement) {
this.element = element;
}
},
}));

function strings(node: ReactNode): string[] {
if (typeof node === "string" || typeof node === "number") return [String(node)];
if (Array.isArray(node)) return node.flatMap(strings);
if (isValidElement(node)) return strings((node.props as { children?: ReactNode }).children);
return [];
}

async function cardText(slug: string) {
const card = (await OpenGraphImage({ params: Promise.resolve({ slug }) })) as unknown as {
element: ReactElement;
};
return strings(card.element);
}

afterEach(() => {
vi.unstubAllEnvs();
});

describe("journal share card", () => {
it("prints the day the post was written in Seoul, not the frontmatter string", async () => {
vi.stubEnv("TZ", "UTC");
const text = await cardText("headache");

expect(text).toContain("May 14, 2026");
expect(text).not.toContain("2026-05-14T01:30:00+09:00");
});

it("gives no card a raw timestamp", async () => {
vi.stubEnv("TZ", "UTC");
const raw: string[] = [];

for (const post of getAllJournalPosts({ includeDrafts: true })) {
const text = await cardText(post.slug);
if (text.some((line) => /\d{4}-\d{2}-\d{2}T/.test(line))) raw.push(post.slug);
}

expect(raw).toEqual([]);
});
});
6 changes: 4 additions & 2 deletions web/src/app/journal/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ImageResponse } from "next/og";
import { getAllJournalPosts, getJournalSlugs } from "@/lib/journal";
import { formatJournalDate, getAllJournalPosts, getJournalSlugs } from "@/lib/journal";

export const runtime = "nodejs";
export const size = {
Expand Down Expand Up @@ -51,7 +51,9 @@ export default async function OpenGraphImage({ params }: OpenGraphImageProps) {
}}
>
<span>patternflow / journal</span>
<span>{post?.date ?? ""}</span>
{/* The Korean and the English page share this card, and its top
line is English capitals, so the date is the English one. */}
<span>{post ? formatJournalDate(post.date, "en") : ""}</span>
</div>
<div
style={{
Expand Down
54 changes: 54 additions & 0 deletions web/src/components/journal/ArticleLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import type { JournalPost } from "@/lib/journal";
import ArticleLayout from "./ArticleLayout";

// The index page is one <main>. A post was an <article> and a <footer> side by
// side, with no <main> around either, so a screen reader's jump to the main
// content found nothing. On 27 October axe flagged all 74 posts at 390 and
// 1440 for landmark-one-main, with 900 blocks outside any landmark (region).

vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; children: ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));

function post(slug: string, title: string, date: string): JournalPost {
return { slug, title, date, excerpt: `${title} excerpt`, lang: "ko", content: "", readingTime: "3분" };
}

const older = post("older", "Older post", "2026-05-14T01:30:00+09:00");
const current = post("current", "Current post", "2026-06-02T20:00:00+09:00");

function renderPost() {
return render(
<ArticleLayout post={current} lang="ko" previous={older} next={null} allPosts={[current, older]}>
<p>Body text</p>
</ArticleLayout>,
);
}

describe("ArticleLayout", () => {
it("puts the whole post inside one main landmark", () => {
const { container } = renderPost();
expect(screen.getAllByRole("main")).toHaveLength(1);
expect([...container.children].map((el) => el.tagName)).toEqual(["MAIN"]);
});

it("keeps the article and the list of nearby posts in it", () => {
renderPost();
const main = screen.getByRole("main");
expect(within(main).getByRole("article")).toBeTruthy();
expect(within(main).getByRole("heading", { level: 1, name: "Current post" })).toBeTruthy();
expect(within(main).getByText("Body text")).toBeTruthy();
expect(within(main).getByRole("link", { name: /Older post/ }).getAttribute("href")).toBe("/journal/older");
});
});
Loading
Loading