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:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user