`docker compose build` without NEXUS_TOKEN produced a Nexus 401 forty seconds in, worded as a credentials problem when it was really an unset variable. The mount's `required=true` asserts the secret was *declared*, not that it holds anything, so an empty one sailed past it and npm sent an empty `_authToken`. The install moves to tools/docker-install-deps.sh, which checks the secret before touching the network and fails in under a second with the three ways to supply it. It is a file rather than inline shell because BuildKit echoes an inline command twice on failure and buries the message. Also ignore .env. It carries NEXUS_TOKEN, compose reads it at build time, and it was not gitignored — .env.example stays tracked. Nothing had been committed: the only prior commit predates all of this. Verified: empty token fails in 0.2s with the message; .env and the environment variable both build; the image carries no trace of the token and serves the full page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
429 lines
21 KiB
Markdown
429 lines
21 KiB
Markdown
# landing-web
|
||
|
||
Landing page for the Pireon collective, built with [Nx](https://nx.dev) and
|
||
[AnalogJS](https://analogjs.org) (Angular 22 on Vite), rendered on the server,
|
||
fed by the public half of the
|
||
[Pireon API](https://landing-api.pireon.dev/docs/api) and built out of
|
||
`@pi/ui`, the collective's own component library.
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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. Put a reverse 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` | runtime, host side | `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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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
|
||
pages/(home).server.ts one load function, ten parallel calls
|
||
pages/(home).page.ts the landing itself
|
||
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
|
||
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 one write goes through this app's own `/api/contact` handler.
|
||
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.
|
||
|
||
**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.
|
||
|
||
## 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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```jsonc
|
||
// 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
|
||
img` targets an `<img>` that belongs to `app-api-image`, so it stays global;
|
||
`.landing-logo__mark` is handed to that component as `imgClass` for the same
|
||
reason.
|
||
- It cannot restyle a library component's internals either. The 404 page leaves
|
||
`pi-empty-state` at the size the library gives it rather than reaching in with
|
||
`::ng-deep` to win a `font-size` argument.
|
||
|
||
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.ts`** wraps the quotes in `pi-carousel`, which
|
||
projects `<ng-template pi-carousel-item>` children and instantiates them
|
||
itself. It needs a `device` to decide how many fit — the library ships
|
||
`DeviceConfigsFacade` for that but nothing in it watches the window, so
|
||
`core/device.ts` reads the viewport instead and answers `DESKTOP` on the
|
||
server. Two of its defaults are worth knowing: `hideSwipeButtons` is `true`,
|
||
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.ts`** fades each section in as it arrives. The hidden state is
|
||
*not* in the server-rendered HTML: it only applies under `html.reveal-ready`,
|
||
a class the directive adds at runtime, so a visitor without JavaScript reads a
|
||
full page instead of a blank column. `prefers-reduced-motion` skips the
|
||
observer entirely.
|
||
|
||
`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 on `html.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: dark` stays on the root.** `mat.theme` emits its values as
|
||
`light-dark(…)`, so that one line flips every Material token, plus the native
|
||
form controls and scrollbars.
|
||
- **`--pi-color-white` is 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.scss` ends in `mat.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, so
|
||
`styles.scss` pulls them in one by one and declares the Material theme with
|
||
the current API.
|
||
- **No SVGs ship with it.** `pi-icon` renders a `mat-icon` whose name is looked
|
||
up in Material's registry, and the library's `IconRegistryService` registers
|
||
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.ts` registers 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-form `icon` that may well be an emoji.
|
||
- **Form fields show errors before they are touched.**
|
||
`PiBaseFormFieldComponent.hasError` is `errors().length > 0`, with no
|
||
`touched` in it, so an untouched empty form renders red borders and "falta el
|
||
nombre" on first paint. The contact form gates every rule behind
|
||
`applyWhen(field, touched, …)`; `submit()` marks the tree touched before it
|
||
checks validity, so an empty form still cannot be sent.
|
||
- **`pi-chip-value` glues the number to its unit** ("4h/semana" as one word), so
|
||
those chips build their own label.
|
||
- **`pi-headline` is a figure, not a heading** — it loads its image from the
|
||
collective's CDN — and `pi-not-found` and `pi-hamburger` translate 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.ts` must 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 first `import type`. `injectLoad<typeof load>()` in the page
|
||
is what keeps it compiled.
|
||
- **`@analogjs/router`'s server entry reads `import.meta.env` at module scope.**
|
||
Nitro leaves node_modules external in dev, so Node imports it raw and it
|
||
throws before any loader runs. `vite.config.ts` inlines the package
|
||
(`nitro.externals.inline`) to put it back through the bundler. Remove that and
|
||
`npm start` renders an empty page.
|
||
- **Partially-compiled libraries need Vite's SSR transform.** `@angular/cdk`,
|
||
`@angular/material`, `@ngx-translate/*` and `@pi/ui` all 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 in `ssr.noExternal` for that reason.
|