chore: update htmx

This commit is contained in:
Jesús Pérez 2026-07-17 00:51:19 +01:00
parent 4f7c7ec7d7
commit cc664d6dd4
18 changed files with 962 additions and 29 deletions

View file

@ -0,0 +1,36 @@
{# Sin esta plantilla la página cae en `pages/static.j2`, que lee exactamente tres claves
(-page-title, -page-subtitle, -page-description): un titular sobre un solo párrafo, y un 200.
Ese es el motivo de que /about-us fuese pobre — no la copia, el renderizador.
Los enlaces viajan DENTRO del valor Fluent y salen por `| safe`: cada idioma lleva su propia
ruta (/case-files vs /expedientes) y el traductor la conserva. #}
<article class="ds-page ds-page-about-us" data-page-id="about-us">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["about-us-page-title"] }}</h1>
<p class="ds-text-lead">{{ texts["about-us-page-subtitle"] }}</p>
</div>
</header>
<div class="ds-page-body">
<div class="ds-container">
<p class="ds-text-lead ds-text-muted">{{ texts["about-us-page-description"] | safe }}</p>
<h2>{{ texts["about-us-h-quien"] }}</h2>
<p>{{ texts["about-us-quien-1"] | safe }}</p>
<p>{{ texts["about-us-quien-2"] | safe }}</p>
<h2>{{ texts["about-us-h-porque"] }}</h2>
<p>{{ texts["about-us-porque-1"] | safe }}</p>
<p>{{ texts["about-us-porque-2"] | safe }}</p>
<h2>{{ texts["about-us-h-como"] }}</h2>
<p>{{ texts["about-us-como-1"] | safe }}</p>
<p>{{ texts["about-us-como-2"] | safe }}</p>
<p>{{ texts["about-us-como-3"] | safe }}</p>
<h2>{{ texts["about-us-h-abierto"] }}</h2>
<p>{{ texts["about-us-abierto-1"] | safe }}</p>
<p>{{ texts["about-us-abierto-2"] | safe }}</p>
</div>
</div>
</article>

View file

@ -171,3 +171,5 @@
</section>
</div>
{% include "partials/glossary.j2" %}

View file

@ -114,3 +114,5 @@
</div>
{% include "partials/image-zoom.j2" %}
{% include "partials/glossary.j2" %}

View file

