Compare commits

...

5 Commits

Author SHA1 Message Date
pigui
6004110fcf Stop the form moving when it reports errors
Pressing Enviar on an incomplete form pushed everything below each
errored field downwards. Material's subscript strip is 10px tall while a
field is valid and 24px once it carries a message, so the form grew by
14px per field at the moment of the click -- the submit button moved out
from under the cursor. The taller height is now reserved up front.

The consent message had two problems of its own. It rendered as an
inline span straight after the label, so it read as part of the
sentence: "Acepto que guardéis mis datos para responderme. Hace falta tu
consentimiento". And nothing in @pi/ui styles `.pi-checkbox__error`, so
it inherited the body colour and size -- ordinary text sitting beside
four red ones. It now gets its own reserved line, in the warn colour at
the same 12px as every other message.

Verified in both themes: the form grows 0px on submit, and all five
messages match at 12px in the warn colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:51:47 +02:00
pigui
7557894b49 Keep field labels stable on focus, and fix the textarea's ring
Focusing a field visibly reflowed its label: "MENSAJE *" turned into
"Mensaje *" in a lighter face under the cursor. @pi/ui applies the
theme's label typography under `:not(.mdc-text-field--focused)`, so
text-transform, letter-spacing and weight all fall away the moment a
field takes focus. Restated without that guard. Colour is left alone --
Material tints the label on focus and that part is wanted.

The textarea was also drawn twice, once in the error colour and once in
the gradient, the two outlines offset from each other. Both take their
radius from `--pi-theme-field-radius`, which the `alternative` shell
sets to 999px: right for a 48px input, a stadium on a 112px textarea. A
fixed radius makes the two shapes coincide.

Both only appear in states the previous fix did not check -- it verified
the resting form only. Measured now in error+focus: all four labels
uppercase at weight 700, and the textarea's ring and outline both at
20px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:08:54 +02:00
pigui
25d0fe500b Fix the hours field's label sitting on the outline
The label read "Horas por semana" struck through by the field's own
border: 5px above the wrapper instead of 18, straddling the 2px outline.

Angular Material measures an outlined field's floating label once on
init and writes the result to the element as an inline transform. This
field is the only one inside an @if, so it is measured before it has any
geometry and keeps the unfloated offset. @pi/ui already publishes the
right values for every other field and its rule matches this one too --
it simply loses to an inline style, so the fix restates the library's
own numbers with `!important`, which is what outranks one.

It also gains a placeholder, which every other field already had.

The e2e config now derives the dev server's port from BASE_URL instead
of pinning 4200. With `reuseExistingServer`, an unrelated project's dev
server on that port gets adopted silently and all five tests fail with
"element(s) not found" while the page under test was never served --
which is exactly what happened while verifying this.

Verified: all four labels now land at -18 with a 48px box, light and
dark; lint, 15 unit tests, build and 5 e2e green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:13:12 +02:00
pigui
68ad014df0 Stop publishing a host port
`docker compose up` failed with "port is already allocated": 3000 was
taken by an unrelated project on the same machine. Publishing it was
never needed — in production the only thing that has to reach this
service is the reverse proxy, which does so over the compose network as
`landing:3000`.

`expose` replaces `ports`, and a commented `ports` block stays for the
cases that do want a host binding. LANDING_PORT is kept but re-labelled
as only mattering then.

