diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index cb28fd9..44df53f 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -90,7 +90,7 @@ export interface Copy { closed: 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 }; } @@ -185,8 +185,10 @@ const es: Copy = { pages: 'Más sobre Pireon', }, page: { - back: 'Volver al inicio', 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: { title: 'Estamos de mantenimiento', @@ -286,8 +288,10 @@ const en: Copy = { pages: 'More about Pireon', }, page: { - back: 'Back to home', 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: { title: 'We are under maintenance', @@ -387,8 +391,10 @@ const ca: Copy = { pages: 'Més sobre Pireon', }, page: { - back: "Tornar a l'inici", 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: { title: 'Estem de manteniment', diff --git a/landing/src/app/ui/_lights.scss b/landing/src/app/ui/_lights.scss new file mode 100644 index 0000000..910b79a --- /dev/null +++ b/landing/src/app/ui/_lights.scss @@ -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 + ); + } +} diff --git a/landing/src/app/ui/content-page.scss b/landing/src/app/ui/content-page.scss index e88f961..e0a980d 100644 --- a/landing/src/app/ui/content-page.scss +++ b/landing/src/app/ui/content-page.scss @@ -1,3 +1,5 @@ +@use './lights'; + :host { 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 { - 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) { - padding-block: 6rem 7rem; + padding-block: 7rem 3.5rem; } } -// A reading column, not the landing's full width: prose past ~70 characters a -// line is hard to follow. -.content-page__inner { - max-width: 48rem; -} - -.content-page__title { +.content-hero__title { margin: 0; - font-size: clamp(2rem, 5vw, 3rem); - line-height: 1.1; + max-width: 22ch; + font-size: clamp(2.25rem, 5.5vw, 3.5rem); + line-height: 1.08; + letter-spacing: -0.02em; } -.content-page__lead { - margin: 1.25rem 0 0; - line-height: 1.6; +.content-hero__lead { + max-width: 46ch; + 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 { - margin: 2.5rem 0 0; + margin: 0 0 2.5rem; overflow: hidden; border-radius: var(--pi-theme-card-radius, 1.5rem); } +// The `` 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 { - margin-top: 2.5rem; font-size: 1.0625rem; - line-height: 1.75; // 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. // Confined to this container, it styles nothing else on the page. ::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 { - margin: 2.5rem 0 0.75rem; - font-size: 1.5rem; - line-height: 1.25; + margin: 3rem 0 1rem; + font-size: clamp(1.5rem, 3vw, 1.875rem); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.015em; } h3 { - margin: 2rem 0 0.5rem; - font-size: 1.2rem; + margin: 2rem 0 0.75rem; + font-size: 1.25rem; + font-weight: 700; } // One rhythm for both: left alone, the library's sheet sets paragraphs @@ -80,12 +113,20 @@ ul, ol { - margin: 0 0 1.25rem; + margin: 0 0 1.5rem; padding-left: 1.25rem; } li { margin-bottom: 0.5rem; + + &::marker { + color: var(--pi-color-primary); + } + } + + strong { + font-weight: 700; } a { @@ -98,20 +139,77 @@ } } -.content-page__back { - display: inline-block; - margin-top: 3rem; - color: var(--pi-color-grey-600, #5b5b6b); - text-decoration: none; +.content-page__aside { + display: flex; + flex-direction: column; + gap: 2rem; - &:hover { - color: inherit; + // Stays in view beside a long page, under the sticky header. + @media (min-width: 1024px) { + position: sticky; + top: 6rem; + align-self: start; } } -// The `` 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__related { + display: grid; + gap: 1rem; + margin: 0; + 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; } diff --git a/landing/src/app/ui/content-page.ts b/landing/src/app/ui/content-page.ts index 4f24eb8..88f3436 100644 --- a/landing/src/app/ui/content-page.ts +++ b/landing/src/app/ui/content-page.ts @@ -11,6 +11,7 @@ import { import { Meta, Title } from '@angular/platform-browser'; import { injectRequest, injectResponse } from '@analogjs/router/tokens'; import { + PiActionButtonComponent, PiTextColorDirective, PiTextLargeDirective, PiTextWeightBoldDirective, @@ -41,6 +42,7 @@ import { SiteHeader } from './site-header'; ApiImage, MaintenanceScreen, NotFoundPage, + PiActionButtonComponent, PiTextColorDirective, PiTextLargeDirective, PiTextWeightBoldDirective, @@ -69,36 +71,78 @@ import { SiteHeader } from './site-header'; />
-
- @if (page.category?.name; as category) { -

{{ category }}

- } +
+
+ @if (page.category?.name; as category) { +

{{ category }}

+ } -

{{ page.title }}

+

{{ page.title }}

- @if (page.excerpt) { -

- {{ page.excerpt }} -

- } + @if (page.excerpt) { +

+ {{ page.excerpt }} +

+ } +
+
- @if (page.featured_image; as image) { -
- +
+ @if (page.featured_image; as image) { +
+ +
+ } + +
+
+ +
- } - -
- - ← {{ copy().page.back }} -
+ + +
@@ -124,6 +168,24 @@ export class ContentPage implements OnInit { protected readonly home = computed(() => homeHref(this.data().locale)); 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( () => this.data().site?.locales ?? [ diff --git a/landing/src/app/ui/hero.scss b/landing/src/app/ui/hero.scss index c9cda38..910c804 100644 --- a/landing/src/app/ui/hero.scss +++ b/landing/src/app/ui/hero.scss @@ -1,52 +1,13 @@ +@use './lights'; + :host { display: block; } .landing-hero { - position: relative; - isolation: isolate; + @include lights.glow; + 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) { padding-block: 7rem 4rem;