@ -0,0 +1,70 @@
{# Conceptos clave — el glosario del proyecto, SERVIDO, no pintado con JS.
Todo el texto se proyecta de .ontoref/ontology/glossary.ncl (scripts/build/gen-conceptos.nu →
conceptos.ftl) — el mismo fichero del que responde `ontoref describe term` en la terminal. Aquí
no se escribe ni una palabra del vocabulario.
Leía `registry.ncl`, que es el ÁRBITRO DE TRADUCCIÓN, no el glosario: la página se llamaba
«Conceptos clave» y listaba `commit`, `push`, `rebase` — decisiones sobre cómo se dicen las
cosas en español, no conceptos de ontoref. Y en inglés salía vacía, porque las reglas del
árbitro se indexan por idioma de DESTINO y el inglés es la fuente. Las 20 definiciones inglesas
llevaban meses escritas en el glosario, sin que nada las leyera.
El tooltip (glossary-tooltip.js) atiende a quien tropieza con una palabra a mitad de frase.
Esta página atiende a los otros dos lectores: el que quiere el mapa antes de empezar, y Google.
Por eso el <dl> llega renderizado del servidor y no por `fetch`. #}
<div class="ds-bg-page">
<style>
.cpt{margin:0}
.cpt dt{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.95rem;
margin-top:1.8rem;scroll-margin-top:5rem;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.cpt dd{margin:.15rem 0 0;line-height:1.6}
.cpt dt:target{outline:2px solid currentColor;outline-offset:.35rem;border-radius:.2rem}
/* La ficha del concepto: qué ES (categoría) y de dónde NACE (axioma, tensión, ADR…). No es
adorno — es la mitad del dato que el glosario guarda, y la página que lo omitía dejaba cada
definición flotando sin decir de qué parte del proyecto sale. */
.cpt .cpt-meta{display:flex;flex-wrap:wrap;gap:.5rem;margin:.15rem 0 .4rem;
font-family:ui-monospace,"Courier New",monospace;font-size:.68rem;
letter-spacing:.06em;text-transform:uppercase;opacity:.62}
.cpt .cpt-cat{border:1px solid currentColor;border-radius:.25rem;padding:.05rem .35rem}
.cpt .cpt-origin{opacity:.85}
/* `verified = false` significa que el término nació en una sesión y todavía no lo ratifica
ningún ADR. Se DICE. Un término en borrador impreso igual que uno ratificado es la página
afirmando de más en nombre del proyecto. */
.cpt .cpt-draft{font-family:ui-monospace,"Courier New",monospace;font-size:.6rem;font-weight:700;
letter-spacing:.1em;padding:.1rem .35rem;border-radius:.25rem;
border:1px solid #c98a22;color:#c98a22;background:rgba(201,138,34,.10)}
.cpt .cpt-rel{display:block;margin-top:.4rem;font-size:.82rem;opacity:.8}
.cpt .cpt-rel a{text-decoration:underline}
.cpt .cpt-more{display:inline-block;margin-top:.4rem;font-size:.8rem;white-space:nowrap}
</style>
<section class="py-ds-8 ds-container">
<div class="mx-auto max-w-3xl">
<h1 class="text-3xl font-bold ds-text mb-2">{{ texts["conceptos-page-title"] }}</h1>
<p class="ds-body-lg ds-text-secondary mb-6">{{ texts["conceptos-page-subtitle"] }}</p>
<p class="ds-body ds-text-secondary mb-8">{{ texts["conceptos-intro"] }}</p>
{# Sin rama «vacío». Antes existía porque el inglés SIEMPRE llegaba a 0 y había que confesarlo.
Leyendo el glosario, cero conceptos no es un estado de la página: es un glosario roto, y lo
refusa el generador antes de escribir nada. Una rama que no puede darse es una rama que
nadie prueba. #}
<div class="ds-text">
{{ texts["conceptos-html"] | safe }}
</div>
</div>
</section>
{# SIN tooltip, deliberadamente. Esta página ES donde se montan las explicaciones: cada término
tiene su definición en el <dd> de al lado. Un tooltip aquí explica una palabra señalando a la
frase que ya está a dos centímetros — y como el script gasta la decoración en la PRIMERA
ocurrencia de cada unidad de lectura, la gastaba justo en el <dt> que el <dd> define.
La razón por la que estuvo puesto (los términos se citan unos a otros) era real y pedía otra
cosa: un enlace al ancla del término citado. Eso es lo que ahora emite el generador desde
`related_terms`, y cada <dt> ya tiene `id` y `:target`. #}
</div>

View file

@ -83,7 +83,7 @@
class="no-underline block ds-text-secondary hover:ds-text ds-caption">
{{ texts["contact-git-profile"] }}
</a>
<a href="{{ texts['contact-blog-url'] | default(value='/blog') }}"
<a href="{{ texts['contact-blog-url'] | default('/blog') }}"
class="no-underline block ds-text-secondary hover:ds-text ds-caption">
{{ texts["contact-read-my-blog"] }}
</a>
@ -101,10 +101,28 @@
</div>
<form class="space-y-ds-4"
hx-post="/api/contact"
{# Migrated to the DECLARATIVE endpoint. Its spec is site/config/forms/contact.ncl, and a new
form is now a .ncl plus a template — no Rust, no rebuild.
This form has been posting to `/api/contact` (403). The handler moved to
`/api/htmx/contact` and the template never followed — its own doc comment says
"replaces the previously dead POST /api/contact target in the contact template". An
endpoint declared in one place and consumed in another, with nothing checking they
agree. There is now ONE endpoint, and the form's own declaration is the contract, so
there is nothing left to drift. #}
hx-post="/api/htmx/form/contact"
hx-target="#contact-form-result"
hx-swap="innerHTML">
<input type="hidden" name="lang" value="{{ lang }}" />
{# Honeypot — hidden from people, irresistible to bots. A value here means the handler
confirms WITHOUT delivering, so the bot gets no signal that it was caught. #}
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label for="contact-website">Website</label>
<input id="contact-website" name="website" type="text" tabindex="-1" autocomplete="off" />
</div>
{# Name + Email row #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-ds-4">
<div>
@ -201,7 +219,10 @@
</div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-project-consultation"] }}</h3>
<p class="ds-text-secondary mb-ds-4">{{ texts["contact-consultation-help"] }}</p>
<a href="{{ texts['contact-services-url'] | default(value='/services') }}#tier2"
{# "Request Quote" now goes to the request form, which is the thing it names. It used to
go to /services#tier2 — and there IS no #tier2 on that page: the anchor was dead, so
the button dropped the reader at the top of a page of prose instead of at the form. #}
<a href="{{ texts['contact-work-request-url'] | default('/work-request') }}"
class="no-underline ds-btn-primary">
{{ texts["contact-request-quote"] }}
</a>
@ -213,7 +234,7 @@
</div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-mentoring-training"] }}</h3>
<p class="ds-text-secondary mb-ds-4">{{ texts["contact-mentoring-help"] }}</p>
<a href="{{ texts['contact-services-url'] | default(value='/services') }}#learn"
<a href="{{ texts['contact-services-url'] | default('/services') }}#learn"
class="ds-btn-primary">
{{ texts["contact-learn-more"] }}
</a>
@ -239,3 +260,5 @@
</div>
{% include "partials/image-zoom.j2" %}
{% include "partials/glossary.j2" %}

View file

@ -0,0 +1,83 @@
{# Knowledge graph — the viewer that was already written and had no page.
`/graph` (and `/grafo`) had no template, fell back to `pages/static.j2`, and rendered a heading
over nothing. And once again the work was not missing: `site/public/js/content-graph-shim.js`
is a complete Cytoscape bridge, `content_graph.json` is generated by `gen-content-graph` and is
served at `/public/r/content_graph.json`. Everything existed except the twenty lines that mount
it — which is the shape of every one of these ten pages.
The shim's own header states its contract, and this template honours it exactly:
· reads `<script type="application/json" id="content-graph-data">{ nodes, edges }</script>`
· is invoked as `window.ContentGraph.render(containerId, focusNodeId, theme)`
The data is fetched rather than inlined by the server: the graph is regenerated by a content
command, not by a rebuild, so a page that baked it in would go stale the moment content moved —
which is the failure this whole exercise has been about. Fetching means the graph is as fresh as
the file. #}
<article class="ds-page ds-page-content-graph" data-page-id="content-graph">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["content-graph-title"] | default(texts["content-graph-page-title"]) }}</h1>
{% if texts["content-graph-subtitle"] %}
<p class="ds-text-lead">{{ texts["content-graph-subtitle"] }}</p>
{% endif %}
</div>
</header>
<div class="ds-page-body">
<div class="ds-container">
{% if texts["content-graph-hint"] %}
<p class="ds-text-muted">{{ texts["content-graph-hint"] }}</p>
{% endif %}
{# The shim reads its data from this element. It is empty until the fetch lands; the shim is
only called once it has, so it never sees a half-filled graph. #}
<script type="application/json" id="content-graph-data">{"nodes":[],"edges":[]}</script>
<div id="content-graph-canvas"
style="width:100%;height:70vh;min-height:28rem"
role="img"
aria-label="{{ texts['content-graph-title'] | default('Knowledge graph') }}">
</div>
<noscript>
<p class="ds-text-muted">{{ texts["content-graph-noscript"] | default(texts["content-graph-hint"]) }}</p>
</noscript>
<script>
(function () {
var data = document.getElementById("content-graph-data");
var canvas = document.getElementById("content-graph-canvas");
if (!data || !canvas) return;
fetch("/public/r/content_graph.json", { cache: "no-cache" })
.then(function (r) {
if (!r.ok) throw new Error("content_graph.json: HTTP " + r.status);
return r.text();
})
.then(function (json) {
data.textContent = json;
// The shim may load after this inline script; poll briefly rather than assume order.
var tries = 0;
(function mount() {
if (window.ContentGraph && window.ContentGraph.render) {
var theme = document.documentElement.getAttribute("data-theme") || "light";
window.ContentGraph.render("content-graph-canvas", null, theme);
return;
}
if (++tries < 40) setTimeout(mount, 50);
else console.error("content-graph: window.ContentGraph never arrived");
})();
})
.catch(function (e) {
// Say it out loud. A graph that silently renders nothing is the failure this page is
// being fixed for; an empty canvas with a clean console is how it stayed hidden.
console.error("content-graph:", e);
canvas.textContent = "";
});
})();
</script>
</div>
</div>
</article>

View file

@ -1,6 +1,47 @@
<div class="ds-bg-page">
{# TEMP: hide the Posts/Recipes door CTAs for now (remove this <style> to restore) #}
<style>.tagline-panel-graph{display:none}</style>
<style>
.tagline-panel-graph{display:none}
/* Hero copy laid over the illustration. Sized in `cqw` against the IMAGE, not the viewport:
the wrapper stops growing at max-w-4xl, so viewport units would keep scaling the text
after the picture had stopped and walk it out of the dark panel it has to sit in.
Below 520px of container the overlay is abandoned entirely — the art's left panel is
then too small for legible copy, and text that merely shrinks with its background is the
exact defect baking it into the raster had. It drops into normal flow under the image. */
.hero-wrap { position: relative; container-type: inline-size; }
.hero-copy { padding: 1rem 0.25rem 0; text-align: center; }
/* `balance` splits the headline into even lines instead of filling the first and orphaning
the tail ("Pisa firme y anda / ligero."). The alternative — an &nbsp; between the words
that must not part — would put TYPOGRAPHY inside spine.ncl, which owns the sentence, not
its line breaks. The layout problem stays in the layout. */
.hero-copy h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.2; text-wrap: balance; }
.hero-copy p { margin-top: 0.5rem; font-size: 0.95rem; line-height: 1.45; }
@container (min-width: 520px) {
/* translateY(10%) rather than a `top` offset: it shifts the block by 10% of its OWN height,
which (inset 0) is the image height — so the drop stays proportional at every width
instead of drifting as the art scales. The art's optical centre sits below its geometric
one: the logo occupies the upper left, and centred copy rides too high against it. */
.hero-copy {
position: absolute; top: 0; bottom: 0; left: 8.6cqw; width: 30cqw;
display: flex; flex-direction: column; justify-content: center;
text-align: left; padding: 0;
transform: translateY(10%);
}
/* .hero-wrap prefix so these outrank the ds-* token classes on the elements themselves:
over the art the copy must be light in BOTH themes, and a theme-aware token would turn
the headline near-black on the night sky in light mode. */
.hero-wrap .hero-copy h1 {
font-size: 3.6cqw; line-height: 1.14; color: #ffffff; text-wrap: balance;
text-shadow: 0 2px 12px rgba(0,0,0,.55);
}
.hero-wrap .hero-copy p {
margin-top: 1.2cqw; font-size: 1.5cqw; line-height: 1.4; color: #8fb4d9;
text-shadow: 0 1px 8px rgba(0,0,0,.5);
}
}
</style>
{# Hero — three-door vestibule, projected from .ontoref/positioning/spine.ncl (ADR-057).
Never an exposition of the core: the doors route, the prize is revealed once inside.
@ -10,20 +51,39 @@
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4 font-mono">
{{ texts["home-hero-badge"] }}
</span>
{# A · hero — the language-specific banner carries logo + headline + sub-hero + the
four value nodes (baked in) and scales fluidly at every width, so it shows at all
breakpoints. The h1/sub stay sr-only for SEO + a11y; the <picture> is click-to-zoom. #}
<h1 class="sr-only">{{ texts["spine-hero"] }}</h1>
<p class="sr-only">{{ texts["spine-sub"] }}</p>
{% set hero_img = "ontoref-home-seguro" if lang == "es" else "ontoref-home-sure-footed" %}
<picture>
<source srcset="/images/{{ hero_img }}.avif" type="image/avif">
<source srcset="/images/{{ hero_img }}.webp" type="image/webp">
<img src="/images/{{ hero_img }}.webp"
alt="{{ texts['spine-hero'] }}"
width="1220" height="417" loading="eager"
class="block mx-auto rounded-lg w-full max-w-4xl h-auto mb-6" />
</picture>
{# A · hero — the banner is now the ILLUSTRATION; the headline is LIVE TEXT over it.
It used to be baked into the raster, and the raster drifted from the spine that
declares it: the picture said "Seguro de rumbo, ligero en tus pasos" while spine.ncl
(and therefore the sr-only h1, the alt and every crawler) said "Pisa firme y anda
ligero." One hero, two texts, and nothing could see it — there was no text to compare,
only pixels. Baked copy also cost a whole artwork per language and made a typo a design
job. The h1 is the headline now, not a screen-reader duplicate of an image.
Still per-language: the four value nodes remain baked in the art. #}
{% set hero_img = "ontoref-home-es" if lang == "es" else "ontoref-home" %}
{% set hero_w = 1224 if lang == "es" else 1220 %}
{% set hero_h = 416 if lang == "es" else 417 %}
<div class="hero-wrap mx-auto max-w-4xl mb-6">
<picture>
<source srcset="/images/{{ hero_img }}.avif" type="image/avif">
<source srcset="/images/{{ hero_img }}.webp" type="image/webp">
{# The alt no longer repeats the headline — that is the <h1> now. It carries the four
value labels still painted inside the raster, which exist in no other file: without
it they are text that no screen reader and no crawler can reach. #}
<img src="/images/{{ hero_img }}.webp"
alt="{{ texts['home-hero-art-alt'] | default(texts['spine-hero']) }}"
width="{{ hero_w }}" height="{{ hero_h }}" loading="eager"
class="block mx-auto rounded-lg w-full h-auto" />
</picture>
{# ds-* tokens, not bare tags: below the overlay breakpoint this copy sits on the PAGE
background, in whichever theme the reader chose. Left unclassed, the sub-hero fell to
a default that is grey-on-grey in dark mode — invisible. The overlay rules below
override both colours, because there the copy sits on the art, which is dark in both
themes. #}
<div class="hero-copy">
<h1 class="ds-text">{{ texts["spine-hero"] }}</h1>
<p class="ds-text-secondary">{{ texts["spine-sub"] }}</p>
</div>
</div>
{# rotating definition-phrases by audience + focus #}
<p class="ds-body-lg ds-text mb-1" style="min-height: 1.6em">
<span
@ -31,7 +91,7 @@
data-audience="all"
data-src="/public/r/taglines.json"
data-focus-target=".tagline-focus"
>{{ texts["spine-hero"] }}</span
>{{ texts["home-tagline-initial"] | default(texts["spine-sub"]) }}</span
>
</p>
<span class="tagline-focus" aria-hidden="true"></span>
@ -84,7 +144,10 @@
{{ texts["spine-prize"] }}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/blog" class="no-underline ds-btn-primary">
{# The destination is a KEY, not a literal: /about is /acerca-de in Spanish, and a hardcoded
href sends every Spanish reader to the English URL. It used to point at /blog — the CTA
said "explore the protocol" and delivered a list of posts. #}
<a href="{{ texts['home-cta-explore-url'] | default('/about') }}" class="no-underline ds-btn-primary">
{{ texts["home-cta-explore"] }}
</a>
<a href="https://github.com/stratum-iops/ontoref" target="_blank" rel="noopener noreferrer"
@ -159,6 +222,20 @@
class="block mx-auto w-full h-auto rounded-lg" />
</picture>
<p class="ds-caption ds-text-secondary mt-3">{{ texts["home-arch-caption"] }}</p>
{# Route to the live view — NAVIGATION, and deliberately NOT the artwork. The <picture>
above belongs to the lightbox (click-to-zoom); wrapping it in an <a> would take the zoom
away in order to give a link. So they do not share a condition: a consumer may have the
diagram with no live view, or a live view with no diagram.
Optional like every other key in this block — declare `home-arch-live-href` in the
site's home FTL and the button appears; omit it and nothing changes for anyone. The
target is per-site content, never this template's business. #}
{% if texts["home-arch-live-href"] %}
<p class="mt-4">
<a href="{{ texts['home-arch-live-href'] }}"
class="inline-block font-mono text-sm px-4 py-2 rounded-md border ds-border ds-text hover:ds-bg-surface transition-colors">{{ texts["home-arch-live-label"] }}</a>
</p>
{% endif %}
</div>
</div>
</section>
@ -204,10 +281,11 @@
<h2 class="text-3xl font-bold ds-text mb-4">{{ texts["home-cta-title"] }}</h2>
<p class="ds-body ds-text-secondary mb-8">{{ texts["home-cta-desc"] }}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/blog" class="no-underline ds-btn-primary">
<a href="{{ texts['home-cta-primary-url'] | default('/about') }}" class="no-underline ds-btn-primary">
{{ texts["home-cta-primary"] }}
</a>
<a href="/blog" class="no-underline ds-btn-secondary">
{# The secondary one DOES mean the blog — "read the protocol" is the writing about it. #}
<a href="{{ texts['home-cta-secondary-url'] | default('/blog') }}" class="no-underline ds-btn-secondary">
{{ texts["home-cta-secondary"] }}
</a>
</div>
@ -217,4 +295,11 @@
{# Hero tagline rotator — typed.js (typewriter) + native fade fallback #}
<script src="/js/typed.umd.js"></script>
<script src="/js/tagline-rotator.js"></script>
{# The lightbox is only "global" on the pages that include it — there is no base layout to
inject it. Home wraps both the hero image and the architecture diagram in <picture> and
said so in a comment, but never loaded the script: two zoomable images that did not zoom. #}
{% include "partials/image-zoom.j2" %}
</div>
{% include "partials/glossary.j2" %}

View file

@ -0,0 +1,111 @@
{# Legal notice — htmx renderer.
Same story as privacy.j2: `/legal` had no template, fell back to `pages/static.j2` (three keys),
and returned 200 over an empty body while 46 declared Fluent keys were thrown away on every
request. Leptos was the first renderer and has this page in full
(`rustelo_pages_leptos/src/legal/unified.rs`); htmx came second and never ported it.
One wrinkle worth writing down: the site's FTL carries TWO NAMES for the same facts —
`legal-owner-value` (what Leptos reads) and `legal-owner-name` (what a later edit added), and
likewise for activity, location, address and contact email. They are aliases, not different
data, and only one family was ever rendered. Every field below reads the Leptos name and falls
back to the newer one, so whichever a site actually wrote, it appears. That is a bridge, not a
fix: the duplication itself should be collapsed in the FTL — declared twice, no witness, one
more time. #}
<article class="ds-page ds-page-legal" data-page-id="legal">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["legal-title"] | default(texts["legal-page-title"]) }}</h1>
{% if texts["legal-subtitle"] %}
<p class="ds-text-lead">{{ texts["legal-subtitle"] }}</p>
{% endif %}
{% if texts["legal-effective-date"] %}
<p class="ds-caption ds-text-muted">{{ texts["legal-effective-date"] }}</p>
{% endif %}
</div>
</header>
<div class="ds-page-body">
<div class="ds-container ds-prose">
{% if texts["legal-intro"] %}
<p class="ds-text-lead">{{ texts["legal-intro"] }}</p>
{% endif %}
{# ── Website information (identification — LSSI art. 10 / GDPR art. 13) #}
{% if texts["legal-website-information"] %}
<h2>{{ texts["legal-website-information"] }}</h2>
<div class="ds-callout">
<p><strong>{{ texts["legal-owner"] }}:</strong> {{ texts["legal-owner-value"] | default(texts["legal-owner-name"]) }}</p>
<p><strong>{{ texts["legal-activity"] }}:</strong> {{ texts["legal-activity-value"] | default(texts["legal-business-activity"]) }}</p>
<p><strong>{{ texts["legal-location"] }}:</strong>
{{ texts["legal-location-value"] | default(texts["legal-business-location"]) }}{% if texts["legal-country-value"] %}, {{ texts["legal-country-value"] }}{% endif %}
</p>
<p><strong>{{ texts["legal-contact"] }}:</strong> {{ texts["legal-contact-value"] | default(texts["legal-contact-email"]) }}</p>
</div>
{% endif %}
{# ── Terms of use #}
{% if texts["legal-terms-of-use"] %}
<h2>{{ texts["legal-terms-of-use"] }}</h2>
{% if texts["legal-acceptance-of-terms"] %}
<h3>{{ texts["legal-acceptance-of-terms"] }}</h3>
<p>{{ texts["legal-acceptance-of-terms-text"] }}</p>
{% endif %}
{% if texts["legal-permitted-use"] %}
<h3>{{ texts["legal-permitted-use"] }}</h3>
{% if texts["legal-permitted-use-text"] %}<p>{{ texts["legal-permitted-use-text"] }}</p>{% endif %}
<ul>
{% if texts["legal-browse-and-read"] %}<li>{{ texts["legal-browse-and-read"] }}</li>{% endif %}
{% if texts["legal-contact-for-business"] %}<li>{{ texts["legal-contact-for-business"] }}</li>{% endif %}
{% if texts["legal-share-with-attribution"] %}<li>{{ texts["legal-share-with-attribution"] }}</li>{% endif %}
</ul>
{% endif %}
{% if texts["legal-prohibited-use"] %}
<h3>{{ texts["legal-prohibited-use"] }}</h3>
{% if texts["legal-prohibited-use-text"] %}<p>{{ texts["legal-prohibited-use-text"] }}</p>{% endif %}
<ul>
{% if texts["legal-unlawful-purpose"] %}<li>{{ texts["legal-unlawful-purpose"] }}</li>{% endif %}
{% if texts["legal-unauthorized-access"] %}<li>{{ texts["legal-unauthorized-access"] }}</li>{% endif %}
{% if texts["legal-reproduce-without-permission"] %}<li>{{ texts["legal-reproduce-without-permission"] }}</li>{% endif %}
</ul>
{% endif %}
{% endif %}
{# ── Intellectual property #}
{% if texts["legal-intellectual-property"] %}
<h2>{{ texts["legal-intellectual-property"] }}</h2>
<p>{{ texts["legal-intellectual-property-text"] }}</p>
{% endif %}
{# ── Technical information #}
{% if texts["legal-technical-information"] %}
<h2>{{ texts["legal-technical-information"] }}</h2>
{% if texts["legal-technical-information-text"] %}<p>{{ texts["legal-technical-information-text"] }}</p>{% endif %}
<ul>
{% if texts["legal-rust-and-leptos"] %}<li>{{ texts["legal-rust-and-leptos"] }}</li>{% endif %}
{% if texts["legal-server-side-rendering"] %}<li>{{ texts["legal-server-side-rendering"] }}</li>{% endif %}
{% if texts["legal-webassembly-functionality"] %}<li>{{ texts["legal-webassembly-functionality"] }}</li>{% endif %}
</ul>
{% endif %}
{# ── Contact #}
{% if texts["legal-contact-information"] %}
<h2>{{ texts["legal-contact-information"] }}</h2>
{% if texts["legal-contact-legal-text"] %}<p>{{ texts["legal-contact-legal-text"] }}</p>{% endif %}
<div class="ds-callout">
{% if texts["legal-email-value"] or texts["legal-contact-email"] %}
<p><strong>{{ texts["legal-email"] | default("Email") }}:</strong> {{ texts["legal-email-value"] | default(texts["legal-contact-email"]) }}</p>
{% endif %}
{% if texts["legal-address-value"] or texts["legal-business-address"] %}
<p><strong>{{ texts["legal-address"] | default("Address") }}:</strong> {{ texts["legal-address-value"] | default(texts["legal-business-address"]) }}</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
</article>

View file

@ -0,0 +1,146 @@
{# GENERADO desde .ontoref/ontology/registry.ncl por scripts/gen-nota-lenguaje.nu. NO EDITAR. #}
{# Vive en site/_htmx/templates/pages/ — NUNCA en htmx-templates/, que `just templates`
borra y reensambla en cada ejecución (adr-070). Las filas de las tablas son DATOS del
registro y por eso van aquí; la prosa es TEXTO y va en Fluent, donde un traductor la alcanza. #}
<article class="ds-page ds-page-lenguaje" data-page-id="lenguaje">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["lenguaje-page-title"] }}</h1>
<p class="ds-text-lead">{{ texts["lenguaje-page-subtitle"] }}</p>
</div>
</header>
<div class="ds-page-body">
<div class="ds-container">
<p>{{ texts["lenguaje-intro"] }}</p>
<h2>{{ texts["lenguaje-h-criterio"] }}</h2>
<p>{{ texts["lenguaje-criterio-lead"] }}</p>
<p><b>{{ texts["lenguaje-criterio-1-t"] }}</b> {{ texts["lenguaje-criterio-1"] }}</p>
<p><b>{{ texts["lenguaje-criterio-2-t"] }}</b> {{ texts["lenguaje-criterio-2"] }}</p>
<p>{{ texts["lenguaje-criterio-3"] }}</p>
<p>{{ texts["lenguaje-criterio-run"] }}</p>
<h2>{{ texts["lenguaje-h-kept"] }}</h2>
<p>{{ texts["lenguaje-kept-lead"] }}</p>
<table class="ds-table">
<thead><tr><th>{{ texts["lenguaje-th-termino"] }}</th><th>{{ texts["lenguaje-th-que-es"] }}</th><th>{{ texts["lenguaje-th-porque-queda"] }}</th></tr></thead>
<tbody>
<tr><td><code>gate</code></td><td>una gate — la condición tipada que una transición debe satisfacer para poder ocurrir</td><td><b>no hay palabra española fiel — traducirlo rompe el concepto</b></td></tr>
<tr><td><code>commit</code></td><td>un commit — la unidad de cambio registrada, con su hash</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>push</code></td><td>un push — el envío de los commits al remoto</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>rebase</code></td><td>un rebase — reescribir la rama sobre otra base</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>pull request</code></td><td>un Pull Request — la propuesta de fusión, con su revisión</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>hook</code></td><td>un hook — el enganche que dispara en un punto del ciclo</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>NCL</code></td><td>NCL — el lenguaje de configuración tipada en el que se declara todo esto</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>ADR</code></td><td>un ADR — el registro de una decisión de arquitectura, con sus restricciones</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>DAG</code></td><td>un DAG — grafo dirigido acíclico; la forma que tiene un modo</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>JSONL</code></td><td>JSONL — un objeto JSON por línea; el formato del testigo</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>Ed25519</code></td><td>Ed25519 — el esquema con el que se firma un testigo</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
<tr><td><code>exit</code></td><td>exit — el código de salida de un proceso (0 = pasa)</td><td><b>es un identificador — traducirlo rompe la referencia</b></td></tr>
</tbody>
</table>
<h2>{{ texts["lenguaje-h-rendered"] }}</h2>
<p>{{ texts["lenguaje-rendered-lead"] }}</p>
<table class="ds-table">
<thead><tr><th>{{ texts["lenguaje-th-en-ingles"] }}</th><th>{{ texts["lenguaje-th-decimos"] }}</th></tr></thead>
<tbody>
<tr><td>witness</td><td><b>testigo</b></td></tr>
<tr><td>door</td><td><b>puerta</b></td></tr>
<tr><td>drift</td><td><b>deriva</b></td></tr>
<tr><td>workflow</td><td><b>flujo de trabajo</b></td></tr>
<tr><td>harness</td><td><b>arnés</b></td></tr>
<tr><td>fixture</td><td><b>configuración de prueba</b></td></tr>
<tr><td>guardrail</td><td><b>salvaguarda</b></td></tr>
<tr><td>guardrails</td><td><b>salvaguardas</b></td></tr>
<tr><td>red-team</td><td><b>prueba adversaria</b></td></tr>
<tr><td>onboarding</td><td><b>incorporación</b></td></tr>
<tr><td>onboarded</td><td><b>incorporado</b></td></tr>
<tr><td>scripted</td><td><b>automatizado</b></td></tr>
<tr><td>FSM</td><td><b>máquina de estados</b></td></tr>
<tr><td>flag</td><td><b>opción de línea de comandos</b></td></tr>
<tr><td>runtime</td><td><b>tiempo de ejecución</b></td></tr>
<tr><td>backlog</td><td><b>cartera de pendientes</b></td></tr>
<tr><td>seed</td><td><b>semilla</b></td></tr>
<tr><td>constraint</td><td><b>restricción</b></td></tr>
<tr><td>constraints</td><td><b>restricciones</b></td></tr>
<tr><td>modes</td><td><b>modos</b></td></tr>
<tr><td>schema</td><td><b>esquema</b></td></tr>
<tr><td>reflection</td><td><b>reflexión</b></td></tr>
<tr><td>merge</td><td><b>fusión</b></td></tr>
<tr><td>parse</td><td><b>analizar</b></td></tr>
<tr><td>deploy</td><td><b>desplegar</b></td></tr>
<tr><td>link</td><td><b>enlazar</b></td></tr>
<tr><td>log</td><td><b>registrar</b></td></tr>
<tr><td>build</td><td><b>compilar</b></td></tr>
<tr><td>debug</td><td><b>depurar</b></td></tr>
<tr><td>test</td><td><b>prueba</b></td></tr>
<tr><td>fork</td><td><b>bifurcación</b></td></tr>
<tr><td>library</td><td><b>biblioteca</b></td></tr>
<tr><td>run</td><td><b>ejecución</b></td></tr>
</tbody>
</table>
<h2>{{ texts["lenguaje-h-verbos"] }}</h2>
<p>{{ texts["lenguaje-verbos-1"] }}</p>
<p>{{ texts["lenguaje-verbos-2"] }}</p>
<p>{{ texts["lenguaje-verbos-3"] }}</p>
<p>{{ texts["lenguaje-verbos-4"] }}</p>
<table class="ds-table">
<thead><tr><th>{{ texts["lenguaje-th-no-escribimos"] }}</th><th>{{ texts["lenguaje-th-escribimos"] }}</th><th>{{ texts["lenguaje-th-porque"] }}</th></tr></thead>
<tbody>
<tr><td><s>gatear</s></td><td><b>evaluar la gate / poner una gate</b></td><td>calco</td></tr>
<tr><td><s>driftar</s></td><td><b>deriva</b></td><td>calco</td></tr>
<tr><td><s>comitear</s></td><td><b>hacer un commit</b></td><td>calco</td></tr>
<tr><td><s>commitear</s></td><td><b>hacer un commit</b></td><td>calco</td></tr>
<tr><td><s>pushear</s></td><td><b>hacer un push</b></td><td>calco</td></tr>
<tr><td><s>rebasear</s></td><td><b>aplicar un rebase</b></td><td>calco</td></tr>
<tr><td><s>mergear</s></td><td><b>fusionar</b></td><td>calco</td></tr>
<tr><td><s>parsear</s></td><td><b>analizar</b></td><td>calco</td></tr>
<tr><td><s>deployar</s></td><td><b>desplegar</b></td><td>calco</td></tr>
<tr><td><s>deploar</s></td><td><b>desplegar</b></td><td>calco</td></tr>
<tr><td><s>linkear</s></td><td><b>enlazar</b></td><td>calco</td></tr>
<tr><td><s>loguear</s></td><td><b>registrar</b></td><td>calco</td></tr>
<tr><td><s>buildear</s></td><td><b>compilar</b></td><td>calco</td></tr>
<tr><td><s>debuggear</s></td><td><b>depurar</b></td><td>calco</td></tr>
<tr><td><s>testear</s></td><td><b>probar</b></td><td>calco</td></tr>
<tr><td><s>forkear</s></td><td><b>bifurcar</b></td><td>calco</td></tr>
<tr><td><s>forkar</s></td><td><b>bifurcar</b></td><td>calco</td></tr>
</tbody>
</table>
<h3>{{ texts["lenguaje-h-friends"] }}</h3>
<p>{{ texts["lenguaje-friends-1"] }}</p>
<p>{{ texts["lenguaje-friends-2"] }}</p>
<table class="ds-table">
<thead><tr><th>{{ texts["lenguaje-th-no-escribimos"] }}</th><th>{{ texts["lenguaje-th-escribimos"] }}</th><th>{{ texts["lenguaje-th-porque"] }}</th></tr></thead>
<tbody>
<tr><td><s>compuerta</s></td><td><b>gate</b></td><td>desplaza — Cognato de la ELECTRÓNICA (logic gate). Arrastra la lectura de circuito a una frase sobre transiciones de estado.</td></tr>
<tr><td><s>puerta</s></td><td><b>gate</b></td><td>desplaza — Ya la usa `door` (la entrada de embudo, ADR-064). Traducir así fusionaría dos conceptos en una palabra.</td></tr>
<tr><td><s>confirmar</s></td><td><b>hacer un commit</b></td><td>falso amigo — git ENVÍA este locale español y dice «confirmar» — y casi nadie lo usa, porque corta al lector de todo mensaje de error y de toda la do…</td></tr>
<tr><td><s>enviar</s></td><td><b>hacer un push</b></td><td>falso amigo — «enviar» pierde que el destino es el remoto declarado, no un destinatario cualquiera.</td></tr>
<tr><td><s>petición de tirón</s></td><td><b>Pull Request</b></td><td>falso amigo — Literal y sin referente: nadie lo reconocería en la forja.</td></tr>
<tr><td><s>enganche</s></td><td><b>hook</b></td><td>desplaza — «enganche» no nombra nada en la herramienta; el lector no puede volver de la palabra al fichero.</td></tr>
<tr><td><s>librería</s></td><td><b>biblioteca</b></td><td>falso amigo — Una librería es una TIENDA. El cognado derivó, y el desplazamiento es invisible precisamente porque la palabra sigue pareciéndose a su ori…</td></tr>
<tr><td><s>corrida</s></td><td><b>ejecución</b></td><td>regional — Válido en varias variantes americanas; en España evoca la tauromaquia y rompe la frase. Es una elección de REGISTRO, no una corrección <20>…</td></tr>
<tr><td><s>corridas</s></td><td><b>ejecuciones</b></td><td>regional — </td></tr>
</tbody>
</table>
<h2>{{ texts["lenguaje-h-hablando"] }}</h2>
<p>{{ texts["lenguaje-hablando-1"] }}</p>
<p>{{ texts["lenguaje-hablando-2"] }}</p>
<p>{{ texts["lenguaje-hablando-3"] }}</p>
<h2>{{ texts["lenguaje-h-legal"] }}</h2>
<p>{{ texts["lenguaje-legal-1"] }}</p>
<p><b>{{ texts["lenguaje-legal-2"] }}</b></p>
<p>{{ texts["lenguaje-legal-3"] }}</p>
<p>{{ texts["lenguaje-legal-4"] }}</p>
<hr>
<p><small>{{ texts["lenguaje-foot"] }} <a href="mailto:ontoref@jesusperez.pro">ontoref@jesusperez.pro</a></small></p>
</div>
</div>
</article>

View file

@ -94,3 +94,5 @@
</article>
{% include "partials/image-zoom.j2" %}
{% include "partials/glossary.j2" %}

View file

@ -0,0 +1,133 @@
{# Privacy policy — htmx renderer.
WHY THIS FILE EXISTS. Until now `/privacy` had NO template of its own, so it fell back to
`pages/static.j2`, which reads exactly three keys: -page-title, -page-subtitle,
-page-description. It returned 200 and rendered a heading over an empty body. Thirty-nine
declared Fluent keys — the entire privacy policy — were written and thrown away on every
request. Nothing logged it, because nothing was wrong: the page rendered fine, it just had
nothing in it.
Root cause: LEPTOS WAS THE FIRST RENDERER. `rustelo_pages_leptos/src/privacy/unified.rs`
renders this page fully from these same keys. The htmx renderer came second and never ported
it. Two renderers, no parity witness. Nine other pages are in the same state (304 keys in
total), and `ore rustelo routes --check` now fails on every one of them.
AND THIS IS NOT A PORT. The Leptos component renders 30 keys; the site declares 42, and the
twelve it does not render are the GDPR-MANDATORY ones — the data controller (art. 13), the
legal basis (art. 6), the right to withdraw consent, portability, restriction, and the right
to lodge a complaint with the supervisory authority. Someone wrote the GDPR rewrite of the
copy and NEITHER renderer ever showed it. Porting Leptos faithfully would have dropped them
a second time. This template renders ALL of them, in the order the regulation asks for.
Every key is guarded with `if`: a site that has not written the GDPR copy gets the older
structure rather than a row of empty headings. #}
<article class="ds-page ds-page-privacy" data-page-id="privacy">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["privacy-title"] | default(texts["privacy-page-title"]) }}</h1>
{% if texts["privacy-subtitle"] %}
<p class="ds-text-lead">{{ texts["privacy-subtitle"] }}</p>
{% endif %}
{% if texts["privacy-effective-date"] %}
<p class="ds-caption ds-text-muted">{{ texts["privacy-effective-date"] }}</p>
{% endif %}
</div>
</header>
<div class="ds-page-body">
<div class="ds-container ds-prose">
{% if texts["privacy-intro"] %}
<p class="ds-text-lead">{{ texts["privacy-intro"] }}</p>
{% endif %}
{# ── Data controller (GDPR art. 13.1.a — mandatory, and rendered by nobody until now) #}
{% if texts["privacy-controller"] %}
<h2>{{ texts["privacy-controller"] }}</h2>
<p>{{ texts["privacy-controller-text"] }}</p>
{% if texts["privacy-spain"] %}
<p class="ds-text-muted"><small>{{ texts["privacy-spain"] }}</small></p>
{% endif %}
{% endif %}
{# ── Legal basis (GDPR art. 6 — mandatory) #}
{% if texts["privacy-legal-basis"] %}
<h2>{{ texts["privacy-legal-basis-title"] | default("Base jurídica") }}</h2>
<p>{{ texts["privacy-legal-basis"] }}</p>
{% endif %}
{# ── What we collect #}
{% if texts["privacy-information-we-collect"] %}
<h2>{{ texts["privacy-information-we-collect"] }}</h2>
{% if texts["privacy-information-you-provide"] %}
<h3>{{ texts["privacy-information-you-provide"] }}</h3>
<ul>
{% if texts["privacy-information-you-provide-text"] %}<li>{{ texts["privacy-information-you-provide-text"] }}</li>{% endif %}
{% if texts["privacy-project-details-text"] %}<li>{{ texts["privacy-project-details-text"] }}</li>{% endif %}
{% if texts["privacy-communication-preferences-text"] %}<li>{{ texts["privacy-communication-preferences-text"] }}</li>{% endif %}
</ul>
{% endif %}
{% if texts["privacy-automatically-collected-information"] %}
<h3>{{ texts["privacy-automatically-collected-information"] }}</h3>
<ul>
{% if texts["privacy-website-usage-data"] %}<li>{{ texts["privacy-website-usage-data"] }}</li>{% endif %}
{% if texts["privacy-technical-information-ip"] %}<li>{{ texts["privacy-technical-information-ip"] }}</li>{% endif %}
{% if texts["privacy-cookies-tracking"] %}<li>{{ texts["privacy-cookies-tracking"] }}</li>{% endif %}
</ul>
{% endif %}
{% endif %}
{# ── Proof of consent (GDPR art. 7.1) #}
{% if texts["privacy-consent-proof"] %}
<p>{{ texts["privacy-consent-proof"] }}</p>
{% endif %}
{# ── How we use it #}
{% if texts["privacy-how-we-use-information"] %}
<h2>{{ texts["privacy-how-we-use-information"] }}</h2>
{% if texts["privacy-how-we-use-text"] %}<p>{{ texts["privacy-how-we-use-text"] }}</p>{% endif %}
<ul>
{% if texts["privacy-respond-to-inquiries"] %}<li>{{ texts["privacy-respond-to-inquiries"] }}</li>{% endif %}
{% if texts["privacy-improve-website"] %}<li>{{ texts["privacy-improve-website"] }}</li>{% endif %}
{% if texts["privacy-send-updates"] %}<li>{{ texts["privacy-send-updates"] }}</li>{% endif %}
{% if texts["privacy-ensure-security"] %}<li>{{ texts["privacy-ensure-security"] }}</li>{% endif %}
{% if texts["privacy-comply-legal"] %}<li>{{ texts["privacy-comply-legal"] }}</li>{% endif %}
</ul>
{% endif %}
{# ── Rights (GDPR arts. 1521). The last four are the ones no renderer has ever shown, and
the right to complain to the supervisory authority is not optional copy. #}
{% if texts["privacy-your-rights"] %}
<h2>{{ texts["privacy-your-rights"] }}</h2>
{% if texts["privacy-your-rights-text"] %}<p>{{ texts["privacy-your-rights-text"] }}</p>{% endif %}
<ul>
{% if texts["privacy-access-review"] %}<li>{{ texts["privacy-access-review"] }}</li>{% endif %}
{% if texts["privacy-request-corrections"] %}<li>{{ texts["privacy-request-corrections"] }}</li>{% endif %}
{% if texts["privacy-request-deletion"] %}<li>{{ texts["privacy-request-deletion"] }}</li>{% endif %}
{% if texts["privacy-opt-out-marketing"] %}<li>{{ texts["privacy-opt-out-marketing"] }}</li>{% endif %}
{% if texts["privacy-right-withdraw"] %}<li>{{ texts["privacy-right-withdraw"] }}</li>{% endif %}
{% if texts["privacy-right-portability"] %}<li>{{ texts["privacy-right-portability"] }}</li>{% endif %}
{% if texts["privacy-right-restriction"] %}<li>{{ texts["privacy-right-restriction"] }}</li>{% endif %}
{% if texts["privacy-right-complaint"] %}<li>{{ texts["privacy-right-complaint"] }}</li>{% endif %}
</ul>
{% endif %}
{# ── Contact #}
{% if texts["privacy-contact-us"] %}
<h2>{{ texts["privacy-contact-us"] }}</h2>
{% if texts["privacy-contact-privacy-text"] %}<p>{{ texts["privacy-contact-privacy-text"] }}</p>{% endif %}
<div class="ds-callout">
{% if texts["legal-contact-email"] %}
<p><strong>{{ texts["privacy-email"] | default("Email") }}:</strong> {{ texts["legal-contact-email"] }}</p>
{% endif %}
{% if texts["legal-business-address"] %}
<p><strong>{{ texts["privacy-address"] | default("Address") }}:</strong> {{ texts["legal-business-address"] }}</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
</article>

View file

@ -209,3 +209,5 @@
</div>
{% include "partials/image-zoom.j2" %}
{% include "partials/glossary.j2" %}

View file

@ -0,0 +1,29 @@
{# Sign-out confirmation. Seven declared Fluent keys, five of them unrendered until now because the
page had no template and fell back to the generic one.
The sign-out ACTION is not here. `/api/htmx/auth/*` owns the session, and a template that cleared
it would be a second place that knows how a session ends. This page confirms and offers the two
ways onward — which is all the Leptos component does too. #}
<article class="ds-page ds-page-signout" data-page-id="signout">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["signout-title"] | default(texts["signout-page-title"]) }}</h1>
{% if texts["signout-subtitle"] %}
<p class="ds-text-lead">{{ texts["signout-subtitle"] }}</p>
{% endif %}
</div>
</header>
<div class="ds-page-body">
<div class="ds-container">
{% if texts["signout-redirect"] %}
<p class="ds-text-muted">{{ texts["signout-redirect"] }}</p>
{% endif %}
<div class="ds-actions">
<a class="ds-btn ds-btn-primary" href="/">{{ texts["signout-home"] | default("Home") }}</a>
<a class="ds-btn" href="/login">{{ texts["signout-login-again"] | default("Sign in again") }}</a>
</div>
</div>
</div>
</article>

View file

@ -0,0 +1,42 @@
{# User profile — the shell for a dashboard that was ALREADY BUILT and had nowhere to live.
`/user` had no template, fell back to `pages/static.j2` (three keys), and returned 200 over an
empty body while 79 declared Fluent keys went unrendered on every request.
And the interesting part: the work was NOT missing. `rustelo_server::api::htmx_user` already
serves `/api/htmx/user/dashboard` with account / bookmarks / notes tabs, authenticated, returning
HTML fragments — everything an htmx dashboard needs. What was missing was the PAGE THAT HOSTS IT.
A finished feature, unreachable, because nobody wrote the twenty lines that mount it.
That is why this file is a shell and not a port of the 34-key Leptos component: the content is
not the template's to render. It is the endpoint's, and the endpoint is right there.
Auth lives in the fragment, not here: `/api/htmx/user/dashboard` returns `unauthorised()` when
there is no session, so an anonymous visitor gets the sign-in prompt from the one place that
actually knows. A template that tried to decide that itself would be a second opinion on the
session — and a second opinion nothing checks. #}
<article class="ds-page ds-page-user" data-page-id="user">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["user-dashboard"] | default(texts["user-page-title"]) }}</h1>
{% if texts["user-page-description"] %}
<p class="ds-text-lead">{{ texts["user-page-description"] }}</p>
{% endif %}
</div>
</header>
<div class="ds-page-body">
<div class="ds-container">
{# The dashboard loads itself. `hx-trigger="load"` fetches on first paint; the fragment
carries its own tab navigation, so this page never has to know what a tab is. #}
<div id="user-dashboard"
hx-get="/api/htmx/user/dashboard"
hx-trigger="load"
hx-swap="innerHTML">
<p class="ds-text-muted">{{ texts["user-loading"] | default("…") }}</p>
</div>
</div>
</div>
</article>

View file

@ -0,0 +1,126 @@
{# Work request — "request a project quote". A PUBLIC LEAD-GEN FORM.
IT LIVES HERE NOW, AND THAT MATTERS. The jesusperez.pro copy of this template exists ONLY in
`htmx-templates/` — the ASSEMBLED tree, which `just templates` deletes and rebuilds on every run
(adr-070). It was one command away from vanishing, and that is exactly how the nav submenus were
lost once. In the framework it is a source file, inherited by every site built from the image.
AND ITS SUBMIT WAS DEAD. It posted to `/api/work-request`, a route registered NOWHERE in the
stack — not in rustelo_server, not in the image, not in the binary. Every submission for as long
as the page has been up went to an endpoint that does not exist. Nothing looked wrong, because a
form that RENDERS looks exactly like a form that WORKS.
It now posts to the declarative endpoint (`POST /api/htmx/form/work-request`), whose spec is
`site/config/forms/work-request.ncl`. The spec declares all eleven fields, so all eleven are
delivered — which is the whole point. Routing this through the four-field contact endpoint would
have returned 200, said "sent", and mailed the name and email with the description, the budget
and the timeline silently dropped, because serde ignores unknown fields by default.
The field names below MUST match the spec: a posted field the spec does not declare is refused
with a 400 that names it. That is deliberate — a template and a spec that disagree should fail
on the first request, not in a quarterly review of an empty inbox. #}
<article class="ds-page ds-page-work-request" data-page-id="work-request">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts["work-request-title"] | default(texts["work-request-page-title"]) }}</h1>
{% if texts["work-request-subtitle"] %}
<p class="ds-text-lead">{{ texts["work-request-subtitle"] }}</p>
{% endif %}
</div>
</header>
<div class="ds-page-body">
<div class="ds-container">
{% if texts["work-request-description"] %}
<p class="ds-text-lead">{{ texts["work-request-description"] }}</p>
{% endif %}
<form class="ds-form space-y-ds-4"
hx-post="/api/htmx/form/work-request"
hx-target="#work-request-result"
hx-swap="innerHTML">
<input type="hidden" name="lang" value="{{ lang }}" />
{# Honeypot. Hidden from people, irresistible to bots; a value here means the handler
confirms without delivering, so the bot gets no signal. #}
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label for="wr-website">Website</label>
<input id="wr-website" name="website" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-ds-4">
<div>
<label for="wr-name" class="ds-label">{{ texts["work-request-your-name"] }}</label>
<input id="wr-name" name="name" type="text" required class="ds-input w-full"
placeholder="{{ texts['work-request-name-placeholder'] }}" />
</div>
<div>
<label for="wr-email" class="ds-label">{{ texts["work-request-your-email"] }}</label>
<input id="wr-email" name="email" type="email" required class="ds-input w-full"
placeholder="{{ texts['work-request-email-placeholder'] }}" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-ds-4">
<div>
<label for="wr-company" class="ds-label">{{ texts["work-request-company"] }}</label>
<input id="wr-company" name="company" type="text" class="ds-input w-full" />
</div>
<div>
<label for="wr-project-type" class="ds-label">{{ texts["work-request-project-type-label"] }}</label>
<select id="wr-project-type" name="project_type" class="ds-input w-full">
<option value=""></option>
{% for opt in ["web", "infrastructure", "consulting", "other"] %}
<option value="{{ opt }}">{{ texts["work-request-project-type-label-" ~ opt] | default(opt) }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label for="wr-description" class="ds-label">{{ texts["work-request-project-description-label"] }}</label>
<textarea id="wr-description" name="project_description" rows="5" required class="ds-input w-full"></textarea>
</div>
<div>
<label for="wr-situation" class="ds-label">{{ texts["work-request-current-situation-label"] }}</label>
<textarea id="wr-situation" name="current_situation" rows="3" class="ds-input w-full"></textarea>
</div>
<div>
<label for="wr-goals" class="ds-label">{{ texts["work-request-goals-label"] }}</label>
<textarea id="wr-goals" name="goals" rows="3" class="ds-input w-full"></textarea>
</div>
<div>
<label for="wr-technical" class="ds-label">{{ texts["work-request-technical-requirements-label"] }}</label>
<textarea id="wr-technical" name="technical_requirements" rows="3" class="ds-input w-full"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-ds-4">
<div>
<label for="wr-timeline" class="ds-label">{{ texts["work-request-timeline-label"] }}</label>
<input id="wr-timeline" name="timeline" type="text" class="ds-input w-full" />
</div>
<div>
<label for="wr-budget" class="ds-label">{{ texts["work-request-budget"] }}</label>
<input id="wr-budget" name="budget" type="text" class="ds-input w-full" />
</div>
<div>
<label for="wr-contact-method" class="ds-label">{{ texts["work-request-contact-method-label"] }}</label>
<input id="wr-contact-method" name="contact_method" type="text" class="ds-input w-full" />
</div>
</div>
<button type="submit" class="ds-btn ds-btn-primary">
{{ texts["work-request-submit"] | default(texts["work-request-send"]) | default("Send") }}
</button>
<div id="work-request-result" role="status" aria-live="polite"></div>
</form>
</div>
</div>
</article>

View file

@ -31,11 +31,23 @@
</div>
<dialog class="nl-modal" aria-label="{{ f.title[lang] or f.title['en'] }}">
{# hx-boost="false" + hx-disable: this form must POST natively cross-origin to
listmonk. Without it, a page-level hx-boost hijacks the submit and htmx
rejects the external URL (htmx:invalidPath) — the POST never fires. #}
<form class="nl-modal__card" method="post" action="{{ newsletter.action }}"
hx-boost="false" hx-disable>
{# The submit is RELAYED by our own server (POST /api/htmx/newsletter), which forwards it to the
provider named in newsletter.ncl and answers with a fragment swapped in right here.
It used to POST natively, cross-origin, straight to listmonk. That works — and it takes the
reader with it: the browser navigated away and left them on listmonk's own page, thanked by a
stranger, in listmonk's language, with no way back. A failure dropped them there too.
Doing it from the page with fetch() could not replace this: a cross-origin response is opaque,
so the modal could not tell success from failure and would have to CLAIM success. Relaying
server-side is what makes the outcome KNOWABLE — and only a knowable outcome can be reported
honestly. #}
<form class="nl-modal__card" method="post"
hx-post="/api/htmx/newsletter"
hx-target="closest .nl-modal__card"
hx-swap="outerHTML"
hx-disabled-elt="[data-nl-submit]">
<input type="hidden" name="lang" value="{{ lang }}">
<button type="button" class="nl-modal__close" data-nl-close
aria-label="{{ f.close_label[lang] or f.close_label['en'] }}">&times;</button>
<h2 class="nl-modal__title">{{ f.title[lang] or f.title['en'] }}</h2>
@ -56,7 +68,8 @@
<input type="text" name="nonce" value="" tabindex="-1" autocomplete="off" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0">
{# single target list #}
<input type="hidden" name="l" value="{{ newsletter.list_uuid }}">
{# The target list is NOT sent from here any more: the relay reads it from the same
newsletter.ncl, so which list a subscriber lands on is not a value a browser can rewrite. #}
<label class="nl-consent">
<input type="checkbox" name="consent" value="1" required data-nl-consent>

View file

@ -0,0 +1,9 @@
{# Glossary tooltips. The script is a PROJECTION of .ontoref/ontology/registry.ncl — it reads
/r/glosario-<lang>.json, which gen-glossary-json.nu emits from the registry. Nothing about the
vocabulary is authored in the page.
It exists because /acerca-de was saying NCL and DAG to readers who had never met either, while
the glosses for both had been sitting in the registry — written explicitly "for a reader who
does not know the term" — with no surface to show them on. The gate's own GlossFirstUse check
had been inert for the same reason, and says so in its comment. #}
<script src="/js/glossary-tooltip.js" defer></script>

View file

@ -24,6 +24,25 @@
<div class="nav-dd__menu">
<a href="{{ item.href }}" class="nav-dd__item">Ontoref</a>
{%- for it in menu_items %}{% if "/dominios" in it.href or "/domains" in it.href %}<a href="{{ it.href }}" class="nav-dd__item">{{ it.label }}</a>{% endif %}{% endfor -%}
{#- Conceptos, en LOS DOS idiomas. Estuvo solo en español y por una razón que era
cierta: /concepts servía el estado vacío, y un enlace a una página vacía es
la mitad de un idioma añadido. Pero la causa no era que faltaran glosas
inglesas — era que la página leía `registry.ncl`, el árbitro de traducción,
cuyas reglas se indexan por idioma de DESTINO; el inglés es la fuente y no
genera ninguna. Las 20 definiciones inglesas llevaban meses escritas en
`glossary.ncl`. Ahora la página las proyecta y el idioma está entero.
La condición se escribió esperando «cuando existan glosas inglesas»: el
diagnóstico apuntaba al dato que faltaba en vez de al consumidor que miraba
donde no era, así que la espera no habría terminado nunca sola. -#}
<a href="{{ '/conceptos' if '/acerca-de' in item.href else '/concepts' }}" class="nav-dd__item">{{ 'Conceptos' if '/acerca-de' in item.href else 'Concepts' }}</a>
{#- ADR en AMBOS idiomas: su ruta declara el mismo path para los dos
(`en = "/adr", es = "/adr"`) y el índice sirve las 12 entradas en inglés, así
que no hay aquí la asimetría que tiene Conceptos. Se enlaza desde el nav y no
con `menu` en la ruta porque este desplegable se escribe a mano: `menu_items`
solo aporta el item padre y `dominios`. La página existía y respondía 200
desde hacía tiempo sin que nada la enlazara. -#}
<a href="/adr" class="nav-dd__item">ADR</a>
{%- if "/acerca-de" in item.href %}<a href="/nosotros" class="nav-dd__item">Nosotros</a>{% else %}<a href="/about-us" class="nav-dd__item">About us</a>{% endif -%}
</div>
</details>