Initial commit
This commit is contained in:
413
README.md
Normal file
413
README.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# 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
|
||||
NEXUS_TOKEN=… docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user