Make the site responsive for phones, tablets, and desktop

Mobile-first layout with breakpoints at 48rem (tablet) and 64rem (desktop):
stacked nav on phones, progressive column counts for the article grid and
footer, scrollable markdown tables, larger tap targets on nav links.
This commit is contained in:
2026-08-25 19:04:40 +02:00
parent abeabb3a44
commit 071bc1eaa5
6 changed files with 141 additions and 39 deletions
+42 -9
View File
@@ -13,8 +13,18 @@
--width: 76rem; --width: 76rem;
--width-prose: 40rem; --width-prose: 40rem;
--space: 1.5rem;
--radius: 2px; --radius: 2px;
/* Breakpoints used throughout: 48rem = tablet, 64rem = desktop. */
--space: 1.1rem;
--gap-section: 2.5rem;
}
@media (min-width: 48rem) {
:root {
--space: 1.5rem;
--gap-section: 4rem;
}
} }
*, *,
@@ -99,7 +109,7 @@ h3 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.25rem; gap: 1.25rem;
margin-block: 4rem 2rem; margin-block: var(--gap-section) 1.75rem;
} }
.section-heading::after { .section-heading::after {
@@ -111,8 +121,8 @@ h3 {
/* Centred title block at the top of a listing page. */ /* Centred title block at the top of a listing page. */
.page-header { .page-header {
margin-bottom: 3.5rem; margin-bottom: var(--gap-section);
padding-bottom: 2rem; padding-bottom: 1.75rem;
text-align: center; text-align: center;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
@@ -127,17 +137,40 @@ h3 {
.card-grid { .card-grid {
display: grid; display: grid;
gap: 3rem 2.5rem; gap: 2.25rem 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); /* One column on phones, two on tablets, three or more on wide screens. */
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
padding: 0; padding: 0;
margin: 0; margin: 0;
list-style: none; list-style: none;
} }
@media (min-width: 48rem) {
.card-grid {
gap: 3rem 2.5rem;
}
}
/* Article body */ /* Article body */
.prose { .prose {
max-width: var(--width-prose); max-width: var(--width-prose);
font-size: 1.075rem; font-size: 1.0125rem;
/* Editors paste long URLs; without this they push the page sideways. */
overflow-wrap: break-word;
}
@media (min-width: 48rem) {
.prose {
font-size: 1.075rem;
}
}
/* Markdown tables have no wrapper to scroll, so the table scrolls itself. */
.prose table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
} }
.prose h2, .prose h2,
@@ -193,10 +226,10 @@ h3 {
.pagination { .pagination {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 2rem; gap: 1rem 2rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 4rem; margin-top: var(--gap-section);
padding-top: 2rem; padding-top: 2rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
font-size: 0.85rem; font-size: 0.85rem;
+6 -7
View File
@@ -10,14 +10,16 @@ const cover = computed(() => mediaUrl(props.article.cover))
<template> <template>
<article class="card"> <article class="card">
<!-- No placeholder box when there is no cover: an empty grey rectangle
reads as a broken image rather than as a deliberate choice. -->
<NuxtLinkLocale <NuxtLinkLocale
v-if="cover"
class="cover-link" class="cover-link"
:to="`/blog/${article.slug}`" :to="`/blog/${article.slug}`"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
> >
<img <img
v-if="cover"
class="cover" class="cover"
:src="cover" :src="cover"
:alt="article.cover?.alternativeText ?? ''" :alt="article.cover?.alternativeText ?? ''"
@@ -25,7 +27,6 @@ const cover = computed(() => mediaUrl(props.article.cover))
:height="article.cover?.height ?? undefined" :height="article.cover?.height ?? undefined"
loading="lazy" loading="lazy"
> >
<span v-else class="cover placeholder" />
</NuxtLinkLocale> </NuxtLinkLocale>
<p v-if="article.category" class="kicker">{{ article.category.name }}</p> <p v-if="article.category" class="kicker">{{ article.category.name }}</p>
@@ -60,11 +61,9 @@ const cover = computed(() => mediaUrl(props.article.cover))
transform: scale(1.03); transform: scale(1.03);
} }
.placeholder { /* Without a cover the kicker leads the card, so it needs no offset. */
display: block; .cover-link + .kicker,
} .cover-link + h2 {
.kicker {
margin-top: 1.1rem; margin-top: 1.1rem;
} }
+11 -2
View File
@@ -35,19 +35,28 @@ function onChange(event: Event) {
select { select {
appearance: none; appearance: none;
padding: 0.35rem 1.6rem 0.35rem 0.7rem; padding: 0.4rem 1.7rem 0.4rem 0.7rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: var(--color-body); color: var(--color-body);
font: inherit; font: inherit;
font-size: 0.75rem; /* Safari on iOS zooms the page when a control smaller than 16px is tapped,
so the picker stays at 1rem on phones and shrinks only from tablet up. */
font-size: 1rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
} }
@media (min-width: 48rem) {
select {
padding: 0.35rem 1.6rem 0.35rem 0.7rem;
font-size: 0.75rem;
}
}
select:hover { select:hover {
border-color: var(--color-ink); border-color: var(--color-ink);
color: var(--color-ink); color: var(--color-ink);
+59 -11
View File
@@ -102,11 +102,17 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
/* Header: centred wordmark over a thin rule of links. */ /* Header: centred wordmark over a thin rule of links. */
.site-header { .site-header {
padding-top: 2.5rem; padding-top: 1.5rem;
text-align: center; text-align: center;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
@media (min-width: 48rem) {
.site-header {
padding-top: 2.5rem;
}
}
.wordmark { .wordmark {
margin: 0; margin: 0;
} }
@@ -127,23 +133,24 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
} }
.site-nav { .site-nav {
margin-top: 2rem; margin-top: 1.25rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
/* Phones stack the links above the language picker, both centred; from tablet
up they sit on one line with the picker pushed to the right. */
.nav-inner { .nav-inner {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 0.75rem 2rem;
align-items: center; align-items: center;
justify-content: space-between; gap: 0.5rem;
padding-block: 0.9rem; padding-block: 0.6rem;
} }
.site-nav ul { .site-nav ul {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem 2rem; gap: 0 1.25rem;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
margin: 0; margin: 0;
@@ -154,10 +161,30 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
text-transform: uppercase; text-transform: uppercase;
} }
@media (min-width: 48rem) {
.site-nav {
margin-top: 2rem;
}
.nav-inner {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem 2rem;
padding-block: 0.9rem;
}
.site-nav ul {
gap: 0.25rem 2rem;
}
}
.site-nav a { .site-nav a {
display: inline-block;
text-decoration: none; text-decoration: none;
color: var(--color-body); color: var(--color-body);
padding-block: 0.25rem; /* Roomy enough to tap accurately on a phone. */
padding-block: 0.6rem;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
} }
@@ -169,7 +196,13 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
.site-main { .site-main {
flex: 1; flex: 1;
padding-block: 3.5rem 5rem; padding-block: 2.25rem 3.5rem;
}
@media (min-width: 48rem) {
.site-main {
padding-block: 3.5rem 5rem;
}
} }
/* Footer: columns, then a centred copyright line. */ /* Footer: columns, then a centred copyright line. */
@@ -179,13 +212,28 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
/* One column on phones, two on tablets, four on desktop — auto-fit would
otherwise leave the fourth block stranded on its own row on a tablet. */
.footer-inner { .footer-inner {
display: grid; display: grid;
gap: 2.5rem; gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); grid-template-columns: 1fr;
padding-bottom: 2.5rem; padding-bottom: 2.5rem;
} }
@media (min-width: 48rem) {
.footer-inner {
grid-template-columns: repeat(2, 1fr);
gap: 2.5rem;
}
}
@media (min-width: 64rem) {
.footer-inner {
grid-template-columns: repeat(4, 1fr);
}
}
.footer-block p:last-of-type { .footer-block p:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }
+7 -1
View File
@@ -99,10 +99,16 @@ useHead({
max-width: 46rem; max-width: 46rem;
/* The gap below the header lives here, not on the cover: an article without /* The gap below the header lives here, not on the cover: an article without
a cover image would otherwise have its body butting against the byline. */ a cover image would otherwise have its body butting against the byline. */
margin: 0 auto 3rem; margin: 0 auto 2rem;
text-align: center; text-align: center;
} }
@media (min-width: 48rem) {
.article-header {
margin-bottom: 3rem;
}
}
.article-header nav a { .article-header nav a {
text-decoration: none; text-decoration: none;
} }
+16 -9
View File
@@ -40,21 +40,20 @@ useSeoMeta({
<p v-else-if="!featured">{{ $t('list.empty') }}</p> <p v-else-if="!featured">{{ $t('list.empty') }}</p>
<template v-else> <template v-else>
<article class="featured"> <article class="featured" :class="{ 'has-cover': featuredCover }">
<NuxtLinkLocale <NuxtLinkLocale
v-if="featuredCover"
class="featured-cover" class="featured-cover"
:to="`/blog/${featured.slug}`" :to="`/blog/${featured.slug}`"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
> >
<img <img
v-if="featuredCover"
:src="featuredCover" :src="featuredCover"
:alt="featured.cover?.alternativeText ?? ''" :alt="featured.cover?.alternativeText ?? ''"
:width="featured.cover?.width ?? undefined" :width="featured.cover?.width ?? undefined"
:height="featured.cover?.height ?? undefined" :height="featured.cover?.height ?? undefined"
> >
<span v-else class="placeholder" />
</NuxtLinkLocale> </NuxtLinkLocale>
<div class="featured-text"> <div class="featured-text">
@@ -97,25 +96,33 @@ useSeoMeta({
<style scoped> <style scoped>
.featured { .featured {
display: grid; display: grid;
gap: 2.5rem; gap: 1.5rem;
align-items: center; align-items: center;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@media (min-width: 52rem) { /* Stacked on phones and portrait tablets — a side-by-side split below this
.featured { width leaves both the image and the headline too cramped to read. The split
also needs a cover to sit in it, or one column would just be empty. */
@media (min-width: 64rem) {
.featured.has-cover {
grid-template-columns: 1.35fr 1fr; grid-template-columns: 1.35fr 1fr;
gap: 3.5rem; gap: 3.5rem;
} }
} }
/* Without a cover the headline is the whole lead, so it gets the full width
but stays inside a readable measure. */
.featured:not(.has-cover) .featured-text {
max-width: 46rem;
}
.featured-cover { .featured-cover {
display: block; display: block;
overflow: hidden; overflow: hidden;
} }
.featured-cover img, .featured-cover img {
.placeholder {
width: 100%; width: 100%;
aspect-ratio: 3 / 2; aspect-ratio: 3 / 2;
object-fit: cover; object-fit: cover;
@@ -156,7 +163,7 @@ useSeoMeta({
} }
.more { .more {
margin-top: 4rem; margin-top: var(--gap-section);
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;