| 1 | package chrome |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // LinkLifetime is how long an emailed sign-in link works. It is |
| 10 | // keymaildev/signin's LinkTTL default, which every sign-in in the suite |
| 11 | // inherits: rastrillo's auth, Home's own flow and the keymail path all |
| 12 | // leave LinkTTL unset (checked 2026-10-01 at signin v0.1.1, rastrillo |
| 13 | // main 2fafa12 and v0.23.0, and Home 7a0f19c). The copy below says the |
| 14 | // number, so a sign-in that changes its lifetime must change this too, or |
| 15 | // the door and the email tell people something false. |
| 16 | const LinkLifetime = 15 * time.Minute |
| 17 | |
| 18 | // DoorCopy is the door's words: the sign-in form, the check-your-email |
| 19 | // state, the signed-out page and the sign-in email. English is the |
| 20 | // suite's one copy set; a locale replaces the whole set through |
| 21 | // SignIn.Copy and SignInNotice.Copy, never a word at a time, so no app |
| 22 | // can drift from the others by overriding one line. |
| 23 | // |
| 24 | // The words are the copy deck in §9.2 of the shared-chrome spec |
| 25 | // (docs/superpowers/specs/2026-09-23-shared-chrome.md, on estilo's |
| 26 | // spec/shared-chrome branch), with Paul's later rulings noted where they |
| 27 | // apply. "§9.2" and "§10" below are that document's sections. |
| 28 | // |
| 29 | // Translations are estilo's follow-up, never an app's. English is the |
| 30 | // only set today. When the suite needs another locale, estilo adds its |
| 31 | // whole DoorCopy here, beside English, with a way to pick one by locale, |
| 32 | // and every app gets it on its next re-pin. An app passes Copy only a set |
| 33 | // estilo publishes; it never writes or edits one, or the one copy set |
| 34 | // fragments again the first time an app translates its own door. |
| 35 | // |
| 36 | // Slots are tokens, not Printf verbs: {app} is the app's name, and {link} |
| 37 | // (the email body only) is the sign-in link. A translation may drop or |
| 38 | // move a slot, or carry a literal "%", and still render as written. |
| 39 | type DoorCopy struct { |
| 40 | // The form: heading (also the tab title's page), address label, the |
| 41 | // one help line under the field, and the submit. |
| 42 | Heading, AddressLabel, Help, Submit string |
| 43 | // The sent state, which replaces the form: heading (and tab title), |
| 44 | // body, and the link back to the form. |
| 45 | SentHeading, SentBody, Again string |
| 46 | // The signed-out page: the tab title's page, the heading, the body |
| 47 | // ({app} is the app's name) and the Sign in button. |
| 48 | SignedOutPage, SignedOutHeading, SignedOutBody, SignIn string |
| 49 | // The sign-in email: subject ({app}) and body ({app} and {link}). |
| 50 | EmailSubject, EmailBody string |
| 51 | } |
| 52 | |
| 53 | // linkMinutes is LinkLifetime in words, so the number lives in one place. |
| 54 | var linkMinutes = fmt.Sprintf("%d minutes", int(LinkLifetime/time.Minute)) |
| 55 | |
| 56 | // English is the door's copy set (shared-chrome spec §9.2). The submit |
| 57 | // is "Sign in": Paul, 2026-10-01, ruled out "Email me a link". The hub is |
| 58 | // never named ("Home"); the suite is Oficina (Paul, 2026-09-23). |
| 59 | var English = DoorCopy{ |
| 60 | Heading: "Sign in", |
| 61 | AddressLabel: "Email address", |
| 62 | Help: "We’ll email you a link that signs you in. It works once and expires in " + linkMinutes + ".", |
| 63 | Submit: "Sign in", |
| 64 | SentHeading: "Check your email", |
| 65 | SentBody: "If that address can sign in here, we’ve sent it a link. It works once and expires in " + linkMinutes + ".", |
| 66 | Again: "Use a different address", |
| 67 | SignedOutPage: "Signed out", |
| 68 | SignedOutHeading: "You’re signed out", |
| 69 | SignedOutBody: "You’ve signed out of {app} on this device.", |
| 70 | SignIn: "Sign in", |
| 71 | EmailSubject: "Sign in to {app}", |
| 72 | EmailBody: "Here’s your link to sign in to {app}:\n\n{link}\n\nIt works once and expires in " + linkMinutes + ". If you didn’t ask for it, you can ignore this email.", |
| 73 | } |
| 74 | |
| 75 | // signedOutBody names the app; with none it names the suite, as the hub's |
| 76 | // own signed-out page does. |
| 77 | func (c DoorCopy) signedOutBody(app string) string { |
| 78 | if app == "" { |
| 79 | app = "Oficina" |
| 80 | } |
| 81 | return fill(c.SignedOutBody, app, "") |
| 82 | } |
| 83 | |
| 84 | // fill puts the app's name and the link into a copy string's {app} and |
| 85 | // {link} slots, in one pass: a name or link that itself contains a token |
| 86 | // is not filled again. |
| 87 | func fill(s, app, link string) string { |
| 88 | return strings.NewReplacer("{app}", app, "{link}", link).Replace(s) |
| 89 | } |
| 90 | |
| 91 | func copyOf(c *DoorCopy) DoorCopy { |
| 92 | if c == nil { |
| 93 | return English |
| 94 | } |
| 95 | return *c |
| 96 | } |
| 97 | |
| 98 | // Title is a page's tab title: "<Page> · <App>" (shared-chrome spec |
| 99 | // §10), or whichever half is set when one is empty. |
| 100 | func Title(page, app string) string { |
| 101 | page, app = strings.TrimSpace(page), strings.TrimSpace(app) |
| 102 | switch { |
| 103 | case page == "": |
| 104 | return app |
| 105 | case app == "": |
| 106 | return page |
| 107 | } |
| 108 | return page + " · " + app |
| 109 | } |
| 110 | |
| 111 | // The form's words, read by estilo-signin. |
| 112 | func (s SignIn) Heading() string { return copyOf(s.Copy).Heading } |
| 113 | func (s SignIn) AddressLabel() string { return copyOf(s.Copy).AddressLabel } |
| 114 | func (s SignIn) Help() string { return copyOf(s.Copy).Help } |
| 115 | func (s SignIn) Submit() string { return copyOf(s.Copy).Submit } |
| 116 | |
| 117 | // Page is the sign-in page's part of the tab title ("Sign in"), for a |
| 118 | // layout that appends the app's name itself; Title is the whole title. |
| 119 | func (s SignIn) Page() string { return s.Heading() } |
| 120 | func (s SignIn) Title() string { return Title(s.Page(), s.App) } |
| 121 | |
| 122 | // Sent is this door's check-your-email state: the door's heading and |
| 123 | // body, and "Use a different address" linking to again (the form, |
| 124 | // keeping the return; empty drops the link). |
| 125 | func (n SignInNotice) Sent(again string) SignInNotice { |
| 126 | c := copyOf(n.Copy) |
| 127 | n.Heading, n.Body, n.page, n.Link = c.SentHeading, c.SentBody, c.SentHeading, nil |
| 128 | if again != "" { |
| 129 | n.Link = &Link{Href: again, Label: c.Again} |
| 130 | } |
| 131 | return n |
| 132 | } |
| 133 | |
| 134 | // SignedOut is this door's signed-out page: "You’re signed out", the |
| 135 | // sentence naming the app, and the primary Sign in linking to signin. |
| 136 | func (n SignInNotice) SignedOut(signin string) SignInNotice { |
| 137 | c := copyOf(n.Copy) |
| 138 | n.Heading, n.Body, n.page, n.Link = c.SignedOutHeading, c.signedOutBody(n.App), c.SignedOutPage, nil |
| 139 | if signin != "" { |
| 140 | n.Link = &Link{Href: signin, Label: c.SignIn} |
| 141 | } |
| 142 | return n |
| 143 | } |
| 144 | |
| 145 | // Page is the notice's part of the tab title: the door's own for Sent and |
| 146 | // SignedOut ("Check your email", "Signed out"), otherwise the heading. |
| 147 | func (n SignInNotice) Page() string { |
| 148 | if n.page != "" { |
| 149 | return n.page |
| 150 | } |
| 151 | return n.Heading |
| 152 | } |
| 153 | |
| 154 | // Title is the notice's whole tab title, "<Page> · <App>". |
| 155 | func (n SignInNotice) Title() string { return Title(n.Page(), n.App) } |
| 156 | |
| 157 | // SignInEmail is this copy set's sign-in email for app: subject and body |
| 158 | // around link. |
| 159 | func (c DoorCopy) SignInEmail(app, link string) (subject, body string) { |
| 160 | return c.MailSubject(app), c.MailBody(app)(link) |
| 161 | } |
| 162 | |
| 163 | // MailSubject is the sign-in email's subject, "Sign in to <App>" in |
| 164 | // English. (Not EmailSubject: that is the field it reads.) |
| 165 | func (c DoorCopy) MailSubject(app string) string { return fill(c.EmailSubject, app, "") } |
| 166 | |
| 167 | // MailBody renders the sign-in email's body around the link. |
| 168 | func (c DoorCopy) MailBody(app string) func(link string) string { |
| 169 | return func(link string) string { return fill(c.EmailBody, app, link) } |
| 170 | } |
| 171 | |
| 172 | // SignInEmail is the sign-in email for app in English: subject and body |
| 173 | // around link. |
| 174 | func SignInEmail(app, link string) (subject, body string) { return English.SignInEmail(app, link) } |
| 175 | |
| 176 | // EmailSubject is the sign-in email's subject in English, "Sign in to |
| 177 | // <App>": what rastrillo's auth.Config.Subject takes. |
| 178 | func EmailSubject(app string) string { return English.MailSubject(app) } |
| 179 | |
| 180 | // EmailBody renders the English sign-in email's body around the link: the |
| 181 | // shape keymaildev/signin's Flow.Body and rastrillo's auth.Config.Body |
| 182 | // take. |
| 183 | func EmailBody(app string) func(link string) string { return English.MailBody(app) } |
| 184 | |