Give projects and members their own pages

The API has always served a project's and a member's detail, with
`path` and `alternates` in three languages, and the site had nowhere to
show it. A project now has its page — tagline, status, technologies,
description, technical decisions, what was learned, gallery, client,
dates, lead, team, repositories and open calls — and so does a member:
headline, bio, links, technologies and projects. Cards on the landing
link to both.

Addresses follow the API's rule read backwards (core/routes.ts):
pages at the root, records under a section named in their language,
the default language unprefixed. One catch-all route serves them all.
Not a route per shape: Analog registers each loader as an internal
Nitro route, and Nitro's router cannot hold `/:prefix/:slug` beside
`/:prefix/:section/:slug` — the first became unreachable.

The frame around a record (header, footer, lights, 404/503, title,
description, canonical, hreflang) is one component now, PageShell, and
each type only brings its body, all sharing one layout partial.

Also fixes chips that stepped down one row after the first, on the
landing's cards too: @pi/ui's reset gives every `li + li` a top margin,
right for prose and wrong for a row spaced by `gap`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 14:57:13 +02:00
parent 9af6b5aff9
commit 439f951f2e
33 changed files with 2001 additions and 745 deletions

View File

@@ -0,0 +1,290 @@
// The layout every page reached by its address shares — a content page, a
// project, a member — so moving between them reads as one site.
//
// A partial each body `@use`s rather than a global sheet: component styles are
// scoped, and each body gets its own scoped copy of these rules.
// 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: 7rem 3.5rem;
}
}
.content-hero__title {
margin: 0;
max-width: 22ch;
font-size: clamp(2.25rem, 5.5vw, 3.5rem);
line-height: 1.08;
letter-spacing: -0.02em;
}
.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: 0 0 2.5rem;
overflow: hidden;
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 {
font-size: 1.0625rem;
// 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: 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.75rem;
font-size: 1.25rem;
font-weight: 700;
}
// One rhythm for both: left alone, the library's sheet sets paragraphs
// tighter than list items and the body reads in two different spacings.
p,
li {
line-height: 1.7;
}
p {
margin: 0 0 1rem;
}
ul,
ol {
margin: 0 0 1.5rem;
padding-left: 1.25rem;
}
li {
margin: 0 0 0.5rem;
&::marker {
color: var(--pi-color-primary);
}
}
strong {
font-weight: 700;
}
a {
color: var(--pi-color-primary);
}
> :first-child {
margin-top: 0;
}
}
}
.content-page__aside {
display: flex;
flex-direction: column;
gap: 2rem;
// Stays in view beside a long page, under the sticky header.
@media (min-width: 1024px) {
position: sticky;
top: 6rem;
align-self: start;
}
}
.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;
}
// Hero actions — a demo, a repository — and the chips under them.
.content-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 2rem;
a {
text-decoration: none;
}
}
.content-hero__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin: 1.5rem 0 0;
padding: 0;
list-style: none;
}
// A section inside the reading column, under its own bold heading.
.content-page__section {
margin-top: 3.5rem;
> h2 {
margin: 0 0 1.25rem;
font-size: clamp(1.5rem, 3vw, 1.875rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
}
}
.content-page__main > :first-child {
margin-top: 0;
}
// A card in the side column that holds facts rather than a link.
.content-page__panel {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
&:hover {
transform: none;
}
h2 {
margin: 0 0 1rem;
font-size: 1.0625rem;
font-weight: 700;
}
}
// Label and value, one pair a row.
.content-page__facts {
display: grid;
gap: 0.875rem;
margin: 0;
dt {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, currentColor 60%, transparent);
}
dd {
margin: 0.125rem 0 0;
}
a {
color: var(--pi-color-primary);
}
}
.content-page__muted {
color: color-mix(in srgb, currentColor 68%, transparent);
}
// Lists this layout spaces with `gap`. `@pi/ui`'s reset adds a top margin to
// every `li + li`, which suits a list of prose and staggers everything else.
.content-hero__chips,
.content-page__related {
> li {
margin: 0;
}
}
.content-hero__chips {
align-items: center;
}