A real front page at oficina.works
The apex served a placeholder that said the suite was not built yet. Six apps are live behind it, so the page was the least true thing on the domain. This is the marketing front page: what Oficina is, a directory of the six apps and where each one runs today, Memoria, how team isolation actually works, and where the native apps have got to. It is one embedded file with one stylesheet and four font subsets, all from this origin — SecurityPolicy's default-src 'self' already admitted them, so the policy is unchanged. There is no sign-up form, because there is no route that would accept one: provisioning is still `oficina provision`, an operator CLI. The page says that rather than posting into a 404, and points at Memoria, which works today. The site assets are public on both Home origins, like the brand marks. origins_test.go listed the shared paths twice; both tests now read one list, so the two cannot drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 files changed,
+725
−43
internal/home/app.go+1 −0internal/home/origins_test.go+26 −5internal/home/page.go+50 −38internal/home/static/site/archivo-ext.woff2binaryinternal/home/static/site/archivo.woff2binaryinternal/home/static/site/home.css+401 −0internal/home/static/site/index.html+247 −0internal/home/static/site/newsreader-ext.woff2binaryinternal/home/static/site/newsreader.woff2binary
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’s running. |
| + Six apps that share one account, in the browser or as real native apps — |
| + with your team’s work sealed inside your team’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’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’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’s |
| + documents and answers questions from them — 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’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 — 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’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 — |
| + 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’t download them yet.</h3> |
| + <p>The apps are built and they work. What’s left is paperwork: |
| + until they carry an Apple Developer ID and have been through |
| + notarization, macOS refuses to open them, and we’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’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 — 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 |