Give the content pages the landing's look

Moving from the landing to a page read as changing sites: a narrow
centred column on a flat background, light headings, no cards. The
page now opens like the landing's hero — the same two lights, the same
left edge as the logo, a bold title with the excerpt as its lead — and
the body's headings are bold like every section title on the landing.

The lights move into a shared mixin (ui/_lights.scss) so the hero and
the pages cannot drift apart. On a page they sit on the whole <main>
with `overflow: clip`, so they fade out on their own instead of ending
in a hard edge right where the text starts, and `clip` rather than
`hidden` keeps the sticky side column working.

A side column keeps a page from being a dead end: "keep reading" cards
for the other pages, its own category first, and a card to get in
touch, both on the landing's card surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-28 10:38:04 +02:00
parent 80a1924519
commit 9af6b5aff9
5 changed files with 288 additions and 108 deletions

View File

@@ -90,7 +90,7 @@ export interface Copy {
closed: string; closed: string;
}; };
footer: { rights: string; pages: string }; footer: { rights: string; pages: string };
page: { back: string; skip: string }; page: { skip: string; related: string; ctaTitle: string; ctaLead: string };
maintenance: { title: string; back: string; until: string }; maintenance: { title: string; back: string; until: string };
} }
@@ -185,8 +185,10 @@ const es: Copy = {
pages: 'Más sobre Pireon', pages: 'Más sobre Pireon',
}, },
page: { page: {
back: 'Volver al inicio',
skip: 'Saltar al contenido', skip: 'Saltar al contenido',
related: 'Sigue leyendo',
ctaTitle: '¿Hablamos?',
ctaLead: 'Si algo de esto encaja con lo que buscas, escríbenos y te respondemos.',
}, },
maintenance: { maintenance: {
title: 'Estamos de mantenimiento', title: 'Estamos de mantenimiento',
@@ -286,8 +288,10 @@ const en: Copy = {
pages: 'More about Pireon', pages: 'More about Pireon',
}, },
page: { page: {
back: 'Back to home',
skip: 'Skip to content', skip: 'Skip to content',
related: 'Keep reading',
ctaTitle: 'Shall we talk?',
ctaLead: 'If any of this fits what you are looking for, write to us and we will get back to you.',
}, },
maintenance: { maintenance: {
title: 'We are under maintenance', title: 'We are under maintenance',
@@ -387,8 +391,10 @@ const ca: Copy = {
pages: 'Més sobre Pireon', pages: 'Més sobre Pireon',
}, },
page: { page: {
back: "Tornar a l'inici",
skip: 'Salta al contingut', skip: 'Salta al contingut',
related: 'Continua llegint',
ctaTitle: 'En parlem?',
ctaLead: "Si alguna cosa d'aquestes encaixa amb el que busques, escriu-nos i et respondrem.",
}, },
maintenance: { maintenance: {
title: 'Estem de manteniment', title: 'Estem de manteniment',

View File

@@ -0,0 +1,53 @@
// The two soft lights behind the top of a page, shared by the landing's hero
// and the content pages so moving from one to the other does not feel like
// changing sites.
//
// Lights rather than an image: nothing to download, and nothing that dates the
// page when the brand shifts. `isolate` keeps the negative z-index inside the
// element instead of behind the page background.
//
// `$overflow: clip` for a tall element with sticky children: `hidden` makes the
// element a scroll container, and `position: sticky` inside one stops
// following the page.
@mixin glow($overflow: hidden) {
position: relative;
isolation: isolate;
overflow: $overflow;
&::before,
&::after {
content: '';
position: absolute;
z-index: -1;
border-radius: 50%;
pointer-events: none;
filter: blur(80px);
}
&::before {
top: -14rem;
left: 50%;
width: 64rem;
height: 38rem;
transform: translateX(-58%);
opacity: 0.5;
background: radial-gradient(
closest-side,
var(--pi-color-primary),
transparent
);
}
&::after {
top: 4rem;
right: -8rem;
width: 26rem;
height: 26rem;
opacity: 0.35;
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
}
}

View File

@@ -1,3 +1,5 @@
@use './lights';
:host { :host {
display: block; display: block;
} }
@@ -16,55 +18,86 @@
} }
} }
// The lights sit on the whole page rather than on its top: on the hero alone
// they end in a hard edge where it does, and here the text starts right there.
// A page is tall enough for them to fade out on their own.
.content-page { .content-page {
padding-block: 4rem 5rem; @include lights.glow($overflow: clip);
}
// The top of the page is the landing's hero, minus the stats and buttons: the
// same left edge as the logo, the same heading weight.
.content-hero {
padding-block: 5rem 2.5rem;
@media (min-width: 768px) { @media (min-width: 768px) {
padding-block: 6rem 7rem; padding-block: 7rem 3.5rem;
} }
} }
// A reading column, not the landing's full width: prose past ~70 characters a .content-hero__title {
// line is hard to follow.
.content-page__inner {
max-width: 48rem;
}
.content-page__title {
margin: 0; margin: 0;
font-size: clamp(2rem, 5vw, 3rem); max-width: 22ch;
line-height: 1.1; font-size: clamp(2.25rem, 5.5vw, 3.5rem);
line-height: 1.08;
letter-spacing: -0.02em;
} }
.content-page__lead { .content-hero__lead {
margin: 1.25rem 0 0; max-width: 46ch;
line-height: 1.6; margin: 1.5rem 0 0;
}
// Reading column and a side column, on the landing's own grid width. The
// column keeps prose to a readable measure; the side keeps the page from
// ending in a dead end.
.content-page__layout {
display: grid;
gap: 3rem;
padding-bottom: 5rem;
@media (min-width: 1024px) {
grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem);
justify-content: space-between;
gap: 4rem;
padding-bottom: 7rem;
}
} }
.content-page__media { .content-page__media {
margin: 2.5rem 0 0; margin: 0 0 2.5rem;
overflow: hidden; overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem); border-radius: var(--pi-theme-card-radius, 1.5rem);
} }
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.content-page__media ::ng-deep img {
display: block;
width: 100%;
height: auto;
}
.content-page__body { .content-page__body {
margin-top: 2.5rem;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.75;
// The body is HTML from the panel's editor, not this component's template, // The body is HTML from the panel's editor, not this component's template,
// so it carries none of the scoping attributes: only `::ng-deep` reaches it. // so it carries none of the scoping attributes: only `::ng-deep` reaches it.
// Confined to this container, it styles nothing else on the page. // Confined to this container, it styles nothing else on the page.
::ng-deep { ::ng-deep {
// Bold, like every heading on the landing — a light heading here was the
// one thing that made the page read as another site.
h2 { h2 {
margin: 2.5rem 0 0.75rem; margin: 3rem 0 1rem;
font-size: 1.5rem; font-size: clamp(1.5rem, 3vw, 1.875rem);
line-height: 1.25; font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
} }
h3 { h3 {
margin: 2rem 0 0.5rem; margin: 2rem 0 0.75rem;
font-size: 1.2rem; font-size: 1.25rem;
font-weight: 700;
} }
// One rhythm for both: left alone, the library's sheet sets paragraphs // One rhythm for both: left alone, the library's sheet sets paragraphs
@@ -80,12 +113,20 @@
ul, ul,
ol { ol {
margin: 0 0 1.25rem; margin: 0 0 1.5rem;
padding-left: 1.25rem; padding-left: 1.25rem;
} }
li { li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
&::marker {
color: var(--pi-color-primary);
}
}
strong {
font-weight: 700;
} }
a { a {
@@ -98,20 +139,77 @@
} }
} }
.content-page__back { .content-page__aside {
display: inline-block; display: flex;
margin-top: 3rem; flex-direction: column;
color: var(--pi-color-grey-600, #5b5b6b); gap: 2rem;
text-decoration: none;
&:hover { // Stays in view beside a long page, under the sticky header.
color: inherit; @media (min-width: 1024px) {
position: sticky;
top: 6rem;
align-self: start;
} }
} }
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it. .content-page__related {
.content-page__media ::ng-deep img { display: grid;
display: block; gap: 1rem;
width: 100%; margin: 0;
height: auto; padding: 0;
list-style: none;
}
// `landing-card` is the landing's card surface and hover, drawn on a link
// rather than on `pi-card`, which brings no padding or radius of its own here.
.content-page__card,
.content-page__cta {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
.content-page__card {
color: inherit;
text-decoration: none;
}
.content-page__card-title {
display: block;
font-size: 1.0625rem;
font-weight: 700;
}
.content-page__card-text {
display: -webkit-box;
margin-top: 0.375rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 0.875rem;
line-height: 1.5;
// Relative to the card's own text colour, so it holds up in dark mode too,
// where the grey token is nearly the background.
color: color-mix(in srgb, currentColor 68%, transparent);
}
.content-page__cta {
padding: 1.5rem;
// The call to action is not a link as a whole, so it does not lift.
&:hover {
transform: none;
}
p {
margin: 0.5rem 0 1.25rem;
line-height: 1.6;
}
}
.content-page__cta-title {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
} }

View File

@@ -11,6 +11,7 @@ import {
import { Meta, Title } from '@angular/platform-browser'; import { Meta, Title } from '@angular/platform-browser';
import { injectRequest, injectResponse } from '@analogjs/router/tokens'; import { injectRequest, injectResponse } from '@analogjs/router/tokens';
import { import {
PiActionButtonComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
@@ -41,6 +42,7 @@ import { SiteHeader } from './site-header';
ApiImage, ApiImage,
MaintenanceScreen, MaintenanceScreen,
NotFoundPage, NotFoundPage,
PiActionButtonComponent,
PiTextColorDirective, PiTextColorDirective,
PiTextLargeDirective, PiTextLargeDirective,
PiTextWeightBoldDirective, PiTextWeightBoldDirective,
@@ -69,24 +71,29 @@ import { SiteHeader } from './site-header';
/> />
<main id="contenido" class="content-page"> <main id="contenido" class="content-page">
<article class="landing-shell content-page__inner"> <header class="content-hero">
<div class="landing-shell">
@if (page.category?.name; as category) { @if (page.category?.name; as category) {
<p class="landing-eyebrow">{{ category }}</p> <p class="landing-eyebrow">{{ category }}</p>
} }
<h1 bold class="content-page__title">{{ page.title }}</h1> <h1 bold class="content-hero__title">{{ page.title }}</h1>
@if (page.excerpt) { @if (page.excerpt) {
<p l pi-color color="grey" class="content-page__lead"> <p l pi-color color="grey" class="content-hero__lead">
{{ page.excerpt }} {{ page.excerpt }}
</p> </p>
} }
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (page.featured_image; as image) { @if (page.featured_image; as image) {
<figure class="content-page__media"> <figure class="content-page__media">
<app-api-image <app-api-image
[image]="image" [image]="image"
sizes="(min-width: 768px) 48rem, 100vw" sizes="(min-width: 1024px) 46rem, 100vw"
loading="eager" loading="eager"
imgClass="content-page__image" imgClass="content-page__image"
/> />
@@ -94,11 +101,48 @@ import { SiteHeader } from './site-header';
} }
<div class="content-page__body" [innerHTML]="page.body ?? ''"></div> <div class="content-page__body" [innerHTML]="page.body ?? ''"></div>
<a class="content-page__back" [href]="home()"
>← {{ copy().page.back }}</a
>
</article> </article>
<aside class="content-page__aside">
@if (related().length > 0) {
<nav [attr.aria-label]="copy().page.related">
<p class="landing-eyebrow">{{ copy().page.related }}</p>
<ul class="content-page__related">
@for (item of related(); track item.id) {
<li>
<a
class="landing-card content-page__card"
[href]="item.path"
>
<span class="content-page__card-title">{{
item.title
}}</span>
@if (item.excerpt) {
<span class="content-page__card-text">{{
item.excerpt
}}</span>
}
</a>
</li>
}
</ul>
</nav>
}
<div class="landing-card content-page__cta">
<h2 class="content-page__cta-title">
{{ copy().page.ctaTitle }}
</h2>
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
<pi-action-button
color="primary"
[text]="copy().nav.contact"
[hasIcon]="true"
(execute)="goHome('#contacto')"
/>
</div>
</aside>
</div>
</main> </main>
<app-site-footer [copy]="copy()" [pages]="data().pages" /> <app-site-footer [copy]="copy()" [pages]="data().pages" />
@@ -124,6 +168,24 @@ export class ContentPage implements OnInit {
protected readonly home = computed(() => homeHref(this.data().locale)); protected readonly home = computed(() => homeHref(this.data().locale));
protected readonly navLinks = computed(() => pageNavLinks(this.data().pages)); protected readonly navLinks = computed(() => pageNavLinks(this.data().pages));
/**
* Somewhere to go next, so a page is not a dead end: the rest of its
* category first, then anything else, three at most.
*/
protected readonly related = computed(() => {
const { page, pages } = this.data();
const others = pages.filter(
(other) => other.id !== page?.id && other.path && other.title,
);
const sameCategory = (other: (typeof pages)[number]) =>
other.category?.id === page?.category?.id;
return [
...others.filter(sameCategory),
...others.filter((other) => !sameCategory(other)),
].slice(0, 3);
});
protected readonly locales = computed( protected readonly locales = computed(
() => () =>
this.data().site?.locales ?? [ this.data().site?.locales ?? [

View File

@@ -1,52 +1,13 @@
@use './lights';
:host { :host {
display: block; display: block;
} }
.landing-hero { .landing-hero {
position: relative; @include lights.glow;
isolation: isolate;
padding-block: 5rem 3rem; padding-block: 5rem 3rem;
overflow: hidden;
// Two soft lights rather than a hero image: nothing to download, and nothing
// that dates the page when the brand shifts. `isolate` on the section keeps
// the negative z-index inside it instead of behind the page background.
&::before,
&::after {
content: '';
position: absolute;
z-index: -1;
border-radius: 50%;
pointer-events: none;
filter: blur(80px);
}
&::before {
top: -14rem;
left: 50%;
width: 64rem;
height: 38rem;
transform: translateX(-58%);
opacity: 0.5;
background: radial-gradient(
closest-side,
var(--pi-color-primary),
transparent
);
}
&::after {
top: 4rem;
right: -8rem;
width: 26rem;
height: 26rem;
opacity: 0.35;
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
}
@media (min-width: 768px) { @media (min-width: 768px) {
padding-block: 7rem 4rem; padding-block: 7rem 4rem;