Initial commit

This commit is contained in:
pigui
2026-08-30 21:11:59 +02:00
commit 3f930bb46a
168 changed files with 35708 additions and 0 deletions

20
.claude/settings.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extraKnownMarketplaces": {
"nx-claude-plugins": {
"source": {
"source": "github",
"repo": "nrwl/nx-ai-agents-config"
}
}
},
"enabledPlugins": {
"nx@nx-claude-plugins": true
},
"sandbox": {
"network": {
"allowedDomains": [
"www.google-analytics.com"
]
}
}
}

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# Everything the image neither builds from nor ships.
node_modules
dist
tmp
.nx
.angular
.git
.github
.vscode
.idea
.claude
docs
landing-e2e
**/*.spec.ts
test-output
.env
.env.*
!.env.example
Dockerfile
.dockerignore
docker-compose*.yml
README.md
AGENTS.md
CLAUDE.md

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# 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
# Host port the container is published on.
LANDING_PORT=3000
# Only needed to *build* the image: `@pi/ui` comes from the private Nexus.
# Not read at runtime, and never baked into a layer — see Dockerfile.
NEXUS_TOKEN=

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# compiled output
dist
tmp
out-tsc
# dependencies
node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
.nx/migrate-runs
.claude/worktrees
.claude/settings.local.json
.nx/polygraph
.angular

16
.npmrc Normal file
View File

@@ -0,0 +1,16 @@
# Default registry stays npmjs on purpose.
#
# nexus.pireon.dev/repository/npm-releases is a *hosted* release repo, not a
# proxy: it answers 404 for anything published on npmjs (verified with tslib).
# Pointing `registry` at it would break every install in this workspace.
registry=https://registry.npmjs.org/
# Pireon's own packages (@pi/*) come from Nexus.
@pi:registry=https://nexus.pireon.dev/repository/npm-releases/
//nexus.pireon.dev/repository/npm-releases/:always-auth=true
# The token is deliberately not here. Locally it already lives in ~/.npmrc; in
# CI, write it before installing:
# npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "$NEXUS_TOKEN"
# Putting `${NEXUS_TOKEN}` in this file instead would make npm fail outright on
# any machine where the variable is unset — including this one.

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
.angular

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ms-playwright.playwright"
]
}

11
AGENTS.md Normal file
View File

@@ -0,0 +1,11 @@
# AGENTS.md
This is an [AnalogJS](https://analogjs.org) app — an Angular meta-framework powered by Vite.
Framework conventions for AI assistants — file-based routing, server/API routes, data fetching, content routes, and modern Angular usage — ship with the framework and are installed alongside your dependencies. Read them here:
- `node_modules/@analogjs/platform/AGENTS.md`
Reading from `@analogjs/platform` keeps the guidance in sync with the installed version instead of drifting from a copy checked into the project.
Full documentation: https://analogjs.org/docs · LLM-friendly index: https://analogjs.org/llms.txt

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
Strictly follow the rules in @AGENTS.md

62
Dockerfile Normal file
View File

@@ -0,0 +1,62 @@
# syntax=docker/dockerfile:1.7
# ---------------------------------------------------------------------------
# Build
#
# Nx and the Angular compiler need the whole workspace, so this stage is heavy
# and is thrown away: only `dist/landing/analog` crosses into the runtime image.
# ---------------------------------------------------------------------------
FROM node:22-alpine AS build
WORKDIR /app
ENV NX_DAEMON=false \
CI=true \
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# Manifests first, so a source-only change does not reinstall the world.
COPY package.json package-lock.json .npmrc ./
# `@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
# in the image history. The npmrc it lands in is written and removed inside one
# layer, so it leaves no trace even in this discarded stage.
RUN --mount=type=secret,id=nexus_token,required=true \
sh -eu -c '\
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 landing ./landing
RUN npx nx build landing
# ---------------------------------------------------------------------------
# Runtime
#
# Nitro vendors its own dependencies into the build output, so there is nothing
# to install here: the whole server is the 17 MB it copies.
# ---------------------------------------------------------------------------
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0
# `node` already exists in the base image, unprivileged and with no shell login.
COPY --from=build --chown=node:node /app/dist/landing/analog ./analog
USER node
EXPOSE 3000
# Node rather than curl or wget: neither is in the base image, and adding one
# for a health check is a package more than the job needs.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "analog/server/index.mjs"]

413
README.md Normal file
View 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.

61
docker-compose.yml Normal file
View File

@@ -0,0 +1,61 @@
# Production. One service.
#
# The Pireon API is external — a separate deployment on the public internet, not
# part of this stack — so there is nothing here to orchestrate alongside the
# landing, and nothing to wait for on startup. What it does mean is that the
# container needs outbound HTTPS: the page 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 if the API were in maintenance.
#
# NEXUS_TOKEN=… docker compose build # the build needs the private registry
# docker compose up -d
#
# Put a reverse proxy in front for TLS. It should pass the response headers
# through untouched: the app answers 503 with `Retry-After` while the API is in
# maintenance, and 404 for addresses it does not serve.
services:
landing:
build:
context: .
dockerfile: Dockerfile
secrets:
- nexus_token
image: pireon/landing:latest
restart: unless-stopped
ports:
- '${LANDING_PORT:-3000}:3000'
environment:
# Where the public API lives. The only setting the app actually reads.
PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api}
PORT: '3000'
NODE_ENV: production
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
# The server writes nothing: the build output is all it has, and Nitro only
# ever needs a scratch directory.
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
secrets:
nexus_token:
# Read from the environment at build time so the token never lands in a
# file next to the compose config.
environment: NEXUS_TOKEN

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

54
eslint.config.mjs Normal file
View File

@@ -0,0 +1,54 @@
import nx from "@nx/eslint-plugin";
export default [
...nx.configs["flat/base"],
...nx.configs["flat/typescript"],
...nx.configs["flat/javascript"],
{
ignores: [
"**/dist",
"**/out-tsc"
]
},
{
files: [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx"
],
rules: {
"@nx/enforce-module-boundaries": [
"error",
{
enforceBuildableLibDependency: true,
allow: [
"^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$"
],
depConstraints: [
{
sourceTag: "*",
onlyDependOnLibsWithTags: [
"*"
]
}
]
}
]
}
},
{
files: [
"**/*.ts",
"**/*.tsx",
"**/*.cts",
"**/*.mts",
"**/*.js",
"**/*.jsx",
"**/*.cjs",
"**/*.mjs"
],
// Override or add rules here
rules: {}
}
];

