oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3// The shared chrome (estilo chrome, round 2): every page's bar, account
4// menu and <title> are built here, once, so no page can drift from
5// another. Before this the bar was rastrillo's topbar markup written out
6// in layout.html, 45px signed out and 51px signed in (the Create and
7// Import buttons were taller than the bar), with a flat account list of
8// Light / Dark / System / Sign out and a title every handler spelled its
9// own way.
10//
11// Spec: estilo spec/shared-chrome, docs/superpowers/specs/
12// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account),
13// §10 (titles). Sheets is the second adopter; the corrections it needed
14// are on estilo feat/r2-sheets-corrections.
15
16import (
17 "net/http"
18 "net/url"
19
20 "amadan.net/oficina/estilo.git/brand"
21 "amadan.net/oficina/estilo.git/chrome"
22 estilomenu "amadan.net/oficina/estilo.git/menu"
23 "amadan.net/oficina/home/connect"
24)
25
26// appName is the brand, the last part of every <title>, and the only
27// name the app goes by (spec §10: "Sheets", never "Oficina Sheets").
28const appName = "Sheets"
29
30// shortcutsDialog is the id of the Keyboard shortcuts dialog the layout
31// renders on every signed-in page, opened from the account menu's Help.
32const shortcutsDialog = "sheets-shortcuts"
33
34// Frame is what layout.html reads from every page: the bar, the parts of
35// the <title> before the app's name, the Appearance preference for
36// <html data-theme>, the shell variant, and the shortcut labels for this
37// request's platform.
38type Frame struct {
39 Chrome chrome.Bar
40 TitleParts []string
41 Theme string
42 // Shell is estilo-shell's value: "fill" on the grid, the one page
43 // that fills the viewport; empty everywhere else.
44 Shell string
45 // Keys are the shortcut labels, formatted server-side by estilo's
46 // menu.Shortcut for the request's platform (⌘Z on a Mac, Ctrl+Z
47 // elsewhere), so nothing flips after paint.
48 Keys map[string]string
49 // Shortcuts renders the Keyboard shortcuts dialog. It is set exactly
50 // when the account menu (which opens it) is.
51 Shortcuts bool
52}
53
54// frame describes one page's chrome. The zero value is a plain page with
55// no context, whose <title> is just "Sheets".
56type frame struct {
57 // parts precede "Sheets" in <title>, most specific first.
58 parts []string
59 // context is the open sheet's title; edit replaces it with the
60 // editable title when the person may rename.
61 context string
62 edit *chrome.TitleEdit
63 actions []chrome.Action
64 // public is a read-only page a stranger reaches (/l/): the brand is
65 // plain text, and "Sign in" carries no return path, because the path
66 // is a capability and must not be copied into another URL.
67 public bool
68 // signinPage is one of the sign-in, sent and signed-out pages: the
69 // corner is empty.
70 signinPage bool
71 // fill is the grid: the shell is the viewport and the bar does not
72 // stick (the document never scrolls).
73 fill bool
74 // ret, when set, is where Appearance returns to instead of this
75 // request's own path and query. The share screen sets it: its query
76 // is a one-shot notice, and reflecting a query into the page is the
77 // channel TestTheShareScreenIgnoresATokenInItsQuery keeps closed.
78 ret string
79}
80
81// titled is the frame for a page whose <title> is these parts.
82func titled(parts ...string) frame { return frame{parts: parts} }
83
84// onSheet is the frame for a page about one sheet: the sheet's title is
85// the bar's context, and page (when not empty) names the sub-page.
86func onSheet(s SheetSummary, page string) frame {
87 f := frame{context: s.Title, parts: []string{s.Title}}
88 if page != "" {
89 f.parts = []string{page, s.Title}
90 }
91 return f
92}
93
94// shortcutKeys are the chords the menus, toolbar tips and the shortcuts
95// dialog label. The bindings themselves live in commands.js and
96// clipboard.js, which accept either Ctrl or ⌘.
97var shortcutKeys = map[string]string{
98 "undo": "Mod+Z",
99 "cut": "Mod+X",
100 "copy": "Mod+C",
101 "paste": "Mod+V",
102 "filldown": "Mod+D",
103 "selectall": "Mod+A",
104 "bold": "Mod+B",
105 "italic": "Mod+I",
106 "delete": "Delete",
107 "enter": "Enter",
108 "tab": "Tab",
109 "esc": "Esc",
110}
111
112func keysFor(r *http.Request) map[string]string {
113 out := make(map[string]string, len(shortcutKeys))
114 for name, chord := range shortcutKeys {
115 out[name] = estilomenu.Shortcut(r, chord)
116 }
117 return out
118}
119
120// frameFor builds a page's Frame. The viewer is read optionally: an
121// error page, the link page and a sign-in page all show whatever the
122// request's session says, without requiring one.
123func (a *App) frameFor(r *http.Request, o frame) Frame {
124 bar := chrome.Bar{
125 App: chrome.App{
126 Name: appName,
127 Slug: "sheets",
128 Home: "/",
129 Icon: brand.Path("sheets"),
130 },
131 Context: o.context,
132 ContextEdit: o.edit,
133 Actions: o.actions,
134 Sticky: !o.fill,
135 Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"},
136 }
137 address, signedIn := "", false
138 if !o.signinPage {
139 address, signedIn = a.identity(r)
140 }
141 switch {
142 case o.signinPage:
143 // The corner is empty on the pages that are themselves sign-in.
144 case signedIn:
145 // The list, not "/", which only redirects a signed-in person
146 // there: one hop fewer from the brand.
147 bar.App.Home = "/sheets"
148 bar.Account = a.account(r, address, o.ret)
149 bar.Switcher = a.switcher()
150 case o.public:
151 bar.SignIn = &chrome.Link{Href: "/signin", Label: "Sign in"}
152 default:
153 bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"}
154 }
155 if o.public {
156 bar.App.Home = ""
157 }
158 f := Frame{
159 Chrome: bar,
160 TitleParts: o.parts,
161 Theme: chrome.ThemeAttr(chrome.Appearance(r)),
162 Keys: keysFor(r),
163 Shortcuts: bar.Account != nil,
164 }
165 if o.fill {
166 f.Shell = "fill"
167 }
168 return f
169}
170
171// returnTarget is where a person should come back to: this page, if it
172// is a page (a GET), otherwise the list.
173func returnTarget(r *http.Request) string {
174 if r.Method != http.MethodGet && r.Method != http.MethodHead {
175 return "/sheets"
176 }
177 return r.URL.RequestURI()
178}
179
180// account is the account menu (spec §8.2): who is signed in, "Connect
181// your Oficina account" when that means something, Appearance, Help, and
182// the one Sign out in the app.
183func (a *App) account(r *http.Request, address, ret string) *chrome.Account {
184 if ret == "" {
185 ret = returnTarget(r)
186 }
187 acc := &chrome.Account{
188 TriggerLabel: "Account",
189 Initials: chrome.Initials("", address),
190 Address: address,
191 Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"},
192 Appearance: &chrome.AppearanceMenu{
193 Action: "/appearance",
194 Return: ret,
195 Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
196 },
197 // Sheets has no Open source page yet, so Help holds only the
198 // shortcuts; the spec's "Open source last" waits for a credits
199 // page (recorded on the branch discussion).
200 Help: []estilomenu.Item{{Dialog: shortcutsDialog, Label: "Keyboard shortcuts…"}},
201 SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"},
202 }
203 if address != "" {
204 acc.TriggerLabel = "Account: " + address
205 }
206 if a.offersHomeLink(r) {
207 acc.Items = []estilomenu.Item{{Href: "/account/home", Label: homeCopy["menu_action"]}}
208 }
209 return acc
210}
211
212// offersHomeLink is whether the account menu offers to connect the
213// person's Oficina account: only when Home is configured, and never to
214// somebody who signed in through Home, whose account is already the
215// connection (spec §12.1 item 4; before this Sheets offered it to Home
216// sessions too).
217func (a *App) offersHomeLink(r *http.Request) bool {
218 if a.home == nil {
219 return false
220 }
221 s, ok := a.homeSessions.From(r)
222 return ok && s.Method != connect.HomeMethod
223}
224
225// switcher is the app switcher, or nil when no hub directory is
226// configured. It shows "All apps" alone and no tiles (Paul's §13 answer
227// 2): a tile today still costs an assertion exchange through the
228// directory session.
229func (a *App) switcher() *chrome.Switcher {
230 if a.directory == "" {
231 return nil
232 }
233 return &chrome.Switcher{
234 Label: "Oficina apps",
235 CurrentLabel: "(this app)",
236 AllApps: estilomenu.Item{Href: a.directory + "/", Label: "All apps"},
237 }
238}
239
240// homeCopy is connect's account-connection copy (which already leaves
241// the hub unnamed — "your Oficina account" — and says Connect, Paul's
242// §13 answer 3), with only Sheets' own words on top.
243var homeCopy = func() map[string]string {
244 out := make(map[string]string, len(connect.English))
245 for k, v := range connect.English {
246 out[k] = v
247 }
248 out["start_body"] = "Use your Oficina account to sign in to this account. Your sheets and sharing settings stay as they are."
249 out["documents_action"] = "Back to Sheets"
250 return out
251}()
252
253// pageHeaders marks a rendered page as depending on the platform its
254// shortcut labels were formatted for: menu.Shortcut reads the
255// User-Agent, so a shared cache must not hand a Mac's page to Windows.
256func pageHeaders(w http.ResponseWriter) {
257 w.Header().Add("Vary", "User-Agent")
258}
259