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

@@ -1,215 +1,5 @@
@use './lights';
@use './detail';
:host {
display: block;
}
.landing-skip {
position: absolute;
left: -9999px;
&:focus {
left: 1rem;
top: 1rem;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--pi-color-white, #fff);
border-radius: 0.5rem;
}
}
// 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 {
@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: 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-bottom: 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;
}