Verified: the container binds nothing on the host, answers 200 with the
full page from another container on the network, and stays healthy —
its health check was always internal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:59:42 +02:00
pigui
a226cbddbd Fail the image build when the Nexus token is missing
`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>
2026-08-30 21:56:11 +02:00
11 changed files with 245 additions and 20 deletions

View File

@@ -11,6 +11,9 @@ tmp
.claude .claude
docs docs
landing-e2e landing-e2e
# tools/ IS needed: the build stage copies docker-install-deps.sh from it.
# The mock API servers in there are dev-only but too small to be worth excluding.
**/*.spec.ts **/*.spec.ts
test-output test-output
.env .env

View File

@@ -1,10 +1,21 @@
# Base URL of the Pireon API, without a trailing slash. Read on the server # Copy to `.env`. Compose reads it on its own — for the runtime settings below
# only: the browser never talks to the API directly. # and for the build-time token. `.env` is gitignored; this file is not.
# Base URL of the Pireon API, without a trailing slash. Read on the server only:
# the browser never talks to the API directly.
PIREON_API_URL=https://landing-api.pireon.dev/api PIREON_API_URL=https://landing-api.pireon.dev/api
# Host port the container is published on. # Only used if you uncomment `ports` in docker-compose.yml. By default the
# container is not published on the host at all: the reverse proxy reaches it
# over the compose network as `landing:3000`, and publishing a host port only
# invites a collision with whatever else is on that number.
LANDING_PORT=3000 LANDING_PORT=3000
# Only needed to *build* the image: `@pi/ui` comes from the private Nexus. # Needed only to *build* the image: `@pi/ui` comes from the private Nexus.
# Not read at runtime, and never baked into a layer — see Dockerfile. # Locally this is the value already sitting in ~/.npmrc:
#
# grep nexus.pireon.dev ~/.npmrc
#
# Never baked into a layer — it is mounted as a BuildKit secret. An empty value
# fails the build immediately with an explanation rather than a Nexus 401.
NEXUS_TOKEN= NEXUS_TOKEN=

10
.gitignore vendored
View File

@@ -46,3 +46,13 @@ Thumbs.db
.nx/polygraph .nx/polygraph
.angular .angular
# Secrets. `.env` holds NEXUS_TOKEN, which compose reads at build time, so it
# must never be committed — `.env.example` is the one that is.
.env
.env.*
!.env.example
# A per-machine npmrc would carry a Nexus token; the committed one at the repo
# root deliberately holds none.
.npmrc.local

View File

@@ -19,14 +19,12 @@ COPY package.json package-lock.json .npmrc ./
# `@pi/ui` comes from the private Nexus, so the install needs a token — passed # `@pi/ui` comes from the private Nexus, so the install needs a token — passed
# as a BuildKit secret rather than a build ARG, which would be readable forever # as a BuildKit secret rather than a build ARG, which would be readable forever
# in the image history. The npmrc it lands in is written and removed inside one # in the image history. The script checks the secret actually holds something
# layer, so it leaves no trace even in this discarded stage. # before spending four minutes finding out from Nexus.
COPY tools/docker-install-deps.sh ./tools/
RUN --mount=type=secret,id=nexus_token,required=true \ RUN --mount=type=secret,id=nexus_token,required=true \
sh -eu -c '\ sh tools/docker-install-deps.sh
printf "//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n" \
"$(cat /run/secrets/nexus_token)" > /tmp/npmrc; \
NPM_CONFIG_USERCONFIG=/tmp/npmrc npm ci --no-audit --no-fund; \
rm -f /tmp/npmrc'
COPY nx.json tsconfig.base.json eslint.config.mjs ./ COPY nx.json tsconfig.base.json eslint.config.mjs ./
COPY landing ./landing COPY landing ./landing

View File

@@ -18,10 +18,25 @@ npm run e2e # Playwright end-to-end tests
## Deploying ## Deploying
```sh ```sh
NEXUS_TOKEN=… docker compose build cp .env.example .env # then fill in NEXUS_TOKEN
docker compose build
docker compose up -d 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 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 workspace and is thrown away; only `dist/landing/analog` crosses into the
runtime image, because Nitro vendors its own dependencies into that output — runtime image, because Nitro vendors its own dependencies into that output —
@@ -56,15 +71,20 @@ 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. 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 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 tmpfs at `/tmp` for Nitro's scratch space. The container publishes no host port: the reverse proxy reaches it over the
and let it pass response headers through untouched: the app answers 503 with 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 `Retry-After` during maintenance and 404 for addresses it does not serve, and
both matter to crawlers and CDNs. both matter to crawlers and CDNs.
| Variable | Where | Default | | Variable | Where | Default |
| --- | --- | --- | | --- | --- | --- |
| `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` | | `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` |
| `LANDING_PORT` | runtime, host side | `3000` | | `LANDING_PORT` | only if you publish a host port | `3000` |
| `NEXUS_TOKEN` | **build only** | — | | `NEXUS_TOKEN` | **build only** | — |
Copy `.env.example` to `.env`; compose reads it. Copy `.env.example` to `.env`; compose reads it.

View File

@@ -24,8 +24,20 @@ services:
- nexus_token - nexus_token
image: pireon/landing:latest image: pireon/landing:latest
restart: unless-stopped restart: unless-stopped
ports: # No `ports`. Publishing on the host collides with whatever else is using
- '${LANDING_PORT:-3000}:3000' # that number on the machine, and in production the only thing that needs
# to reach this is the reverse proxy — which does so over the compose
# network, by service name, on the port below.
#
# proxy_pass http://landing:3000;
#
# To reach it from the host anyway (a quick check, or a deployment with no
# proxy in front), uncomment this and set LANDING_PORT to a free port:
#
# ports:
# - '${LANDING_PORT:-3000}:3000'
expose:
- '3000'
environment: environment:
# Where the public API lives. The only setting the app actually reads. # Where the public API lives. The only setting the app actually reads.
PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api} PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api}

View File

@@ -12,6 +12,19 @@ const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, '..'); const workspaceRoot = resolve(here, '..');
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/**
* The dev server is started on whatever port `BASE_URL` names, rather than
* always 4200.
*
* `reuseExistingServer` means Playwright will happily adopt whatever is already
* listening there — and if that is a different project's dev server, every test
* fails with "element(s) not found" while the real cause is that the page under
* test was never served. Set BASE_URL to move both halves at once:
*
* BASE_URL=http://localhost:4280 npx nx e2e landing-e2e
*/
const port = new URL(baseURL).port || '4200';
export default defineConfig({ export default defineConfig({
testDir: './src', testDir: './src',
outputDir: resolve(workspaceRoot, 'dist/.playwright/landing-e2e/test-output'), outputDir: resolve(workspaceRoot, 'dist/.playwright/landing-e2e/test-output'),
@@ -25,7 +38,7 @@ export default defineConfig({
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
webServer: { webServer: {
command: 'npx nx run landing:serve', command: `npx nx run landing:serve --port=${port}`,
url: baseURL, url: baseURL,
reuseExistingServer: true, reuseExistingServer: true,
timeout: 180_000, timeout: 180_000,

View File

@@ -78,6 +78,7 @@ export interface Copy {
email: string; email: string;
organisation: string; organisation: string;
hours: string; hours: string;
hoursHint: string;
message: string; message: string;
messageHint: string; messageHint: string;
consent: string; consent: string;
@@ -167,6 +168,7 @@ const es: Copy = {
email: 'Correo', email: 'Correo',
organisation: 'Organización', organisation: 'Organización',
hours: 'Horas por semana', hours: 'Horas por semana',
hoursHint: 'Por ejemplo, 4',
message: 'Mensaje', message: 'Mensaje',
messageHint: 'Mínimo 20 caracteres.', messageHint: 'Mínimo 20 caracteres.',
consent: 'Acepto que guardéis mis datos para responderme.', consent: 'Acepto que guardéis mis datos para responderme.',
@@ -264,6 +266,7 @@ const en: Copy = {
email: 'E-mail', email: 'E-mail',
organisation: 'Organisation', organisation: 'Organisation',
hours: 'Hours per week', hours: 'Hours per week',
hoursHint: 'For example, 4',
message: 'Message', message: 'Message',
messageHint: 'At least 20 characters.', messageHint: 'At least 20 characters.',
consent: 'I agree to have my details stored so you can reply.', consent: 'I agree to have my details stored so you can reply.',
@@ -361,6 +364,7 @@ const ca: Copy = {
email: 'Correu', email: 'Correu',
organisation: 'Organització', organisation: 'Organització',
hours: 'Hores per setmana', hours: 'Hores per setmana',
hoursHint: 'Per exemple, 4',
message: 'Missatge', message: 'Missatge',
messageHint: 'Mínim 20 caràcters.', messageHint: 'Mínim 20 caràcters.',
consent: 'Accepto que deseu les meves dades per respondre’m.', consent: 'Accepto que deseu les meves dades per respondre’m.',

View File

@@ -28,3 +28,95 @@
margin: 0; margin: 0;
color: var(--pi-color-warn, #d64545); color: var(--pi-color-warn, #d64545);
} }
// The hours field sits inside an @if, and that is what breaks its label.
//
// Angular Material measures an outlined field's floating label once, on init,
// and writes the result to the element as an inline
// `transform: var(--mat-mdc-form-field-label-transform, translateY(-50%) …)`.
// A field created inside a conditional block is measured before it has any
// geometry, so the value it keeps is the unfloated one: the label ends up 5px
// above the wrapper instead of 18, straddling the 2px outline and struck
// through by it.
//
// `@pi/ui` already publishes the right geometry for every other field
// (`translateY(-3rem) scale(1)`, a 48px label box), and that rule matches this
// one too — it simply loses to the inline style. `!important` is what beats an
// inline declaration, and it is restating the library's own values, not
// inventing new ones.
:host ::ng-deep .pi-field.pi-form-field__number {
.mdc-notched-outline .mdc-floating-label {
height: var(--pi-spacing-xxxx-large) !important;
}
.mdc-text-field:not(.mat-form-field-no-animations) .mdc-floating-label {
transform: translateY(-3rem) scale(1) !important;
}
}
// Two things the library gets wrong once a field is in a real state.
//
// 1. The label changes shape when you focus it. `@pi/ui` applies the theme's
// label typography under `:not(.mdc-text-field--focused)`, so text-transform,
// letter-spacing and weight all fall away the moment the field takes focus:
// "MENSAJE *" becomes "Mensaje *" in a lighter face, and the label visibly
// reflows under the cursor. Restated here without that guard. Colour is
// deliberately left alone — Material tints the label on focus, and that part
// is wanted.
//
// 2. The focus ring is a stadium on the textarea. Both the notched outline and
// the gradient ring take their radius from `--pi-theme-field-radius`, which
// the `alternative` shell sets to 999px. That is right for a 48px-tall input
// and wrong for a 112px-tall textarea: the two shapes stop coinciding and
// the field is drawn twice, once in the error colour and once in the
// gradient, offset from each other. A fixed radius makes them agree again.
:host ::ng-deep {
.pi-field .mdc-text-field:not(.mdc-text-field--disabled) .mdc-floating-label {
text-transform: var(--pi-theme-field-label-transform, none);
letter-spacing: var(--pi-theme-field-label-letter-spacing, normal);
font-weight: var(--pi-theme-field-label-weight, inherit);
}
.pi-field.pi-form-field__textarea {
--pi-theme-field-radius: 1.25rem;
}
}
// Error states, which move the form around and read wrong.
//
// 1. Submitting an incomplete form shoved everything below it downwards.
// Material's subscript strip is 10px tall while a field is valid and 24px
// once it carries a message, so every errored field grew the form by 14px at
// once — the fields under the cursor jumped out from under it. Reserving the
// taller height up front costs 14px of whitespace and buys a form that does
// not move.
//
// 2. `pi-checkbox` renders its message as an inline span straight after the
// label, so it ran on as part of the sentence: "Acepto que guardéis mis
// datos para responderme. Hace falta tu consentimiento". It belongs on its
// own line, like every other field's message.
:host ::ng-deep {
.pi-field .mat-mdc-form-field-subscript-wrapper {
min-height: 1.5rem;
}
// Its own line, in space reserved for it. Left in flow it would push the
// submit button 25px down at the exact moment you press it — the same jump
// the reserved subscript above removes for the fields.
.pi-checkbox {
position: relative;
padding-bottom: 1.25rem;
}
// Nothing in the library styles this class, so the message inherited the
// body colour and read as ordinary text next to four red ones.
.pi-checkbox__error {
display: block;
position: absolute;
left: 0;
bottom: 0;
color: var(--pi-color-warn);
// `mat-error` renders the other four at 12px; this one inherited 15px.
font-size: var(--pi-font-size-x-small, 0.75rem);
}
}

View File

@@ -115,7 +115,17 @@ type Kind = 'contribute' | 'request';
<pi-label [isRequired]="true">{{ copy().form.organisation }}</pi-label> <pi-label [isRequired]="true">{{ copy().form.organisation }}</pi-label>
</pi-text-field> </pi-text-field>
} @else { } @else {
<pi-number-field [formField]="contact.hours_per_week"> <!--
The placeholder is not decoration. Without one, Material leaves
this label in its unfloated position and writes an inline
translateY(-50%) that drops the text straight onto the outline's
top border, struck through by it. Every other field here has a
placeholder, which is why this was the only one that looked wrong.
-->
<pi-number-field
[formField]="contact.hours_per_week"
[placeholder]="copy().form.hoursHint"
>
<pi-label>{{ copy().form.hours }}</pi-label> <pi-label>{{ copy().form.hours }}</pi-label>
</pi-number-field> </pi-number-field>
} }

52
tools/docker-install-deps.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/sh
# Installs the workspace's dependencies inside the image.
#
# Split out of the Dockerfile so a failure prints this file's own message
# instead of BuildKit echoing a twenty-line inline shell twice.
set -eu
TOKEN_FILE=/run/secrets/nexus_token
NEXUS_PATH=//nexus.pireon.dev/repository/npm-releases/
# `required=true` on the mount only asserts the secret was declared, not that it
# holds anything. An empty one sails past it and surfaces four hundred packages
# later as a Nexus 401 that reads like a credentials problem.
if [ ! -s "$TOKEN_FILE" ]; then
cat >&2 <<'MSG'
ERROR: the nexus_token build secret is empty.
@pi/ui is published to the private Nexus, so the install needs a token.
compose: NEXUS_TOKEN=... docker compose build
or put NEXUS_TOKEN in .env, which compose reads on its own
docker: docker build --secret id=nexus_token,env=NEXUS_TOKEN .
Locally the value is the one already in ~/.npmrc:
grep nexus.pireon.dev ~/.npmrc
MSG
exit 1
fi
# Written and removed inside a single layer, so it leaves no trace even in this
# stage — which is discarded anyway.
NPMRC=/tmp/npmrc
printf '%s:_authToken=%s\n' "$NEXUS_PATH" "$(cat "$TOKEN_FILE")" > "$NPMRC"
if NPM_CONFIG_USERCONFIG="$NPMRC" npm ci --no-audit --no-fund; then
rm -f "$NPMRC"
else
status=$?
rm -f "$NPMRC"
if [ "$status" -ne 0 ]; then
echo "" >&2
echo "npm ci failed. If the error above is a 401 from nexus.pireon.dev," >&2
echo "the token was present but is not valid for npm-releases." >&2
echo "" >&2
fi
exit "$status"
fi