View File

@@ -0,0 +1,15 @@
import playwright from "eslint-plugin-playwright";
import baseConfig from "../eslint.config.mjs";
export default [
playwright.configs["flat/recommended"],
...baseConfig,
{
files: [
"**/*.ts",
"**/*.js"
],
// Override or add rules here
rules: {}
}
];

View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
/**
* Written without `@nx/playwright`'s preset on purpose: Playwright loads this
* file through Node's ESM loader, and pulling in `@nx/devkit` from there drags
* Nx's native binding in under `runSync`, where it fails to initialise. Only
* `testDir`, `outputDir` and the web server came from the preset anyway.
*/
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, '..');
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
export default defineConfig({
testDir: './src',
outputDir: resolve(workspaceRoot, 'dist/.playwright/landing-e2e/test-output'),
timeout: 60_000,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: [['list']],
use: {
baseURL,
trace: 'on-first-retry',
},
webServer: {
command: 'npx nx run landing:serve',
url: baseURL,
reuseExistingServer: true,
timeout: 180_000,
cwd: workspaceRoot,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

11
landing-e2e/project.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "landing-e2e",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "landing-e2e/src",
"implicitDependencies": [
"landing"
],
"// targets": "to see all targets run: nx show project landing-e2e --web",
"targets": {}
}

View File

@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test';
test.describe('landing', () => {
test('renders the hero server-side', async ({ page }) => {
const response = await page.goto('/');
expect(response?.status()).toBe(200);
await expect(page.locator('h1')).toContainText('Construimos software');
});
test('serves the page in the language the URL asks for', async ({ page }) => {
await page.goto('/?lang=en');
await expect(page.locator('h1')).toContainText('We build software');
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
test('server-renders the whole page, not just the top of it', async ({
browser,
}) => {
// With JavaScript off, what is left is exactly what the server sent. The
// check is the last section and the footer: a component that throws during
// rendering does not merely lose itself, it abandons everything after it,
// and the page still answers 200 while quietly ending early. That is how
// `pi-carousel` reaching for `document` once cost this page its contact
// form server-side.
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
await page.goto('/');
await expect(page.locator('h1')).toContainText('Construimos software');
await expect(page.locator('#contacto')).toBeAttached();
await expect(page.getByRole('contentinfo')).toContainText('Pireon');
await context.close();
});
test('answers 404 for an address it does not serve', async ({ page }) => {
const response = await page.goto('/no-existe');
expect(response?.status()).toBe(404);
await expect(page.locator('h1')).toContainText('no existe');
});
test('refuses an incomplete contact submission without calling the API', async ({
page,
}) => {
await page.goto('/');
await page.locator('#contacto').scrollIntoViewIfNeeded();
const before = page.url();
const submit = page.getByRole('button', { name: 'Enviar' });
// Enabled only once hydration attached the handler — before that a click
// would submit natively and navigate away with the message. The generous
// timeout is for the dev server's first compile, not for hydration.
await expect(submit).toBeEnabled({ timeout: 30_000 });
await submit.click();
// Nothing navigates and nothing is sent: the form holds the visitor there
// rather than spending one of their throttled attempts on a bad payload.
await expect(submit).toBeVisible();
expect(page.url()).toBe(before);
});
});

24
landing-e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.mts",
"src/**/*.spec.ts",
"src/**/*.spec.js",
"src/**/*.test.ts",
"src/**/*.test.js",
"src/**/*.d.ts"
]
}

38
landing/eslint.config.mjs Normal file
View File

@@ -0,0 +1,38 @@
import nx from "@nx/eslint-plugin";
import baseConfig from "../eslint.config.mjs";
export default [
...nx.configs["flat/angular"],
...nx.configs["flat/angular-template"],
...baseConfig,
{
files: [
"**/*.ts"
],
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case"
}
]
}
},
{
files: [
"**/*.html"
],
// Override or add rules here
rules: {}
}
];

