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-prose: 40rem;
--space: 1.5rem;
--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;
align-items: center;
gap: 1.25rem;
margin-block: 4rem 2rem;
margin-block: var(--gap-section) 1.75rem;
}
.section-heading::after {
@@ -111,8 +121,8 @@ h3 {
/* Centred title block at the top of a listing page. */
.page-header {
margin-bottom: 3.5rem;
padding-bottom: 2rem;
margin-bottom: var(--gap-section);
padding-bottom: 1.75rem;
text-align: center;
border-bottom: 1px solid var(--color-border);
}
@@ -127,17 +137,40 @@ h3 {
.card-grid {
display: grid;
gap: 3rem 2.5rem;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 2.25rem 1.5rem;
/* 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;
margin: 0;
list-style: none;
}
@media (min-width: 48rem) {
.card-grid {
gap: 3rem 2.5rem;
}
}
/* Article body */
.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,
@@ -193,10 +226,10 @@ h3 {
.pagination {
display: flex;
flex-wrap: wrap;
gap: 2rem;
gap: 1rem 2rem;
align-items: center;
justify-content: center;
margin-top: 4rem;
margin-top: var(--gap-section);
padding-top: 2rem;
border-top: 1px solid var(--color-border);
font-size: 0.85rem;
+6 -7
View File
@@ -10,14 +10,16 @@ const cover = computed(() => mediaUrl(props.article.cover))
<template>
<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
v-if="cover"
class="cover-link"
:to="`/blog/${article.slug}`"
tabindex="-1"
aria-hidden="true"
>
<img
v-if="cover"
class="cover"
:src="cover"
:alt="article.cover?.alternativeText ?? ''"
@@ -25,7 +27,6 @@ const cover = computed(() => mediaUrl(props.article.cover))
:height="article.cover?.height ?? undefined"
loading="lazy"
>
<span v-else class="cover placeholder" />
</NuxtLinkLocale>
<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);
}
.placeholder {
display: block;
}
.kicker {
/* Without a cover the kicker leads the card, so it needs no offset. */
.cover-link + .kicker,
.cover-link + h2 {
margin-top: 1.1rem;
}
+11 -2
View File
@@ -35,19 +35,28 @@ function onChange(event: Event) {
select {
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-radius: 0;
background: transparent;
color: var(--color-body);
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;
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
}
@media (min-width: 48rem) {
select {
padding: 0.35rem 1.6rem 0.35rem 0.7rem;
font-size: 0.75rem;
}
}
select:hover {
border-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. */
.site-header {
padding-top: 2.5rem;
padding-top: 1.5rem;
text-align: center;
border-bottom: 1px solid var(--color-border);
}
@media (min-width: 48rem) {
.site-header {
padding-top: 2.5rem;
}
}
.wordmark {
margin: 0;
}
@@ -127,23 +133,24 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
}
.site-nav {
margin-top: 2rem;
margin-top: 1.25rem;
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 {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 2rem;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding-block: 0.9rem;
gap: 0.5rem;
padding-block: 0.6rem;
}
.site-nav ul {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 2rem;
gap: 0 1.25rem;
justify-content: center;
padding: 0;
margin: 0;
@@ -154,10 +161,30 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
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 {
display: inline-block;
text-decoration: none;
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;
}
@@ -169,7 +196,13 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
.site-main {
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. */
@@ -179,13 +212,28 @@ const { data: categories } = await useFetch<Category[]>('/api/categories', {
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 {
display: grid;
gap: 2.5rem;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 2rem;
grid-template-columns: 1fr;
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 {
margin-bottom: 0;
}
+7 -1
View File
@@ -99,10 +99,16 @@ useHead({
max-width: 46rem;
/* 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. */
margin: 0 auto 3rem;
margin: 0 auto 2rem;
text-align: center;
}
@media (min-width: 48rem) {
.article-header {
margin-bottom: 3rem;
}
}
.article-header nav a {
text-decoration: none;
}
+16 -9
View File
@@ -40,21 +40,20 @@ useSeoMeta({
<p v-else-if="!featured">{{ $t('list.empty') }}</p>
<template v-else>
<article class="featured">
<article class="featured" :class="{ 'has-cover': featuredCover }">
<NuxtLinkLocale
v-if="featuredCover"
class="featured-cover"
:to="`/blog/${featured.slug}`"
tabindex="-1"
aria-hidden="true"
>
<img
v-if="featuredCover"
:src="featuredCover"
:alt="featured.cover?.alternativeText ?? ''"
:width="featured.cover?.width ?? undefined"
:height="featured.cover?.height ?? undefined"
>
<span v-else class="placeholder" />
</NuxtLinkLocale>
<div class="featured-text">
@@ -97,25 +96,33 @@ useSeoMeta({
<style scoped>
.featured {
display: grid;
gap: 2.5rem;
gap: 1.5rem;
align-items: center;
grid-template-columns: 1fr;
}
@media (min-width: 52rem) {
.featured {
/* Stacked on phones and portrait tablets — a side-by-side split below this
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;
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 {
display: block;
overflow: hidden;
}
.featured-cover img,
.placeholder {
.featured-cover img {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
@@ -156,7 +163,7 @@ useSeoMeta({
}
.more {
margin-top: 4rem;
margin-top: var(--gap-section);
text-align: center;
font-size: 0.75rem;
font-weight: 600;