oficina / home Public

Merge branch 'front-page' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 25c644909947e52b2178f96f5dede397711c7a64 parent 65e1790parent 243c2d4
9 files changed, +725 −43
  • internal/home/app.go +1 −0
  • internal/home/origins_test.go +26 −5
  • internal/home/page.go +50 −38
  • internal/home/static/site/archivo-ext.woff2 binary
  • internal/home/static/site/archivo.woff2 binary
  • internal/home/static/site/home.css +401 −0
  • internal/home/static/site/index.html +247 −0
  • internal/home/static/site/newsreader-ext.woff2 binary
  • internal/home/static/site/newsreader.woff2 binary

CI passed — run details

diff --git a/internal/home/app.go b/internal/home/app.go
index 85631e5..408a2f3 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -236,6 +236,7 @@ func (a *App) Router() http.Handler {
r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
mountBrand(r)
+ mountSite(r)
return r
}
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
index 737da3f..867ed41 100644
--- a/internal/home/origins_test.go
+++ b/internal/home/origins_test.go
@@ -8,15 +8,37 @@ import (
"github.com/go-chi/chi/v5"
)
+// sharedPublicPaths are served on BOTH Home origins: the design tokens, the
+// brand marks, and the front page's stylesheet and fonts. They are listed
+// once because two copies of this list would drift, and a route that
+// quietly stopped being shared — or quietly started — is exactly what the
+// two tests below exist to catch.
+var sharedPublicPaths = []string{
+ "/healthz", "/static/tokens.css", "/static/theme.css",
+ "/static/brand/icon.svg", "/static/brand/icon-32.png", "/static/brand/apple-touch-icon.png", "/static/brand/brand.css", "/static/brand/LICENSE.lucide", "/static/brand/docs.svg", "/static/brand/sheets.svg", "/static/brand/slides.svg", "/static/brand/calendar.svg", "/static/brand/meet.svg", "/static/brand/memoria.svg",
+ "/static/site/home.css", "/static/site/archivo.woff2", "/static/site/archivo-ext.woff2",
+ "/static/site/newsreader.woff2", "/static/site/newsreader-ext.woff2",
+}
+
+func sharedPublic(route string) bool {
+ for _, p := range sharedPublicPaths {
+ if p == route {
+ return true
+ }
+ }
+ return false
+}
+
func TestEveryCredentialRouteRefusesDirectoryHost(t *testing.T) {
app := newTestApp(t)
router := app.Router()
routes := router.(chi.Routes)
count := 0
err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
- switch route {
- case "/", "/healthz", "/static/tokens.css", "/static/theme.css", "/help/open-source", "/static/credits.css",
- "/static/brand/icon.svg", "/static/brand/icon-32.png", "/static/brand/apple-touch-icon.png", "/static/brand/brand.css", "/static/brand/LICENSE.lucide", "/static/brand/docs.svg", "/static/brand/sheets.svg", "/static/brand/slides.svg", "/static/brand/calendar.svg", "/static/brand/meet.svg", "/static/brand/memoria.svg":
+ // "/" is the front page, which both origins answer (the credential
+ // host redirects rather than rendering — see the test below).
+ switch {
+ case route == "/", route == "/help/open-source", route == "/static/credits.css", sharedPublic(route):
return nil
}
count++
@@ -81,8 +103,7 @@ func TestDirectoryIsNotWithinCredentialRP(t *testing.T) {
func TestSharedRoutesOnBothOrigins(t *testing.T) {
app := newTestApp(t)
for _, host := range []string{credentialHost, directoryHost} {
- for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css",
- "/static/brand/icon.svg", "/static/brand/icon-32.png", "/static/brand/apple-touch-icon.png", "/static/brand/brand.css", "/static/brand/LICENSE.lucide", "/static/brand/docs.svg", "/static/brand/sheets.svg", "/static/brand/slides.svg", "/static/brand/calendar.svg", "/static/brand/meet.svg", "/static/brand/memoria.svg"} {
+ for _, path := range sharedPublicPaths {
req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil)
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
diff --git a/internal/home/page.go b/internal/home/page.go
index 796e11d..c6c8c38 100644
--- a/internal/home/page.go
+++ b/internal/home/page.go
@@ -1,46 +1,58 @@
package home
-import "net/http"
+import (
+ "embed"
+ "net/http"
-// indexPage is the entire placeholder page: bare fact, not marketing.
-// It has no dynamic content and no user input to escape, so a plain
-// constant string stands in for html/template here rather than
-// pretending to need one.
+ "github.com/go-chi/chi/v5"
+)
+
+//go:embed static/site
+var siteFS embed.FS
+
+// The front page is a real page now, not the skeleton's bare fact. It is
+// authored as a file rather than a Go string because it is long enough
+// that a const would hide its diffs, and it has no dynamic content and no
+// user input to escape — so it is served as bytes, with no template step
+// standing between the file and the response.
//
-// No stylesheet is linked. rastrillo's ui/tokens.css needs a theme
-// (day, plain or signal — see ui/themes/*.css) to paint anything;
-// picking one is a design decision, and design-system questions are
-// paused on this project (AGENTS.md's Frontend section assumes a
-// themed app, which this skeleton deliberately is not yet). Semantic,
-// unstyled HTML is the honest choice until that pause lifts.
-const indexPage = `<!doctype html>
-<html lang="en">
-<head>
-<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png">
-<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg">
-<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png">
-<link rel="stylesheet" href="/static/brand/brand.css">
-<meta charset="utf-8">
-<title>Oficina home</title>
-</head>
-<body>
-<main>
-<h1>Oficina home</h1>
-<p>This is the Oficina suite home: identity, a directory of your teams,
-and provisioning, shared by every app in the suite.</p>
-<p>It is not built yet. This page is a placeholder that proves the
-deploy path works. There is no sign-in, no directory, and no
-provisioning here.</p>
-<p>The design is at
-<code>docs/superpowers/specs/2026-08-30-home-design.md</code> in this
-repository.</p>
-</main>
-</body>
-</html>
-`
+// It links one stylesheet and four font subsets, all from this origin.
+// SecurityPolicy's `default-src 'self'` already admits them, which is why
+// adding the page needed no change to the policy: fonts fall back to
+// default-src, and the stylesheet is covered by `style-src 'self'`.
+//
+// Everything the page points at has to exist. The app links go to the six
+// live app hosts; there is no sign-up form, because there is no route that
+// would accept one (provisioning is still `oficina provision`, an operator
+// CLI). A form posting into a 404 is worse than saying so plainly, and the
+// page says so plainly.
+var indexPage = siteAsset("index.html")
+
+func siteAsset(name string) []byte {
+ b, err := siteFS.ReadFile("static/site/" + name)
+ if err != nil {
+ panic(err) // Embedded above; unreachable unless the build is broken.
+ }
+ return b
+}
+
+// Public on both Home origins, and a closed list for the same reason
+// mountBrand keeps one: a request path must never be treated as a file
+// name.
+func mountSite(r chi.Router) {
+ for name, contentType := range map[string]string{
+ "home.css": "text/css; charset=utf-8",
+ "archivo.woff2": "font/woff2",
+ "archivo-ext.woff2": "font/woff2",
+ "newsreader.woff2": "font/woff2",
+ "newsreader-ext.woff2": "font/woff2",
+ } {
+ r.Get("/static/site/"+name, serveAsset(siteAsset(name), contentType))
+ }
+}
-// handleIndex serves the one page this skeleton has.
+// handleIndex serves the front page.
func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write([]byte(indexPage))
+ w.Write(indexPage)
}
diff --git a/internal/home/static/site/archivo-ext.woff2 b/internal/home/static/site/archivo-ext.woff2
new file mode 100644
index 0000000..462b5cf
Binary files /dev/null and b/internal/home/static/site/archivo-ext.woff2 differ
diff --git a/internal/home/static/site/archivo.woff2 b/internal/home/static/site/archivo.woff2
new file mode 100644
index 0000000..7af3f7d
Binary files /dev/null and b/internal/home/static/site/archivo.woff2 differ
diff --git a/internal/home/static/site/home.css b/internal/home/static/site/home.css
new file mode 100644
index 0000000..6b9136a
--- /dev/null
+++ b/internal/home/static/site/home.css
@@ -0,0 +1,401 @@
+/* Oficina — the front door.
+ The suite's own theme (estilo) is deliberately unremarkable; this page is
+ the one place that gets to have a voice. One family, two widths: Archivo
+ expanded does the signage, Archivo normal does the interface, Newsreader
+ does the prose. Saffron means "new, or not yet" and nothing else. */
+
+@font-face {
+ font-family: "Archivo";
+ src: url("/static/site/archivo.woff2") format("woff2");
+ font-weight: 400 800;
+ font-stretch: 62% 125%;
+ font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
+ U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
+ U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: "Archivo";
+ src: url("/static/site/archivo-ext.woff2") format("woff2");
+ font-weight: 400 800;
+ font-stretch: 62% 125%;
+ font-display: swap;
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
+ U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
+ U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: "Newsreader";
+ src: url("/static/site/newsreader.woff2") format("woff2");
+ font-weight: 300 600;
+ font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
+ U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
+ U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: "Newsreader";
+ src: url("/static/site/newsreader-ext.woff2") format("woff2");
+ font-weight: 300 600;
+ font-display: swap;
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
+ U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
+ U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+:root {
+ /* Contrast, measured against these exact values (WCAG 2.2):
+ ink/paper 15.6 · muted/paper 5.2 · cobalt/paper 6.2 · ochre/paper 5.0
+ bone/ink 13.4 · dim/ink 7.5 · sky/ink 7.6 · saffron/ink 7.2 · ink/saffron 8.0
+ Put a colour on a surface not listed and recompute before you ship it. */
+ --ink: #151e3a;
+ --ink-2: #1d2848;
+ --ink-rule: #37436f;
+ --paper: #eeede6;
+ --paper-2: #e4e2d8;
+ --paper-rule: #cfcdc0;
+ --cobalt: #2748cc;
+ --cobalt-deep: #1b36a8;
+ --sky: #93aeff;
+ --saffron: #e0a02c;
+ --ochre: #8a5a06;
+ --bone: #eae8df;
+ --dim: #a7afc8;
+ --muted: #5a6178;
+
+ --sig: "Archivo", system-ui, sans-serif; /* signage + interface */
+ --prose: "Newsreader", Georgia, serif; /* running text */
+
+ color-scheme: light;
+
+ --shell: 74rem;
+ --gutter: clamp(1.25rem, 5vw, 4rem);
+ --step: clamp(4rem, 9vw, 7.5rem);
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
+@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
+
+body {
+ margin: 0;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--sig);
+ font-size: 1.0625rem;
+ line-height: 1.55;
+ font-synthesis-weight: none;
+ text-rendering: optimizeLegibility;
+}
+
+h1, h2, h3 { margin: 0; font-weight: 700; font-stretch: 112%; letter-spacing: -0.018em; line-height: 1.02; }
+p { margin: 0 0 1.1em; }
+ul { margin: 0; padding: 0; list-style: none; }
+
+a { color: var(--cobalt); text-decoration-thickness: 1px; text-underline-offset: 0.18em; }
+a:hover { color: var(--cobalt-deep); }
+
+:where(a, button, input, summary):focus-visible {
+ outline: 2px solid var(--cobalt);
+ outline-offset: 3px;
+ border-radius: 2px;
+}
+.band-ink :where(a, button, input):focus-visible { outline-color: var(--sky); }
+
+.vh {
+ position: absolute; width: 1px; height: 1px; margin: -1px;
+ padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
+}
+
+.skip {
+ position: absolute; left: 0.75rem; top: -4rem; z-index: 10;
+ background: var(--bone); color: var(--ink); padding: 0.6rem 1rem;
+ border-radius: 3px; font-weight: 600; text-decoration: none;
+}
+.skip:focus { top: 0.75rem; }
+
+.shell { max-width: var(--shell); margin-inline: auto; padding-inline: var(--gutter); }
+
+/* ── Bands ────────────────────────────────────────────────────────────── */
+.band { padding-block: var(--step); }
+.band-ink { background: var(--ink); color: var(--bone); }
+.band-quiet { background: var(--paper-2); }
+.band:not(.band-ink) + .band:not(.band-ink) { border-top: 1px solid var(--paper-rule); }
+
+.band-ink a { color: var(--sky); }
+.band-ink a:hover { color: #b9caff; }
+.band h2 { font-size: clamp(1.75rem, 3.4vw, 2.6rem); max-width: 18ch; }
+.band h2.wide { max-width: 24ch; margin-bottom: 1.5rem; }
+
+/* ── Bar ──────────────────────────────────────────────────────────────── */
+.bar { background: var(--ink); color: var(--bone); }
+.bar-in {
+ display: flex; align-items: center; gap: clamp(1rem, 4vw, 3rem);
+ min-height: 4.25rem;
+}
+.mark {
+ font-weight: 800; font-stretch: 118%; font-size: 1.32rem;
+ letter-spacing: -0.02em; color: var(--bone); text-decoration: none;
+ margin-right: auto;
+}
+.nav { display: flex; gap: clamp(0.9rem, 2.4vw, 1.9rem); }
+.nav a {
+ color: var(--dim); text-decoration: none; font-size: 0.95rem; font-weight: 500;
+}
+.nav a:hover { color: var(--bone); }
+.bar-cta { flex: none; }
+@media (max-width: 46rem) {
+ .bar-in { flex-wrap: wrap; padding-block: 0.9rem; row-gap: 0.75rem; }
+ .nav { order: 3; width: 100%; }
+ .bar-cta { order: 2; }
+}
+
+/* ── Hero ─────────────────────────────────────────────────────────────── */
+.hero { padding-top: clamp(3rem, 7vw, 5.5rem); }
+.hero-h {
+ font-size: clamp(2.2rem, 9.2vw, 6.6rem);
+ font-weight: 800;
+ font-stretch: 118%;
+ letter-spacing: -0.03em;
+ line-height: 0.92;
+ margin-bottom: 1.4rem;
+}
+.hero-lead {
+ font-family: var(--prose);
+ font-size: clamp(1.1rem, 1.9vw, 1.3rem);
+ line-height: 1.62;
+ color: var(--dim);
+ max-width: 56ch;
+ margin-bottom: 2.6rem;
+}
+
+@media (max-width: 30rem) {
+ .hero-h { font-stretch: 104%; letter-spacing: -0.022em; }
+ .hero-h br { display: none; }
+}
+
+/* ── The plate: an engraved directory board ───────────────────────────── */
+.plate {
+ background: var(--ink-2);
+ border: 1px solid var(--ink-rule);
+ border-radius: 3px;
+ padding: clamp(1rem, 2.6vw, 1.6rem) clamp(1rem, 2.6vw, 2rem);
+}
+.plate-flat { padding-block: 0.4rem; }
+
+.directory { width: 100%; border-collapse: collapse; }
+.directory caption {
+ text-align: left;
+ font-family: var(--prose);
+ font-size: 0.98rem;
+ color: var(--dim);
+ padding-bottom: 0.9rem;
+}
+.directory thead th {
+ text-align: right;
+ font-size: 0.82rem;
+ font-weight: 600;
+ font-stretch: 100%;
+ color: var(--dim);
+ padding: 0 0 0.55rem;
+ border-bottom: 1px solid var(--ink-rule);
+ width: 7.5rem;
+}
+.directory thead th:first-child, .directory thead th:nth-child(2) { width: auto; }
+
+.directory tbody tr { border-bottom: 1px solid var(--ink-rule); }
+.directory tbody tr:last-child { border-bottom: 0; }
+.directory tbody th, .directory tbody td { padding-block: 0.95rem; vertical-align: baseline; }
+
+.c-app {
+ text-align: left;
+ font-size: clamp(1.3rem, 2.3vw, 1.7rem);
+ font-weight: 700;
+ font-stretch: 115%;
+ letter-spacing: -0.02em;
+ color: var(--bone);
+ white-space: nowrap;
+ padding-right: 1.5rem;
+}
+.c-app em {
+ font-style: normal; font-size: 0.68rem; font-weight: 700; font-stretch: 100%;
+ letter-spacing: 0.04em; color: var(--ink); background: var(--saffron);
+ border-radius: 2px; padding: 0.16em 0.42em; vertical-align: 0.42em;
+ margin-left: 0.45rem;
+}
+.c-what {
+ font-family: var(--prose);
+ font-size: 1.03rem;
+ color: var(--dim);
+ padding-right: 1.5rem;
+}
+.c-get { text-align: right; white-space: nowrap; }
+.c-get a { font-weight: 600; font-size: 0.97rem; text-decoration: none; border-bottom: 1px solid rgba(147,174,255,.4); }
+.c-get a:hover { border-bottom-color: currentColor; }
+.c-none span { color: #7d86a0; }
+.c-get a.soon { color: var(--saffron); border-bottom-color: rgba(224,160,44,.45); }
+
+.acts { display: flex; flex-wrap: wrap; gap: 0.85rem; margin-top: 2.4rem; }
+
+/* ── Buttons ──────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-block;
+ font: inherit;
+ font-weight: 600;
+ font-stretch: 104%;
+ line-height: 1;
+ padding: 0.82rem 1.35rem;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ text-decoration: none;
+ cursor: pointer;
+ transition: background-color .14s ease, color .14s ease, border-color .14s ease;
+}
+.btn-sm { padding: 0.6rem 1rem; font-size: 0.93rem; }
+.btn-bone { background: var(--bone); color: var(--ink); }
+.btn-bone:hover { background: #fff; color: var(--ink); }
+.btn-ghost { background: transparent; color: var(--sky); border-color: var(--ink-rule); }
+.btn-ghost:hover { border-color: var(--sky); color: var(--sky); }
+.btn-cobalt { background: var(--cobalt); color: #fff; }
+.btn-cobalt:hover { background: var(--cobalt-deep); color: #fff; }
+.btn-saffron { background: var(--saffron); color: var(--ink); }
+.btn-saffron:hover { background: #f0b444; color: var(--ink); }
+.band-ink .btn-bone, .band-ink .btn-saffron { color: var(--ink); }
+.bar .btn-bone { padding: 0.62rem 1.05rem; font-size: 0.95rem; }
+
+/* ── Two-column bands ─────────────────────────────────────────────────── */
+.two {
+ display: grid;
+ grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
+ gap: clamp(2rem, 5vw, 4.5rem);
+ align-items: start;
+}
+.two-tight { align-items: center; }
+@media (max-width: 56rem) { .two { grid-template-columns: 1fr; } }
+
+.prose {
+ font-family: var(--prose);
+ font-size: 1.12rem;
+ line-height: 1.66;
+ max-width: 60ch;
+ color: #2b3350;
+}
+.prose.sm { font-size: 1rem; color: var(--muted); }
+.two-a h2 { margin-bottom: 1.3rem; }
+.lnk { font-weight: 600; }
+
+/* Memoria's demonstration */
+.ask {
+ background: var(--paper-2);
+ border: 1px solid var(--paper-rule);
+ border-left: 3px solid var(--ochre);
+ border-radius: 3px;
+ padding: clamp(1.3rem, 3vw, 1.9rem);
+}
+.ask-q { font-weight: 600; font-size: 1.06rem; margin-bottom: 1.1rem; }
+.ask-a {
+ font-family: var(--prose); font-size: 1.14rem; line-height: 1.6;
+ margin-bottom: 1.1rem;
+}
+.ask-cite { margin: 0; font-size: 0.94rem; }
+.ask-cite a { color: var(--ochre); font-weight: 600; }
+.ask-note { margin: 0.9rem 0 0; font-size: 0.92rem; color: var(--muted); }
+
+/* Isolation facts */
+.facts { margin: 0; }
+.facts > div { padding: 1.05rem 0; border-top: 1px solid var(--paper-rule); }
+.facts > div:first-child { border-top: 0; padding-top: 0; }
+.facts dt { font-weight: 700; font-stretch: 108%; font-size: 1.02rem; }
+.facts dd { margin: 0.32rem 0 0; font-family: var(--prose); color: var(--muted); max-width: 48ch; }
+
+/* ── Downloads ────────────────────────────────────────────────────────── */
+#native .hero-lead { margin-bottom: 2.2rem; }
+.dl-list { display: grid; grid-template-columns: 1fr 1fr; column-gap: clamp(1.5rem, 4vw, 3rem); }
+.dl-list li {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 1.5rem; padding: 1.05rem 0; border-bottom: 1px solid var(--ink-rule);
+}
+.dl-list li:nth-last-child(-n+2) { border-bottom: 0; }
+@media (max-width: 44rem) {
+ .dl-list { grid-template-columns: 1fr; }
+ .dl-list li:nth-last-child(2) { border-bottom: 1px solid var(--ink-rule); }
+}
+.dl-state { color: var(--dim); font-size: 0.95rem; font-family: var(--prose); }
+.dl-app { font-weight: 700; font-stretch: 112%; font-size: 1.18rem; color: var(--bone); }
+.dl-req { margin: 0; padding: 1rem 0 1.1rem; border-top: 1px solid var(--ink-rule); font-size: 0.92rem; color: var(--dim); }
+
+.tf { margin-top: clamp(2.5rem, 5vw, 3.5rem); max-width: 62ch; }
+.tf h3 { font-size: clamp(1.4rem, 2.6vw, 1.9rem); margin-bottom: 0.85rem; }
+.tf h3::after {
+ content: ""; display: block; width: 3.5rem; height: 3px;
+ background: var(--saffron); margin-top: 0.85rem;
+}
+.tf p { font-family: var(--prose); color: var(--dim); font-size: 1.08rem; line-height: 1.62; }
+
+/* ── Forms ────────────────────────────────────────────────────────────── */
+.form { display: grid; gap: 1.1rem; }
+.form-inline { grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 0.8rem; }
+@media (max-width: 34rem) { .form-inline { grid-template-columns: 1fr; align-items: stretch; } }
+
+.field { display: grid; gap: 0.4rem; }
+.field label { font-size: 0.88rem; font-weight: 600; color: var(--muted); }
+.band-ink .field label { color: var(--dim); }
+.field input {
+ font: inherit;
+ padding: 0.78rem 0.9rem;
+ border: 1px solid var(--paper-rule);
+ border-radius: 3px;
+ background: #fff;
+ color: var(--ink);
+ min-width: 0;
+}
+.band-ink .field input { background: var(--ink-2); border-color: var(--ink-rule); color: var(--bone); }
+.field input::placeholder { color: #8d93a5; }
+.band-ink .field input::placeholder { color: #6e7793; }
+.field input:hover { border-color: var(--muted); }
+.hint { margin: 0; font-size: 0.88rem; color: var(--muted); }
+.hint-url { font-weight: 600; color: var(--ink); }
+#signup .form { align-content: start; }
+#signup .btn { justify-self: start; }
+
+/* ── Footer ───────────────────────────────────────────────────────────── */
+.foot { background: var(--ink); color: var(--dim); padding-block: clamp(2.5rem, 5vw, 3.75rem); }
+.foot-in { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: clamp(2rem, 5vw, 4rem); align-items: start; }
+@media (max-width: 46rem) { .foot-in { grid-template-columns: 1fr 1fr; } .colophon { grid-column: 1 / -1; } }
+.foot-h { font-size: 0.88rem; font-weight: 600; font-stretch: 100%; color: var(--bone); margin-bottom: 0.8rem; letter-spacing: 0; }
+.foot-col li { padding: 0.22rem 0; }
+.foot-col a { color: var(--dim); text-decoration: none; font-size: 0.95rem; }
+.foot-col a:hover { color: var(--bone); text-decoration: underline; }
+.colophon {
+ margin: 0; font-family: var(--prose); font-size: 0.98rem; line-height: 1.6;
+ max-width: 52ch; color: var(--dim);
+}
+
+/* ── The one orchestrated moment: the board fills in on arrival ───────── */
+@media (prefers-reduced-motion: no-preference) {
+ .directory tbody tr {
+ animation: settle .5s cubic-bezier(.2, .7, .3, 1) backwards;
+ animation-delay: calc(var(--row) * 70ms + 120ms);
+ }
+ @keyframes settle {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: none; }
+ }
+}
+
+/* ── Small screens: the board becomes a stack of entries ──────────────── */
+@media (max-width: 48rem) {
+ .directory, .directory tbody, .directory tbody tr, .directory tbody th, .directory tbody td { display: block; }
+ .directory thead, .directory caption { display: none; }
+ .directory tbody tr { padding-block: 1.1rem; }
+ .directory tbody th, .directory tbody td { padding-block: 0; }
+ .c-app { padding-right: 0; }
+ .c-what { padding: 0.15rem 0 0.7rem; }
+ .directory tbody td.c-get {
+ display: flex; justify-content: space-between; align-items: baseline;
+ text-align: left; padding: 0.28rem 0; border-top: 1px solid rgba(55,67,111,.5);
+ }
+ .directory tbody td.c-get::before { content: attr(data-label); color: var(--dim); font-size: 0.88rem; }
+ .r-new { border-left: 3px solid var(--saffron); padding-left: 0.9rem; }
+}
diff --git a/internal/home/static/site/index.html b/internal/home/static/site/index.html
new file mode 100644
index 0000000..0826a32
--- /dev/null
+++ b/internal/home/static/site/index.html
@@ -0,0 +1,247 @@
+<!DOCTYPE html>
+<html lang="en-GB">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Oficina — open your new office suite</title>
+<meta name="description" content="Oficina is a fast, lightweight office suite that values your privacy. Docs, Sheets, Slides, Calendar, Calls and Memoria, in the browser and as native Mac apps.">
+<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png">
+<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg">
+<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png">
+<link rel="stylesheet" href="/static/site/home.css">
+</head>
+<body>
+
+<a class="skip" href="#directory">Skip to the app directory</a>
+
+<header class="bar">
+ <div class="shell bar-in">
+ <a class="mark" href="/"><span>Oficina</span></a>
+ <nav class="nav" aria-label="Sections of this page">
+ <a href="#directory">Apps</a>
+ <a href="#memoria">Memoria</a>
+ <a href="#isolation">Privacy</a>
+ <a href="#native">Native apps</a>
+ </nav>
+ <a class="btn btn-bone bar-cta" href="https://memoria.oficina.works">Try Memoria</a>
+ </div>
+</header>
+
+<main>
+
+ <!-- ───────── Hero: the lobby directory ───────── -->
+ <section class="band band-ink hero">
+ <div class="shell">
+ <h1 class="hero-h">Open your new<br>office suite.</h1>
+ <p class="hero-lead">Oficina is fast, modern and light enough to forget it&rsquo;s running.
+ Six apps that share one account, in the browser or as real native apps &mdash;
+ with your team&rsquo;s work sealed inside your team&rsquo;s own instance.</p>
+
+ <div class="plate" id="directory">
+ <table class="directory">
+ <caption>Everything in Oficina, and where you can open it today. Every app runs in the browser now; the Mac and iPhone apps are close.</caption>
+ <thead>
+ <tr>
+ <th scope="col"><span class="vh">App</span></th>
+ <th scope="col"><span class="vh">What it does</span></th>
+ <th scope="col">Web</th>
+ <th scope="col">Mac</th>
+ <th scope="col">iPhone</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr style="--row:1">
+ <th scope="row" class="c-app">Docs</th>
+ <td class="c-what">Write together, in the same paragraph.</td>
+ <td class="c-get" data-label="Web"><a href="https://docs.oficina.works">Open</a></td>
+ <td class="c-get" data-label="Mac"><a class="soon" href="#native">Soon</a></td>
+ <td class="c-get" data-label="iPhone"><a class="soon" href="#testflight">Soon</a></td>
+ </tr>
+ <tr style="--row:2">
+ <th scope="row" class="c-app">Sheets</th>
+ <td class="c-what">Formulas, ranges and a grid that keeps up.</td>
+ <td class="c-get" data-label="Web"><a href="https://sheets.oficina.works">Open</a></td>
+ <td class="c-get" data-label="Mac"><a class="soon" href="#native">Soon</a></td>
+ <td class="c-get" data-label="iPhone"><a class="soon" href="#testflight">Soon</a></td>
+ </tr>
+ <tr style="--row:3">
+ <th scope="row" class="c-app">Slides</th>
+ <td class="c-what">Build the deck, then stand up and present it.</td>
+ <td class="c-get" data-label="Web"><a href="https://slides.oficina.works">Open</a></td>
+ <td class="c-get c-none" data-label="Mac"><span aria-hidden="true">—</span><span class="vh">No Mac app</span></td>
+ <td class="c-get c-none" data-label="iPhone"><span aria-hidden="true">—</span><span class="vh">No iPhone app</span></td>
+ </tr>
+ <tr style="--row:4">
+ <th scope="row" class="c-app">Calendar</th>
+ <td class="c-what">Everyone&rsquo;s week, without the reply-all.</td>
+ <td class="c-get" data-label="Web"><a href="https://calendar.oficina.works">Open</a></td>
+ <td class="c-get" data-label="Mac"><a class="soon" href="#native">Soon</a></td>
+ <td class="c-get" data-label="iPhone"><a class="soon" href="#testflight">Soon</a></td>
+ </tr>
+ <tr style="--row:5">
+ <th scope="row" class="c-app">Calls</th>
+ <td class="c-what">Meet without installing anything first.</td>
+ <td class="c-get" data-label="Web"><a href="https://meet.oficina.works">Open</a></td>
+ <td class="c-get c-none" data-label="Mac"><span aria-hidden="true">—</span><span class="vh">No Mac app</span></td>
+ <td class="c-get c-none" data-label="iPhone"><span aria-hidden="true">—</span><span class="vh">No iPhone app</span></td>
+ </tr>
+ <tr class="r-new" style="--row:6">
+ <th scope="row" class="c-app">Memoria <em>New</em></th>
+ <td class="c-what">Ask your own documents a question.</td>
+ <td class="c-get" data-label="Web"><a href="https://memoria.oficina.works">Open</a></td>
+ <td class="c-get" data-label="Mac"><a class="soon" href="#native">Soon</a></td>
+ <td class="c-get" data-label="iPhone"><a class="soon" href="#testflight">Soon</a></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <div class="acts">
+ <a class="btn btn-bone" href="https://memoria.oficina.works">Try Memoria</a>
+ <a class="btn btn-ghost" href="#native">See the native apps</a>
+ </div>
+ </div>
+ </section>
+
+ <!-- ───────── Memoria ───────── -->
+ <section class="band" id="memoria">
+ <div class="shell two two-tight">
+ <div class="two-a">
+ <h2>Introducing Memoria, your organisation&rsquo;s shared memory.</h2>
+ <p class="prose">Every team keeps its answers in places nobody can search: an old thread,
+ a deck from March, the contract nobody re-read. Memoria holds your team&rsquo;s
+ documents and answers questions from them &mdash; and it shows you the passage
+ it answered from, every time, so you can check its work.</p>
+ <p class="prose">Your documents are sealed at rest under your team&rsquo;s own key, and
+ nothing is sent anywhere to be indexed. The index is built inside your instance,
+ on your own hardware or ours.</p>
+ <p><a class="lnk" href="https://memoria.oficina.works">Try Memoria with your own documents</a></p>
+ </div>
+
+ <div class="two-b">
+ <div class="ask">
+ <p class="ask-q">What did we promise Vitoria about keeping their data?</p>
+ <p class="ask-a">Ninety days by default, extendable to a year on request. Agreed
+ in the 4 March call and written into the contract addendum.</p>
+ <p class="ask-cite"><a href="#memoria">Vitoria &mdash; contract addendum, §4.2</a></p>
+ </div>
+ <p class="ask-note">Memoria never answers without a passage to point at.</p>
+ </div>
+ </div>
+ </section>
+
+ <!-- ───────── Isolation ───────── -->
+ <section class="band band-quiet" id="isolation">
+ <div class="shell">
+ <h2 class="wide">Every team gets its own building.</h2>
+ <div class="two">
+ <div class="two-a">
+ <p class="prose">Most suites put every customer in one enormous database and
+ separate you with a column. Oficina doesn&rsquo;t. Your team gets its own
+ instance, its own store and its own key, so isolation is a property of
+ the architecture rather than a promise about our query filters.</p>
+ <p class="prose">It also means you can take it with you. Run Oficina on our
+ infrastructure, in your own cloud, or on a machine in your office &mdash;
+ the apps are the same either way.</p>
+ </div>
+ <dl class="facts two-b">
+ <div>
+ <dt>One instance per team</dt>
+ <dd>Your data has its own store and its own encryption key. There is no shared table to leak across.</dd>
+ </div>
+ <div>
+ <dt>Nothing leaves to be indexed</dt>
+ <dd>Search and Memoria build their indexes inside your instance. No document is sent out to be read.</dd>
+ </div>
+ <div>
+ <dt>Sharing stops at your edge</dt>
+ <dd>Documents are shared by name, inside your team, and stay that way until someone changes it.</dd>
+ </div>
+ </dl>
+ </div>
+ </div>
+ </section>
+
+ <!-- ───────── Native apps ───────── -->
+ <section class="band band-ink" id="native">
+ <div class="shell">
+ <h2 class="wide">Native apps, not a browser in a costume.</h2>
+ <p class="hero-lead">The Mac and iPhone apps edit the same documents as the web
+ apps, with native editors rather than an embedded web view. They launch fast,
+ they keep working when the connection drops, and they sync when it comes back.</p>
+
+ <div class="plate plate-flat">
+ <ul class="dl-list">
+ <li><span class="dl-app">Docs</span><span class="dl-state">Mac and iPhone built</span></li>
+ <li><span class="dl-app">Sheets</span><span class="dl-state">Mac and iPhone built</span></li>
+ <li><span class="dl-app">Calendar</span><span class="dl-state">Mac and iPhone built</span></li>
+ <li><span class="dl-app">Memoria</span><span class="dl-state">Mac and iPhone built</span></li>
+ </ul>
+ <p class="dl-req">Slides and Calls run in the browser and have no native app planned yet.</p>
+ </div>
+
+ <div class="tf" id="testflight">
+ <h3>Why you can&rsquo;t download them yet.</h3>
+ <p>The apps are built and they work. What&rsquo;s left is paperwork:
+ until they carry an Apple Developer ID and have been through
+ notarization, macOS refuses to open them, and we&rsquo;d rather send you
+ nothing than send you something your Mac calls damaged. The iPhone
+ builds are waiting on TestFlight.</p>
+<p class="dl-req" style="border:0;padding-bottom:0">Until then, every app runs in the browser with nothing to install.</p>
+ </div>
+ </div>
+ </section>
+
+ <!-- ───────── Where to start ───────── -->
+ <section class="band" id="signup">
+ <div class="shell two two-tight">
+ <div class="two-a">
+ <h2>Oficina isn&rsquo;t open for sign-ups yet.</h2>
+ <p class="prose">Creating a team still means one of us provisioning the
+ instance by hand, so there is no form here that would do anything. The
+ apps above are real and running, though, and Memoria is the one worth
+ your time first.</p>
+ <p class="prose sm">When sign-ups open, the same page will say so.</p>
+ </div>
+ <div class="two-b">
+ <div class="ask">
+ <p class="ask-q">Already have an Oficina account?</p>
+ <p class="ask-a">Open any app above and sign in with your work email.
+ One account reaches every app your team has.</p>
+ <p class="ask-cite"><a href="https://memoria.oficina.works">Start with Memoria</a></p>
+ </div>
+ </div>
+ </div>
+ </section>
+
+</main>
+
+<footer class="foot">
+ <div class="shell foot-in">
+ <div class="foot-col">
+ <h2 class="foot-h">Apps</h2>
+ <ul>
+ <li><a href="https://docs.oficina.works">Docs</a></li>
+ <li><a href="https://sheets.oficina.works">Sheets</a></li>
+ <li><a href="https://slides.oficina.works">Slides</a></li>
+ <li><a href="https://calendar.oficina.works">Calendar</a></li>
+ <li><a href="https://meet.oficina.works">Calls</a></li>
+ <li><a href="https://memoria.oficina.works">Memoria</a></li>
+ </ul>
+ </div>
+ <div class="foot-col">
+ <h2 class="foot-h">Source</h2>
+ <ul>
+ <li><a href="https://amadan.net/oficina/home">Home</a></li>
+ <li><a href="https://amadan.net/oficina/memoria">Memoria</a></li>
+ <li><a href="https://amadan.net/oficina/docs/-/releases/v0.1.0">Releases</a></li>
+ </ul>
+ </div>
+ <p class="colophon">This page is one HTML file, one stylesheet and two fonts served
+ from our own machines. It runs no scripts, sets no cookies and calls nothing
+ third-party &mdash; which is why there is no banner asking your permission.</p>
+ </div>
+</footer>
+
+</body>
+</html>
diff --git a/internal/home/static/site/newsreader-ext.woff2 b/internal/home/static/site/newsreader-ext.woff2
new file mode 100644
index 0000000..622a9f7
Binary files /dev/null and b/internal/home/static/site/newsreader-ext.woff2 differ
diff --git a/internal/home/static/site/newsreader.woff2 b/internal/home/static/site/newsreader.woff2
new file mode 100644
index 0000000..8cd8eff
Binary files /dev/null and b/internal/home/static/site/newsreader.woff2 differ