53
landing/index.html Normal file
View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Pireon — Construimos software en abierto</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0f1116" media="(prefers-color-scheme: dark)" />
<!-- The mark itself, at the two sizes a tab and a bookmark ask for. The
scaffold's `favicon.ico` stays as the last-resort fallback for clients
that request it by convention and ignore these. -->
<link rel="icon" type="image/png" sizes="96x96" href="/logo-96.png" />
<link rel="icon" type="image/png" sizes="256x256" href="/logo.png" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/>
<!-- The API is on another host and every page fetches it during SSR;
the browser still loads images from it. -->
<link rel="preconnect" href="https://landing-api.pireon.dev" crossorigin />
<link rel="stylesheet" href="/src/styles.scss" />
<script>
// Runs before first paint so a visitor who wants dark never sees a white
// flash: the page is server-rendered, and the server cannot know. The key
// and its values are the ones in `core/appearance.ts`.
(function () {
try {
var stored = localStorage.getItem('landing-appearance');
var dark =
stored === 'dark' ||
(stored !== 'light' &&
matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark-mode');
} catch (e) {
// Storage unavailable; the page stays light until Angular decides.
}
})();
</script>
</head>
<!-- `PiThemeService` rewrites this class from its own effect; naming the
theme here too means the server-rendered HTML is already themed instead
of flashing the default one. `alternative` is the library's marketing
shell: gradient fills, hover glow, larger controls. -->
<body class="pi-shell pi-shell__alternative">
<app-root></app-root>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2289
landing/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

3
landing/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

69
landing/project.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "landing",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "landing/src",
"tags": [],
"targets": {
"build": {
"executor": "@analogjs/platform:vite",
"defaultConfiguration": "production",
"outputs": [
"{workspaceRoot}/dist/apps/landing"
],
"options": {
"configFile": "landing/vite.config.ts",
"main": "landing/src/main.ts",
"outputPath": "dist/landing/client",
"tsConfig": "landing/tsconfig.app.json"
},
"configurations": {
"development": {
"mode": "development"
},
"production": {
"sourcemap": false,
"mode": "production"
}
}
},
"serve": {
"executor": "@analogjs/platform:vite-dev-server",
"continuous": true,
"defaultConfiguration": "development",
"options": {
"buildTarget": "landing:build",
"port": 4200
},
"configurations": {
"development": {
"buildTarget": "landing:build:development",
"hmr": true
},
"production": {
"buildTarget": "landing:build:production"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "landing:build",
"port": 4200,
"staticFilePath": "dist/landing/browser",
"spa": true
}
},
"test": {
"executor": "@analogjs/vitest-angular:test",
"options": {
"config": "vite.config.ts"
}
}
}
}

0
landing/public/.gitkeep Normal file
View File

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2M13 21v-4h4v4"/><path d="M6 11h.01M6 15h.01"/></svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m8 12.2 2.7 2.8L16 9.5"/></svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4.5 12.5 5 5 10-11"/></svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6-6 6 6 6"/></svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9.5 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 6 6 6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14.5 6-6 6 6"/></svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6.5" rx="7" ry="3"/><path d="M5 6.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5M5 11.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 7 7.4 5.3a2 2 0 0 0 2.2 0L20.5 7"/></svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.3h.01"/></svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3"/></svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z"/></svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V4M4 20h16"/><path d="m7.5 15 3.5-4 3 2.6L20 7"/></svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.7h.01"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.9v.2h5v-.2c0-.8.4-1.5 1-1.9A6 6 0 0 0 12 3Z"/></svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.1.1l2.9-2.9a5 5 0 0 0-7.1-7.1L11 4.9"/><path d="M14 11a5 5 0 0 0-7.1-.1L4 13.8a5 5 0 0 0 7.1 7.1l1.9-1.9"/></svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/></svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13.4A8 8 0 1 1 10.6 4a6.6 6.6 0 0 0 9.4 9.4Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5.5v13l10-6.5-10-6.5Z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg>

After

Width:  |  Height:  |  Size: 226 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m12 3.6 2.5 5.2 5.7.8-4.1 4 1 5.6-5.1-2.7-5.1 2.7 1-5.6-4.1-4 5.7-.8L12 3.6Z"/></svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4"/></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9.5 5.6 5.6M14.5 9.5l3.9-3.9M14.5 14.5l3.9 3.9M9.5 14.5l-3.9 3.9"/><circle cx="12" cy="12" r="3.5"/></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.2 2"/></svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h9a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8"/><path d="M4 5.5 5.8 7.5 9 3.8M9 11h7M9 15h5"/></svg>

After

Width:  |  Height:  |  Size: 280 B

BIN
landing/public/favicon.ico Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More