oficina / docs Public

Adopt estilo's shared chrome: bar, account menu, errors, sign-in, return_to

Every page renders views/layout.html with estilo's bar instead of
rastrillo's topbar shell: one 52px row with the brand "Docs", the
document's title editable in place (POST /d/{sqid}/title now 303s a
browser back), Share... as the bar's action, the switcher (All apps
only, when HOME_DIRECTORY_ORIGIN is set) and the account menu (who is
signed in, Connect your Oficina account, Appearance, the one Sign out).
The <title> is built in the layout as "{part} · Docs".

The menu bar is rebuilt on estilo-menubar and estilo-menu in the
estilo-menus group: Insert, Find and replace, Show ruler and File > Sign
out are gone, View and Format show checks, labels carry ellipses where
they open something, and every shortcut is formatted server-side for the
reader's platform. The [docs-menu-*] CSS is deleted; dialogs and jump-to
take the float radius; borderless toolbar buttons go square.

Errors a browser navigates to are the estilo error page inside the
layout (router NotFound and MethodNotAllowed, a recoverer inside the
signed-in group, App.ErrorPage for rastrillo.Serve); fetch and native
answers stay plain text, decided by Accept. Every remaining http.Error
site is classified by a test. Notices are rastrillo callouts with real
tones. Sign-in, sent and signed-out pages are estilo's, with the suite's
copy. return_to: the local link lands on the remembered page
(__Host-oficina-docs-home-return), standalone Docs gets the same sealed
cookie and a guard that carries return_to, and an already-signed-in GET
/signin goes straight back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1204d45e076d55f6827e92dd98c10c3de04eb013 parent 62e20f8
38 files changed, +1998 −850
  • internal/docs/app.go +61 −5
  • internal/docs/auth.go +73 −34
  • internal/docs/auth_navigation.go +3 −3
  • internal/docs/auth_test.go +2 −2
  • internal/docs/chrome.go +178 −0
  • internal/docs/chrome_test.go +288 −0
  • internal/docs/errors.go +216 −0
  • internal/docs/errors_test.go +400 −0
  • internal/docs/export.go +3 −3
  • internal/docs/grants_edges_test.go +5 −1
  • internal/docs/handlers.go +70 −29
  • internal/docs/home_config.go +36 −0
  • internal/docs/home_local_navigation.go +6 −1
  • internal/docs/home_render.go +22 −4
  • internal/docs/home_router_integration_test.go +12 −3
  • internal/docs/home_routes.go +7 −0
  • internal/docs/list_test.go +15 −42
  • internal/docs/menu.go +79 −76
  • internal/docs/menu_test.go +3 −19
  • internal/docs/pages_test.go +11 −13
  • internal/docs/publish.go +9 −7
  • internal/docs/render.go +136 −92
  • internal/docs/share.go +14 −11
  • internal/docs/share_nav_test.go +14 −10
  • internal/docs/signin_return.go +158 −0
  • internal/docs/team_access.go +4 −6
  • internal/docs/team_router_test.go +1 −1
  • internal/docs/testing.go +20 −0
  • internal/docs/views/error.html +3 −0
  • internal/docs/views/head.html +0 −1
  • internal/docs/views/home_link.html +5 −2
  • internal/docs/views/layout.html +42 −0
  • internal/docs/views/list.html +5 −21
  • internal/docs/views/partials/jumpto.html +8 −1
  • internal/docs/views/public.html +6 −12
  • internal/docs/views/share.html +2 −4
  • internal/docs/views/show.html +65 −317
  • internal/docs/views/signin.html +16 −130
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 2422f7f..9af9048 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -20,6 +20,8 @@ import (
"strings"
"time"
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/home/handoff"
nativeauth "amadan.net/rastrillo/native/server"
"github.com/carlosframework/docs/internal/docs/linkstore"
@@ -37,7 +39,10 @@ import (
// Config configures New.
type Config struct {
Home *HomeConfig
- Team *TeamConfig
+ // Directory is the hub directory origin ($HOME_DIRECTORY_ORIGIN): the
+ // app switcher's "All apps". Empty means no switcher.
+ Directory string
+ Team *TeamConfig
// DBPath is the SQLite file New opens via rastrillo's db package.
DBPath string
@@ -460,7 +465,20 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: build auth: %w", err)
}
- h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin, isMember: list.configured, oneShots: newOneShots()}
+ directory, err := validatedDirectory(cfg.Directory)
+ if err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, err
+ }
+ h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin, isMember: list.configured, oneShots: newOneShots(), directory: directory}
+ render.frame = h.frame
+ // peek: the viewer on routes no session guards (chrome.go). Standalone
+ // reads rastrillo/auth's own cookie; with Home, the Home bridge's.
+ h.peek = func(r *http.Request) (string, bool) {
+ id, ok := authPlugin.SessionFrom(r)
+ return id.Address, ok
+ }
var home *homeRoutes
if homeSettings != nil {
@@ -471,6 +489,20 @@ func New(cfg Config) (*App, error) {
return nil, err
}
h.homeEnabled = true
+ h.peek = func(r *http.Request) (string, bool) {
+ s, ok := home.bridge.sessions.From(r)
+ if !ok || s.Subject == "" {
+ return "", false
+ }
+ if s.Method == "magiclink" || s.Method == "keymail" {
+ return s.Subject, true
+ }
+ id, err := home.bridge.resolver.resolve(sessions.WithSession(r, s))
+ if err != nil {
+ return "", false
+ }
+ return actorAddress(id.Actor), true
+ }
if store.team != nil {
store.team.bridge = home.bridge
home.bridge.resolver.team = store.team
@@ -512,7 +544,19 @@ func New(cfg Config) (*App, error) {
return nil, err
}
native := nativeauth.New(nativeSessions, cfg.Origin, "Docs", h.requireCurrent)
+ var standalone *localLinkNavigation
+ if home == nil {
+ if standalone, err = standaloneReturn(instanceKey, cfg.Origin); err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, err
+ }
+ }
r := chi.NewRouter()
+ // Every route that is not a route is the 404 page for a browser, and a
+ // page GET to a POST-only path is too (errors.go).
+ r.NotFound(h.notFound)
+ r.MethodNotAllowed(h.methodNotAllowed(r))
r.Use(native.Resume)
// Mounted above every route, per rastrillo SKILL.md §5: refuses a
// cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/
@@ -529,10 +573,16 @@ func New(cfg Config) (*App, error) {
// static assets that page (and the shell around it) needs to
// render at all.
if home == nil {
- r.Get("/signin", h.signin)
+ r.Get("/signin", h.standaloneSignin(standalone))
+ r.Get(localLinkCompletePath, standalone.completeStandalone)
+ r.Get("/signed-out", h.signedOut)
} else {
home.mountPublic(r)
}
+ // Appearance (estilo chrome): inside the app-wide CSRF check above and
+ // outside every session guard below, so a session that expired while
+ // the menu was open cannot turn the click into a sign-in loop.
+ r.Post("/appearance", chrome.HandleAppearance)
r.Post("/signin", h.beginSignin(authPlugin.Begin))
r.Get("/auth/callback", localAuthGuard(home, authPlugin.Callback))
r.Get("/auth/verify", localAuthGuard(home, authPlugin.Verify))
@@ -543,7 +593,7 @@ func New(cfg Config) (*App, error) {
// exception carved into the middleware.
r.Get("/p/{token}", h.publicShow)
if home == nil {
- r.Post("/signout", authPlugin.Signout)
+ r.Post("/signout", signoutToSignedOut(authPlugin.Signout))
} else {
r.Post("/signout", home.signout)
}
@@ -553,6 +603,9 @@ func New(cfg Config) (*App, error) {
// not two competing route registrations. Still unauthenticated,
// still mounted above the RequireSession group below.
r.Handle("/static/*", staticHandler(render.assets.Handler()))
+ // The suite's app icons, served from estilo (the bar's brand, and the
+ // switcher's tiles once they render).
+ r.Handle(brand.Prefix+"*", brand.Handler())
// Everything else needs a real, signed-in person — rastrillo
// SKILL.md §5/§1's "signed-in routes... in a Group with
@@ -569,11 +622,14 @@ func New(cfg Config) (*App, error) {
}
r.Group(func(r chi.Router) {
if home == nil {
- r.Use(authPlugin.RequireSession)
+ r.Use(requireSessionReturning(authPlugin))
} else {
r.Use(home.bridge.sessions.Require)
r.Use(home.bridge.resolver.middleware("/signin/local"))
}
+ // After the session middleware, so a 500 page shows the viewer's
+ // own chrome (errors.go).
+ r.Use(h.recoverToErrorPage)
// Admission is re-asked per request, not only at sign-in, so
// revoking a guest's last grant ends their access now rather
// than whenever their 30-day session happens to expire.
diff --git a/internal/docs/auth.go b/internal/docs/auth.go
index ae36017..3a561fb 100644
--- a/internal/docs/auth.go
+++ b/internal/docs/auth.go
@@ -13,6 +13,8 @@ import (
"strings"
"sync"
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
rauth "github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/mail"
"github.com/carlosframework/rastrillo/sessions"
@@ -405,12 +407,11 @@ func requireAdmissionOf(list allowlist, signinPath string, identity func(*http.R
// Delivery and callback admission are checked independently. No local passkey
// second factor is enrolled; Keymail verifies credentials at its own origin.
func newAuthPlugin(cfg Config, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger) (*rauth.Auth, error) {
- signedInPath := "/"
- if cfg.Home != nil {
- signedInPath = "/auth/local/complete"
- }
+ // Both modes land on /auth/local/complete, which sends a person to
+ // the page they came from (signin_return.go), since Verify itself can
+ // only land on one fixed path.
return rauth.New(rauth.Config{
- SignedInPath: signedInPath,
+ SignedInPath: localLinkCompletePath,
DB: writer,
Origin: cfg.Origin,
InstanceKey: instanceKey,
@@ -424,15 +425,26 @@ func newAuthPlugin(cfg Config, writer *sql.DB, instanceKey string, mailer mail.S
// (Begin, Callback, Verify) never render a page themselves — SKILL.md
// §5/magic-links.md: "the sign-in page stays yours" — they report
// outcomes by redirecting back here with a query this handler reads.
+//
+// Exactly one of Form, Sent and ContinueURL is set: the form (estilo's
+// estilo-signin), the check-your-email state that replaces it
+// (estilo-signin-sent), or the Keymail continuation (auth_navigation.go).
type signinPage struct {
- ForceEmail bool
- ContinueURL string
- Title string
- Body string
- AddressLabel string
- SubmitLabel string
- Sent string // non-empty: a link was emailed — show it
- ErrorMessage string // non-empty: something went wrong — show it
+ Frame Frame
+ Form *chrome.SignIn
+ Sent *chrome.SignInNotice
+ ContinueURL string
+ SubmitLabel string
+}
+
+// signinAgainPath is where "Use a different address" goes: the local
+// form, which with Home configured is /signin/local (plain /signin starts
+// a Home exchange).
+func (h *handlers) signinAgainPath() string {
+ if h.homeEnabled {
+ return "/signin/local"
+ }
+ return "/signin"
}
// signin serves GET /signin — the form itself, and the landing spot
@@ -449,36 +461,63 @@ type signinPage struct {
// complete, ask for a new link," and docs.error.generic is authored
// for a render failure with nowhere styled to land, not for this
// page's own ordinary, recoverable outcomes.
+//
+// An address error goes in the field's own error slot (the form sets
+// novalidate, so the browser's bubble never shows); every other error is
+// a negative callout above the form (spec §9.2).
func (h *handlers) signin(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
- data := signinPage{
- ForceEmail: q.Get("force") != "",
- Title: rastrillo.T(r, "docs.signin.title"),
- Body: rastrillo.T(r, "docs.signin.body"),
- AddressLabel: rastrillo.T(r, "docs.signin.address.label"),
- SubmitLabel: rastrillo.T(r, "docs.signin.submit"),
- }
+ icon := brand.Path("docs")
+ data := signinPage{}
+ title := rastrillo.T(r, "docs.signin.title")
if q.Get("sent") == "1" {
- data.Sent = rastrillo.T(r, "docs.signin.sent")
- }
- switch q.Get("err") {
- case "":
- // nothing to report
- case "rate":
- data.ErrorMessage = rastrillo.T(r, "docs.signin.error.rate")
- case "address":
- data.ErrorMessage = rastrillo.T(r, "docs.signin.error.address")
- case "expired":
- data.ErrorMessage = rastrillo.T(r, "docs.signin.error.expired")
- default:
- data.ErrorMessage = rastrillo.T(r, "docs.signin.error.unknown")
+ title = rastrillo.T(r, "docs.signin.sent.title")
+ data.Sent = &chrome.SignInNotice{
+ Icon: icon,
+ Heading: title,
+ Body: rastrillo.T(r, "docs.signin.sent.body"),
+ Link: &chrome.Link{Href: h.signinAgainPath(), Label: rastrillo.T(r, "docs.signin.sent.again")},
+ }
+ } else {
+ form := &chrome.SignIn{
+ Icon: icon,
+ Heading: title,
+ Action: "/signin",
+ AddressLabel: rastrillo.T(r, "docs.signin.address.label"),
+ Help: rastrillo.T(r, "docs.signin.help"),
+ Submit: rastrillo.T(r, "docs.signin.submit"),
+ }
+ if q.Get("force") != "" {
+ form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
+ }
+ switch q.Get("err") {
+ case "":
+ // nothing to report
+ case "rate":
+ form.Error = rastrillo.T(r, "docs.signin.error.rate")
+ case "address":
+ form.AddressError = rastrillo.T(r, "docs.signin.error.address")
+ case "expired":
+ form.Error = rastrillo.T(r, "docs.signin.error.expired")
+ default:
+ form.Error = rastrillo.T(r, "docs.signin.error.unknown")
+ }
+ data.Form = form
}
+ data.Frame = h.frame(r, frameOptions{titleParts: []string{title}, signinPage: true})
if err := h.render.page(w, r, "signin", data); err != nil {
h.logger.Error("docs: render signin", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ h.fail(w, r, http.StatusInternalServerError, failStatus)
}
}
+// signedOut is GET /signed-out, where Sign out lands: "You’re signed out",
+// and a Sign in button (spec §9.2). With Home configured it is the same
+// page (home_routes.go), which is why it takes the path to sign in at.
+func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) {
+ h.render.homeLink(w, r, "signedout", "", http.StatusOK)
+}
+
// linkPath extracts the request-target (path plus query, no
// scheme/host) from an emailed magic link — what
// httptest.NewRequest, and a real browser following a same-origin
diff --git a/internal/docs/auth_navigation.go b/internal/docs/auth_navigation.go
index e8a83cb..0496657 100644
--- a/internal/docs/auth_navigation.go
+++ b/internal/docs/auth_navigation.go
@@ -20,7 +20,7 @@ func (h *handlers) beginSignin(next http.HandlerFunc) http.HandlerFunc {
external := destination != "" && !strings.HasPrefix(destination, "/")
if result.status == http.StatusSeeOther && external {
if !keymailDestination(destination, h.origin) {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadGateway)
+ h.fail(w, r, http.StatusBadGateway, failStatus)
return
}
for _, cookie := range result.header.Values("Set-Cookie") {
@@ -29,13 +29,13 @@ func (h *handlers) beginSignin(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
data := signinPage{
- Title: rastrillo.T(r, "docs.signin.title"),
+ Frame: h.frame(r, frameOptions{titleParts: []string{rastrillo.T(r, "docs.signin.title")}, signinPage: true}),
SubmitLabel: rastrillo.T(r, "docs.signin.keymail"),
ContinueURL: destination,
}
if err := h.render.page(w, r, "signin", data); err != nil {
h.logger.Error("docs: render Keymail continuation", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ h.fail(w, r, http.StatusInternalServerError, failStatus)
}
return
}
diff --git a/internal/docs/auth_test.go b/internal/docs/auth_test.go
index 8a9c1b4..1d5d5dd 100644
--- a/internal/docs/auth_test.go
+++ b/internal/docs/auth_test.go
@@ -115,10 +115,10 @@ func TestSignInPageRendersSignedOut(t *testing.T) {
t.Fatalf("GET /signin: status %d, want 200", rec.Code)
}
body := rec.Body.String()
- if !strings.Contains(body, `<label rst-field-label for="signin-address">docs.signin.address.label</label>`) {
+ if !strings.Contains(body, `<label rst-field-label for="estilo-signin-address">docs.signin.address.label</label>`) {
t.Error("email input has no persistent associated translated label")
}
- if !strings.Contains(body, `type="email" id="signin-address" name="address" required autocomplete="email"`) {
+ if !strings.Contains(body, `type="email" id="estilo-signin-address" name="address" required autocomplete="email"`) {
t.Error("label target must remain the required email input with email autocomplete")
}
diff --git a/internal/docs/chrome.go b/internal/docs/chrome.go
new file mode 100644
index 0000000..4011d84
--- /dev/null
+++ b/internal/docs/chrome.go
@@ -0,0 +1,178 @@
+package docs
+
+// The shared chrome (estilo chrome, round 2): every page's bar, account
+// menu and <title> are built here, once, so no page template can drift
+// from another. Before this, each page defined its own rastrillo shell
+// blocks, and the share page fell back to rastrillo's default "Home"
+// brand because it forgot one.
+//
+// Spec: estilo spec/shared-chrome, docs/superpowers/specs/
+// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account),
+// §10 (titles).
+
+import (
+ "net/http"
+ "net/url"
+
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
+ "github.com/carlosframework/rastrillo"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+// Frame is what views/layout.html reads from every page: the bar, the
+// parts of the <title> before the app's name, and the Appearance
+// preference for <html data-theme>.
+type Frame struct {
+ Chrome chrome.Bar
+ TitleParts []string
+ Theme string
+}
+
+// frameOptions describes one page's chrome. The zero value is a plain
+// signed-in-or-out page with no context.
+type frameOptions struct {
+ // titleParts precede "Docs" in <title>, most specific first.
+ titleParts []string
+ // context is the open object's title; edit replaces it with the
+ // editable title when the person may rename.
+ context string
+ edit *chrome.TitleEdit
+ actions []chrome.Link
+ // public is a read-only page a stranger reaches: the brand is plain
+ // text, and "Sign in" carries no return path (the path is a
+ // capability and must not be copied into another URL).
+ public bool
+ // signinPage is one of the sign-in, sent and signed-out pages: the
+ // corner is empty.
+ signinPage bool
+}
+
+// frame builds a page's Frame. The viewer is read optionally: an error
+// page, the public page and a sign-in page all show whatever the
+// request's session says, without requiring one.
+func (h *handlers) frame(r *http.Request, o frameOptions) Frame {
+ bar := chrome.Bar{
+ App: chrome.App{
+ Name: rastrillo.T(r, "docs.app.name"),
+ Slug: "docs",
+ Home: "/",
+ Icon: brand.Path("docs"),
+ },
+ Context: o.context,
+ ContextEdit: o.edit,
+ Actions: o.actions,
+ Sticky: true,
+ Labels: chrome.BarLabels{Skip: rastrillo.T(r, "docs.chrome.skip"), Menu: rastrillo.T(r, "docs.chrome.menu")},
+ }
+ if o.public {
+ bar.App.Home = ""
+ }
+ address, signedIn := "", false
+ if !o.signinPage {
+ address, signedIn = h.viewer(r)
+ }
+ switch {
+ case o.signinPage:
+ // The corner is empty on the pages that are themselves sign-in.
+ case signedIn:
+ bar.Account = h.account(r, address)
+ bar.Switcher = h.switcher(r)
+ case o.public:
+ bar.SignIn = &chrome.Link{Href: "/signin", Label: rastrillo.T(r, "docs.chrome.signin")}
+ default:
+ bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + url.QueryEscape(returnTarget(r)), Label: rastrillo.T(r, "docs.chrome.signin")}
+ }
+ return Frame{Chrome: bar, TitleParts: o.titleParts, Theme: chrome.ThemeAttr(chrome.Appearance(r))}
+}
+
+// returnTarget is where a person should come back to: this page, if it
+// is a page (a GET), otherwise the list.
+func returnTarget(r *http.Request) string {
+ if r.Method != http.MethodGet {
+ return "/"
+ }
+ return r.URL.RequestURI()
+}
+
+func (h *handlers) account(r *http.Request, address string) *chrome.Account {
+ a := &chrome.Account{
+ TriggerLabel: rastrillo.T(r, "docs.chrome.account.anonymous"),
+ Initials: chrome.Initials("", address),
+ Address: address,
+ Labels: chrome.AccountLabels{
+ SignedInAs: rastrillo.T(r, "docs.chrome.signedinas"),
+ Appearance: rastrillo.T(r, "docs.chrome.appearance"),
+ Help: rastrillo.T(r, "docs.chrome.help"),
+ },
+ Appearance: &chrome.AppearanceMenu{
+ Action: "/appearance",
+ Return: returnTarget(r),
+ Options: chrome.AppearanceOptions(chrome.Appearance(r),
+ rastrillo.T(r, "docs.chrome.appearance.light"),
+ rastrillo.T(r, "docs.chrome.appearance.dark"),
+ rastrillo.T(r, "docs.chrome.appearance.system")),
+ },
+ SignOut: chrome.SignOut{Action: "/signout", Label: rastrillo.T(r, "docs.signout.label")},
+ }
+ if address != "" {
+ a.TriggerLabel = rastrillo.Tf(r, "docs.chrome.account", "address", address)
+ }
+ if label := h.homeLinkLabel(r); label != "" {
+ a.Items = []estilomenu.Item{{Href: "/account/home", Label: label}}
+ }
+ return a
+}
+
+// switcher is the app switcher, or nil when no hub directory is
+// configured. Until Home's /apps/<slug> changes and the hidden id.*
+// interstitial have merged (spec §6.2 a, b and the §9.3 hide), it shows
+// "All apps" alone and no tiles: a tile today costs two assertion
+// exchanges and flashes an interstitial mid-switch.
+func (h *handlers) switcher(r *http.Request) *chrome.Switcher {
+ if h.directory == "" {
+ return nil
+ }
+ return &chrome.Switcher{
+ Label: rastrillo.T(r, "docs.chrome.apps"),
+ CurrentLabel: rastrillo.T(r, "docs.chrome.thisapp"),
+ AllApps: estilomenu.Item{Href: h.directory + "/", Label: rastrillo.T(r, "docs.chrome.allapps")},
+ }
+}
+
+// viewer is who the request is signed in as, read optionally. Inside a
+// signed-in route the session middleware has already resolved it; on a
+// route no session guards (an error page, the public page) peek reads
+// the cookie without requiring it and without writing anything.
+//
+// The address is empty for a session whose person has no verified
+// address; the account menu then shows the person glyph.
+func (h *handlers) viewer(r *http.Request) (string, bool) {
+ if id, ok := currentIdentity(r); ok {
+ return actorAddress(id.Actor), true
+ }
+ if s, ok := sessions.Current(r); ok && s.Subject != "" {
+ if s.Method == "magiclink" || s.Method == "keymail" {
+ return s.Subject, true
+ }
+ }
+ if h.peek != nil {
+ return h.peek(r)
+ }
+ return "", false
+}
+
+func actorAddress(a Actor) string {
+ if a.VerifiedAddress == nil {
+ return ""
+ }
+ return *a.VerifiedAddress
+}
+
+// pageHeaders marks a rendered page as depending on the platform its
+// shortcut labels were formatted for: menu.Shortcut reads the
+// User-Agent, so a shared cache must not hand a Mac's page to Windows.
+func pageHeaders(w http.ResponseWriter) {
+ w.Header().Add("Vary", "User-Agent")
+}
diff --git a/internal/docs/chrome_test.go b/internal/docs/chrome_test.go
new file mode 100644
index 0000000..271a861
--- /dev/null
+++ b/internal/docs/chrome_test.go
@@ -0,0 +1,288 @@
+package docs_test
+
+// The shared chrome as Docs renders it (estilo chrome, spec
+// 2026-09-23-shared-chrome.md). A direct App.Handler() call installs no
+// locale middleware, so rastrillo.T returns catalog keys verbatim: every
+// label below is its key.
+
+import (
+ "html"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func getAccept(t *testing.T, app *docs.App, cookies []*http.Cookie, path string, header ...string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("Accept", "text/html,application/xhtml+xml")
+ for i := 0; i+1 < len(header); i += 2 {
+ req.Header.Set(header[i], header[i+1])
+ }
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ return rec
+}
+
+func titleOf(body string) string {
+ m := regexp.MustCompile(`<title>([^<]*)</title>`).FindStringSubmatch(body)
+ if m == nil {
+ return ""
+ }
+ return m[1]
+}
+
+// Every page's <title> is "{part} · {part} · Docs", built by the layout
+// alone (spec §10): the root is just the app's name.
+func TestPageTitlesFollowTheSuitePattern(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+
+ cases := []struct {
+ name, path string
+ cookies []*http.Cookie
+ want string
+ }{
+ {"list", "/", alice, "docs.app.name"},
+ {"search", "/?q=min", alice, "“min” · docs.app.name"},
+ {"document", "/d/" + sqid, alice, "Minutes · docs.app.name"},
+ {"share", "/d/" + sqid + "/share", alice, "docs.share.title · Minutes · docs.app.name"},
+ {"sign in", "/signin", nil, "docs.signin.title · docs.app.name"},
+ {"sent", "/signin?sent=1", nil, "docs.signin.sent.title · docs.app.name"},
+ {"signed out", "/signed-out", nil, "docs.signedout.title · docs.app.name"},
+ }
+ for _, c := range cases {
+ rec := getAccept(t, app, c.cookies, c.path)
+ if got := titleOf(rec.Body.String()); got != c.want {
+ t.Errorf("%s: <title> = %q, want %q (status %d)", c.name, got, c.want, rec.Code)
+ }
+ }
+}
+
+// The bar is the same bar on every signed-in page: 52px is estilo's, and
+// this is the structure behind it. Exactly one Sign out, the account menu
+// last in the end group, and no rastrillo shell left anywhere.
+func TestEverySignedInPageHasOneSignOutInTheSameCorner(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+ for _, path := range []string{"/", "/?q=min", "/d/" + sqid, "/d/" + sqid + "/share", "/no/such/page", "/d/doesnotexist"} {
+ body := getAccept(t, app, alice, path).Body.String()
+ if n := strings.Count(body, "docs.signout.label"); n != 1 {
+ t.Errorf("%s: Sign out appears %d times, want exactly 1", path, n)
+ }
+ if strings.Contains(body, "rst-shell") {
+ t.Errorf("%s: the rastrillo shell is still rendered", path)
+ }
+ end := strings.Index(body, "<div estilo-bar-end>")
+ acct := strings.Index(body, "<details estilo-account")
+ closeEnd := strings.Index(body[acct:], "</details>\n </div>\n</header>")
+ if end < 0 || acct < end || closeEnd < 0 {
+ t.Errorf("%s: the account menu is not the last thing in the bar's end group", path)
+ }
+ if !strings.Contains(body, "alice@example.com") {
+ t.Errorf("%s: the account menu does not say who is signed in", path)
+ }
+ }
+}
+
+// Signed out, the corner is "Sign in", carrying the page to come back to;
+// on the sign-in pages themselves the corner is empty; on the public page
+// the brand is plain text and Sign in carries no capability path.
+func TestTheSignedOutCorner(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+ body := getAccept(t, app, nil, "/no/such/page?x=1").Body.String()
+ if !strings.Contains(body, `<a rst-btn href="/signin?return_to=`+url.QueryEscape("/no/such/page?x=1")+`" estilo-bar-signin>docs.chrome.signin</a>`) {
+ t.Error("the signed-out corner has no Sign in carrying this page", body)
+ }
+ for _, path := range []string{"/signin", "/signin?sent=1", "/signin?err=expired", "/signed-out"} {
+ body := getAccept(t, app, nil, path).Body.String()
+ if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") {
+ t.Errorf("%s: a sign-in page shows the sign-in corner", path)
+ }
+ if !strings.Contains(body, "<header estilo-bar") {
+ t.Errorf("%s: a sign-in page has no bar (and so no name)", path)
+ }
+ }
+
+ post(t, app, alice, "/d/"+sqid+"/share/link", "on=1")
+ share := get(t, app, alice, "/d/"+sqid+"/share").Body.String()
+ m := regexp.MustCompile(`/p/[A-Za-z0-9_-]+`).FindString(share)
+ if m == "" {
+ t.Fatal("no public link to follow")
+ }
+ public := getAccept(t, app, nil, m).Body.String()
+ if strings.Contains(public, "<a estilo-bar-brand") || !strings.Contains(public, "<span estilo-bar-brand>") {
+ t.Error("the public page's brand is a link a stranger is bounced by")
+ }
+ if !strings.Contains(public, `<a rst-btn href="/signin" estilo-bar-signin>`) {
+ t.Error("the public page's Sign in carries the capability path, or is missing")
+ }
+ if !strings.Contains(public, `rst-tone="info"`) || strings.Contains(public, "rst-notice") {
+ t.Error("the public notice is not an info callout")
+ }
+}
+
+// The switcher appears only with a hub directory configured, and until
+// Home's /apps/<slug> changes land it is "All apps" alone, with no tiles.
+func TestTheSwitcherIsAllAppsOnly(t *testing.T) {
+ plain, _ := docs.NewForTest(t)
+ if body := get(t, plain, signInCookies(t, plain, "a@example.com"), "/").Body.String(); strings.Contains(body, "estilo-switcher") {
+ t.Error("a switcher with no hub directory")
+ }
+ app, err := docs.NewForTestWith(t, docs.Config{Directory: "https://oficina.works"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := get(t, app, signInCookies(t, app, "a@example.com"), "/").Body.String()
+ if !strings.Contains(body, `<details estilo-switcher name="estilo-menus">`) || !strings.Contains(body, `<a estilo-menu-item href="https://oficina.works/"><span estilo-menu-label>docs.chrome.allapps</span></a>`) {
+ t.Error("the switcher does not offer All apps", body)
+ }
+ if strings.Contains(body, "estilo-switcher-app") {
+ t.Error("the switcher renders tiles before Home can serve them")
+ }
+ for _, bad := range []string{"http://oficina.works", "https://oficina.works/", "https://Oficina.works", "https://127.0.0.1", "https://localhost", "https://a.b:8443", "https://user@a.b"} {
+ if _, err := docs.NewForTestWith(t, docs.Config{Directory: bad}); err == nil {
+ t.Errorf("directory %q was accepted", bad)
+ }
+ }
+}
+
+// A writer renames in the bar: a real form, so Enter works with scripts
+// off, and it lands back on the document. A reader gets the title
+// read-only (spec §5.4).
+func TestTheBarRenamesTheDocument(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Untitled")
+ body := get(t, app, alice, "/d/"+sqid).Body.String()
+ if !strings.Contains(body, `<form estilo-bar-title method="post" action="/d/`+sqid+`/title">`) || !strings.Contains(body, `name="title" value="Untitled" aria-label="docs.chrome.title.label"`) {
+ t.Fatal("a writer has no editable title in the bar")
+ }
+ req := httptest.NewRequest(http.MethodPost, "/d/"+sqid+"/title", strings.NewReader("title=Board+minutes"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "text/html")
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, c := range alice {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/d/"+sqid {
+ t.Fatalf("rename: %d %q, want 303 back to the document", rec.Code, rec.Header().Get("Location"))
+ }
+ if title := titleOf(get(t, app, alice, "/d/"+sqid).Body.String()); title != "Board minutes · docs.app.name" {
+ t.Fatalf("after rename the title is %q", title)
+ }
+
+ bob := signInCookies(t, app, "bob@example.com")
+ post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader")
+ reader := get(t, app, bob, "/d/"+sqid).Body.String()
+ if strings.Contains(reader, "estilo-bar-title") || !strings.Contains(reader, `<span estilo-bar-context dir="auto" title="Board minutes">Board minutes</span>`) {
+ t.Error("a reader is offered rename, or loses the title")
+ }
+}
+
+// Appearance is per app, in a host-only cookie, and the layout writes it
+// into <html data-theme> so the first paint is right.
+func TestAppearanceIsRememberedAndRendered(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ if body := get(t, app, alice, "/").Body.String(); strings.Contains(body, "data-theme") || !strings.Contains(body, `value="system" aria-pressed="true"`) {
+ t.Fatal("no preference should follow the system")
+ }
+ rec := post(t, app, alice, "/appearance", "mode=dark&return_to=%2F%3Fq%3Dx")
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/?q=x" {
+ t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+ cookies := append(rec.Result().Cookies(), alice...)
+ body := get(t, app, cookies, "/").Body.String()
+ if !strings.Contains(body, `<html lang="en" dir="ltr" data-theme="dark">`) || !strings.Contains(body, `value="dark" aria-pressed="true"`) {
+ t.Fatal("the chosen appearance is not rendered")
+ }
+ // Outside the session guard, inside the CSRF check.
+ signedOut := post(t, app, nil, "/appearance", "mode=light&return_to=%2F")
+ if signedOut.Code != http.StatusSeeOther {
+ t.Errorf("an expired session turns Appearance into %d", signedOut.Code)
+ }
+ cross := httptest.NewRequest(http.MethodPost, "/appearance", strings.NewReader("mode=light"))
+ cross.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ cross.Header.Set("Sec-Fetch-Site", "cross-site")
+ crossRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(crossRec, cross)
+ if crossRec.Code == http.StatusSeeOther || len(crossRec.Result().Cookies()) != 0 {
+ t.Error("a cross-site form set the appearance")
+ }
+}
+
+// Every notice is a rastrillo callout with one of its four tones (spec
+// §4): rst-notice, a valued rst-callout attribute and "warn" are gone.
+func TestTemplatesUseOnlyCalloutTones(t *testing.T) {
+ for _, bad := range []string{"rst-notice", `rst-callout="`, `rst-tone="warn"`} {
+ for path, body := range docs.TestViewSources(t) {
+ if strings.Contains(body, bad) {
+ t.Errorf("%s writes %s", path, bad)
+ }
+ }
+ }
+}
+
+// Shortcut labels are the reader's platform's, decided on the server so
+// nothing flips after paint (spec §7.3).
+func TestShortcutLabelsFollowThePlatform(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+ // html/template writes "+" in an attribute as &#43;; a browser reads it
+ // back as "+", and so does this.
+ mac := html.UnescapeString(getAccept(t, app, alice, "/d/"+sqid, "User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)").Body.String())
+ win := html.UnescapeString(getAccept(t, app, alice, "/d/"+sqid, "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)").Body.String())
+ for _, want := range []string{`data-docs-shortcut="⌘Z"`, `data-docs-shortcut="⇧⌘Z"`, `data-docs-shortcut="⌘B"`, `data-docs-shortcut="⌘K"`, `data-keys="⇧⇥"`, `data-keys="⎋"`} {
+ if !strings.Contains(mac, want) {
+ t.Errorf("Mac page lacks %s", want)
+ }
+ }
+ for _, want := range []string{`data-docs-shortcut="Ctrl+Z"`, `data-docs-shortcut="Ctrl+Y"`, `data-docs-shortcut="Ctrl+B"`, `data-docs-shortcut="Ctrl+K"`, `data-keys="Shift+Tab"`, `data-keys="Esc"`} {
+ if !strings.Contains(win, want) {
+ t.Errorf("Windows page lacks %s", want)
+ }
+ }
+ if strings.Contains(mac, "Ctrl+") {
+ t.Error("the Mac page still says Ctrl")
+ }
+}
+
+// Unbuilt features are hidden, not disabled (spec §7.3): no Insert menu,
+// no Find and replace, no Show ruler, and Sign out is not in File.
+func TestUnbuiltMenuItemsAreHidden(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+ body := get(t, app, alice, "/d/"+sqid).Body.String()
+ menubar := body[strings.Index(body, "<nav estilo-menubar"):]
+ menubar = menubar[:strings.Index(menubar, "</nav>")]
+ for _, gone := range []string{"docs.editor.menu.insert", "insert-", "find_replace", "view_ruler", "data-docs-unavailable", "/signout"} {
+ if strings.Contains(menubar, gone) {
+ t.Errorf("the menu bar still offers %s", gone)
+ }
+ }
+ for _, want := range []string{"docs.editor.menu.file", "docs.editor.menu.edit", "docs.editor.menu.view", "docs.editor.menu.format", "docs.editor.menu.tools", `data-docs-jumpto`} {
+ if !strings.Contains(menubar, want) {
+ t.Errorf("the menu bar lost %s", want)
+ }
+ }
+ if n := strings.Count(menubar, `name="estilo-menus"`); n != 5 {
+ t.Errorf("%d menubar menus in the shared exclusivity group, want 5", n)
+ }
+}
diff --git a/internal/docs/errors.go b/internal/docs/errors.go
new file mode 100644
index 0000000..83eda15
--- /dev/null
+++ b/internal/docs/errors.go
@@ -0,0 +1,216 @@
+package docs
+
+// HTML error pages (spec §3). Before this, every failure a browser could
+// navigate to was http.Error's plain text in a monospace page with an
+// empty <title> — "Not found." for a bad document link, and Go's own
+// "404 page not found" for an unknown route.
+//
+// The rule for which answers are pages: a page is rendered only when the
+// request says it wants HTML (its Accept names text/html, as every
+// browser navigation and form post does). The update log, the stream,
+// search and the native API are fetched by script or by the native apps
+// and never ask for HTML, so they keep their plain-text and JSON answers
+// even when a shared helper (getDocumentForUpdates) serves both. Docs has
+// no /api/ prefix to decide it by, and a prefix would be the wrong rule
+// anyway (spec §3.3).
+//
+// A missing document and one that is not shared with you are the same
+// 404 (§4.2): nothing here distinguishes them, so nothing is enumerable.
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "runtime/debug"
+ "strings"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ "github.com/carlosframework/rastrillo"
+ "github.com/go-chi/chi/v5"
+)
+
+// failKind says which of the spec's error pages a status stands for.
+type failKind int
+
+const (
+ // failRoute: nothing lives at this address.
+ failRoute failKind = iota
+ // failDocument: the document is missing or not shared with you.
+ failDocument
+ // failStatus: the page for the status itself (403, 5xx, 400).
+ failStatus
+)
+
+type errorPageData struct {
+ Frame Frame
+ Error chrome.ErrorPage
+}
+
+// wantsHTML reports whether the request asked for a page.
+func wantsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// fail answers an error. A request that asked for HTML gets the page;
+// anything else gets the plain text it got before this existed, so a
+// script or a native client reading the body sees no change.
+func (h *handlers) fail(w http.ResponseWriter, r *http.Request, status int, kind failKind) {
+ if !wantsHTML(r) {
+ key := "docs.error.generic"
+ if status == http.StatusNotFound {
+ key = "docs.error.notfound"
+ }
+ http.Error(w, rastrillo.T(r, key), status)
+ return
+ }
+ ref := ""
+ if status == http.StatusInternalServerError {
+ // The page tells a person to quote the reference below, so every
+ // 500 page has one, logged beside the path it answered.
+ ref = rastrillo.NewRef()
+ h.logger.Error("docs: error page", "ref", ref, "path", r.URL.Path)
+ }
+ h.errorPage(w, r, status, kind, ref)
+}
+
+// errorPage renders the page for status. ref is the reference quoted on
+// a 500 (rastrillo.NewRef), empty otherwise.
+func (h *handlers) errorPage(w http.ResponseWriter, r *http.Request, status int, kind failKind, ref string) {
+ app := rastrillo.T(r, "docs.app.name")
+ home := chrome.Link{Href: "/", Label: rastrillo.Tf(r, "docs.error.home", "app", app)}
+ key := "docs.error.page.500"
+ switch {
+ case status == http.StatusNotFound && kind == failDocument:
+ key = "docs.error.page.document"
+ case status == http.StatusNotFound:
+ key = "docs.error.page.404"
+ case status == http.StatusForbidden:
+ key = "docs.error.page.403"
+ case status == http.StatusBadRequest:
+ key = "docs.error.page.400"
+ case status == http.StatusServiceUnavailable:
+ key = "docs.error.page.503"
+ }
+ page := chrome.ErrorPage{
+ Status: status,
+ Title: rastrillo.Tf(r, key+".heading", "app", app),
+ Body: rastrillo.T(r, key+".body"),
+ Home: home,
+ }
+ // Try again is the same URL, and only for a GET: re-sending a POST
+ // from an error page is the one thing it must never do.
+ if status >= 500 && r.Method == http.MethodGet {
+ page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: rastrillo.T(r, "docs.error.retry")}
+ }
+ if status == http.StatusInternalServerError && ref != "" {
+ page.Ref, page.RefLabel = ref, rastrillo.T(r, "docs.error.ref")
+ }
+ if back := h.sameOriginReferer(r); back != "" && status < 500 {
+ page.Back = &chrome.Link{Href: back, Label: rastrillo.T(r, "docs.error.back")}
+ }
+ data := errorPageData{
+ Frame: h.frame(r, frameOptions{titleParts: []string{rastrillo.Tf(r, key+".title", "app", app)}}),
+ Error: page,
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ if err := h.render.pageStatus(w, r, "error", data, status); err != nil {
+ // The page could not render: say so in the plainest way left.
+ h.logger.Error("docs: render error page", "status", status, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), status)
+ }
+}
+
+// sameOriginReferer is the page a person came from, when it is this app
+// and not this page — rastrillo's rule for the error page's Back link. An
+// unchecked Referer is an open redirect with better manners.
+func (h *handlers) sameOriginReferer(r *http.Request) string {
+ raw := r.Header.Get("Referer")
+ if raw == "" {
+ return ""
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.User != nil || u.Scheme+"://"+u.Host != h.origin {
+ return ""
+ }
+ back := u.EscapedPath()
+ if u.RawQuery != "" {
+ back += "?" + u.RawQuery
+ }
+ if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() {
+ return ""
+ }
+ return back
+}
+
+// notFound is the router's NotFound: an unknown route is the 404 page for
+// a browser and chi's plain text for anything else.
+func (h *handlers) notFound(w http.ResponseWriter, r *http.Request) {
+ h.fail(w, r, http.StatusNotFound, failRoute)
+}
+
+// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
+// path that only takes a POST (someone opening /signout from history) is
+// the same 404 page as an unknown route: to a person there is no page
+// there. Any other method gets 405 in plain text, with the Allow header
+// set here — chi's default sets it, a custom handler does not.
+func (h *handlers) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var allowed []string
+ for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
+ if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
+ allowed = append(allowed, m)
+ }
+ }
+ get := false
+ for _, m := range allowed {
+ get = get || m == http.MethodGet
+ }
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
+ h.fail(w, r, http.StatusNotFound, failRoute)
+ return
+ }
+ w.Header().Set("Allow", strings.Join(allowed, ", "))
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ }
+}
+
+// recoverToErrorPage turns a panic in a handler into the 500 page with a
+// reference, logged with the stack under the same reference.
+//
+// It is mounted INSIDE the signed-in group, after the session middleware,
+// so the page carries the viewer's own chrome: mounted outside, every 500
+// would look signed out, which is Calendar's bug (spec §3.3). Panics
+// outside it reach rastrillo.Serve's own recoverer through
+// App.ErrorPage.
+func (h *handlers) recoverToErrorPage(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ v := recover()
+ if v == nil {
+ return
+ }
+ if v == http.ErrAbortHandler {
+ panic(v)
+ }
+ ref := rastrillo.NewRef()
+ h.logger.Error("docs: panic", "ref", ref, "path", r.URL.Path, "panic", fmt.Sprint(v), "stack", string(debug.Stack()))
+ if !wantsHTML(r) {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ h.errorPage(w, r, http.StatusInternalServerError, failStatus, ref)
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
+
+// ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's
+// own panic recovery renders, for a panic outside the router's signed-in
+// group. It reads the session optionally like every other error page.
+func (a *App) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
+ if !wantsHTML(r) {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), status)
+ return
+ }
+ a.handlers.errorPage(w, r, status, failStatus, ref)
+}
diff --git a/internal/docs/errors_test.go b/internal/docs/errors_test.go
new file mode 100644
index 0000000..b41f2fa
--- /dev/null
+++ b/internal/docs/errors_test.go
@@ -0,0 +1,400 @@
+package docs
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+func browserGet(app *App, cookies []*http.Cookie, method, path string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, nil)
+ req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8")
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ return rec
+}
+
+func signedInForTest(t *testing.T, app *App, address string) []*http.Cookie {
+ t.Helper()
+ link, err := app.TestSignIn(t, address)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, link, nil))
+ return rec.Result().Cookies()
+}
+
+var pageTitle = regexp.MustCompile(`<title>([^<]*)</title>`)
+
+// Every error a browser can navigate to is the error page, inside the
+// normal layout and bar, showing the viewer's own chrome (spec §3). The
+// table is {unknown route, a page GET of a POST-only path, a missing
+// document, a bad parameter, a panic} × {signed in, signed out}.
+func TestHTMLErrorsRenderTheErrorPage(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ alice := signedInForTest(t, app, "alice@example.com")
+ panics := app.handlers.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") }))
+
+ type result struct {
+ code int
+ contentType string
+ body string
+ }
+ serveCase := func(cookies []*http.Cookie, run func([]*http.Cookie) *httptest.ResponseRecorder) result {
+ rec := run(cookies)
+ return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()}
+ }
+ path := func(method, p string) func([]*http.Cookie) *httptest.ResponseRecorder {
+ return func(c []*http.Cookie) *httptest.ResponseRecorder { return browserGet(app, c, method, p) }
+ }
+ panicking := func(c []*http.Cookie) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, "/d/anything", nil)
+ req.Header.Set("Accept", "text/html")
+ for _, cookie := range c {
+ req.AddCookie(cookie)
+ }
+ rec := httptest.NewRecorder()
+ panics.ServeHTTP(rec, req)
+ return rec
+ }
+ badParameter := func(c []*http.Cookie) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, "/documents", strings.NewReader("title="+strings.Repeat("x", 2000)))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "text/html")
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, cookie := range c {
+ req.AddCookie(cookie)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ return rec
+ }
+
+ cases := []struct {
+ name string
+ signedIn bool
+ run func([]*http.Cookie) *httptest.ResponseRecorder
+ status int
+ title string
+ }{
+ {"unknown route", true, path("GET", "/no/such/page"), 404, "docs.error.page.404.title · docs.app.name"},
+ {"unknown route", false, path("GET", "/no/such/page"), 404, "docs.error.page.404.title · docs.app.name"},
+ {"GET of a POST-only path", true, path("GET", "/signout"), 404, "docs.error.page.404.title · docs.app.name"},
+ {"GET of a POST-only path", false, path("GET", "/documents"), 404, "docs.error.page.404.title · docs.app.name"},
+ {"missing document", true, path("GET", "/d/doesnotexist"), 404, "docs.error.page.document.title · docs.app.name"},
+ {"missing published document", false, path("GET", "/p/doesnotexist"), 404, "docs.error.page.document.title · docs.app.name"},
+ {"missing export", true, path("GET", "/d/doesnotexist/export.pdf"), 404, "docs.error.page.document.title · docs.app.name"},
+ {"bad parameter", true, badParameter, 400, "docs.error.page.400.title · docs.app.name"},
+ {"panic", true, panicking, 500, "docs.error.page.500.title · docs.app.name"},
+ {"panic", false, panicking, 500, "docs.error.page.500.title · docs.app.name"},
+ }
+ for _, c := range cases {
+ who := "signed out"
+ var cookies []*http.Cookie
+ if c.signedIn {
+ who, cookies = "signed in", alice
+ }
+ t.Run(c.name+", "+who, func(t *testing.T) {
+ got := serveCase(cookies, c.run)
+ if got.code != c.status {
+ t.Fatalf("status %d, want %d", got.code, c.status)
+ }
+ if !strings.HasPrefix(got.contentType, "text/html") {
+ t.Fatalf("Content-Type %q, want text/html", got.contentType)
+ }
+ if m := pageTitle.FindStringSubmatch(got.body); m == nil || m[1] != c.title {
+ t.Fatalf("<title> %v, want %q", m, c.title)
+ }
+ if !strings.Contains(got.body, "<header estilo-bar") || !strings.Contains(got.body, "<div rst-error>") {
+ t.Fatal("the error page is not inside the layout and bar")
+ }
+ if account := strings.Contains(got.body, "<details estilo-account"); account != c.signedIn {
+ t.Fatalf("account menu present = %v, want %v", account, c.signedIn)
+ }
+ if c.status == 500 && !regexp.MustCompile(`rst-error-ref dir="auto">docs.error.ref [a-z0-9]{6}<`).MatchString(got.body) {
+ t.Fatal("a 500 page carries no reference")
+ }
+ })
+ }
+
+ // Non-HTML stays non-HTML: the editor's fetches and the native apps
+ // never ask for a page and never get one.
+ for _, p := range []string{"/no/such/page", "/d/doesnotexist/updates"} {
+ req := httptest.NewRequest(http.MethodGet, p, nil)
+ for _, c := range alice {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != 404 || strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
+ t.Errorf("%s without Accept: %d %q, want a plain 404", p, rec.Code, rec.Header().Get("Content-Type"))
+ }
+ }
+ // Any other method to a known path is 405 in plain text, with Allow.
+ req := httptest.NewRequest(http.MethodDelete, "/signout", nil)
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != "POST" {
+ t.Errorf("DELETE /signout: %d Allow=%q", rec.Code, rec.Header().Get("Allow"))
+ }
+}
+
+// The error page's Back link is offered only for a same-origin Referer
+// that is not this page (rastrillo's rule; otherwise it is an open
+// redirect).
+func TestErrorPageBackLinkIsSameOriginOnly(t *testing.T) {
+ app, _ := NewForTest(t)
+ h := app.handlers
+ for referer, want := range map[string]string{
+ DefaultOrigin + "/d/abc?x=1": "/d/abc?x=1",
+ "https://evil.example/d/abc": "",
+ DefaultOrigin + "/no/such/page": "",
+ "//evil.example/": "",
+ "http://user@localhost:8080/d/abc": "",
+ } {
+ req := httptest.NewRequest(http.MethodGet, "/no/such/page", nil)
+ req.Header.Set("Referer", referer)
+ if got := h.sameOriginReferer(req); got != want {
+ t.Errorf("Referer %q: back %q, want %q", referer, got, want)
+ }
+ }
+}
+
+// httpErrorSites lists every remaining http.Error / http.NotFound call in
+// this package, keyed "file:function", with how many each has. Every one
+// is classified below as a non-HTML answer; an unclassified new site
+// fails the test, so a page handler cannot quietly go back to plain text.
+func httpErrorSites(t *testing.T) map[string]int {
+ t.Helper()
+ fset := token.NewFileSet()
+ files, err := filepath.Glob("*.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sites := map[string]int{}
+ for _, f := range files {
+ if strings.HasSuffix(f, "_test.go") || f == "testing.go" {
+ continue
+ }
+ file, err := parser.ParseFile(fset, f, nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range file.Decls {
+ fn, ok := d.(*ast.FuncDecl)
+ if !ok {
+ continue
+ }
+ name := fn.Name.Name
+ if fn.Recv != nil {
+ typ := fn.Recv.List[0].Type
+ if s, ok := typ.(*ast.StarExpr); ok {
+ typ = s.X
+ }
+ if id, ok := typ.(*ast.Ident); ok {
+ name = id.Name + "." + name
+ }
+ }
+ ast.Inspect(fn, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
+ if x, ok := sel.X.(*ast.Ident); ok && x.Name == "http" && (sel.Sel.Name == "Error" || sel.Sel.Name == "NotFound") {
+ sites[f+":"+name]++
+ }
+ }
+ return true
+ })
+ }
+ }
+ return sites
+}
+
+func TestEveryPlainTextErrorIsClassified(t *testing.T) {
+ // file:function → count, and why it is not a page.
+ classified := map[string]struct {
+ n int
+ why string
+ }{
+ "app.go:New": {1, "live stream re-check: an EventSource, never a page"},
+ "assets.go:serveIcon": {1, "static asset 404"},
+ "assets.go:serveBundle": {1, "static asset 404"},
+ "auth.go:requireAdmissionOf": {2, "shared guard in front of page and fetch routes; its non-GET 403 must stay text for save-queue.mjs, and a GET never reaches the 503 without a database stall (R3)"},
+ "errors.go:handlers.fail": {1, "the non-HTML half of the negotiated answer"},
+ "errors.go:handlers.errorPage": {1, "the error page itself could not render"},
+ "errors.go:handlers.methodNotAllowed": {1, "405 for a non-GET method, which no navigation sends"},
+ "errors.go:handlers.recoverToErrorPage": {1, "the non-HTML half of a recovered panic"},
+ "errors.go:App.ErrorPage": {1, "the non-HTML half of rastrillo's recovered panic"},
+ "handlers.go:handlers.updatesGet": {3, "the update log, fetched by the editor"},
+ "handlers.go:handlers.updatesPost": {7, "the update log, posted by the editor"},
+ "handlers.go:parseSince": {1, "the update log's cursor"},
+ "home_local_navigation.go:localLinkNavigation.guard": {1, "405 for a non-GET method"},
+ "home_local_navigation.go:localLinkNavigation.Begin": {2, "Home link start, sealing failure (R3: moves with home/connect)"},
+ "home_render.go:renderer.homeLink": {1, "the page itself could not render"},
+ "home_routes.go:homeCSRF": {1, "unknown Host: not a page anyone navigates to"},
+ "identity.go:identityResolver.middleware": {1, "shared guard in front of page and fetch routes (R3)"},
+ "identity.go:refuseIdentity": {1, "non-GET refusal; a GET redirects to sign-in"},
+ "native.go:nativeJSON": {1, "native API JSON"},
+ "native.go:handlers.nativeList": {2, "native API JSON"},
+ "search.go:handlers.searchJSON": {3, "jump-to palette JSON"},
+ "stream.go:handlers.stream": {6, "live update stream"},
+ "team_access.go:teamRuntime.admission": {1, "shared guard, identity missing: a wiring failure, not a page state"},
+ "team_emission.go:writeGuardedBody": {1, "response already guarded"},
+ }
+ got := httpErrorSites(t)
+ var problems []string
+ for site, n := range got {
+ c, ok := classified[site]
+ switch {
+ case !ok:
+ problems = append(problems, fmt.Sprintf("%s: %d unclassified plain-text error(s) — a page handler should call h.fail", site, n))
+ case c.n != n:
+ problems = append(problems, fmt.Sprintf("%s: %d plain-text error(s), classified %d (%s)", site, n, c.n, c.why))
+ }
+ }
+ for site := range classified {
+ if _, ok := got[site]; !ok {
+ problems = append(problems, site+": classified but no longer present — drop it from the list")
+ }
+ }
+ sort.Strings(problems)
+ for _, p := range problems {
+ t.Error(p)
+ }
+ if len(got) == 0 {
+ t.Fatal("found no http.Error sites at all; the walk is looking at nothing")
+ }
+}
+
+// A deep link opened signed out, then signed in, lands on the deep link
+// (spec §9.4), in standalone Docs: RequireSession's redirect now carries
+// return_to, GET /signin remembers it, and the magic link lands on
+// /auth/local/complete, which goes back there once.
+func TestSigningInReturnsToTheDeepLink(t *testing.T) {
+ app, _ := NewForTest(t)
+ owner := signedInForTest(t, app, "alice@example.com")
+ req := httptest.NewRequest(http.MethodPost, "/documents", strings.NewReader("title=Minutes"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, c := range owner {
+ req.AddCookie(c)
+ }
+ created := httptest.NewRecorder()
+ app.Handler().ServeHTTP(created, req)
+ deep := created.Header().Get("Location") + "?view=1"
+
+ jar := map[string]*http.Cookie{}
+ do := func(method, path string) *httptest.ResponseRecorder {
+ // The landing checks the Host, so ask it the way a browser would.
+ req := httptest.NewRequest(method, DefaultOrigin+path, nil)
+ for _, c := range jar {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ for _, c := range rec.Result().Cookies() {
+ if c.MaxAge < 0 {
+ delete(jar, c.Name)
+ } else {
+ jar[c.Name] = c
+ }
+ }
+ return rec
+ }
+ first := do("GET", deep)
+ signin := first.Header().Get("Location")
+ if first.Code != http.StatusSeeOther || signin != "/signin?return_to="+url.QueryEscape(deep) {
+ t.Fatalf("signed-out deep link: %d %q", first.Code, signin)
+ }
+ if rec := do("GET", signin); rec.Code != 200 || jar[homeReturnCookie] == nil {
+ t.Fatalf("GET %s: %d, return remembered: %v", signin, rec.Code, jar[homeReturnCookie] != nil)
+ }
+ link, err := app.TestSignIn(t, "alice@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ verified := do("GET", link)
+ if verified.Header().Get("Location") != localLinkCompletePath {
+ t.Fatalf("the link landed on %q, not %s", verified.Header().Get("Location"), localLinkCompletePath)
+ }
+ landed := do("GET", localLinkCompletePath)
+ if landed.Code != http.StatusSeeOther || landed.Header().Get("Location") != deep {
+ t.Fatalf("landed on %d %q, want %q", landed.Code, landed.Header().Get("Location"), deep)
+ }
+ if jar[homeReturnCookie] != nil {
+ t.Fatal("the return path outlived its one use")
+ }
+ if again := do("GET", localLinkCompletePath); again.Header().Get("Location") != "/" {
+ t.Fatalf("a second landing went to %q, want the list", again.Header().Get("Location"))
+ }
+ // Signed in now: GET /signin goes straight back, with no form.
+ if back := do("GET", "/signin?return_to="+url.QueryEscape(deep)); back.Code != http.StatusSeeOther || back.Header().Get("Location") != deep {
+ t.Fatalf("already signed in: %d %q", back.Code, back.Header().Get("Location"))
+ }
+ for _, bad := range []string{"https://evil.example/", "//evil.example/", "/\\evil", "/%09/evil"} {
+ if back := do("GET", "/signin?return_to="+url.QueryEscape(bad)); back.Header().Get("Location") != "/" {
+ t.Errorf("return_to %q sent a signed-in person to %q", bad, back.Header().Get("Location"))
+ }
+ }
+ // Signing out lands on the signed-out page, not straight back on the
+ // form.
+ out := httptest.NewRequest(http.MethodPost, "/signout", nil)
+ out.Header.Set("Sec-Fetch-Site", "same-origin")
+ out.Header.Set("Origin", DefaultOrigin)
+ for _, c := range jar {
+ out.AddCookie(c)
+ }
+ outRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(outRec, out)
+ if outRec.Code != http.StatusSeeOther || outRec.Header().Get("Location") != "/signed-out" {
+ t.Fatalf("sign out: %d %q", outRec.Code, outRec.Header().Get("Location"))
+ }
+}
+
+// The layout links estilo's stylesheets in the suite's order, each at a
+// content-hashed path, and the brand icons are served from estilo.
+func TestEstiloChromeAssetsAreLinkedInOrder(t *testing.T) {
+ app, _ := NewForTest(t)
+ body := browserGet(app, nil, "GET", "/signin").Body.String()
+ order := []string{`<meta charset="utf-8">`, "/static/tokens.", "/static/theme.", "/static/brand/brand.", "/static/estilo/base.", "/static/estilo/menu.", "/static/estilo/chrome."}
+ last := -1
+ for _, marker := range order {
+ i := strings.Index(body, marker)
+ if i < 0 || i < last {
+ t.Fatalf("%s is missing or out of order", marker)
+ }
+ last = i
+ }
+ for _, name := range []string{"base", "menu", "chrome"} {
+ m := regexp.MustCompile(`/static/estilo/` + name + `\.[0-9a-f]{16}\.css`).FindString(body)
+ if m == "" {
+ t.Fatalf("%s.css is not fingerprinted", name)
+ }
+ if rec := browserGet(app, nil, "GET", m); rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") {
+ t.Fatalf("%s: %d", m, rec.Code)
+ }
+ }
+ icon := browserGet(app, nil, "GET", "/static/estilo/brand/docs.svg")
+ if icon.Code != 200 || icon.Header().Get("Content-Type") != "image/svg+xml" {
+ t.Fatalf("brand icon: %d", icon.Code)
+ }
+}
diff --git a/internal/docs/export.go b/internal/docs/export.go
index 5f5ddd7..ed8a4a6 100644
--- a/internal/docs/export.go
+++ b/internal/docs/export.go
@@ -238,7 +238,7 @@ func (h *handlers) exportPDF(w http.ResponseWriter, r *http.Request) {
body, report, err := export.PDF(structure)
if err != nil {
h.logger.Error("docs: render pdf export", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
if !report.Empty() {
@@ -288,14 +288,14 @@ func (h *handlers) documentForExport(w http.ResponseWriter, r *http.Request) (*D
structureJSON, err := h.store.JSON(r.Context(), doc)
if err != nil {
h.logger.Error("docs: document structure", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return nil, export.Document{}, false
}
structure, err := export.Parse(doc.Title, structureJSON)
if err != nil {
h.logger.Error("docs: parse document structure", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return nil, export.Document{}, false
}
return doc, structure, true
diff --git a/internal/docs/grants_edges_test.go b/internal/docs/grants_edges_test.go
index 2417ed2..225849f 100644
--- a/internal/docs/grants_edges_test.go
+++ b/internal/docs/grants_edges_test.go
@@ -1,6 +1,7 @@
package docs_test
import (
+ "html"
"net/http"
"strings"
"testing"
@@ -24,7 +25,10 @@ func TestGrantRefusesNonsenseAddresses(t *testing.T) {
if rec.Code == http.StatusSeeOther {
t.Errorf("granted to %q", bad)
}
- if body := get(t, app, alice, "/d/"+sqid+"/share").Body.String(); strings.Contains(body, bad) {
+ // Looked for in a grant row, not the whole page: the page's
+ // account menu names alice@example.com, which contains
+ // "@example.com".
+ if body := get(t, app, alice, "/d/"+sqid+"/share").Body.String(); strings.Contains(body, "<span docs-share-who>"+html.EscapeString(bad)+"</span>") {
t.Errorf("the share page lists %q", bad)
}
}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 3bfc1f8..b5a9d2c 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -14,6 +14,8 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/savestatus"
"github.com/carlosframework/rastrillo"
@@ -92,6 +94,13 @@ type handlers struct {
// with a Host header of their choosing. csrf.Protect already trusts
// cfg.Origin for exactly this reason; this trusts the same value.
origin string
+
+ // peek reads the request's session without requiring one, for the
+ // chrome on routes no session guards (chrome.go's viewer). It never
+ // writes. directory is the hub directory origin behind the app
+ // switcher's "All apps", empty when none is configured.
+ peek func(*http.Request) (string, bool)
+ directory string
}
// listPage is GET /'s view data (spec §6.4). Title and the group
@@ -114,6 +123,7 @@ type handlers struct {
// (rastrillo/auth.DefaultSessionTTL); a page with no way to end one
// on purpose is a real gap on something public, not a cosmetic one.
type listPage struct {
+ Frame Frame
PartialCatalog string
HomeLinkLabel string
@@ -254,7 +264,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
who, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
@@ -284,12 +294,19 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
JumpTo: newJumpToView(r),
JumpToScript: jumpToScriptPath(),
}
+ // The list is the app's root: its tab says just "Docs". A result page
+ // names what was searched for (spec §10).
+ var titleParts []string
+ if query != "" {
+ titleParts = []string{"“" + query + "”"}
+ }
+ data.Frame = h.frame(r, frameOptions{titleParts: titleParts})
if query != "" {
hits, err := h.store.SearchFor(r.Context(), who, query)
if err != nil {
h.logger.Error("docs: search documents", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
now := time.Now().UTC()
@@ -306,7 +323,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
}
if err := h.render.page(w, r, "list", data); err != nil {
h.logger.Error("docs: render list", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
}
return
}
@@ -314,13 +331,13 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
docs, err := h.store.ListFor(r.Context(), who)
if err != nil {
h.logger.Error("docs: list documents", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context()), who.PersonID)
if err := h.render.page(w, r, "list", data); err != nil {
h.logger.Error("docs: render list", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
}
}
@@ -344,27 +361,27 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
if err := r.ParseForm(); err != nil {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ h.fail(w, r, http.StatusBadRequest, failStatus)
return
}
token := r.PostFormValue("creation_token")
if token != "" {
parsed, err := uuid.Parse(token)
if err != nil {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ h.fail(w, r, http.StatusBadRequest, failStatus)
return
}
token = parsed.String()
}
title := strings.TrimSpace(r.PostFormValue("title"))
if len(title) > 1024 {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ h.fail(w, r, http.StatusBadRequest, failStatus)
return
}
if title == "" {
@@ -374,7 +391,7 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
doc, err := h.store.CreateDraft(r.Context(), owner, title, token)
if err != nil {
h.logger.Error("docs: create document", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
if wantsNative(r) {
@@ -394,6 +411,7 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
// string, so the template can emit it unescaped — Store.HTML's own
// doc comment is where that "trusted markup" decision is made.
type showPage struct {
+ Frame Frame
HomeLinkLabel string
Title string
@@ -549,11 +567,11 @@ func newDialogView(r *http.Request) dialogView {
// four a person is least likely to discover and most likely to
// need told.
ShortcutsExtra: []shortcutRow{
- {rastrillo.T(r, "docs.editor.shortcuts.indent"), "Tab"},
- {rastrillo.T(r, "docs.editor.shortcuts.outdent"), "Shift+Tab"},
- {rastrillo.T(r, "docs.editor.shortcuts.undoformat"), "Backspace"},
- {rastrillo.T(r, "docs.editor.shortcuts.linebreak"), "Shift+Enter"},
- {rastrillo.T(r, "docs.editor.shortcuts.leave"), "Escape"},
+ {rastrillo.T(r, "docs.editor.shortcuts.indent"), estilomenu.Shortcut(r, "Tab")},
+ {rastrillo.T(r, "docs.editor.shortcuts.outdent"), estilomenu.Shortcut(r, "Shift+Tab")},
+ {rastrillo.T(r, "docs.editor.shortcuts.undoformat"), estilomenu.Shortcut(r, "Backspace")},
+ {rastrillo.T(r, "docs.editor.shortcuts.linebreak"), estilomenu.Shortcut(r, "Shift+Enter")},
+ {rastrillo.T(r, "docs.editor.shortcuts.leave"), estilomenu.Shortcut(r, "Esc")},
},
}
}
@@ -573,7 +591,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
actor, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
@@ -582,11 +600,11 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
doc, err := h.store.GetFor(r.Context(), actor, sqid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
- http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
+ h.fail(w, r, http.StatusNotFound, failDocument)
return
}
h.logger.Error("docs: get document", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
@@ -597,7 +615,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
html, err := h.store.HTML(r.Context(), doc)
if err != nil {
h.logger.Error("docs: document html", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
@@ -623,9 +641,25 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
PageLabel: rastrillo.T(r, "docs.editor.page.number"),
Dialogs: newDialogView(r),
}
+ // The bar: the document's title, editable in place for a writer, and
+ // the owner's Share… (spec §5.1, §5.4).
+ chromeFor := frameOptions{titleParts: []string{doc.Title}, context: doc.Title}
+ if data.CanWrite {
+ chromeFor.edit = &chrome.TitleEdit{
+ Action: "/d/" + doc.Sqid + "/title",
+ Name: "title",
+ Value: doc.Title,
+ Label: rastrillo.T(r, "docs.chrome.title.label"),
+ Submit: rastrillo.T(r, "docs.chrome.title.rename"),
+ }
+ }
+ if data.CanShare {
+ chromeFor.actions = []chrome.Link{{Href: "/d/" + doc.Sqid + "/share", Label: rastrillo.T(r, "docs.share.action")}}
+ }
+ data.Frame = h.frame(r, chromeFor)
if err := h.render.page(w, r, "show", data); err != nil {
h.logger.Error("docs: render show", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
}
}
@@ -651,7 +685,7 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request)
actor, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return nil, false
}
@@ -661,11 +695,11 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request)
doc, err = h.store.GetFor(r.Context(), actor, sqid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
- http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
+ h.fail(w, r, http.StatusNotFound, failDocument)
return nil, false
}
h.logger.Error("docs: get document", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return nil, false
}
return doc, true
@@ -1029,12 +1063,12 @@ func (h *handlers) rename(w http.ResponseWriter, r *http.Request) {
}
actor, err := h.actor(r)
if err != nil {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
if err := r.ParseForm(); err != nil {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ h.fail(w, r, http.StatusBadRequest, failStatus)
return
}
title := strings.TrimSpace(r.PostFormValue("title"))
@@ -1042,16 +1076,23 @@ func (h *handlers) rename(w http.ResponseWriter, r *http.Request) {
title = rastrillo.T(r, "docs.document.untitled")
}
if len(title) > 1024 {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ h.fail(w, r, http.StatusBadRequest, failStatus)
return
}
if err := h.store.Rename(r.Context(), actor, doc, title); err != nil {
if errors.Is(err, ErrNotWritable) || errors.Is(err, gorm.ErrRecordNotFound) {
- http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
+ h.fail(w, r, http.StatusNotFound, failDocument)
return
}
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
- w.WriteHeader(http.StatusNoContent)
+ // The native apps rename with a fetch and read 204. The bar's title
+ // field is a plain form, so a browser is sent back to the document,
+ // 303 so a reload never re-posts.
+ if wantsNative(r) {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther)
}
diff --git a/internal/docs/home_config.go b/internal/docs/home_config.go
index 499b51f..796a0fd 100644
--- a/internal/docs/home_config.go
+++ b/internal/docs/home_config.go
@@ -3,6 +3,9 @@ package docs
import (
"encoding/base64"
"errors"
+ "net"
+ "net/url"
+ "strings"
"amadan.net/oficina/home/handoff/seal"
"amadan.net/rastrillo/rastrillo/assertion"
@@ -90,3 +93,36 @@ func homeConfigBase64(encoded string, limit int) ([]byte, bool) {
}
return decoded, true
}
+
+var errDirectoryConfig = errors.New("docs: invalid $HOME_DIRECTORY_ORIGIN")
+
+// validatedDirectory checks the hub directory origin the app switcher
+// links to, by Home's own originHost rules (home internal/home/origins.go):
+// https, a lower-case DNS name of at least two labels, no IP address, no
+// port, userinfo, path, query or fragment. Empty is allowed and means no
+// switcher. Docs reads it itself because it is not on home/connect, where
+// it would be connect.Credentials.Directory (spec §6.2); that move is R3.
+func validatedDirectory(origin string) (string, error) {
+ if origin == "" {
+ return "", nil
+ }
+ u, err := url.Parse(origin)
+ if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || origin != "https://"+u.Host || u.Host != strings.ToLower(u.Host) || net.ParseIP(u.Host) != nil || len(u.Host) > 253 {
+ return "", errDirectoryConfig
+ }
+ labels := strings.Split(u.Host, ".")
+ if len(labels) < 2 {
+ return "", errDirectoryConfig
+ }
+ for _, label := range labels {
+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+ return "", errDirectoryConfig
+ }
+ for _, c := range label {
+ if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
+ return "", errDirectoryConfig
+ }
+ }
+ }
+ return origin, nil
+}
diff --git a/internal/docs/home_local_navigation.go b/internal/docs/home_local_navigation.go
index 584d441..e5886d2 100644
--- a/internal/docs/home_local_navigation.go
+++ b/internal/docs/home_local_navigation.go
@@ -135,7 +135,12 @@ func (n *localLinkNavigation) Complete(w http.ResponseWriter, r *http.Request) {
return
}
now := n.now()
- target := "/"
+ // The page the person was sent to sign in from (spec §9.4), or the
+ // list. The Home link-start marker still wins: that flow resumes at
+ // /account/home. The remembered page is left in place, as it always
+ // was: LinkStart reads it too, so connecting accounts right after
+ // signing in still returns to the same document.
+ target := n.returnPath(r)
if n.marker(r, now.Unix()) {
count := 0
for _, c := range r.Cookies() {
diff --git a/internal/docs/home_render.go b/internal/docs/home_render.go
index 9c891da..7baf3bd 100644
--- a/internal/docs/home_render.go
+++ b/internal/docs/home_render.go
@@ -3,12 +3,18 @@ package docs
import (
"net/http"
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
"github.com/carlosframework/rastrillo"
)
+// homeLinkPage is views/home_link.html's data: the Home account-connection
+// screens, and the signed-out page (SignedOut set, everything else empty).
type homeLinkPage struct {
+ Frame Frame
Title, Body, Address, Action, ActionLabel, CancelLabel string
PostAction bool
+ SignedOut *chrome.SignInNotice
}
func (rd *renderer) homeLink(w http.ResponseWriter, r *http.Request, state, address string, status int) {
@@ -25,8 +31,15 @@ func (rd *renderer) homeLink(w http.ResponseWriter, r *http.Request, state, addr
page.Action = "/signin/local"
page.ActionLabel = rastrillo.T(r, key+"local_action")
case "signedout":
- page.Action = "/signin"
- page.ActionLabel = rastrillo.T(r, key+"signedout_action")
+ // The signed-out page (spec §9.2): "You’re signed out", with Sign
+ // in going back through Home.
+ page.SignedOut = &chrome.SignInNotice{
+ Icon: brand.Path("docs"),
+ Heading: rastrillo.T(r, "docs.signedout.heading"),
+ Body: rastrillo.T(r, "docs.signedout.body"),
+ Link: &chrome.Link{Href: "/signin", Label: rastrillo.T(r, "docs.chrome.signin")},
+ }
+ page.Title = rastrillo.T(r, "docs.signedout.title")
case "success":
page.Action = "/"
page.ActionLabel = rastrillo.T(r, key+"documents_action")
@@ -35,11 +48,16 @@ func (rd *renderer) homeLink(w http.ResponseWriter, r *http.Request, state, addr
page.Action = "/account/home"
page.ActionLabel = rastrillo.T(r, key+"menu_action")
}
- page.Title = rastrillo.T(r, key+state+"_title")
- page.Body = rastrillo.T(r, key+state+"_body")
+ if page.SignedOut == nil {
+ page.Title = rastrillo.T(r, key+state+"_title")
+ page.Body = rastrillo.T(r, key+state+"_body")
+ }
if state == "start" || state == "confirm" {
page.CancelLabel = rastrillo.T(r, key+"cancel_action")
}
+ if rd.frame != nil {
+ page.Frame = rd.frame(r, frameOptions{titleParts: []string{page.Title}, signinPage: state == "signedout"})
+ }
w.Header().Set("Cache-Control", "no-store")
if err := rd.pageStatus(w, r, "home-link", page, status); err != nil {
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
diff --git a/internal/docs/home_router_integration_test.go b/internal/docs/home_router_integration_test.go
index a7bd9be..971967b 100644
--- a/internal/docs/home_router_integration_test.go
+++ b/internal/docs/home_router_integration_test.go
@@ -115,12 +115,19 @@ func (b *routerBrowser) localLink(t *testing.T, address string) string {
}
return path
}
-func (b *routerBrowser) localSignin(t *testing.T, address string) {
+// localSignin completes a local email sign-in. It lands on the list, or
+// on landing when the browser was sent to sign in from a page (spec
+// §9.4: the remembered return path, not "/").
+func (b *routerBrowser) localSignin(t *testing.T, address string, landing ...string) {
t.Helper()
+ want := "/"
+ if len(landing) == 1 {
+ want = landing[0]
+ }
path := b.localLink(t, address)
rec := b.do("GET", path, "")
routerRedirect(t, rec, localLinkCompletePath)
- routerRedirect(t, b.do("GET", localLinkCompletePath, ""), "/")
+ routerRedirect(t, b.do("GET", localLinkCompletePath, ""), want)
if _, ok := b.cookies[b.f.sessions.CookieName()]; !ok {
t.Fatal("local verification did not set real session")
}
@@ -256,7 +263,9 @@ func TestHomeRouterExistingAccountRoundTripPreservesOwnedAndSharedContent(t *tes
if routerRows(t, f, "people") != 2 || routerRows(t, f, "documents") != 3 || routerRows(t, f, "docs_identity_links") != 0 || b.cookies[f.sessions.CookieName()] != nil {
t.Fatal("unknown Home subject created an account or session")
}
- b.localSignin(t, "alice@example.com")
+ // The local link lands on the document this browser was sent to sign
+ // in from, not the list (spec §9.4).
+ b.localSignin(t, "alice@example.com", resource)
localCookie := b.cookies[f.sessions.CookieName()].Value
b.linkProof(t, "opaque-alice", "alice@example.com")
if b.cookies[f.sessions.CookieName()].Value != localCookie || routerRows(t, f, "docs_identity_links") != 0 {
diff --git a/internal/docs/home_routes.go b/internal/docs/home_routes.go
index 7f93cd0..a5abdfb 100644
--- a/internal/docs/home_routes.go
+++ b/internal/docs/home_routes.go
@@ -117,6 +117,13 @@ func (h *homeRoutes) signin(w http.ResponseWriter, r *http.Request) {
h.handlers.signin(w, r)
return
}
+ // Already signed in to Docs: straight back, with no form and no new
+ // Home exchange (spec §9.2). Read, not required: a person with no
+ // session goes on to Home as before.
+ if _, ok := h.bridge.sessions.From(r); ok {
+ alreadySignedIn(w, r)
+ return
+ }
path := "/"
if values, ok := q["return_to"]; ok && len(values) == 1 && handoff.SafePath(values[0]) {
path = values[0]
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
index 95b96fd..b99a976 100644
--- a/internal/docs/list_test.go
+++ b/internal/docs/list_test.go
@@ -415,22 +415,15 @@ func TestUnknownDocument404s(t *testing.T) {
// person on it with nothing but the browser's own back button, which
// such an arrival gives nothing to go back to.
//
-// The link has moved once since. It was [docs-back], a breadcrumb this
-// page drew above its own title; it is [rst-shell-brand] now, the
-// topbar shell's brand slot, which show.html fills with the same href
-// and the same label (render.go's shellLayout comment has the why).
-// This test moved with it rather than being deleted, because what it
-// holds is the route, not the element that happened to carry it.
+// The link has moved twice since. It was [docs-back], a breadcrumb this
+// page drew above its own title; then the rastrillo topbar's brand slot;
+// and now the estilo bar's brand (chrome.go), which every page renders
+// from the same data. This test moved with it rather than being deleted,
+// because what it holds is the route, not the element that carries it.
//
-// Note what that means for each half below, because they are not
-// equally load-bearing any more. The layout's own default brand is
-// already <a rst-shell-brand href="/">, so a page that stopped
-// defining "brand" would keep the href and lose only the label: the
-// first assertion would still pass and the second is what would
-// catch it. The first is not useless — it catches the shell being
-// swapped for one with no brand at all, which is exactly the kind of
-// whole-app change this branch itself was — but the label comparison
-// is the one doing the work.
+// The label changed with the last move, deliberately: the brand names the
+// app, "Docs", and no longer borrows the list's heading, "Documents"
+// (spec §10: the brand is the app's name; the h1 names the screen).
//
// Asserted on the rendered page rather than the template's bytes: the
// href has to survive the handler populating it, not merely be
@@ -456,35 +449,15 @@ func TestDocumentPageLinksBackToTheList(t *testing.T) {
}
body := rec.Body.String()
- if !strings.Contains(body, `<a rst-shell-brand estilo-brand href="/">`) {
+ if !strings.Contains(body, `<a estilo-bar-brand href="/">`) {
t.Error("no brand link to / on the document page — a document reached directly has no route to the list")
}
-
- // The label is docs.list.title, deliberately shared with the screen
- // it points at rather than given a docs.show.back of its own
- // (showPage.BackLabel's doc comment). Assert that by rendering the
- // list and comparing the two, rather than by looking for the
- // literal "Documents": a direct App.Handler() call installs no
- // locale middleware, so rastrillo.T here returns the key verbatim
- // (render.go's Locales comment) and the English string never
- // appears. Comparing the two pages holds the invariant that
- // actually matters — the link and its destination's own title are
- // one string — and holds it in every locale rather than only in the
- // one this test happens to run in.
- listReq := httptest.NewRequest(http.MethodGet, "/", nil)
- for _, c := range cookies {
- listReq.AddCookie(c)
- }
- listRec := httptest.NewRecorder()
- app.Handler().ServeHTTP(listRec, listReq)
- if listRec.Code != http.StatusOK {
- t.Fatalf("list status %d", listRec.Code)
- }
- listTitle := betweenTags(t, listRec.Body.String(), "<h1>", "</h1>")
- backLabel := betweenTags(t, body, `<a rst-shell-brand estilo-brand href="/">`, "</a>")
- backLabel = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(backLabel, "")
- if backLabel == "" || backLabel != listTitle {
- t.Errorf("back link label = %q, want the list's own <h1> (%q)", backLabel, listTitle)
+ // A direct App.Handler() call installs no locale middleware, so
+ // rastrillo.T returns the key: the brand's label is the app's name key.
+ label := betweenTags(t, body, `<a estilo-bar-brand href="/">`, "</a>")
+ label = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(label, "")
+ if label != "docs.app.name" {
+ t.Errorf("brand label = %q, want the app's name (docs.app.name)", label)
}
}
diff --git a/internal/docs/menu.go b/internal/docs/menu.go
index 3779653..f561a96 100644
--- a/internal/docs/menu.go
+++ b/internal/docs/menu.go
@@ -1,7 +1,8 @@
package docs
-// The menu bar's model and copy — File / Edit / View / Insert / Format
-// / Tools, rendered by views/show.html's [docs-menubar] and given its
+// The menu bar's model and copy — File / Edit / View / Format / Tools,
+// rendered by views/show.html on estilo's [estilo-menubar] and
+// [estilo-menu] (the one menu style the suite shares) and given its
// keyboard behaviour by fixtures/menu.mjs.
//
// It replaces the two export links that used to be pinned to the
@@ -49,6 +50,7 @@ package docs
import (
"net/http"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"github.com/carlosframework/rastrillo"
)
@@ -74,30 +76,39 @@ const (
// menuItem is one row in one menu.
//
-// Disabled is set here, server-side, for items whose feature has not
-// landed yet — and they are rendered rather than omitted on purpose. A
-// menu that hides what the app will obviously grow gets redesigned when
-// the feature arrives; one that greys it gets filled in. Each disabled
-// item below names the section of the spec it is waiting on, so the
-// list cannot rot into a set of items nobody remembers the plan for.
+// Every item works in this build. An item whose feature is not built is
+// not in the menu at all (spec §7.3: hidden, not disabled): the Insert
+// menu, Find and replace and Show ruler used to be rendered disabled
+// with the spec section each was waiting on, and a bar of greyed-out
+// promises is the main thing that made the editor read as a prototype.
+// Disabled is now only ever contextual — Redo with nothing to redo —
+// and that is decided by the command itself in fixtures/menu.mjs.
type menuItem struct {
Kind string
Command string // menuCommand: the data-docs-command name
Href string // menuLink: the target
Action string // menuForm: the POST target
Download bool // menuLink: adds the download attribute
- Label string
- Shortcut string // shown right-aligned; the binding itself lives in the bundle's keymap
- Disabled bool
- // Pressed renders aria-pressed on a command item — for the items
- // that report a state rather than only performing an action. The
- // View menu's paged/infinite pair is the only user today, and it is
- // a one-of group: exactly one is pressed.
+ // JumpTo marks the File menu's "Open a document…": a link to the
+ // list that fixtures/jump-to.mjs turns into the ⌘K palette.
+ JumpTo bool
+ Label string
+ // Shortcut is the chord, already formatted for the request's
+ // platform by estilo's menu.Shortcut (⇧⌘Z on a Mac, Ctrl+Y
+ // elsewhere), so nothing flips after paint. The binding itself lives
+ // in the bundle's keymap.
+ Shortcut string
+ // Checkable items report a state with estilo's check column: the View
+ // menu's paged/infinite pair, the Format menu's paragraph styles and
+ // alignments. Each is a one-of group, so exactly one is pressed.
//
- // Rendered server-side so the menu is right before any script runs,
- // which matters because the mode itself is decided server-side from
- // a cookie. fixtures/menu.mjs keeps it in step afterwards.
- Pressed bool
+ // Pressed is rendered server-side where the server knows the state
+ // (the view mode, from its cookie), so the menu is right before any
+ // script runs; fixtures/menu.mjs keeps every checkable item in step
+ // with the editor afterwards, on every state update and after a view
+ // switch.
+ Checkable bool
+ Pressed bool
}
// menu is one top-level <details> in the bar.
@@ -109,6 +120,18 @@ type menu struct {
Items []menuItem
}
+// Checks reports whether the menu has any checkable item. Such a menu
+// renders the check column on every row, so labels stay aligned; a plain
+// menu has no empty gutter.
+func (m menu) Checks() bool {
+ for _, it := range m.Items {
+ if it.Checkable {
+ return true
+ }
+ }
+ return false
+}
+
// menuView is what views/show.html renders.
type menuView struct {
// Label is the bar's own accessible name.
@@ -172,8 +195,11 @@ func fileItems(r *http.Request, sqid string, canCreate bool) []menuItem {
if canCreate {
items = append(items, menuItem{Kind: menuForm, Action: "/documents", Label: rastrillo.T(r, "docs.editor.menu.file.new")})
}
+ // Sign out used to be the last item here too. It is in the account
+ // menu and nowhere else now (spec §8.2): once, in the same corner, in
+ // every app.
return append(items,
- menuItem{Kind: menuLink, Href: "/", Label: rastrillo.T(r, "docs.editor.menu.file.open")},
+ menuItem{Kind: menuLink, Href: "/", JumpTo: true, Label: rastrillo.T(r, "docs.editor.menu.file.open"), Shortcut: estilomenu.Shortcut(r, "Mod+K")},
menuItem{Kind: menuDivider},
// The two exports, moved here from [docs-doc-actions]. Still plain
// links with the download attribute, so they behave identically to
@@ -181,14 +207,23 @@ func fileItems(r *http.Request, sqid string, canCreate bool) []menuItem {
// did not change, only where it is named.
menuItem{Kind: menuLink, Href: "/d/" + sqid + "/export.pdf", Download: true, Label: rastrillo.T(r, "docs.export.pdf.label")},
menuItem{Kind: menuLink, Href: "/d/" + sqid + "/export.md", Download: true, Label: rastrillo.T(r, "docs.export.markdown.label")},
- menuItem{Kind: menuCommand, Command: "print", Label: rastrillo.T(r, "docs.editor.menu.file.print"), Shortcut: "Ctrl+P"},
- menuItem{Kind: menuDivider},
- menuItem{Kind: menuForm, Action: "/signout", Label: rastrillo.T(r, "docs.signout.label")},
+ menuItem{Kind: menuCommand, Command: "print", Label: rastrillo.T(r, "docs.editor.menu.file.print"), Shortcut: estilomenu.Shortcut(r, "Mod+P")},
)
}
+// redoChord is the redo binding a person on this platform expects. The
+// keymap binds both (editor-entry.js: Mod-y and Mod-Shift-z); the label
+// names the one each platform's own editors use.
+func redoChord(r *http.Request) string {
+ if estilomenu.Apple(r) {
+ return "Mod+Shift+Z"
+ }
+ return "Mod+Y"
+}
+
func newMenuView(r *http.Request, sqid string, canCreate bool) menuView {
mode := viewMode(r)
+ key := func(chord string) string { return estilomenu.Shortcut(r, chord) }
return menuView{
Label: rastrillo.T(r, "docs.editor.menu.label"),
Menus: []menu{
@@ -201,16 +236,10 @@ func newMenuView(r *http.Request, sqid string, canCreate bool) menuView {
ID: "edit",
Title: rastrillo.T(r, "docs.editor.menu.edit"),
Items: []menuItem{
- {Kind: menuCommand, Command: "undo", Label: rastrillo.T(r, "docs.editor.toolbar.undo"), Shortcut: "Ctrl+Z"},
- {Kind: menuCommand, Command: "redo", Label: rastrillo.T(r, "docs.editor.toolbar.redo"), Shortcut: "Ctrl+Y"},
+ {Kind: menuCommand, Command: "undo", Label: rastrillo.T(r, "docs.editor.toolbar.undo"), Shortcut: key("Mod+Z")},
+ {Kind: menuCommand, Command: "redo", Label: rastrillo.T(r, "docs.editor.toolbar.redo"), Shortcut: key(redoChord(r))},
{Kind: menuDivider},
- {Kind: menuCommand, Command: "select_all", Label: rastrillo.T(r, "docs.editor.menu.edit.selectall"), Shortcut: "Ctrl+A"},
- {Kind: menuDivider},
- // Find and replace is spec §11, not built yet.
- // Deliberately NOT wired to the existing Ctrl-K
- // palette, which searches for documents rather than
- // within one — see the catalog comment on this key.
- {Kind: menuCommand, Command: "find_replace", Label: rastrillo.T(r, "docs.editor.menu.edit.find"), Disabled: true},
+ {Kind: menuCommand, Command: "select_all", Label: rastrillo.T(r, "docs.editor.menu.edit.selectall"), Shortcut: key("Mod+A")},
},
},
{
@@ -219,9 +248,7 @@ func newMenuView(r *http.Request, sqid string, canCreate bool) menuView {
Items: []menuItem{
// A pair rather than one toggle, so the current mode
// is readable without interpreting a checkmark's
- // absence. Exactly one is pressed;
- // fixtures/menu.mjs sets aria-pressed from the mode
- // the same way the toolbar's alignment buttons work.
+ // absence. Exactly one is pressed.
//
// aria-pressed on a <button>, NOT aria-checked:
// aria-checked belongs to menuitemcheckbox and
@@ -230,29 +257,8 @@ func newMenuView(r *http.Request, sqid string, canCreate bool) menuView {
// own comment for why). Using the attribute that
// does not apply to the element is worse than using
// none.
- {Kind: menuCommand, Command: "view_paged", Label: rastrillo.T(r, "docs.editor.menu.view.paged"), Pressed: mode == ViewPaged},
- {Kind: menuCommand, Command: "view_infinite", Label: rastrillo.T(r, "docs.editor.menu.view.infinite"), Pressed: mode != ViewPaged},
- {Kind: menuDivider},
- // Spec §7.3: the ruler arrives with tab stops, which
- // are specified and deliberately not built tonight.
- {Kind: menuCommand, Command: "view_ruler", Label: rastrillo.T(r, "docs.editor.menu.view.ruler"), Disabled: true},
- },
- },
- {
- ID: "insert",
- Title: rastrillo.T(r, "docs.editor.menu.insert"),
- Items: []menuItem{
- // Link is spec §10 and ours; image and table belong
- // to Plan 7 and another session. The command names
- // were agreed with that session so its work drops
- // into this same table rather than forcing a menu
- // redesign — see the spec's §1.4.
- {Kind: menuCommand, Command: "insert-link", Label: rastrillo.T(r, "docs.editor.menu.insert.link"), Shortcut: "Ctrl+Shift+K", Disabled: true},
- {Kind: menuCommand, Command: "insert-image", Label: rastrillo.T(r, "docs.editor.menu.insert.image"), Disabled: true},
- {Kind: menuCommand, Command: "insert-table", Label: rastrillo.T(r, "docs.editor.menu.insert.table"), Disabled: true},
- {Kind: menuDivider},
- {Kind: menuCommand, Command: "insert-page-break", Label: rastrillo.T(r, "docs.editor.menu.insert.pagebreak"), Disabled: true},
- {Kind: menuCommand, Command: "insert-rule", Label: rastrillo.T(r, "docs.editor.menu.insert.rule"), Disabled: true},
+ {Kind: menuCommand, Command: "view_paged", Label: rastrillo.T(r, "docs.editor.menu.view.paged"), Checkable: true, Pressed: mode == ViewPaged},
+ {Kind: menuCommand, Command: "view_infinite", Label: rastrillo.T(r, "docs.editor.menu.view.infinite"), Checkable: true, Pressed: mode != ViewPaged},
},
},
{
@@ -264,29 +270,26 @@ func newMenuView(r *http.Request, sqid string, canCreate bool) menuView {
// adds no behaviour here at all — it is a second
// surface onto the same names, which is the whole
// reason property 2 above is worth a test.
- {Kind: menuCommand, Command: "strong", Label: rastrillo.T(r, "docs.editor.toolbar.bold"), Shortcut: "Ctrl+B"},
- {Kind: menuCommand, Command: "em", Label: rastrillo.T(r, "docs.editor.toolbar.italic"), Shortcut: "Ctrl+I"},
+ {Kind: menuCommand, Command: "strong", Label: rastrillo.T(r, "docs.editor.toolbar.bold"), Shortcut: key("Mod+B")},
+ {Kind: menuCommand, Command: "em", Label: rastrillo.T(r, "docs.editor.toolbar.italic"), Shortcut: key("Mod+I")},
{Kind: menuCommand, Command: "code", Label: rastrillo.T(r, "docs.editor.toolbar.code")},
{Kind: menuDivider},
- // The paragraph styles. A Format menu without them
- // is not a Format menu — caught in review, and the
- // omission came from reading the toolbar's Style
- // select as covering it. It does, for a person who
- // can see the bar; the menu is the surface for
- // someone working from the keyboard, and it has to
- // stand alone.
+ // The paragraph styles, checked for the one the caret
+ // is in. "Body text" used to show as DISABLED there,
+ // because setting the style it already has does
+ // nothing: disabled standing in for current.
//
// The command names match the block select's own
// option values (export.go's Blocks), so the two
// controls set the same thing by the same name.
- {Kind: menuCommand, Command: "block_paragraph", Label: rastrillo.T(r, "docs.editor.toolbar.block.paragraph")},
- {Kind: menuCommand, Command: "block_heading-1", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading1")},
- {Kind: menuCommand, Command: "block_heading-2", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading2")},
- {Kind: menuCommand, Command: "block_heading-3", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading3")},
+ {Kind: menuCommand, Command: "block_paragraph", Label: rastrillo.T(r, "docs.editor.toolbar.block.paragraph"), Checkable: true},
+ {Kind: menuCommand, Command: "block_heading-1", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading1"), Checkable: true},
+ {Kind: menuCommand, Command: "block_heading-2", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading2"), Checkable: true},
+ {Kind: menuCommand, Command: "block_heading-3", Label: rastrillo.T(r, "docs.editor.toolbar.block.heading3"), Checkable: true},
{Kind: menuDivider},
- {Kind: menuCommand, Command: "align_left", Label: rastrillo.T(r, "docs.editor.toolbar.align.left")},
- {Kind: menuCommand, Command: "align_center", Label: rastrillo.T(r, "docs.editor.toolbar.align.center")},
- {Kind: menuCommand, Command: "align_right", Label: rastrillo.T(r, "docs.editor.toolbar.align.right")},
+ {Kind: menuCommand, Command: "align_left", Label: rastrillo.T(r, "docs.editor.toolbar.align.left"), Checkable: true},
+ {Kind: menuCommand, Command: "align_center", Label: rastrillo.T(r, "docs.editor.toolbar.align.center"), Checkable: true},
+ {Kind: menuCommand, Command: "align_right", Label: rastrillo.T(r, "docs.editor.toolbar.align.right"), Checkable: true},
{Kind: menuDivider},
{Kind: menuCommand, Command: "bullet_list", Label: rastrillo.T(r, "docs.editor.toolbar.bulletlist")},
{Kind: menuCommand, Command: "ordered_list", Label: rastrillo.T(r, "docs.editor.toolbar.orderedlist")},
@@ -297,8 +300,8 @@ func newMenuView(r *http.Request, sqid string, canCreate bool) menuView {
// keymap in editor-entry.js. The shortcut shown
// here is therefore a statement of fact rather than
// a second binding.
- {Kind: menuCommand, Command: "indent", Label: rastrillo.T(r, "docs.editor.menu.format.indent"), Shortcut: "Tab"},
- {Kind: menuCommand, Command: "outdent", Label: rastrillo.T(r, "docs.editor.menu.format.outdent"), Shortcut: "Shift+Tab"},
+ {Kind: menuCommand, Command: "indent", Label: rastrillo.T(r, "docs.editor.menu.format.indent"), Shortcut: key("Tab")},
+ {Kind: menuCommand, Command: "outdent", Label: rastrillo.T(r, "docs.editor.menu.format.outdent"), Shortcut: key("Shift+Tab")},
{Kind: menuDivider},
{Kind: menuCommand, Command: "clear_formatting", Label: rastrillo.T(r, "docs.editor.menu.format.clear")},
},
diff --git a/internal/docs/menu_test.go b/internal/docs/menu_test.go
index 5b726fa..0f415c7 100644
--- a/internal/docs/menu_test.go
+++ b/internal/docs/menu_test.go
@@ -59,20 +59,8 @@ func TestMenuCommandsExistInTheBundle(t *testing.T) {
}
v := newMenuViewForTest()
- unavailable := map[string]bool{}
- for _, m := range v.Menus {
- for _, it := range m.Items {
- if it.Kind == menuCommand && it.Disabled {
- unavailable[it.Command] = true
- }
- }
- }
-
seen := 0
for _, name := range menuCommandNames(v) {
- if unavailable[name] {
- continue
- }
seen++
if strings.Contains(string(toolbar), name) ||
strings.Contains(string(menu), name) ||
@@ -82,14 +70,13 @@ func TestMenuCommandsExistInTheBundle(t *testing.T) {
t.Errorf("menu.go offers %q, which none of fixtures/toolbar.mjs, fixtures/menu.mjs or fixtures/editor-entry.js implements", name)
}
- // A guard on the guard. If every command were flagged unavailable
- // — which a careless edit to menu.go could do — the loop above
- // would check nothing and report success. This repo has found
+ // A guard on the guard. If the menu offered no commands at all the
+ // loop above would check nothing and report success. This repo has found
// several assertions unable to fail against the thing they name;
// the shape is always a check that cannot distinguish "no" from "I
// looked at nothing".
if seen == 0 {
- t.Fatal("no available menu commands to check — every item is flagged unavailable, so this test verified nothing")
+ t.Fatal("no menu commands to check, so this test verified nothing")
}
}
@@ -206,9 +193,6 @@ func TestExportsAreScriptlessLinksInTheFileMenu(t *testing.T) {
if !it.Download {
t.Errorf("%s is missing the download attribute", it.Href)
}
- if it.Disabled {
- t.Errorf("%s is disabled", it.Href)
- }
}
for href, found := range want {
if !found {
diff --git a/internal/docs/pages_test.go b/internal/docs/pages_test.go
index 68ed949..8c5b085 100644
--- a/internal/docs/pages_test.go
+++ b/internal/docs/pages_test.go
@@ -141,29 +141,27 @@ func TestTheViewMenuReportsTheCurrentMode(t *testing.T) {
}
}
-// Neither View item ships disabled any more.
-//
-// They were rendered with data-docs-unavailable while pagination was
-// unbuilt, which is how this menu marks an item whose feature does not
-// exist — and fixtures/menu.mjs never enables one of those. Leaving the
-// flag on after building the feature would have shipped a View menu
-// that greys out the thing it just gained.
-func TestTheViewModeItemsAreAvailable(t *testing.T) {
+// Both View items are checkable: the pair shows the current mode with
+// estilo's check column, and exactly one is pressed.
+func TestTheViewModeItemsAreCheckable(t *testing.T) {
v := newMenuViewForTest()
- found := 0
+ found, pressed := 0, 0
for _, m := range v.Menus {
for _, it := range m.Items {
if it.Command != "view_paged" && it.Command != "view_infinite" {
continue
}
found++
- if it.Disabled {
- t.Errorf("%q is still marked unavailable, so the menu will never enable it", it.Command)
+ if !it.Checkable {
+ t.Errorf("%q has no check state", it.Command)
+ }
+ if it.Pressed {
+ pressed++
}
}
}
- if found != 2 {
- t.Fatalf("found %d view-mode items, expected 2 — this test no longer describes the View menu", found)
+ if found != 2 || pressed != 1 {
+ t.Fatalf("found %d view-mode items with %d pressed, expected 2 and 1", found, pressed)
}
}
diff --git a/internal/docs/publish.go b/internal/docs/publish.go
index 361a31a..7d6fb1f 100644
--- a/internal/docs/publish.go
+++ b/internal/docs/publish.go
@@ -21,6 +21,7 @@ import (
// and printing it on a page anyone can read would hand a stranger the URL
// the signed-in app is organised around.
type publicPage struct {
+ Frame Frame
Title string
HTML template.HTML
@@ -46,18 +47,18 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) {
doc, err := h.store.ByToken(r.Context(), r.PathValue("token"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
- http.NotFound(w, r)
+ h.fail(w, r, http.StatusNotFound, failDocument)
return
}
h.logger.Error("docs: resolve public link", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
html, err := h.store.HTML(r.Context(), doc)
if err != nil {
h.logger.Error("docs: public document html", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
@@ -76,12 +77,13 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
if err := h.render.page(w, r, "public", publicPage{
+ Frame: h.frame(r, frameOptions{titleParts: []string{doc.Title}, context: doc.Title, public: true}),
Title: doc.Title,
HTML: html,
ReadOnly: rastrillo.T(r, "docs.public.readonly"),
}); err != nil {
h.logger.Error("docs: render public document", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
}
}
@@ -104,7 +106,7 @@ func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) {
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for publish", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
sqid := r.PathValue("sqid")
@@ -130,9 +132,9 @@ func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
case errors.Is(err, gorm.ErrRecordNotFound):
- http.NotFound(w, r)
+ h.fail(w, r, http.StatusNotFound, failDocument)
default:
h.logger.Error("docs: set public link", "sqid", sqid, "on", on, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
}
}
diff --git a/internal/docs/render.go b/internal/docs/render.go
index 2e2e741..a1a87fa 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -9,83 +9,68 @@ import (
"net/http"
"testing/fstest"
+ "amadan.net/oficina/estilo.git/base"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/savestatus"
"amadan.net/oficina/estilo.git/theme"
"github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/ui"
)
-// dayTheme and shellLayout pick the design system's day theme and
-// topbar shell.
+// dayTheme is the design system's day theme, which estilo's theme is
+// byte-for-byte (estilo AGENTS.md).
//
-// The shell was "column" from Task 1 until the formatting bar
-// landed — rastrillo new's own default, and the right shape while
-// every screen really was one centred column of content. The editor
-// is what made it wrong. A document screen carries three things that
-// are not the document: its own identity, the account, and the
-// document-level actions. With no shell bar to hold any of them they
-// all ended up in [rst-page-header], which is a *screen heading*
-// ("Documents", "Posts") and not an app bar. What that looked like:
-// a document's title set at 22px with the design system's rake
-// stroke under it, and PDF / Markdown / Sign out as three
-// identically-weighted ghost buttons beside it — chrome competing
-// with the document for the top of the screen.
-//
-// topbar gives both signed-in screens one bar: brand, the screen's
-// own identity, the account menu. That is the layout language Google
-// Docs and Word share, and the reason a document and a document list
-// read as one app in either of them. It is the design system's own
-// shell rather than a bar this app drew for itself, which is what
-// spec §10's "the thing to watch is drift in the chrome" asks for.
-//
-// Two things to know before changing this again. It is a whole-app
-// choice — newRenderer parses exactly one layout, so there is no
-// per-screen opt-out. And a page's only lever on the bar is the
-// blocks the layout exposes (brand, nav, account, locale, foot):
-// the bar element itself, the account disclosure and the narrow-width
-// hamburger are markup the shell emits outside all of them. Leaving
-// "account" undefined does remove the account menu — tokens.css hides
-// a disclosure whose menu is empty — but nothing removes the default
-// brand, so a screen that must offer no bar at all hides it in its
-// own stylesheet. signin.html is the one that does, and its comment
-// says why, including why the obvious selector for it silently fails.
-const (
- dayTheme = "day"
- shellLayout = "topbar"
-)
+// There is no rastrillo shell any more. The topbar shell's bar hard-coded
+// an "Account" summary and a tail that wrapped (45px on the public page,
+// 51px elsewhere, 96px at 390px), and estilo may not restyle rastrillo's
+// markup, so every page now renders views/layout.html with estilo's own
+// bar (chrome.go builds its data). That is the change the whole suite
+// makes in round 2; Docs is the first to make it.
+const dayTheme = "day"
// Catalog is CARLOS Docs' own English strings. Every user-visible
// string this app writes is a key here (never a literal in a
// template or a handler), resolved through rastrillo.T at render
// time. cmd/docs/main.go wires it in as rastrillo.Options.BaseCatalog.
var Catalog = rastrillo.Catalog{
- "docs.home.signedout_title": "Signed out of Docs",
- "docs.home.signedout_body": "You can sign in again with Home.",
- "docs.home.signedout_action": "Sign in with Home",
-
- "docs.home.start_title": "Connect Docs to Home",
- "docs.home.start_body": "Use Home to sign in to this Docs account. Your documents and sharing settings stay as they are.",
- "docs.home.start_action": "Continue to Home",
+ // The signed-out page (spec §9.2). It says what is true under the
+ // in-app sign-out we have: this app, on this device. (Signing out of
+ // Oficina's own session as well is the Home sign-out chain, a
+ // follow-up.)
+ "docs.signedout.title": "Signed out",
+ "docs.signedout.heading": "You’re signed out",
+ "docs.signedout.body": "You’ve signed out of Docs on this device.",
+
+ // docs.home.* — connecting a Docs account to the suite account. The
+ // hub is never named ("Home" leaves user copy, spec §10 and §13 Q3):
+ // the account is "your Oficina account", and the verb stays Connect.
+ "docs.home.start_title": "Connect your Oficina account",
+ "docs.home.start_body": "Use your Oficina account to sign in to this Docs account. Your documents and sharing settings stay as they are.",
+ "docs.home.start_action": "Connect",
"docs.home.cancel_action": "Back to Docs",
"docs.home.confirm_title": "Confirm account connection",
- "docs.home.confirm_body": "Connect the Docs account below to the Home account you just signed into.",
+ "docs.home.confirm_body": "Connect the Docs account below to the Oficina account you just signed in to.",
"docs.home.confirm_action": "Connect accounts",
- "docs.home.unknown_title": "Home account not connected",
- "docs.home.unknown_body": "Sign in to your existing Docs account, then connect Home from the account menu.",
+ "docs.home.unknown_title": "Oficina account not connected",
+ "docs.home.unknown_body": "Sign in to your existing Docs account, then connect your Oficina account from the account menu.",
"docs.home.local_action": "Sign in to Docs",
"docs.home.failed_title": "Could not connect accounts",
"docs.home.failed_body": "Return to Docs and start again.",
"docs.home.success_title": "Accounts connected",
- "docs.home.success_body": "You can now use Home to sign in to this Docs account.",
+ "docs.home.success_body": "You can now use your Oficina account to sign in to this Docs account.",
"docs.home.documents_action": "Back to documents",
- "docs.home.menu_action": "Connect Home account",
+ "docs.home.menu_action": "Connect your Oficina account",
// docs.list.* — GET /, spec §6.4. Title is the screen's noun, per
// page-header's own contract ("Posts", not "Home"). The four group
// labels are recency buckets (handlers.go's recencyBucket); a
// document's own title and its row's timestamp are data, not
// authored copy, so neither is a catalog key.
- "docs.share.title": "Share",
+ "docs.share.title": "Share",
+ // docs.share.action is the bar's action on a document (spec §7.3: the
+ // ellipsis, because it opens a form before anything happens).
+ "docs.share.action": "Share…",
"docs.share.heading": "Who can open this",
"docs.share.address": "Email address",
"docs.share.role": "They can",
@@ -191,6 +176,60 @@ var Catalog = rastrillo.Catalog{
"docs.jumpto.failed": "Search is unavailable right now.",
"docs.jumpto.close": "Close",
+ // docs.app.name is the brand in the bar and the last part of every
+ // <title> (spec §10): the app's name, never "Documents", which is the
+ // list's heading.
+ "docs.app.name": "Docs",
+
+ // docs.chrome.* is the estilo bar and account menu (chrome.go).
+ "docs.chrome.skip": "Skip to content",
+ "docs.chrome.menu": "Menu",
+ "docs.chrome.signin": "Sign in",
+ "docs.chrome.account": "Account: {address}",
+ "docs.chrome.account.anonymous": "Account",
+ "docs.chrome.signedinas": "Signed in as",
+ "docs.chrome.appearance": "Appearance",
+ "docs.chrome.appearance.light": "Light",
+ "docs.chrome.appearance.dark": "Dark",
+ "docs.chrome.appearance.system": "System",
+ "docs.chrome.help": "Help",
+ // The switcher. "All apps" rather than "Oficina": as a menu label,
+ // the suite's name collides with the brand on the hub's own bar
+ // (spec §13 Q2, Paul's answer).
+ "docs.chrome.apps": "Oficina apps",
+ "docs.chrome.allapps": "All apps",
+ "docs.chrome.thisapp": "(this app)",
+ // The editable title in the bar (spec §5.4, §13 Q7).
+ "docs.chrome.title.label": "Document title",
+ "docs.chrome.title.rename": "Rename",
+
+ // docs.error.page.* is the error page (errors.go, spec §3.2). Each
+ // status has a tab title, a heading and one sentence; {app} is the
+ // app's name. A document that is missing and one not shared with you
+ // are one page, so neither can be told from the other.
+ "docs.error.page.404.title": "Page not found",
+ "docs.error.page.404.heading": "We can’t find that page",
+ "docs.error.page.404.body": "The link may be out of date, or the page may have moved.",
+ "docs.error.page.document.title": "Document not available",
+ "docs.error.page.document.heading": "This document isn’t available",
+ "docs.error.page.document.body": "It may have been deleted, or it hasn’t been shared with you.",
+ "docs.error.page.403.title": "No access",
+ "docs.error.page.403.heading": "You don’t have access to this page",
+ "docs.error.page.403.body": "Ask whoever runs this team if you think you should.",
+ "docs.error.page.400.title": "Something went wrong",
+ "docs.error.page.400.heading": "That didn’t work",
+ "docs.error.page.400.body": "Go back and try again.",
+ "docs.error.page.500.title": "Something went wrong",
+ "docs.error.page.500.heading": "Something went wrong",
+ "docs.error.page.500.body": "Try again in a moment. If it keeps happening, quote the reference below.",
+ "docs.error.page.503.title": "{app} is unavailable",
+ "docs.error.page.503.heading": "{app} is unavailable right now",
+ "docs.error.page.503.body": "Try again in a few minutes.",
+ "docs.error.home": "Go to {app}",
+ "docs.error.retry": "Try again",
+ "docs.error.back": "Back",
+ "docs.error.ref": "Reference:",
+
// docs.error.generic is the one fallback error string this task
// has: a render failure with nowhere styled to land yet (SKILL.md
// §7's view.Fail/ErrorPage wiring is later work). Still a catalog
@@ -243,16 +282,25 @@ var Catalog = rastrillo.Catalog{
// finer distinction between ?err=keymail and ?err=1 is drawn
// either (rastrillo/auth's own handlers already refuse to be an
// oracle between "wrong" and "unknown").
+ //
+ // The copy is the suite's (spec §9.2): "Email me a link", the help
+ // line saying what the link does and how long it lasts (keymaildev
+ // signin's LinkTTL is 15 minutes), and a sent state that replaces the
+ // form. The sent state cannot name the address, which the spec's copy
+ // does: rastrillo/auth's Begin redirects to ?sent=1 and carries no
+ // address, so it says "that address".
"docs.signin.title": "Sign in",
- "docs.signin.body": "Enter your email address to sign in.",
"docs.signin.address.label": "Email address",
- "docs.signin.submit": "Continue",
+ "docs.signin.help": "We’ll email you a link that signs you in. It works once and expires in 15 minutes.",
+ "docs.signin.submit": "Email me a link",
"docs.signin.keymail": "Continue to Keymail",
- "docs.signin.sent": "Check your email for a link to sign in.",
- "docs.signin.error.rate": "Too many attempts. Wait a few minutes and try again.",
- "docs.signin.error.address": "That doesn't look like a valid email address.",
- "docs.signin.error.expired": "That link has expired or was already used. Enter your email below for a new one.",
- "docs.signin.error.unknown": "We couldn't sign you in. Enter your email below for a new link.",
+ "docs.signin.sent.title": "Check your email",
+ "docs.signin.sent.body": "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.",
+ "docs.signin.sent.again": "Use a different address",
+ "docs.signin.error.rate": "Too many attempts. Wait a few minutes, then try again.",
+ "docs.signin.error.address": "That doesn’t look like an email address.",
+ "docs.signin.error.expired": "That link has expired or was already used. Enter your email to get a new one.",
+ "docs.signin.error.unknown": "We couldn’t sign you in. Enter your email to get a new link.",
// docs.signout.label is the sign-out control's text — both its
// button label and its accessible name. It lives in the shell
@@ -339,7 +387,7 @@ var Catalog = rastrillo.Catalog{
"docs.editor.toolbar.align.center": "Align center",
"docs.editor.toolbar.align.right": "Align right",
- // docs.editor.menu.* — the menu bar (show.html's [docs-menubar],
+ // docs.editor.menu.* — the menu bar (show.html's [estilo-menubar],
// built by menu.go, given its keyboard behaviour by
// fixtures/menu.mjs).
//
@@ -358,31 +406,20 @@ var Catalog = rastrillo.Catalog{
"docs.editor.menu.file": "File",
"docs.editor.menu.edit": "Edit",
"docs.editor.menu.view": "View",
- "docs.editor.menu.insert": "Insert",
"docs.editor.menu.format": "Format",
"docs.editor.menu.tools": "Tools",
"docs.editor.menu.file.new": "New document",
- "docs.editor.menu.file.open": "Open a document",
- "docs.editor.menu.file.print": "Print",
+ // The ellipsis (U+2026) marks an item that needs more before anything
+ // happens: a palette, a dialog (spec §7.3). An item that simply does
+ // its thing, or navigates, has none.
+ "docs.editor.menu.file.open": "Open a document…",
+ "docs.editor.menu.file.print": "Print…",
"docs.editor.menu.edit.selectall": "Select all",
- // "Find and replace", not "Find" — the distinction is load-bearing
- // on this screen, which already has a Ctrl-K palette that finds
- // DOCUMENTS. This one finds text inside the document you are in.
- // Two things called "Find" one keystroke apart is how a person
- // learns to distrust both.
- "docs.editor.menu.edit.find": "Find and replace in this document",
"docs.editor.menu.view.paged": "Paged",
"docs.editor.menu.view.infinite": "Infinite",
- "docs.editor.menu.view.ruler": "Show ruler",
-
- "docs.editor.menu.insert.link": "Link",
- "docs.editor.menu.insert.image": "Image",
- "docs.editor.menu.insert.table": "Table",
- "docs.editor.menu.insert.pagebreak": "Page break",
- "docs.editor.menu.insert.rule": "Horizontal line",
"docs.editor.menu.format.indent": "Increase indent",
"docs.editor.menu.format.outdent": "Decrease indent",
@@ -390,8 +427,8 @@ var Catalog = rastrillo.Catalog{
// editor calls it, and the verb people search for.
"docs.editor.menu.format.clear": "Clear formatting",
- "docs.editor.menu.tools.wordcount": "Word count",
- "docs.editor.menu.tools.shortcuts": "Keyboard shortcuts",
+ "docs.editor.menu.tools.wordcount": "Word count…",
+ "docs.editor.menu.tools.shortcuts": "Keyboard shortcuts…",
// The number on a page break in paged mode. {n} is substituted by
// fixtures/pages.mjs.
@@ -475,8 +512,12 @@ var viewsFS embed.FS
// must not be cloned per page.
const headTemplate = "views/head.html"
+// layoutTemplate defines "layout", the document every page renders into.
+const layoutTemplate = "views/layout.html"
+
// pageTemplates maps a page name to its file under views/.
var pageTemplates = map[string]string{
+ "error": "views/error.html",
"home-link": "views/home_link.html",
"list": "views/list.html",
"public": "views/public.html",
@@ -490,6 +531,9 @@ var pageTemplates = map[string]string{
type renderer struct {
pages map[string]*template.Template
assets *rastrillo.Assets
+ // frame builds a page's chrome for the pages the renderer draws on
+ // its own (home_render.go); app.go sets it to handlers.frame.
+ frame func(*http.Request, frameOptions) Frame
}
// newRenderer builds the renderer: the vendored design-system CSS/JS
@@ -512,23 +556,17 @@ func newRenderer() (*renderer, error) {
return nil, fmt.Errorf("docs: parse rastrillo ui partials: %w", err)
}
- layout, ok := ui.Layout(shellLayout)
- if !ok {
- return nil, fmt.Errorf("docs: rastrillo ui: shell %q not shipped", shellLayout)
+ // estilo's menu and chrome templates (the bar, account menu,
+ // switcher, error body and sign-in pages), then this app's layout
+ // and "head", all into the base every page is cloned from.
+ if base, err = base.ParseFS(estilomenu.Templates(), "templates/*.html"); err != nil {
+ return nil, fmt.Errorf("docs: parse estilo menu templates: %w", err)
}
- base, err = base.Parse(string(layout))
- if err != nil {
- return nil, fmt.Errorf("docs: parse rastrillo shell %q: %w", shellLayout, err)
+ if base, err = base.ParseFS(chrome.Templates(), "templates/*.html"); err != nil {
+ return nil, fmt.Errorf("docs: parse estilo chrome templates: %w", err)
}
-
- // This app's own "head" definition, parsed into base *after* the
- // shell so it overrides the shell's empty {{block "head"}}
- // default, and before the per-page clones below so all three
- // pages inherit the one definition rather than each carrying a
- // copy of the icon link. Not in pageTemplates: it defines no
- // "content" and is not a page.
- if base, err = base.ParseFS(viewsFS, headTemplate); err != nil {
- return nil, fmt.Errorf("docs: parse head template: %w", err)
+ if base, err = base.ParseFS(viewsFS, layoutTemplate, headTemplate); err != nil {
+ return nil, fmt.Errorf("docs: parse layout: %w", err)
}
base, err = base.Parse(savestatus.Template())
@@ -562,7 +600,7 @@ func newRenderer() (*renderer, error) {
}
// page renders the named page's "layout" template — its own content
-// wrapped in rastrillo's shell — into w.
+// inside views/layout.html and the estilo bar — into w.
func (rd *renderer) page(w http.ResponseWriter, r *http.Request, name string, data any) error {
return rd.pageStatus(w, r, name, data, 0)
}
@@ -579,6 +617,7 @@ func (rd *renderer) pageStatus(w http.ResponseWriter, r *http.Request, name stri
return fmt.Errorf("docs: render page %q: %w", name, err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ pageHeaders(w)
return writeGuardedBody(w, r, status, buf.Bytes())
}
@@ -605,6 +644,11 @@ func vendoredStaticFS() (fs.FS, error) {
"static/rastrillo.js": {Data: ui.ShimJS()},
"static/select.js": {Data: ui.SelectJS()},
"static/datetime.js": {Data: ui.DatetimeJS()},
+ // estilo's shared chrome, fingerprinted like the theme.
+ "static/estilo/base.css": {Data: base.CSS()},
+ "static/estilo/menu.css": {Data: estilomenu.CSS()},
+ "static/estilo/chrome.css": {Data: chrome.CSS()},
+ "static/estilo/chrome.js": {Data: chrome.JS()},
}
for _, name := range []string{"icon-32.png", "apple-touch-icon.png", "brand.css", "LICENSE.lucide"} {
body, err := brandFS.ReadFile("brand/" + name)
diff --git a/internal/docs/share.go b/internal/docs/share.go
index 2bcfd0c..af4e954 100644
--- a/internal/docs/share.go
+++ b/internal/docs/share.go
@@ -10,6 +10,7 @@ import (
// sharePage is the share screen's view data, resolved in the handler rather
// than in the template, as every other page here does it.
type sharePage struct {
+ Frame Frame
Title string
DocTitle string
Sqid string
@@ -68,7 +69,7 @@ func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) {
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for share", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
sqid := r.PathValue("sqid")
@@ -81,7 +82,7 @@ func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) {
grants, err := h.store.ListGrants(r.Context(), owner, sqid)
if err != nil {
h.logger.Error("docs: list grants", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
@@ -97,7 +98,7 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu
if err != nil {
h.logger.Error("docs: read published state", "sqid", doc.Sqid, "err", err)
if wantsNative(r) {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
+ h.fail(w, r, http.StatusServiceUnavailable, failStatus)
return
}
if h.store.team != nil {
@@ -163,6 +164,8 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu
if errKey != "" {
page.Error = rastrillo.T(r, errKey)
}
+ // "Share · {document} · Docs"; the bar names the document, read-only.
+ page.Frame = h.frame(r, frameOptions{titleParts: []string{page.Title, doc.Title}, context: doc.Title})
// no-store whenever this render carries the plaintext capability.
// "Shown once" was a server-side truth only: the browser's disk cache
@@ -174,7 +177,7 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu
}
if err := h.render.pageStatus(w, r, "share", page, status); err != nil {
h.logger.Error("docs: render share", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
}
}
@@ -193,7 +196,7 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for share", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
sqid := r.PathValue("sqid")
@@ -223,13 +226,13 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
grants, listErr := h.store.ListGrants(r.Context(), owner, sqid)
if listErr != nil {
h.logger.Error("docs: list grants after refusal", "sqid", sqid, "err", listErr)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
h.renderShare(w, r, doc, grants, "docs.share.error.address", http.StatusBadRequest)
default:
h.logger.Error("docs: grant", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
}
}
@@ -245,7 +248,7 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
owner, err := h.owner(r)
if err != nil {
h.logger.Error("docs: resolve owner for revoke", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
sqid := r.PathValue("sqid")
@@ -256,7 +259,7 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
}
if err := h.store.Revoke(r.Context(), owner, sqid, r.PostFormValue("address")); err != nil {
h.logger.Error("docs: revoke", "sqid", sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ h.fail(w, r, h.internalFailureStatus(), failStatus)
return
}
if wantsNative(r) {
@@ -268,8 +271,8 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
func (h *handlers) ownerLookupFailure(w http.ResponseWriter, r *http.Request, err error) {
if h.store.team != nil && errors.Is(err, errTeamUnavailable) {
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
+ h.fail(w, r, http.StatusServiceUnavailable, failStatus)
return
}
- http.NotFound(w, r)
+ h.fail(w, r, http.StatusNotFound, failDocument)
}
diff --git a/internal/docs/share_nav_test.go b/internal/docs/share_nav_test.go
index 93ff9ce..e9237e2 100644
--- a/internal/docs/share_nav_test.go
+++ b/internal/docs/share_nav_test.go
@@ -13,10 +13,10 @@ import (
// to it — sharing worked only for somebody who knew to type /share onto a
// URL, which is not a feature, it is a rumour.
//
-// The control goes in the shell's own "nav" block, which topbar.html puts
-// inside [rst-shell-tail] immediately before the account disclosure. That
-// is the requested position, and it is a slot the design system already
-// owns rather than a bar this app draws for itself (spec §10).
+// The control is the estilo bar's one action, "Share…", which the bar puts
+// at the inline end immediately before the switcher and the account menu.
+// That is the requested position, and it is a slot the suite's shared bar
+// owns rather than one this app draws for itself.
func TestTheOwnerGetsAShareControlInTheBar(t *testing.T) {
app, _ := docs.NewForTest(t)
alice := signInCookies(t, app, "alice@example.com")
@@ -31,15 +31,19 @@ func TestTheOwnerGetsAShareControlInTheBar(t *testing.T) {
t.Error("the document page offers no route to its own share page")
}
- // Position, not just presence: the link has to be in the shell's tail,
- // beside the account dropdown, rather than anywhere on the page. A
+ // Position, not just presence: the link has to be in the bar's end
+ // group, beside the account menu, rather than anywhere on the page. A
// test that only asserted presence would pass on a link buried in the
// footer.
- tail := body[strings.Index(body, "rst-shell-tail"):]
- acct := strings.Index(tail, "rst-shell-account")
- link := strings.Index(tail, "/d/"+sqid+"/share")
+ end := strings.Index(body, "<div estilo-bar-end>")
+ if end < 0 {
+ t.Fatal("the bar has no end group")
+ }
+ tail := body[end:]
+ acct := strings.Index(tail, "<details estilo-account")
+ link := strings.Index(tail, `<div estilo-bar-actions><a rst-btn href="/d/`+sqid+`/share">`)
if link < 0 || acct < 0 || link > acct {
- t.Error("the share control is not in the shell tail before the account dropdown")
+ t.Error("the share control is not in the bar's actions before the account menu")
}
}
diff --git a/internal/docs/signin_return.go b/internal/docs/signin_return.go
new file mode 100644
index 0000000..ee12d9a
--- /dev/null
+++ b/internal/docs/signin_return.go
@@ -0,0 +1,158 @@
+package docs
+
+// Returning to the page you came from after signing in (spec §9.4), and
+// the two sign-in behaviours that go with it: an already-signed-in
+// GET /signin goes straight back, and signing out lands on a page that
+// says so.
+//
+// With Home configured, the return path already rode the Home hand-off
+// (home_routes.go's signin remembers it in __Host-oficina-docs-home-
+// return). What did not honour it was the local email link: rastrillo's
+// auth.Verify always lands on its fixed SignedInPath, so the value cannot
+// ride the emailed link and lives in that same sealed, 600-second cookie
+// instead, read at /auth/local/complete.
+//
+// Standalone Docs (no Home) had nothing: rastrillo's RequireSession drops
+// the path (its refuseSession redirects to a bare /signin) and its
+// SignedInPath was "/". It now gets the same cookie, sealed under a key
+// derived from the instance key, the same /auth/local/complete landing,
+// and a guard that puts return_to on the sign-in redirect. (Spec §9.4
+// left standalone as "a small return cookie of its own, or lands on /";
+// this is the first, so the Playwright deep-link gate can run against a
+// local dev server.)
+//
+// auth_navigation.go's "never comes from return_to" governs the Keymail
+// authorize URL, which must be exactly https://…/oauth/authorize. The
+// path here is a separate value, validated by handoff.SafePath (a
+// same-origin path: no scheme, no host, no //, no control characters),
+// so it can never become an off-site destination.
+
+import (
+ "crypto/sha256"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/handoff/seal"
+ rauth "github.com/carlosframework/rastrillo/auth"
+)
+
+// standaloneReturn builds the standalone instance's return memory: the
+// same sealed cookie localLinkNavigation keeps for Home instances, keyed
+// from the instance key rather than Home's DEK (standalone has none).
+func standaloneReturn(instanceKey, origin string) (*localLinkNavigation, error) {
+ key := sha256.Sum256([]byte("oficina/docs standalone return v1\x00" + instanceKey))
+ sealer, err := seal.New(key[:], "oficina/docs")
+ if err != nil {
+ return nil, err
+ }
+ u, err := url.Parse(origin)
+ if err != nil {
+ return nil, err
+ }
+ return &localLinkNavigation{sealer: sealer, origin: origin, host: u.Host, now: time.Now}, nil
+}
+
+// requestedReturn is the return_to a sign-in GET carries, when it is
+// exactly one same-origin path.
+func requestedReturn(r *http.Request) (string, bool) {
+ q, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ return "", false
+ }
+ values, ok := q["return_to"]
+ if !ok || len(values) != 1 || !handoff.SafePath(values[0]) {
+ return "", false
+ }
+ return values[0], true
+}
+
+// signinOutcome reports whether GET /signin is showing an outcome of the
+// email flow (?sent, ?err, ?force) rather than starting one.
+func signinOutcome(r *http.Request) bool {
+ q := r.URL.Query()
+ return q.Has("sent") || q.Has("err") || q.Has("force")
+}
+
+// alreadySignedIn sends a signed-in GET /signin back where it came
+// from, with no form (spec §9.2: 303 to return_to or /).
+func alreadySignedIn(w http.ResponseWriter, r *http.Request) {
+ target := "/"
+ if path, ok := requestedReturn(r); ok {
+ target = path
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, target, http.StatusSeeOther)
+}
+
+// standaloneSignin is GET /signin without Home: already signed in goes
+// back; a return_to is remembered for the landing; then the form.
+func (h *handlers) standaloneSignin(n *localLinkNavigation) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !signinOutcome(r) {
+ if _, ok := h.viewer(r); ok {
+ alreadySignedIn(w, r)
+ return
+ }
+ }
+ if path, ok := requestedReturn(r); ok {
+ n.rememberReturn(w, path)
+ }
+ h.signin(w, r)
+ }
+}
+
+// completeStandalone is the standalone SignedInPath: land on the
+// remembered page, once, or on the list.
+func (n *localLinkNavigation) completeStandalone(w http.ResponseWriter, r *http.Request) {
+ if !n.guard(w, r, localLinkCompletePath) {
+ return
+ }
+ target := n.returnPath(r)
+ localNavigationCookie(w, homeReturnCookie, "", -1)
+ http.Redirect(w, r, target, http.StatusSeeOther)
+}
+
+// requireSessionReturning is rastrillo's RequireSession, except that a
+// signed-out page request is sent to /signin with return_to, as
+// rastrillo's own sessions.Require already does and auth.RequireSession
+// (its refuseSession) does not. Anything else is exactly what it was.
+func requireSessionReturning(a *rauth.Auth) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ guarded := a.RequireSession(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
+ if _, ok := a.SessionFrom(r); !ok {
+ http.Redirect(w, r, "/signin?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
+ return
+ }
+ }
+ guarded.ServeHTTP(w, r)
+ })
+ }
+}
+
+// signoutToSignedOut is rastrillo's Signout, landing on /signed-out ("You’re
+// signed out") rather than straight back on the sign-in form, which read
+// as though nothing had happened.
+func signoutToSignedOut(signout http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ result := &signinResponse{header: make(http.Header)}
+ signout(result, r)
+ if result.status == http.StatusSeeOther && strings.HasPrefix(result.header.Get("Location"), "/signin") {
+ result.header.Set("Location", "/signed-out")
+ }
+ for name, values := range result.header {
+ for _, value := range values {
+ w.Header().Add(name, value)
+ }
+ }
+ if result.status == 0 {
+ result.status = http.StatusOK
+ }
+ w.WriteHeader(result.status)
+ _, _ = w.Write(result.body.Bytes())
+ }
+}
diff --git a/internal/docs/team_access.go b/internal/docs/team_access.go
index 6b409fb..737510c 100644
--- a/internal/docs/team_access.go
+++ b/internal/docs/team_access.go
@@ -202,7 +202,7 @@ func (h *handlers) requireMemberRequest(w http.ResponseWriter, r *http.Request,
if h.member(r) {
return true
}
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
+ h.fail(w, r, http.StatusForbidden, failStatus)
return false
}
actor, e := h.actor(r)
@@ -212,16 +212,14 @@ func (h *handlers) requireMemberRequest(w http.ResponseWriter, r *http.Request,
if e == nil {
return true
}
- code := http.StatusServiceUnavailable
- key := "docs.error.generic"
+ code, kind := http.StatusServiceUnavailable, failStatus
if errors.Is(e, errTeamNotMember) {
code = http.StatusForbidden
if item {
- code = http.StatusNotFound
- key = "docs.error.notfound"
+ code, kind = http.StatusNotFound, failDocument
}
}
- http.Error(w, rastrillo.T(r, key), code)
+ h.fail(w, r, code, kind)
return false
}
diff --git a/internal/docs/team_router_test.go b/internal/docs/team_router_test.go
index 1f05d09..851939e 100644
--- a/internal/docs/team_router_test.go
+++ b/internal/docs/team_router_test.go
@@ -231,7 +231,7 @@ func TestTeamRouterLocalLinkMembershipRemovalAndIndependentGrant(t *testing.T) {
if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 {
t.Fatal("grant unavailable during roster failure", r.Code)
}
- if r := b.do("GET", "/", ""); r.Code != 200 || !strings.Contains(r.Body.String(), `rst-notice role="status">docs.team.partial`) || strings.Contains(r.Body.String(), "Alice original") {
+ if r := b.do("GET", "/", ""); r.Code != 200 || !strings.Contains(r.Body.String(), `rst-tone="warning"`) || !strings.Contains(r.Body.String(), `<p>docs.team.partial</p>`) || strings.Contains(r.Body.String(), "Alice original") {
t.Fatal("partial list misrepresented unavailable owned documents", r.Code)
}
if r := b.do("GET", "/documents/search?q=shared", ""); r.Code != 200 || r.Header().Get("Docs-Catalog-Partial") != "1" {
diff --git a/internal/docs/testing.go b/internal/docs/testing.go
index 7ff32a8..61104db 100644
--- a/internal/docs/testing.go
+++ b/internal/docs/testing.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"io"
+ "io/fs"
"net/http"
"net/http/httptest"
"net/url"
@@ -356,3 +357,22 @@ func (a *App) TestRowContains(t *testing.T, table, needle string) bool {
}
return false
}
+
+// TestViewSources returns every template this app authors, by path, for
+// the tests that hold its markup to a vocabulary.
+func TestViewSources(t *testing.T) map[string]string {
+ t.Helper()
+ out := map[string]string{}
+ err := fs.WalkDir(viewsFS, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ b, err := fs.ReadFile(viewsFS, path)
+ out[path] = string(b)
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
diff --git a/internal/docs/views/error.html b/internal/docs/views/error.html
new file mode 100644
index 0000000..7f6a8f6
--- /dev/null
+++ b/internal/docs/views/error.html
@@ -0,0 +1,3 @@
+{{/* error — every HTML error answer (errors.go), inside the normal layout
+ and bar, so a signed-in person keeps their own chrome on a 404. */}}
+{{define "content"}}{{template "estilo-error" .Error}}{{end}}
diff --git a/internal/docs/views/head.html b/internal/docs/views/head.html
index f01046e..a2faa03 100644
--- a/internal/docs/views/head.html
+++ b/internal/docs/views/head.html
@@ -5,5 +5,4 @@
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}">
<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
-<style>[rst-shell-brand]{display:inline-flex;align-items:center;min-block-size:24px}</style>
{{end}}
diff --git a/internal/docs/views/home_link.html b/internal/docs/views/home_link.html
index 6da4860..17ca43d 100644
--- a/internal/docs/views/home_link.html
+++ b/internal/docs/views/home_link.html
@@ -1,10 +1,13 @@
-{{define "title"}}{{.Title}}{{end}}
+{{/* home-link — the Home account-connection screens, and the signed-out
+ page, which is estilo's estilo-signed-out. */}}
{{define "content"}}
+{{- if .SignedOut}}{{template "estilo-signed-out" .SignedOut}}{{else}}
<header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header>
<div rst-box>
<p>{{.Body}}</p>{{if .Address}}<p style="overflow-wrap: anywhere"><strong>{{.Address}}</strong></p>{{end}}
{{if .PostAction}}<form method="post" action="{{.Action}}"><button rst-btn="primary" type="submit">{{.ActionLabel}}</button></form>
{{else}}<a rst-btn="primary" href="{{.Action}}">{{.ActionLabel}}</a>{{end}}
- {{if .CancelLabel}}<a href="/">{{.CancelLabel}}</a>{{end}}
+ {{if .CancelLabel}}<a rst-btn="ghost" href="/">{{.CancelLabel}}</a>{{end}}
</div>
+{{- end}}
{{end}}
diff --git a/internal/docs/views/layout.html b/internal/docs/views/layout.html
new file mode 100644
index 0000000..b992966
--- /dev/null
+++ b/internal/docs/views/layout.html
@@ -0,0 +1,42 @@
+{{/* layout — every page's document: the head, the estilo bar and <main>.
+ It replaces rastrillo's topbar shell, whose <header rst-shell-bar>
+ hard-codes an "Account" summary and a tail that wraps, neither of which
+ estilo may restyle (estilo's CSS may not select rastrillo's markup).
+
+ Every page's data carries a Frame (chrome.go): the bar, and the
+ <title> parts before the app's name. The title is built here and only
+ here, "{part} · {part} · Docs", so no handler can drift from the
+ suite's separator.
+
+ <meta charset> comes first, before anything that could be read as
+ text. The stylesheets are in the suite's fixed order: tokens, theme,
+ brand, then estilo's base, menu and chrome, then the page's own. */}}
+{{define "layout"}}<!doctype html>
+<html lang="en" dir="ltr"{{with .Frame.Theme}} data-theme="{{.}}"{{end}}>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{range .Frame.TitleParts}}{{.}} · {{end}}{{.Frame.Chrome.App.Name}}</title>
+<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
+<link rel="stylesheet" href="{{asset "static/theme.css"}}">
+{{template "head" .}}
+<link rel="stylesheet" href="{{asset "static/estilo/base.css"}}">
+<link rel="stylesheet" href="{{asset "static/estilo/menu.css"}}">
+<link rel="stylesheet" href="{{asset "static/estilo/chrome.css"}}">
+<script defer src="{{asset "static/rastrillo.js"}}"></script>
+{{iconAssets}}
+<script defer src="{{asset "static/select.js"}}"></script>
+<script defer src="{{asset "static/datetime.js"}}"></script>
+<script defer src="{{asset "static/estilo/chrome.js"}}"></script>
+</head>
+<body>
+<div estilo-shell>
+<a rst-skip href="#main">{{.Frame.Chrome.Labels.Skip}}</a>
+{{template "estilo-bar" .Frame.Chrome}}
+<main rst-page id="main">
+{{template "content" .}}
+</main>
+</div>
+</body>
+</html>
+{{end}}
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
index aeb4c84..243478c 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -39,26 +39,10 @@
yours" is why they are not spelled rst-*, and they carry their
own tiny stylesheet here rather than a static/ file this
hand-built app does not otherwise have one of. */}}
-{{define "title"}}{{.Title}}{{end}}
-{{/* brand and account fill two of the topbar shell's own slots
- (render.go's shellLayout). Defined per page rather than once in
- views/head.html because they differ per screen — show.html's
- brand carries a document's name beside the link home, and
- sign-in offers no account menu at all.
-
- This screen's brand is just the app's own name, which is
- listPage.Title, the same string the heading below uses. That
- repetition is deliberate and is what both reference applications
- do: the bar names the app, the heading names the screen, and on
- the one screen where those are the same thing they read as the
- same thing. */}}
-{{define "brand"}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="/static/icon.svg" alt="" width="28" height="28">{{.Title}}</a>{{end}}
-{{/* account is the dropdown's MENU BODY, not the whole menu — see
- show.html's own copy of this block for the reasoning; the two are
- identical on purpose, and are two definitions rather than one
- shared partial because a page template is where a shell block
- override has to live for the clone that page is parsed into. */}}
-{{define "account"}}{{if .HomeLinkLabel}}<a href="/account/home">{{.HomeLinkLabel}}</a>{{end}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
+{{/* The bar, the account menu and the <title> are the layout's
+ (views/layout.html, chrome.go): this screen defines only its content.
+ The bar names the app, "Docs"; the heading below names the screen,
+ "Documents". */}}
{{/* thumb — one document's page one, drawn from the line boxes
preview.go reports. aria-hidden because it is decorative: it is
wholly redundant with the document's own title beside it, and
@@ -317,7 +301,7 @@
</form>{{end}}
</div>
</header>
-{{if .PartialCatalog}}<p rst-notice role="status">{{.PartialCatalog}}</p>{{end}}
+{{if .PartialCatalog}}{{template "callout" (dict "Body" .PartialCatalog "Tone" "warning")}}{{end}}
{{/* Searching renders a ranked, UNGROUPED result list. Recency
grouping is what an unfiltered list is for; grouping ranked
results under "Today" and "Earlier" would order the same rows
diff --git a/internal/docs/views/partials/jumpto.html b/internal/docs/views/partials/jumpto.html
index a6c6c5e..a425ddd 100644
--- a/internal/docs/views/partials/jumpto.html
+++ b/internal/docs/views/partials/jumpto.html
@@ -39,7 +39,8 @@
display: flex;
flex-direction: column;
overflow: hidden;
- border-radius: var(--rst-radius);
+ /* A float: the suite's one floating radius (estilo menu.css). */
+ border-radius: var(--estilo-float-radius);
background: var(--rst-surface);
box-shadow: var(--rst-shadow-pop);
}
@@ -76,6 +77,12 @@
}
/* aria-selected is the state, and background alone would be colour
carrying meaning on its own (§10.3) — the weight changes too. */
+ /* Hover matches the selected row's background, so a pointer and the
+ arrow keys point at a row the same way; only the selected row takes
+ the weight and colour that carry the state. */
+ [docs-jumpto-list] li:hover {
+ background: var(--rst-accent-soft);
+ }
[docs-jumpto-list] li[aria-selected="true"] {
background: var(--rst-accent-soft);
color: var(--rst-accent);
diff --git a/internal/docs/views/public.html b/internal/docs/views/public.html
index db3bbc3..6b36e6b 100644
--- a/internal/docs/views/public.html
+++ b/internal/docs/views/public.html
@@ -1,15 +1,10 @@
-{{define "title"}}{{.Title}}{{end}}
{{/* The published document: the one screen in this app a stranger sees.
- No "account" block is defined, and that is deliberate rather than an
- omission. tokens.css hides a disclosure whose menu is empty, so
- leaving it undefined removes the account dropdown — and a signed-out
- reader must not be offered a sign-out control for a session they do
- not have. render.go's own comment on shellLayout records that this is
- how a screen opts out of it.
-
- No "nav" block either: the share control lives there on the private
- page, and this reader owns nothing to share.
+ The bar is the layout's (chrome.go, frameOptions.public): the brand is
+ plain text rather than a link, because "/" would bounce a stranger
+ straight to Sign in, and the corner offers "Sign in" (or the account,
+ for someone who happens to be signed in). There is no share control:
+ this reader owns nothing to share.
No editor. Not disabled, not hidden — absent. A read-only surface
built by hiding an editor is one CSS rule away from being an editor
@@ -17,10 +12,9 @@
guard anyway, so the mount point would only produce a page that
fails at the network. Ruling 15's other half: every write has a
named actor, and whoever is reading this has no name. */}}
-{{define "brand"}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="/static/icon.svg" alt="" width="28" height="28">{{.Title}}</a>{{end}}
{{define "content"}}
<article rst-stack docs-public>
- <p rst-notice docs-public-notice>{{.ReadOnly}}</p>
+ <div docs-public-notice>{{template "callout" (dict "Body" .ReadOnly "Tone" "info")}}</div>
<div docs-public-body>{{.HTML}}</div>
</article>
{{end}}
diff --git a/internal/docs/views/share.html b/internal/docs/views/share.html
index 86f0545..f8ad7a5 100644
--- a/internal/docs/views/share.html
+++ b/internal/docs/views/share.html
@@ -1,5 +1,3 @@
-{{define "title"}}{{.Title}} — {{.DocTitle}}{{end}}
-{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
{{/* The share screen. Scriptless throughout, per spec §6.2, which lists
sharing among the working form posts: two plain <form>s, no token
@@ -26,7 +24,7 @@
<a href="/d/{{.Sqid}}">{{.BackLabel}}</a>
</header>
- {{if .Error}}<p rst-notice role="alert">{{.Error}}</p>{{end}}
+ {{if .Error}}{{template "callout" (dict "Body" .Error "Tone" "negative" "Alert" true)}}{{end}}
{{/* Who reaches it today. An empty list says so in words rather than
rendering an empty table, because a table with no rows reads as a
@@ -92,7 +90,7 @@
a URL (suite ruling 16). Afterwards this section says the
document is published and offers to replace or stop it. */}}
{{if .PublicURL}}
- <p rst-notice docs-share-once role="status">{{.OnceWarning}}</p>
+ <div docs-share-once role="status">{{template "callout" (dict "Body" .OnceWarning "Tone" "warning")}}</div>
<p docs-share-url><code>{{.PublicURL}}</code></p>
{{else}}
<p rst-muted docs-share-state>{{.PublishedBody}}</p>
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index 83ead77..8b6491a 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -40,46 +40,11 @@
list.html's own comment on why, and
render_test.go's TestOurTemplatesUseTheAttributeGrammar, which
holds it against this file too. */}}
-{{define "title"}}{{.Title}}{{end}}
-{{/* brand and account fill two of the topbar shell's own slots
- (render.go's shellLayout, rastrillo ui/layouts/topbar.html).
- Defined here rather than in views/head.html — which is the one
- definition all three pages share — because these two differ per
- screen: this page's brand carries a document's name beside the
- link home, and sign-in must offer no account menu at all.
-
- brand emits two elements, not one. The block sits directly inside
- [rst-shell-bar], which is a flex row, so the link and the title
- become two flex items with the bar's own --rst-sp-4 gap between
- them; there is no wrapper because a wrapper would need a layout
- rule of its own to reproduce what the bar already does.
-
- The title is printed as-is, the same way list.html's row titles
- are — it is the document's own data, not authored copy, so it
- never goes through rastrillo.T. */}}
-{{define "brand"}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="/static/icon.svg" alt="" width="28" height="28">{{.BackLabel}}</a><span docs-shell-title>{{.Title}}</span>{{end}}
-{{/* account is the dropdown's MENU BODY, not the whole menu: the
- shell owns the <details>/<summary> and this block supplies only
- what sits inside [rst-dropdown-menu]. A <form> rather than a
- link, because ending a session is a POST (auth.go), and
- [rst-dropdown-menu] button already styles a bare button as a
- full-width menu row, so it needs no rst-btn of its own — which
- is the point of putting it here instead of leaving it as a ghost
- button in a page header. */}}
-{{/* nav is the shell's slot inside [rst-shell-tail], rendered
- immediately before the account disclosure — so this link lands
- beside the account dropdown at the top right, which is where a
- share control belongs and where Paul asked for one.
-
- A link, not a form: opening the share page is a read, and GET is
- what a prefetcher, a middle-click and a bookmark all expect. The
- mutations live on that page, each in its own POST.
-
- Its text rather than an icon because the design system has no
- share glyph — see showPage.CanShare's comment for why inventing
- one here would have shipped an invisible control. */}}
-{{define "nav"}}{{if .CanShare}}<a href="/d/{{.Sqid}}/share">{{.ShareLabel}}</a>{{end}}{{end}}
-{{define "account"}}{{if .HomeLinkLabel}}<a href="/account/home">{{.HomeLinkLabel}}</a>{{end}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
+{{/* The bar is the layout's (views/layout.html, chrome.go). On this
+ screen it carries the document's title, editable in place for anyone
+ who may write (estilo's title slot, POST /d/{sqid}/title), and the
+ owner's "Share…" action. Sign out is in the account menu and nowhere
+ else. */}}
{{define "content"}}<link rel="stylesheet" href="{{asset "static/estilo-save-status.css"}}"><style>
/* This screen is the one that is not a column of content. It is a
document on a desk, and in both of the applications this layout
@@ -112,47 +77,10 @@
max-inline-size: none;
padding: 0;
}
- /* [docs-shell-title] is the document's own name in the shell bar,
- beside the brand link back to the list — the pattern both
- reference applications use, and the reason this page no longer
- carries a [rst-page-header] of its own (see render.go's
- shellLayout comment for the whole argument).
-
- It is a <span>, not a heading: the document's title already IS a
- heading in the only place that counts, <title>, and a visible h1
- that is not the document's first line was the thing making the
- old header read as a screen heading rather than a document's
- name.
-
- flex: 1 1 0, and the zero basis is the whole point rather than a
- tidy default. [rst-shell-bar] wraps (tokens.css), and a flex
- container decides its line breaks from each item's HYPOTHETICAL
- main size — for `flex-basis: auto` on a nowrap span, its full
- untruncated width — before shrinking or min-inline-size get a
- say. So the obvious spelling, flex: 0 1 auto with
- min-inline-size: 0, does not truncate a long title at all: it
- drops the title onto a second row of the bar and the account
- control onto a third, and only ellipsises once the title alone
- is wider than the whole bar. A zero basis never forces a break,
- so the item grows into whatever space is free and shrinks with
- an ellipsis when there is none — which is the behaviour this
- paragraph used to claim and did not have.
-
- What that costs, knowingly: a truncated title is then readable
- nowhere on this screen — the span is not focusable and the
- document's own first line is not its title. It is still in the
- tab title, and in full on the list. A wrapping bar would keep it
- legible at the price of the chrome changing height as you open
- documents, which is the thing this whole branch is undoing. */
- [docs-shell-title] {
- flex: 1 1 0;
- min-inline-size: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- color: var(--rst-text);
- font-size: var(--rst-fs-sm);
- }
+ /* The document's title is in the estilo bar now (chrome.go), weighted
+ 550 as the page's subject and editable in place for a writer. It
+ used to be [docs-shell-title], a 12.5px regular span that could not
+ be renamed. */
/* [docs-canvas] is the tray the page sits in — it is chrome around
paper, so it stays close to the surrounding theme's tone, but it
still opts out of the scheme (color-scheme: light) because its
@@ -503,9 +431,9 @@
[docs-page-gap] {
display: none;
}
- /* Every piece of chrome. The shell's own bar is rastrillo's and is
- named by its vocabulary, not ours. */
- [rst-shell-bar],
+ /* Every piece of chrome. The bar is estilo's and is named by its
+ vocabulary, not ours. */
+ [estilo-bar],
[docs-chrome],
[docs-jumpto] {
display: none !important;
@@ -574,13 +502,14 @@
max-inline-size: min(30rem, calc(100vw - var(--rst-sp-6)));
padding: var(--rst-sp-4);
border: 1px solid var(--rst-line);
- border-radius: var(--rst-radius-sm);
+ /* A float: the suite's one floating radius (estilo menu.css). */
+ border-radius: var(--estilo-float-radius);
background: var(--rst-surface);
color: var(--rst-text);
box-shadow: var(--rst-shadow-pop);
}
[docs-dialog]::backdrop {
- background: rgba(0, 0, 0, 0.32);
+ background: var(--rst-overlay);
}
[docs-dialog] h2 {
margin-block: 0 var(--rst-sp-3);
@@ -621,6 +550,7 @@
}
[docs-shortcuts-row] {
display: flex;
+ align-items: baseline;
gap: var(--rst-sp-4);
justify-content: space-between;
padding-block: var(--rst-sp-1);
@@ -812,15 +742,15 @@
outline: none;
}
- /* [docs-menubar] and [docs-bar] are two strips of one slab of
- chrome, between the shell's own bar and the paper: menus on top,
+ /* [estilo-menubar] and [docs-bar] are two strips of one slab of
+ chrome, between the estilo bar and the paper: menus on top,
formatting under them. They read as one object rather than two
because only the lower one carries a bottom rule — the seam
between them is a hairline, not a gap, and the paper below is the
figure.
- Both are sticky, and they stack: the menu bar pins at 0 and the
- formatting bar directly beneath it. A formatting bar that scrolls
+ Both are sticky, and they stack: the menu bar pins directly under
+ the estilo bar and the formatting bar directly beneath it. A formatting bar that scrolls
away with the first page is not there when a person is writing on
the third one, and the same is true of File.
@@ -837,7 +767,7 @@
the app's theme tokens and repaint with the reader's light/dark
preference. */
/* ONE sticky element, wrapping both strips — not two.
- [docs-menubar] and [docs-bar] were each `position: sticky;
+ The menu bar and [docs-bar] were each `position: sticky;
inset-block-start: 0` for about an hour, which does not stack: two
sticky siblings pinned to the same offset occupy the same band, so
on the first scroll the formatting bar slid underneath the menu bar
@@ -846,18 +776,17 @@
"sticks to the top" is exactly what you would write.
A container that sticks, holding two children that do not, is the
shape that actually keeps them in order. */
+ /* Below the estilo bar, which is sticky too: the published height,
+ never a copied 52px (spec §5.3). z-index 3 stays under the bar's 45,
+ so the account menu paints over the menubar and the toolbar. */
[docs-chrome] {
position: sticky;
- inset-block-start: 0;
+ inset-block-start: var(--estilo-bar-block-size);
z-index: 3;
background: var(--rst-surface);
border-block-end: 1px solid var(--rst-line);
}
- [docs-menubar] {
- display: flex;
- flex-wrap: wrap;
- align-items: stretch;
- gap: 2px;
+ [docs-chrome] > [estilo-menubar] {
padding: 0 var(--rst-sp-3);
}
[docs-bar] {
@@ -867,187 +796,12 @@
gap: var(--rst-sp-2);
padding: var(--rst-sp-2) var(--rst-sp-4);
}
- /* A menu is a <details>, so the panel is positioned against it and it
- needs to establish the containing block. See the markup's own
- comment for why <details> rather than a scripted menubar. */
- [docs-menu] {
- position: relative;
- }
- /* The disclosure triangle goes: a menu title is a word, and the
- marker would be the only thing on this screen claiming the six of
- them are a list of expandable sections rather than a menu bar.
- display on the ::marker pseudo-element does not remove it in every
- engine, so both spellings are here — the ::-webkit-details-marker
- rule is what actually clears it in WebKit. */
- [docs-menu-title] {
- display: flex;
- align-items: center;
- min-block-size: 32px;
- padding: 0 var(--rst-sp-2);
- border-radius: var(--rst-radius-sm);
- color: var(--rst-text);
- font-size: var(--rst-fs-sm);
- line-height: 1;
- white-space: nowrap;
- cursor: pointer;
- list-style: none;
- }
- [docs-menu-title]::-webkit-details-marker {
- display: none;
- }
- [docs-menu-title]::marker {
- content: "";
- }
- [docs-menu-title]:hover {
- background: var(--rst-accent-soft);
- }
- /* The open menu's title keeps the same background as its own panel,
- so the two read as one shape rather than as a button with a box
- under it. */
- [docs-menu][open] > [docs-menu-title] {
- background: var(--rst-accent-soft);
- color: var(--rst-accent);
- }
- /* The panel. One shadow level, the design system's own radius, and
- no entrance animation: this is a control a person opens dozens of
- times a day, and motion on it is decoration that costs a frame
- every time.
-
- min-inline-size rather than a fixed width — the widest item is
- "Find and replace in this document", and a menu sized to its
- content is a menu whose items do not wrap. */
- [docs-menu-items] {
- position: absolute;
- inset-block-start: 100%;
- inset-inline-start: 0;
- z-index: 4;
- min-inline-size: 14rem;
- max-inline-size: min(22rem, calc(100vw - var(--rst-sp-4)));
- padding: var(--rst-sp-1) 0;
- border: 1px solid var(--rst-line);
- border-radius: var(--rst-radius-sm);
- background: var(--rst-surface);
- box-shadow: var(--rst-shadow-pop);
- /* The cap and the scroll, taken from rastrillo's own
- [rst-dropdown-menu] rather than reinvented — tokens.css states at
- length why each part is there, and both bugs it fixes apply
- here. Without a cap, a menu taller than a short viewport simply
- has unreachable entries: no scrollbar, nothing to drag. The 8rem
- floor is the part that looks like decoration and is not — dvh is
- the viewport, and inside a short iframe (a preview frame, a
- gallery) `100dvh - 6rem` can compute to a few pixels, so a fix
- aimed at unreachable entries shipped a menu with no entries at
- all. An overflowing menu can be scrolled to; an invisible one
- cannot be anything.
-
- Not adopted wholesale as [rst-dropdown-menu] because that panel
- is inset-inline-end: 0 — right-aligned under its summary, which
- is right for a header overflow control and wrong for a menu bar,
- where each panel hangs from the leading edge of its own title. */
- max-block-size: min(20rem, max(calc(100dvh - 6rem), 8rem));
- overflow-y: auto;
- overscroll-behavior: contain;
- }
- /* Every item is the same row whatever element it is — a <button>, an
- <a>, or the <button> inside a one-control <form>. They have to be
- interchangeable visually precisely because they are NOT
- interchangeable structurally: a link is a link so it works without
- JavaScript, and a form is a form for the same reason. */
- [docs-menu-item] {
- display: flex;
- align-items: center;
- gap: var(--rst-sp-3);
- inline-size: 100%;
- min-block-size: 28px;
- padding: 0 var(--rst-sp-3);
- border: 0;
- background: transparent;
- color: var(--rst-text);
- font: inherit;
- font-size: var(--rst-fs-sm);
- text-align: start;
- text-decoration: none;
- cursor: pointer;
- }
- [docs-menu-form] {
- margin: 0;
- }
- [docs-menu-item]:hover:not(:disabled),
- [docs-menu-item]:focus-visible {
- background: var(--rst-accent-soft);
- }
- [docs-menu-item]:disabled {
- color: var(--rst-text-faint);
- cursor: default;
- }
- /* The shortcut sits at the trailing end, quiet. aria-hidden in the
- markup: the binding is announced by the item's own accessible name
- being the command, and reading "Ctrl plus B" after "Bold" on every
- item is noise. */
- [docs-menu-shortcut] {
- margin-inline-start: auto;
- color: var(--rst-text-muted);
- font-size: var(--rst-fs-sm);
- white-space: nowrap;
- }
- [docs-menu-divider] {
- margin: var(--rst-sp-1) 0;
- border: 0;
- border-block-start: 1px solid var(--rst-line);
- }
- /* 320px, which this app supports without exemption (AGENTS.md, and
- SC 1.4.10). Six titles — File Edit View Insert Format Tools — do
- not fit 288px of usable width, and there is a live reflow failure
- on this app's list screen right now for exactly this kind of
- reason, so this is a measured constraint rather than a precaution.
-
- The bar wraps rather than scrolls. flex-wrap above is what does it:
- the titles take two lines at narrow widths and every one stays
- reachable, where a horizontally scrolling menu bar hides half of
- itself behind a gesture with no affordance. Wrapping also keeps
- this one component in one arrangement — nothing here swaps the
- markup for a different control at a breakpoint, so the command
- table, the keyboard behaviour and the tests are the same at every
- width.
-
- The panel is the part that actually needs the media query: at
- 320px an absolutely positioned 14rem panel anchored to a title
- near the trailing edge overflows the viewport. Below 30rem it
- spans the bar instead of hanging off its own title, which is the
- one place this component does change shape. */
- @media (max-width: 30rem) {
- [docs-menu-items] {
- position: absolute;
- inset-inline: 0;
- min-inline-size: 0;
- max-inline-size: none;
- }
- /* The shortcut labels go at this width, and this is a real WCAG
- failure that shipped rather than a tidy-up.
- A browser drive on main measured `span[docs-menu-shortcut]`
- ending at x=334 in a 320px viewport: the panel spans the bar
- correctly, but "Find and replace in this document" plus its
- shortcut on one row is wider than the bar, and neither half
- wraps. SC 1.4.10, on the document screen, reintroduced by this
- menu bar twelve hours after the same failure was fixed on the
- list screen.
- Hiding them is the right fix rather than the cheap one: they are
- already aria-hidden decoration for the eye, and a viewport this
- narrow is a phone, which has no Ctrl key to press. The item's own
- label — which is its accessible name and the thing a person acts
- on — is untouched. The bindings still work for anyone with a
- keyboard attached, and the shortcuts sheet still lists them. */
- [docs-menu-shortcut] {
- display: none;
- }
- [docs-menu] {
- position: static;
- }
- [docs-menubar] {
- position: sticky;
- padding: 0 var(--rst-sp-2);
- }
- }
+ /* The menu bar's titles and panels are estilo's ([estilo-menubar],
+ [estilo-menu], estilo menu.css): the one menu style the suite shares,
+ square titles in the page flow and a 9px floating panel with inset
+ rows, a check column and a key column. The [docs-menu-*] rules that
+ used to be here drew a second, different menu (6px panels, full-bleed
+ rows, 6px rounded titles) and are gone. */
/* [docs-toolbar] is a sibling of [docs-canvas], never inside
[docs-editor]: ProseMirror's {mount: el} takes over everything
inside that element, so a toolbar rendered in there would be
@@ -1104,7 +858,10 @@
min-block-size: 28px;
padding: 0 4px;
border: 0;
- border-radius: var(--rst-radius-sm);
+ /* Square: a borderless toolbar button's hover and pressed backgrounds
+ are in-flow highlights (the suite's square rule, spec §11 and Paul's
+ §13 Q4 answer). The bordered selects beside them keep day's radius. */
+ border-radius: 0;
background: transparent;
color: var(--rst-text);
font: inherit;
@@ -1245,25 +1002,19 @@
{{/* This page has no [rst-page-header]. It used to, and what it held
is not gone — every piece of it moved to where its equivalent
lives in the two applications this layout language comes from.
- The argument for the move is in render.go's shellLayout comment;
- what follows is only the map.
+ What follows is only the map.
The route back to the list was [docs-back], a breadcrumb above
- the title, and is now the shell bar's brand link — same
- destination, same label (showPage.BackLabel, which *is*
- docs.list.title, so it needs no glyph to be understood and
- nothing about it has to be mirrored for the RTL locales in this
- app's twelve), one row up. Until [docs-back] landed this page had
- no in-app navigation at all, so arriving by a bookmark or a mailed
- link left a person with no route to their own list; promoting it
- to the brand slot keeps that fixed and makes it the same control
- on every screen rather than one this page draws for itself.
-
- The title is [docs-shell-title], beside that brand link.
-
- Sign-out (POST /signout, auth.go) is in the bar's account menu,
- which is where a 30-day session belongs and is now the one place
- both screens end one.
+ the title, and is now the bar's brand link, "Docs" — the same
+ control on every screen rather than one this page draws for
+ itself. Until [docs-back] landed this page had no in-app
+ navigation at all, so arriving by a bookmark or a mailed link left
+ a person with no route to their own list.
+
+ The title is in the bar beside the brand, editable in place.
+
+ Sign-out (POST /signout, auth.go) is in the bar's account menu and
+ nowhere else: it used to be the last item of File as well.
The exports are items in the File menu below. They used to be two
ghost buttons pinned to the formatting bar's trailing end, which is
@@ -1287,9 +1038,11 @@
guarantee (editor_view_test.go's TestShowRendersContentWithoutScript);
a JS-driven role="menubar" would have moved the only route to a
person's own document behind a control that never opens for them.
- <details> opens with no script at all, and name="docs-menubar"
- makes the six an exclusive group — opening one closes the rest —
- with no state for anything to keep.
+ <details> opens with no script at all, and name="estilo-menus"
+ makes the five an exclusive group — opening one closes the rest —
+ with no state for anything to keep. It is the SAME group as the
+ bar's account menu, switcher and Menu sheet, so opening File closes
+ the account menu and the other way round (spec §7.1).
It stays a disclosure rather than being relabelled role="menubar"
with role="menuitem" children. <summary> is a real disclosure
@@ -1319,25 +1072,20 @@
Both rules were correct. Neither had an element. */}}
<div docs-chrome>
-<nav docs-menubar aria-label="{{.Menu.Label}}">
- {{range .Menu.Menus}}<details docs-menu name="docs-menubar" id="docs-menu-{{.ID}}">
- <summary docs-menu-title>{{.Title}}</summary>
- <div docs-menu-items>
+<nav estilo-menubar aria-label="{{.Menu.Label}}">
+ {{range .Menu.Menus}}{{$checks := .Checks}}<details estilo-menubar-menu name="estilo-menus" id="docs-menu-{{.ID}}">
+ <summary estilo-menubar-title>{{.Title}}</summary>
+ <div estilo-menu>
{{range .Items}}
- {{- if eq .Kind "divider"}}<hr docs-menu-divider>
- {{- else if eq .Kind "link"}}<a docs-menu-item href="{{.Href}}"{{if .Download}} download{{end}}>{{.Label}}</a>
- {{- else if eq .Kind "form"}}<form docs-menu-form method="post" action="{{.Action}}"><button docs-menu-item type="submit">{{.Label}}</button></form>
- {{- else}}{{/* Every command item ships disabled, because
- without the bundle there is no editor to act on. Two
- different reasons for that, and they must not be confused:
- an ordinary item is enabled by fixtures/menu.mjs as soon as
- the editor mounts, while one marked data-docs-unavailable
- is waiting on a feature nobody has built yet (each is
- annotated in menu.go with the spec section it waits on) and
- menu.mjs never enables it. Without the flag, "not built"
- and "not mounted yet" look identical in the markup, and the
- first person to wire up the enabling loop would switch on
- an item whose command does not exist. */}}<button docs-menu-item type="button" data-docs-command="{{.Command}}"{{if .Shortcut}} data-docs-shortcut="{{.Shortcut}}"{{end}}{{if .Disabled}} data-docs-unavailable{{end}}{{if .Pressed}} aria-pressed="true"{{else if eq .Command "view_paged" "view_infinite"}} aria-pressed="false"{{end}} disabled>{{.Label}}{{with .Shortcut}}<span docs-menu-shortcut aria-hidden="true">{{.}}</span>{{end}}</button>
+ {{- if eq .Kind "divider"}}<hr>
+ {{- else if eq .Kind "link"}}<a estilo-menu-item href="{{.Href}}"{{if .Download}} download{{end}}{{if .JumpTo}} data-docs-jumpto{{end}}{{with .Shortcut}} data-docs-shortcut="{{.}}"{{end}}>{{if $checks}}<span estilo-menu-check aria-hidden="true"></span>{{end}}<span estilo-menu-label>{{.Label}}</span>{{with .Shortcut}}<kbd estilo-menu-key aria-hidden="true">{{.}}</kbd>{{end}}</a>
+ {{- else if eq .Kind "form"}}<form method="post" action="{{.Action}}"><button estilo-menu-item type="submit">{{if $checks}}<span estilo-menu-check aria-hidden="true"></span>{{end}}<span estilo-menu-label>{{.Label}}</span></button></form>
+ {{- else}}{{/* Every command item ships disabled, because without
+ the bundle there is no editor to act on; fixtures/menu.mjs
+ enables each as soon as the editor mounts, and afterwards
+ exactly when its command would do something. A checkable
+ item carries aria-pressed on every row of its group, and
+ the check glyph, not colour, shows the current one. */}}<button estilo-menu-item type="button" data-docs-command="{{.Command}}"{{with .Shortcut}} data-docs-shortcut="{{.}}"{{end}}{{if .Checkable}} aria-pressed="{{if .Pressed}}true{{else}}false{{end}}"{{end}} disabled>{{if $checks}}<span estilo-menu-check aria-hidden="true">{{if .Checkable}}{{template "estilo-icon-check"}}{{end}}</span>{{end}}<span estilo-menu-label>{{.Label}}</span>{{with .Shortcut}}<kbd estilo-menu-key aria-hidden="true">{{.}}</kbd>{{end}}</button>
{{- end}}
{{end}}
</div>
diff --git a/internal/docs/views/signin.html b/internal/docs/views/signin.html
index 109ef5e..0a37e1a 100644
--- a/internal/docs/views/signin.html
+++ b/internal/docs/views/signin.html
@@ -1,133 +1,19 @@
-{{/* signin — GET /signin (auth.go's signin handler). This is the
- page rastrillo/auth's own handlers never render themselves
- (magic-links.md: "the sign-in page stays yours") — Begin,
- Callback and Verify report every outcome by redirecting back
- here with a query this page reads: ?sent=1 once a link has been
- emailed, ?err=rate|address|expired|1 when something went wrong.
- auth.go's signin handler resolves those into Sent/ErrorMessage
- already-translated strings; this template only decides whether
- to show them.
+{{/* signin — GET /signin (auth.go's signin handler): estilo's sign-in
+ form, the check-your-email state that replaces it, or the Keymail
+ continuation (auth_navigation.go), which needs its meta refresh.
- Writes the bare rst-* attribute grammar throughout, same
- convention as list.html/show.html and the same test
- (render_test.go's TestOurTemplatesUseTheAttributeGrammar) holds
- it to. The "callout" partial is rastrillo's own (ui/partials);
- invoking it here embeds none of its own markup into this file's
- bytes at all — only the {{template}} call does, which is exactly
- what that test walks. */}}
-{{define "title"}}{{.Title}}{{end}}
-{{define "content"}}<style>
- /* No shell bar on the front door. The topbar shell (render.go's
- shellLayout) writes [rst-shell-bar] on every page, and unlike
- brand/nav/account — which are blocks a page can define its way
- out of — the bar and the account disclosure are markup the
- layout emits outside any block. So this screen hides them in CSS
- rather than by overriding a block, which is the only lever it
- has.
+ rastrillo/auth's own handlers never render this page (magic-links.md:
+ "the sign-in page stays yours"). Begin, Callback and Verify report
+ every outcome by redirecting back here with ?sent=1 or ?err=…, which
+ auth.go resolves into the data below.
- It hides them because there is nothing true to put in them. The
- account menu is not actually the problem: tokens.css already
- carries `[rst-shell-account]:has(> [rst-dropdown-menu]:empty)
- { display: none }`, so a page that defines no "account" block —
- which this one does not — loses it without being asked. What is
- left over is the part this page really does have to deal with:
- the shell's DEFAULT brand, a link to "/" that RequireSession
- bounces straight back here, and below 800px the hamburger, which
- would open an empty tail. An empty bar is worse than none — a
- strip of chrome that looks like it should do something.
-
- Hiding the bar rather than the two controls inside it, so the
- rule cannot go stale the day the shell grows a third one.
- display: none and not visibility/opacity: this must leave the
- accessibility tree too, or a screen-reader user tabs into an
- "Account" disclosure that sighted people cannot see. The shell's
- own skip link is a sibling of the bar, not inside it, so it
- survives — this page keeps a working skip-to-content.
-
- Written as a descendant of [rst-shell-topbar] to match the
- framework's own selector, and that is load-bearing rather than
- stylistic. tokens.css lays the bar out with
- `[rst-shell-topbar] [rst-shell-bar]`, specificity (0,2,0); a bare
- `[rst-shell-bar]` here is (0,1,0) and loses to it, so the first
- version of this rule did nothing at all and the bar stayed on
- screen showing rastrillo's default brand — a "Home" link to a
- route this page's own guard bounces straight back here. Matching
- the framework's shape means matching its specificity, and the
- later origin then decides it. */
- [rst-shell-topbar] [rst-shell-bar] {
- display: none;
- }
- /* [docs-signin] gives this screen one measure, shared by the
- callouts and the box below them.
- Measured before it existed: the box filled the shell's whole
- 992px column, and the single email field inside it stretched to
- 950px with its submit button sitting flush against the underside
- — no gap at all — so the eye had to travel the full width of the
- field and come back to find the control that acts on it. A
- one-field form rendered at the width of a data table is the
- shape of markup nobody has laid out yet, and this is the app's
- front door: the one screen a person meets before they have any
- reason to extend it credit.
- Left-aligned rather than centred, so the box still hangs off the
- same axis as the "Sign in" heading above it and the rest of the
- app's screens. */
- [docs-signin] {
- max-inline-size: 32rem;
- }
- /* The field and its button share a line, with real space between
- them. flex-basis 12rem with room to shrink and min-inline-size: 0
- is what keeps that true down to the 320px reflow width spec §10.3
- commits to — an <input>'s automatic minimum size is its content
- width, so without the explicit 0 it refuses to shrink and the row
- overflows instead of fitting. Below that the button wraps under
- the field rather than either one being squeezed unusable. */
- [docs-signin-form] {
- display: flex;
- flex-wrap: wrap;
- align-items: end;
- gap: var(--rst-sp-3);
- }
- [docs-signin-form] [rst-field] {
- flex: 1 1 12rem;
- min-inline-size: 0;
- margin: 0;
- }
- [docs-signin-form] [rst-input] {
- min-inline-size: 0;
- }
-</style>
-<header rst-page-header>
- <div rst-page-header-titles>
- <h1>{{.Title}}</h1>
- </div>
-</header>
-<div docs-signin>
- {{/* The callouts live inside [docs-signin] with the box, not above
- it at the shell's full width: a confirmation or an error about
- this form is part of the form's own column, and one that ran
- twice the width of the thing it referred to read as a banner
- about the app rather than an answer to what was just
- submitted. */}}
- {{if .Sent}}
- {{template "callout" dict "Body" .Sent "Tone" "positive"}}
- {{end}}
- {{if .ErrorMessage}}
- {{template "callout" dict "Body" .ErrorMessage "Tone" "negative" "Alert" true}}
- {{end}}
- <div rst-box>
- {{if .Body}}<div rst-box-head>
- <p>{{.Body}}</p>
- </div>{{end}}
- {{if .ContinueURL}}
- <meta http-equiv="refresh" content="0;url={{.ContinueURL}}">
- <a rst-btn="primary" href="{{.ContinueURL}}">{{.SubmitLabel}}</a>
- {{else}}
- <form docs-signin-form method="post" action="/signin">
- {{if .ForceEmail}}<input type="hidden" name="force" value="1">{{end}}
- {{template "field" dict "ID" "signin-address" "Name" "address" "Type" "email" "Label" .AddressLabel "Autocomplete" "email" "Required" true}}
- <button rst-btn="primary" type="submit">{{.SubmitLabel}}</button>
- </form>
- {{end}}
- </div>
-</div>
+ The bar shows the brand only: no account, no Sign in (chrome.go's
+ signinPage). It used to be hidden outright, because the rastrillo
+ shell could not be emptied, and the front door had no name on it. */}}
+{{define "content"}}
+{{- if .ContinueURL}}
+<meta http-equiv="refresh" content="0;url={{.ContinueURL}}">
+<section estilo-signin><p><a rst-btn="primary" href="{{.ContinueURL}}">{{.SubmitLabel}}</a></p></section>
+{{- else if .Sent}}{{template "estilo-signin-sent" .Sent}}
+{{- else}}{{template "estilo-signin" .Form}}{{end}}
{{end}}