Merge branch 'front-page' into main
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 |