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>
38 files changed,
+1998
−850
internal/docs/app.go+61 −5internal/docs/auth.go+73 −34internal/docs/auth_navigation.go+3 −3internal/docs/auth_test.go+2 −2internal/docs/chrome.go+178 −0internal/docs/chrome_test.go+288 −0internal/docs/errors.go+216 −0internal/docs/errors_test.go+400 −0internal/docs/export.go+3 −3internal/docs/grants_edges_test.go+5 −1internal/docs/handlers.go+70 −29internal/docs/home_config.go+36 −0internal/docs/home_local_navigation.go+6 −1internal/docs/home_render.go+22 −4internal/docs/home_router_integration_test.go+12 −3internal/docs/home_routes.go+7 −0internal/docs/list_test.go+15 −42internal/docs/menu.go+79 −76internal/docs/menu_test.go+3 −19internal/docs/pages_test.go+11 −13internal/docs/publish.go+9 −7internal/docs/render.go+136 −92internal/docs/share.go+14 −11internal/docs/share_nav_test.go+14 −10internal/docs/signin_return.go+158 −0internal/docs/team_access.go+4 −6internal/docs/team_router_test.go+1 −1internal/docs/testing.go+20 −0internal/docs/views/error.html+3 −0internal/docs/views/head.html+0 −1internal/docs/views/home_link.html+5 −2internal/docs/views/layout.html+42 −0internal/docs/views/list.html+5 −21internal/docs/views/partials/jumpto.html+8 −1internal/docs/views/public.html+6 −12internal/docs/views/share.html+2 −4internal/docs/views/show.html+65 −317internal/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 +; 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}} |