"Web oficial" did nothing when clicked, and it was not alone: six places wrapped `<pi-action-button>` in an `<a>`. That component renders a `<button>`, so each one was a button inside a link -- invalid HTML, and the button swallows the click. Besides "Web oficial" it covered Demo, Repositorio, the contact CTA on every detail page, the 404's way home and a call card's apply button. They become one `app-link-button`, which renders a real anchor. The click was only part of it: a link can also be opened in a new tab, middle-clicked, copied, tabbed to and followed by a crawler. Its look does not come from @pi/ui's button classes, and not for want of trying: those rules are scoped to the element (`html button:not(…)` in `_pi-button.scss`), so on an anchor they contribute nothing except `pi-color__primary` -- a *text* colour, which painted the label violet on a violet fill. The stylesheet reads the same `--pi-theme-button-*` tokens the library reads instead, so a change of shell or theme still moves these buttons and dark mode needs nothing of its own. Verified: the anchor reports href, target and a real navigation to the external site; no `<button>` inside an `<a>` remains on the page; and the rendered pill matches the header's button in radius, gradient fill, white text and weight, in both themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
landing-web
Landing page for the Pireon collective, built with Nx and
AnalogJS (Angular 22 on Vite), rendered on the server,
fed by the public half of the
Pireon API and built out of
@pi/ui, the collective's own component library.
npm start # dev server on http://localhost:4200
npm run build # production build into dist/landing
npm run preview # run the built server (PORT=3000 by default)
npm test # unit tests (Vitest)
npm run lint # ESLint
npm run e2e # Playwright end-to-end tests
Deploying
cp .env.example .env # then fill in NEXUS_TOKEN
docker compose build
docker compose up -d
Compose reads .env on its own, for the build token as well as the runtime
settings. .env is gitignored; .env.example is the one that is committed. If
you would rather not keep the token on disk:
NEXUS_TOKEN=… docker compose build
An empty or missing token fails the build in under a second with a message
saying so. That check is there because the mount's own required=true only
asserts the secret was declared — an empty one passes it and then surfaces a
minute later as a Nexus 401 that reads like a credentials problem when it is
really a forgotten variable.
Two stages. The build one runs Nx and the Angular compiler over the whole
workspace and is thrown away; only dist/landing/analog crosses into the
runtime image, because Nitro vendors its own dependencies into that output —
17 MB that runs with no install step, on node:22-alpine, as the unprivileged
node user. The image comes out at 182 MB, nearly all of it the base.
@pi/ui comes from the private Nexus, so the install needs a token. It is
passed as a BuildKit secret, not a build ARG: an ARG stays readable in the
image history forever. docker history on the result shows no trace of it.
/api/health answers the container health check. It deliberately does not call
the Pireon API — a check that failed when the upstream was down would have the
orchestrator restart a container that is working correctly, since the landing
answers maintenance in that case by design.
The API is external — a separate deployment on a sibling subdomain, not part of this stack.
The landing must keep /api on its own origin. Analog serves the app's
server routes there, and — more importantly — resolves every page's data
through /api/_analog/pages/*. Handing that path to the Pireon API on a shared
origin would not cost the contact form, it would blank the site. Moving the
app's own prefix is not the way out either: apiPrefix exists in the Analog
config but get-page-handlers.js hardcodes /api for the loader routes
regardless of it, so setting it splits the two apart and the loaders stop being
registered at all. Verified by trying it. Different subdomains, as deployed
today, have no conflict. The consequence for deployment is egress: the landing is
server-rendered, so every request it serves makes ten calls to
landing-api.pireon.dev from inside the container. Behind a restrictive
egress policy that host has to be allowed, or every page renders as though the
API were in maintenance. core/api.ts gives each call an 8-second budget, which
is what stands between a slow upstream and a hung render.
The container runs with a read-only filesystem and no-new-privileges, with a
tmpfs at /tmp for Nitro's scratch space. The container publishes no host port: the reverse proxy reaches it over the
compose network as landing:3000, and binding a host port only invites a
collision with whatever else is on that number. docker-compose.yml carries a
commented ports block for when you do want one.
Put that proxy in front for TLS and let it pass response headers through
untouched: the app answers 503 with
Retry-After during maintenance and 404 for addresses it does not serve, and
both matter to crawlers and CDNs.
| Variable | Where | Default |
|---|---|---|
PIREON_API_URL |
runtime | https://landing-api.pireon.dev/api |
LANDING_PORT |
only if you publish a host port | 3000 |
NEXUS_TOKEN |
build only | — |
Copy .env.example to .env; compose reads it.
Seeing it with content
The live API answers {"data": []} for every collection until the panel has
something published, so npm start shows a hero and a contact form and nothing
else. That is correct, and impossible to review. Two terminals:
npm run mock # stand-in API on :4599, with content in every section
npm run start:mock # the app pointed at it, on :4200
npm run mock:down is the other half — every endpoint answering 503 — which is
the only way to see the maintenance banner:
npm run mock:down
PIREON_API_URL=http://localhost:4600/api npm run preview
docs/screenshots/ holds a capture of every screen in both modes, taken against
those two servers.
What it talks to
Only /public/v1/* — unauthenticated, read-only, and already published on the
public website. The landing assembles itself from ten endpoints in one parallel
pass on the server:
| Section | Endpoint |
|---|---|
| Hero numbers, locales, form switch | /site |
| Qué hacemos | /services |
| Proyectos | /projects?per_page=6&translated=1 |
| Con quién trabajamos | /clients |
| Lo que dicen | /testimonials |
| Radar técnico | /radar |
| Quiénes somos | /members |
| Buscamos gente | /calls |
| Primeras contribuciones | /good-first-issues?limit=6 |
| Charlas | /talks?limit=6 |
| Contact form | POST /contact |
landing/openapi.yaml is the specification the app was written against, and
landing/src/app/core/api.types.ts is the API's own generated type file. Both
are copies — refresh them from the API when the contract moves:
curl -o landing/openapi.yaml https://landing-api.pireon.dev/openapi.yaml
curl -o landing/src/app/core/api.types.ts https://landing-api.pireon.dev/api-types.d.ts
How it is put together
landing/src/
app/
core/api.ts server-side API client (never imported by the browser)
core/api.types.ts generated payload types from the API
core/copy.ts the chrome's own words, in es / en / ca
core/icons.ts the icon set, inlined and registered as literals
core/pi-strings.ts the strings `@pi/ui` translates on its own
core/routes.ts which record an address belongs to (resolveRoute)
core/routed-page.ts the load function for every address but the landing
pages/(home).server.ts one load function, eleven parallel calls
pages/(home).page.ts the landing itself
pages/[...path].page.ts everything else: pages, projects, members, 404
ui/page-shell.ts header, footer, lights, 404/503 and SEO around a record
ui/routed-page.ts picks the body for the record's type
ui/ presentational components, one per payload type
(thin wrappers over `@pi/ui`), each with its `.scss`
server/
routes/api/contact.post.ts forwards the contact form to the API
routes/api/calls/[slug]/apply.post.ts forwards an application to one call
routes/sitemap.xml.get.ts every published address, from the API's /slugs
routes/robots.txt.get.ts points crawlers at the sitemap
routes/blog/rss.xml.get.ts the blog as RSS, one feed per language
middleware/redirects.ts 301s for slugs renamed in the panel
middleware/not-found-status.ts
styles.scss Material theme, `@pi/ui` sheets, shared primitives
Nothing renders from the browser. All reads happen in the page's load
function, and the two writes go through this app's own handlers —
/api/contact for the general form, /api/calls/{slug}/apply for a call's —
which share one check (core/submission.ts) before spending a round trip. An
application to a call sends no kind: the API refuses one there, because
answering a call is always an offer of time.
That is what the API expects: it throttles submissions per e-mail address
rather than per IP because an SSR frontend posts on the visitor's behalf, and
keeping the API host out of the browser means CORS never comes up.
No call can reject. core/api.ts turns every failure into
{ data, maintenance, error }, so a section whose endpoint blinked renders
empty instead of taking the page down.
Maintenance takes over the whole page. When /public/v1/* answers 503 the
landing is replaced by ui/maintenance-screen.ts, not covered with a banner:
every collection is unavailable at that point, so what was left underneath was a
hero, eight empty sections and a contact form the API would refuse. The notice
arrives already resolved to the visitor's language and is rendered as sent.
The response says so too — 503, the API's own Retry-After, and
Cache-Control: no-store to override the s-maxage route rule, because a
cached maintenance page outlives the maintenance. That status is set from the
page component through injectResponse() (@analogjs/router/tokens), which is
the render request's response; the one a page load sees belongs to the
internal request Analog resolves loaders through and never reaches the browser.
That is also why the 404 status comes from middleware instead — except for
addresses shaped like a record, where only the API knows whether it exists: the
middleware lets those through and ui/page-shell.ts sets the 404 itself from
ngOnInit, once its input has arrived.
@pi/ui's reset spaces every li + li with a top margin — right for a
list of prose, wrong for a row of chips laid out with gap, where it dropped
every chip but the first. Lists spaced by gap reset it on their own > li.
Empty sections disappear, along with their entry in the nav — a young site shows a hero and a contact form rather than eight headings over nothing.
Languages
Content is translatable and the API resolves it per request. The locale comes
from ?lang=es|en|ca and nothing else: Analog resolves load functions through
an internal request that forwards the query string but not the headers, and a
page cached with s-maxage that varied on Accept-Language would hand the
second visitor the wrong language anyway. The switcher in the header is three
plain links, and <html lang> follows.
Records are the exception, because the API decides their address: a
record's path and alternates put the default language unprefixed and every
other one under its code, pages at the root and everything else under a section
named in that language:
/sobre-nosotros /en/about-us /ca/qui-som
/proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
/equipo/ada /en/team/ada /ca/equip/ada
/colabora/frontend /en/contribute/frontend /ca/col-labora/frontend
/servicios/formacion /en/services/formacion /ca/serveis/formacion
Besides records there are listings, pages this site composes itself —
the tech radar at /radar, /en/radar, /ca/radar, and the ways to help at
/colabora, /en/contribute, /ca/col-labora — the calls' own section, so it
lists what /colabora/{slug} are, next to good first issues and a guide to a
first contribution (LISTINGS). A panel page
with the same slug would be shadowed by one, so they are few and deliberately
named. Radar logos sit on a light tile in both themes: a brand's mark is not
ours to recolour, and some (Rust's, black) would vanish in dark mode.
core/routes.ts#resolveRoute is that rule read backwards, and its SECTIONS
mirror the API's config/site.php (paths) — a change there has to be made
here too. /es/… is not a shape the API produces, so a record has one address
rather than two. The switcher on a record follows alternates, and a language
it has no translation for leads to the landing in that language instead of a
404. The same alternates become hreflang links, with a canonical.
One catch-all route (pages/[...path]) serves every address but the
landing, and resolveRoute sorts the shapes out. Not one route per shape:
Analog registers each loader as an internal Nitro route, and Nitro's router
cannot hold two parameters of different names at the same depth —
/:prefix/:slug beside /:prefix/:section/:slug made the first unreachable.
Featured pages go in the header after the section anchors; every page is in the footer.
The blog
Not a type of its own: a post is a page in the panel's "Blog" category —
written, translated, scheduled, reviewed and reachable by the MCP server like
any page. That category has to exist with the slug blog in every language
(core/blog.ts), because the API filters by the slug in the language asked for.
/blog(/en/blog,/ca/blog) lists them newest first.- A post shows its date and leads back to the blog; its structured data is a
BlogPosting. /blog/rss.xml?lang=xxis the feed, one per language, announced in<head>.- Once there is a post, the blog is in the header menu (desktop and mobile) and the landing shows the three latest under "Novedades"; the footer links it too, but does not list posts one by one — it would grow without end. A blog reachable only from the footer turned out to be one nobody found.
Search engines and link previews
/sitemap.xmlcomes from the API's/slugs— one request for every published address — plus the landing and the listings. Each address is listed in every language, withhreflangalternates andx-default. If the API is down it answers 503, not a sitemap missing half the site./robots.txtallows everything and names the sitemap, absolute, on whatever domain the proxy says this is.- Renamed slugs redirect. The panel writes a redirect the moment somebody
renames one;
middleware/redirects.tsanswers the old address with a 301, from the API's/redirectslist kept for a minute (and kept whole through an outage, so renamed links do not turn back into 404s). - Every page says what it is: title, description,
og:*with the record's own picture (the logo otherwise), and schema.org JSON-LD —Organizationon the landing,WebPage,CreativeWork,PersonorServiceelsewhere.
In development only /api/* reaches Nitro, so /sitemap.xml, /robots.txt
and /blog/rss.xml answer 404 under npm start; build and run npm run preview to
see them. Middleware does run in development.
Rendering and caching
Nothing is prerendered: the page is assembled from content the panel publishes
and answers on ?lang=, so a build artefact would be stale and wrong for two of
three locales. Freshness is bought back with
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600, so
a CDN absorbs the traffic and the origin re-renders once a minute.
Package registry (Nexus)
.npmrc keeps npmjs as the default registry and routes only the @pi scope to
Nexus, which is where @pi/ui comes from:
registry=https://registry.npmjs.org/
@pi:registry=https://nexus.pireon.dev/repository/npm-releases/
This is not a preference — npm-releases is a hosted repo, not a proxy. It
answers 404 for anything published on npmjs, so making it the default registry
breaks every install in the workspace. There is no group/proxy repo on that
Nexus today; if you want a single registry for everything, someone has to
create an npmjs proxy repo and a group that bundles it with npm-releases,
and then registry= can point at the group.
Credentials are not in this file. Locally they live in ~/.npmrc. In CI,
write the token before installing:
npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "$NEXUS_TOKEN"
An ${NEXUS_TOKEN} placeholder inside .npmrc would make npm fail outright on
any machine where the variable is unset, so it is deliberately absent.
Publishing a library from this workspace to Nexus — the app itself is
private and is not publishable:
// libs/<name>/package.json
{
"name": "@pi/<name>",
"publishConfig": {
"registry": "https://nexus.pireon.dev/repository/npm-releases/"
}
}
Styles
SCSS throughout, and no Tailwind — the library brings its own tokens, so a second system on top would have been two vocabularies for one page.
Each component owns its stylesheet next to it (hero.ts + hero.scss), scoped
by Angular's emulated encapsulation. styles.scss holds only what cannot be
scoped: the Material theme, the @pi/ui sheets, the document base, and the few
primitives shared across components — .landing-shell, .landing-eyebrow, and
the .landing-card* family every card on the page uses.
Two consequences of that encapsulation are worth remembering, because both bit during the split:
- A parent's sheet cannot reach inside a child component.
.landing-card__media imgtargets an<img>that belongs toapp-api-image, so it stays global;.landing-logo__markis handed to that component asimgClassfor the same reason. - It cannot restyle a library component's internals either. The 404 page leaves
pi-empty-stateat the size the library gives it rather than reaching in with::ng-deepto win afont-sizeargument.
Component styles are injected after the global sheet, so a library component's
own :host { display: … } ties on specificity and wins on order. Where the page
needs to override one — hiding the locale switcher on mobile — the selector is
scoped through an ancestor to break the tie.
nx.json generates scss for new applications, components and pages.
The component library
Everything visible is @pi/ui — pi-card, pi-chip, pi-stat-card,
pi-avatar, pi-list-item, the form fields — under the library's
alternative shell (its marketing look: gradients, hover glow, larger
controls). The components in ui/ are thin adapters: they turn one API
payload into one library component and hold nothing else. Page rhythm — the
band spacing, the header bar, the hero — is the only CSS this app writes, in
styles.scss.
The mark, and the colour that comes from it
public/logo.png is the brand mark, used in the header and on the maintenance
screen, and as the favicon at two sizes. It came in as a 1024×1024 PNG of
1.4 MB with most of the frame empty; it is cropped to the artwork and resized,
which is what takes it to 42 KB — the header draws it at 32 px, so shipping the
original would have been thirty times the bytes for the same pixels. The glow is
baked into the file, so it needs no background of its own and reads on both the
light and the dark surface.
The brand palette is sampled from it, not chosen. The mark's gradient runs
from #b952fe through #317ef4 to #1388dc, so styles.scss takes its two
ends — hue 276° and 205° — as --pi-color-primary and --pi-color-accent,
replacing the violet and teal that pi-shell__alternative ships. The values are
not the samples themselves: at the logo's own lightness, white text on them
lands at 3.7:1, under the 4.5:1 floor. Each is the darkest step of its hue that
clears it, with saturation pulled back so a page of them is not electric.
Everything downstream — buttons, chips, the hero aura, the card edges, the focus
ring — reads those tokens, so the page and the mark are one brand.
favicon.ico is still the scaffold's. Modern browsers take the PNG links ahead
of it; it only answers for clients that ask for /favicon.ico by convention.
The rest of the logo folder. /Users/pigui/Desktop/logos/white/ is a
byte-for-byte duplicate of the folder above it, not a light-on-dark set. Of the
eight files, the three wordmarks (10_38_19, 10_41_03, 10_47_10) have no
alpha channel — the transparency checkerboard is painted into the pixels — so
they cannot be used as-is. The four that are genuinely transparent are
10_39_58, 10_42_40 (the one in use), 10_49_03 and 10_49_22. That last
one is the newest file and the best asset in the set: the full "pireon" wordmark
in white, transparent, with the gradient e. It is unused here only because it
is white-on-dark and the landing's default is light; putting it in the header
needs either a dark-text export of the same lockup, or a decision to make that
bar dark in both modes.
Motion
Two pieces, both of which have to survive a page that renders on the server:
ui/testimonial-carousel.tswraps the quotes inpi-carousel, which projects<ng-template pi-carousel-item>children and instantiates them itself. It needs adeviceto decide how many fit — the library shipsDeviceConfigsFacadefor that but nothing in it watches the window, socore/device.tsreads the viewport instead and answersDESKTOPon the server. Two of its defaults are worth knowing:hideSwipeButtonsistrue, so the arrows have to be asked for, and the controls only appear at all once there are more items than fit — with two testimonials it degrades to a plain row, which is the right answer.ui/reveal.tsassembles each section as it arrives: its heading first, then what it holds, and a grid's cards one after another (up to eight, then together). The hidden state is not in the server-rendered HTML: it only applies underhtml.reveal-ready, a class the directive adds at runtime, so a visitor without JavaScript reads a full page instead of a blank column.- The hero enters on load, in CSS (
hero.scss), so it plays with or without JavaScript: eyebrow, title, lead, buttons, and the stats last, at 0.8s. The stats count up from zero — but only if the page hydrates before then (COUNT_UP_BEFORE_MSinstat-grid.ts). Later than that the number has already been read, and dropping it to zero to count again would be a flicker. - Scroll-driven, no JavaScript: the hero's lights drift and fade as it
scrolls away, and the header gains a shadow once the page moves
(
animation-timeline: scroll()). Browsers without it simply do not animate.
prefers-reduced-motion turns every one of these off.
PiThemeService owns the shell class, pinned to alternative in
app.config.ts — the library's marketing look: pill buttons with a gradient
fill, a tinted field surface, rounded cards with elevation. styles.scss layers
on what the theme contract cannot express: the contract stops at
--pi-theme-card-radius and --pi-theme-card-shadow, with no token for a
card's fill, so the gradient surface, the gradient edge (mask-composite, the
same technique the library uses for its focus ring) and the hover lift are
written by hand. The gradient theme's focus ring is a token, so it is lifted
onto this shell without swapping themes.
The dark palette — the service writes body.className from its own effect and
starts on default, so without that the page would render server-side in one
shell and visibly swap to the other on hydration.
Dark mode
The library has none: PiThemeService toggles html.dark-mode and persists it,
but grep -r dark-mode node_modules/@pi/ui/styles comes back empty, and its
theme layer exposes exactly one surface token (--pi-theme-field-surface), so a
dark palette cannot be expressed as a theme. It is written in styles.scss
instead, by redefining the library's own colour contract. Three things about it
are worth knowing before touching it:
- The tokens go on
html.dark-mode .pi-shell, not onhtml.dark-mode. The shell class writes its own primary / accent / highlighted ramps to<body>, and a custom property set on<html>is only inherited — so the shell won every tie and half the palette stayed light. Matching the element and adding a class is enough. color-scheme: darkstays on the root.mat.themeemits its values aslight-dark(…), so that one line flips every Material token, plus the native form controls and scrollbars.--pi-color-whiteis deliberately not inverted. The library uses it as "the colour that contrasts with a brand fill" — the text of a filled button — not only as a surface. The surfaces that do resolve to it are redirected one by one, with!important, because the library paints a card from a five-class selector this file cannot outrank.
AppearanceService owns the switch rather than PiThemeService, whose
readStoredDarkMode() is !!localStorage.getItem(key) while persist() writes
the boolean through: storing false reads back as the string "false", which is
truthy, so dark mode could be turned on but never off. It also honours
prefers-color-scheme on a first visit, and an inline script in index.html
applies the class before first paint so a dark visitor never sees a white flash.
Five things had to be worked around, all documented at their call site:
- The library's own stylesheet does not compile.
@pi/ui/styles/common/index.scssends inmat.define-palette()/mat.define-light-theme(), removed from Angular Material in v19, while the library asks for^22.0.0. The six parts it aggregates do compile, sostyles.scsspulls them in one by one and declares the Material theme with the current API. - No SVGs ship with it.
pi-iconrenders amat-iconwhose name is looked up in Material's registry, and the library'sIconRegistryServiceregisters every name as a URL under/assets/icons/. That is one request per icon, and during SSR those have no origin to resolve against — every one failed and the server HTML came out iconless.core/icons.tsregisters the same names as literals instead: no network, and the icon is in the first byte the browser receives. A name from the API is never passed through unchecked; services carry a free-formiconthat may well be an emoji. - Form fields show errors before they are touched.
PiBaseFormFieldComponent.hasErroriserrors().length > 0, with notouchedin it, so an untouched empty form renders red borders and "falta el nombre" on first paint. The contact form gates every rule behindapplyWhen(field, touched, …);submit()marks the tree touched before it checks validity, so an empty form still cannot be sent. pi-chip-valueglues the number to its unit ("4h/semana" as one word), so those chips build their own label.pi-headlineis a figure, not a heading — it loads its image from the collective's CDN — andpi-not-foundandpi-hamburgertranslate their own strings through an HTTP loader. Section titles and the mobile menu are plain HTML with the library's text directives.
Two input.required surprises worth knowing: pi-card requires alignment,
innerPadding and context, and pi-list-item requires color and
borderType. The library's README flags this — the Angular 14 → 22 migration
turned @Input() x!: T into input.required(), which those never were.
Analog gotchas worth knowing before editing
- A
.server.tsmust be referenced from its.page.ts. Analog treats an unreferenced one as a server-function module, parses it as JavaScript, and the build fails on the firstimport type.injectLoad<typeof load>()in the page is what keeps it compiled. @analogjs/router's server entry readsimport.meta.envat module scope. Nitro leaves node_modules external in dev, so Node imports it raw and it throws before any loader runs.vite.config.tsinlines the package (nitro.externals.inline) to put it back through the bundler. Remove that andnpm startrenders an empty page.- Partially-compiled libraries need Vite's SSR transform.
@angular/cdk,@angular/material,@ngx-translate/*and@pi/uiall ship partially compiled. Left external for SSR, the Angular linker never runs over them and the first Material component to render asks for the JIT compiler. They are listed inssr.noExternalfor that reason.