oficina / meet Public

ui: rastrillo theme and topbar shell; Start a call on the list

Vendor internal/meet/static/theme.css (ui.ThemeCSS("day")) and link it
from a rewritten layout.html on rastrillo's topbar shell: skip link,
brand link to /, and a sign-out corner when a Viewer is present. render
gains a page(r, data) helper that adds Viewer from currentPerson(r);
wired into every render call reachable while signed in, plus ErrorPage.

Add a one-click "Start a call" form above "New meeting" on the list,
POST /meetings/start, members only under the existing CSRF gate,
minting a "Call, <day> <month> <hour:minute>" title through the
existing store.createMeeting and the existing link-once page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev 77227de07c9b3418be7965b14ae11fbabdd83069 parent 19ccf42
13 files changed, +342 −34
  • docs/superpowers/copy/2026-09-08-slice-1-strings.md +9 −0
  • internal/meet/app.go +1 −0
  • internal/meet/auth.go +1 −1
  • internal/meet/handlers_join.go +2 −2
  • internal/meet/handlers_meetings.go +34 −11
  • internal/meet/icons/icons.go +1 −0
  • internal/meet/render.go +20 −1
  • internal/meet/static/theme.css +187 −0
  • internal/meet/templates/index.html +3 −0
  • internal/meet/templates/layout.html +24 −2
  • internal/meettest/index_test.go +30 −17
  • internal/meettest/meetings_test.go +25 −0
  • internal/meettest/vendored_test.go +5 −0

CI passed — run details

diff --git a/docs/superpowers/copy/2026-09-08-slice-1-strings.md b/docs/superpowers/copy/2026-09-08-slice-1-strings.md
index 449d43f..e451bd2 100644
--- a/docs/superpowers/copy/2026-09-08-slice-1-strings.md
+++ b/docs/superpowers/copy/2026-09-08-slice-1-strings.md
@@ -25,6 +25,15 @@ Reviewed and approved by Paul on 2026-09-08 (copy-review, 52 strings, two edited
| You're signed in as {{.Email}}. This team's Meet has no meetings for you. If someone sent you a meeting link, open it again now. | `templates/welcome.html`, `content` block |
| Sign out | `templates/welcome.html`, `content` block, submit button |
+## Start a call (`/meetings/start`)
+
+_added 2026-09-10, copy review waived for this round per suite #5_
+
+| String | Where |
+| --- | --- |
+| Start a call | `templates/index.html`, `content` block, submit button |
+| Call, {day} {month} {hour:minute} | `handlers_meetings.go`, `callTitle` — the title a started call gets, e.g. "Call, 10 Sep 09:15", formatted in the instance zone |
+
## Meetings list (`/`)
| String | Where |
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 5419246..57a1450 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -215,6 +215,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Group(func(r chi.Router) {
r.Use(a.requireMember)
r.Get("/", a.index)
+ r.Post("/meetings/start", a.startCall)
r.Post("/meetings", a.createMeeting)
r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink)
r.Post("/meetings/{sqid}/links", a.replaceLink)
diff --git a/internal/meet/auth.go b/internal/meet/auth.go
index d412ef2..8891699 100644
--- a/internal/meet/auth.go
+++ b/internal/meet/auth.go
@@ -238,5 +238,5 @@ func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person)
func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
- render(w, "welcome", map[string]any{"Email": p.Email})
+ render(w, "welcome", page(r, map[string]any{"Email": p.Email}))
}
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go
index 738b7cd..6fdfda5 100644
--- a/internal/meet/handlers_join.go
+++ b/internal/meet/handlers_join.go
@@ -77,9 +77,9 @@ func (a *app) holding(w http.ResponseWriter, r *http.Request) {
ErrorPage(w, r, http.StatusNotFound, "")
return
}
- render(w, "join", map[string]any{
+ render(w, "join", page(r, map[string]any{
"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
"Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
"Floor": !a.cfg.RelayConfigured,
- })
+ }))
}
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go
index f06fdba..8375f2e 100644
--- a/internal/meet/handlers_meetings.go
+++ b/internal/meet/handlers_meetings.go
@@ -62,10 +62,10 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star
upcoming = append(upcoming, rw)
}
}
- render(w, "index", map[string]any{
+ render(w, "index", page(r, map[string]any{
"Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone,
"TitleError": titleErr, "StartError": startErr,
- })
+ }))
}
func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) {
@@ -91,17 +91,40 @@ func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) {
ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
- a.renderLinkOnce(w, m, token, false)
+ a.renderLinkOnce(w, r, m, token, false)
+}
+
+// callTitle is the title a one-click "Start a call" meeting gets, in
+// the instance zone: "Call, 10 Sep 09:15". now is passed in rather
+// than read off the clock here so a test can fix it.
+func callTitle(now time.Time, zone *time.Location) string {
+ return "Call, " + now.In(zone).Format("2 Jan 15:04")
+}
+
+// startCall is POST /meetings/start: the list's one-click button. It
+// creates a meeting with no start time — a call starts now — through
+// the same store.createMeeting every scheduled meeting goes through,
+// and answers with the same link-once page.
+func (a *app) startCall(w http.ResponseWriter, r *http.Request) {
+ p, _ := currentPerson(r)
+ title := callTitle(a.store.now(), a.cfg.zone)
+ m, token, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p))
+ if err != nil {
+ a.logger.Error("meet: start call", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ a.renderLinkOnce(w, r, m, token, false)
}
// renderLinkOnce answers with a page, not a redirect: a token in a
// query string lands in the edge's access logs (Sheets' ruling), and
// the page is the one time the token exists in plaintext.
-func (a *app) renderLinkOnce(w http.ResponseWriter, m Meeting, token string, replaced bool) {
- render(w, "link_once", map[string]any{
+func (a *app) renderLinkOnce(w http.ResponseWriter, r *http.Request, m Meeting, token string, replaced bool) {
+ render(w, "link_once", page(r, map[string]any{
"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
"Link": a.linkURL(token), "Replaced": replaced,
- })
+ }))
}
// meetingForMember loads the meeting in the URL or answers 404.
@@ -124,12 +147,12 @@ func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "confirm", map[string]any{
+ render(w, "confirm", page(r, map[string]any{
"Title": "Replace the link?",
"Body": "Everyone holding the current link for \"" + a.store.title(m) + "\" will get a not-found page. You will see the new link once.",
"Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link",
"CancelHref": "/", "Sqid": m.Sqid,
- })
+ }))
}
func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) {
@@ -148,7 +171,7 @@ func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) {
ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
- a.renderLinkOnce(w, m, token, true)
+ a.renderLinkOnce(w, r, m, token, true)
}
func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) {
@@ -156,12 +179,12 @@ func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "confirm", map[string]any{
+ render(w, "confirm", page(r, map[string]any{
"Title": "End the meeting?",
"Body": "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.",
"Action": "/meetings/" + m.Sqid + "/end", "Label": "End the meeting",
"CancelHref": "/", "Sqid": m.Sqid,
- })
+ }))
}
func (a *app) endMeeting(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/meet/icons/icons.go b/internal/meet/icons/icons.go
index 1f2546c..5338e66 100644
--- a/internal/meet/icons/icons.go
+++ b/internal/meet/icons/icons.go
@@ -27,6 +27,7 @@ var icons = map[string]template.HTML{
"help-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`,
"info": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`,
"kebab": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>`,
+ "menu": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
"plus": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`,
"search": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
"x": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
diff --git a/internal/meet/render.go b/internal/meet/render.go
index a964950..81ea640 100644
--- a/internal/meet/render.go
+++ b/internal/meet/render.go
@@ -48,9 +48,28 @@ func render(w http.ResponseWriter, name string, data any) {
buf.WriteTo(w)
}
+// Viewer is the signed-in person the shell's account corner names —
+// nothing more, since that corner has nothing else to show yet.
+type Viewer struct{ Email string }
+
+// page builds render's data map for a request that may be signed in:
+// it adds "Viewer" when the request carries a signed-in person
+// (currentPerson, auth.go), so layout.html's account corner and
+// sign-out form appear; a request with no person in context (signin,
+// followLink before a session exists) gets data back unchanged, and
+// the layout's {{if .Viewer}} renders nothing. A page reached only
+// while signed out (templates/signin.html) has no need to call this
+// and renders with the plain render(w, name, data) instead.
+func page(r *http.Request, data map[string]any) map[string]any {
+ if p, ok := currentPerson(r); ok {
+ data["Viewer"] = Viewer{Email: p.Email}
+ }
+ return data
+}
+
// ErrorPage renders the one error template with the status and the
// reference rastrillo minted for the log line.
func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
w.WriteHeader(status)
- render(w, "errors", map[string]any{"Status": status, "Ref": ref})
+ render(w, "errors", page(r, map[string]any{"Status": status, "Ref": ref}))
}
diff --git a/internal/meet/static/theme.css b/internal/meet/static/theme.css
new file mode 100644
index 0000000..5da4384
--- /dev/null
+++ b/internal/meet/static/theme.css
@@ -0,0 +1,187 @@
+/* day — rastrillo's default theme: an everyday blue on white and grey.
+ Structure lives in tokens.css; this file is colour, type and shape.
+ rastrillo new writes it once as static/theme.css.
+
+ The look: the ordinary, personable interface. A white page, a plain
+ grey text scale, one friendly blue that means "this does something",
+ and the conventional green/amber/red for status. Nothing here is
+ trying to be memorable — day is the theme an app ships on while it
+ works out what it wants to look like, and the one that never gets in
+ the way of the content. Semantic status colours are functional and
+ never the accent, so a pill can never be mistaken for a link.
+
+ ── One block, both schemes ───────────────────────────────────────────
+ Every token is declared once, as light-dark(<light>, <dark>), under a
+ single :root that sets color-scheme: light dark. The browser picks the
+ half that matches the resolved scheme, so there is no second copy of
+ the palette to keep in sync and no way to author dark half-done. The
+ two rules at the bottom are the whole toggle: setting color-scheme on
+ :root[data-theme=…] re-resolves every light-dark() in the file at
+ once, in both directions, which is why an explicit choice beats the OS
+ without restating a single colour.
+
+ Tokens with one value in both schemes — the font stack, the radii —
+ are written plain, because light-dark(x, x) says nothing twice.
+
+ light-dark() is a colour function: it may only stand where a <color>
+ is expected. So a shadow token wraps its colour in it and writes the
+ geometry once, outside the call — `0 8px 24px light-dark(a, b)`, never
+ `light-dark(0 8px 24px a, …)`, which no browser would accept as a
+ box-shadow.
+
+ ── The shape axis ────────────────────────────────────────────────────
+ Radius and shadow are the theme's now, not tokens.css's: a theme that
+ can only change colour cannot change how an app feels. day is soft and
+ unremarkable — 8px cards, 6px controls, shadows with a wide blur and
+ little alpha, the depth of paper resting on paper.
+
+ ── WCAG 2.2 AA, measured ─────────────────────────────────────────────
+ Normal text needs 4.5:1; large text and UI component boundaries need
+ 3:1. Every pair below was computed from these exact hex values, in
+ both schemes. If you change a token, recompute its rows.
+
+ LIGHT ratio requirement
+ --rst-text on --rst-surface 16.91 4.5:1 text
+ --rst-text on --rst-bg 16.91 4.5:1 text
+ --rst-text on --rst-surface-2 15.77 4.5:1 text
+ --rst-text on --rst-accent-soft 15.21 4.5:1 text
+ --rst-text-muted on --rst-surface 7.56 4.5:1 text
+ --rst-text-muted on --rst-bg 7.56 4.5:1 text
+ --rst-text-muted on --rst-surface-2 7.05 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 6.80 4.5:1 text
+ --rst-text-faint on --rst-surface 5.29 4.5:1 text
+ --rst-text-faint on --rst-bg 5.29 4.5:1 text
+ --rst-text-faint on --rst-surface-2 4.93 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 4.76 4.5:1 text
+ --rst-accent on --rst-surface 5.28 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 5.28 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 4.93 4.5:1 text
+ --rst-accent on --rst-accent-soft 4.75 4.5:1 text
+ --rst-on-accent on --rst-accent 5.28 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 7.18 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 3.63 3:1 control border
+ --rst-line-strong on --rst-bg 3.63 3:1 control border
+ --rst-line-strong on --rst-surface-2 3.39 3:1 control border
+ pill neutral fg on its own tint 9.02 4.5:1 text
+ pill positive fg on its own tint 6.49 4.5:1 text
+ pill warning fg on its own tint 6.15 4.5:1 text
+ pill negative fg on its own tint 5.30 4.5:1 text
+ --rst-on-accent on negative fg (fill) 6.47 4.5:1 danger button label
+
+ DARK ratio requirement
+ --rst-text on --rst-surface 13.74 4.5:1 text
+ --rst-text on --rst-bg 15.32 4.5:1 text
+ --rst-text on --rst-surface-2 14.63 4.5:1 text
+ --rst-text on --rst-accent-soft 13.02 4.5:1 text
+ --rst-text-muted on --rst-surface 7.56 4.5:1 text
+ --rst-text-muted on --rst-bg 8.43 4.5:1 text
+ --rst-text-muted on --rst-surface-2 8.05 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 7.16 4.5:1 text
+ --rst-text-faint on --rst-surface 6.33 4.5:1 text
+ --rst-text-faint on --rst-bg 7.06 4.5:1 text
+ --rst-text-faint on --rst-surface-2 6.74 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 6.00 4.5:1 text
+ --rst-accent on --rst-surface 6.95 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 7.75 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 7.40 4.5:1 text
+ --rst-accent on --rst-accent-soft 6.59 4.5:1 text
+ --rst-on-accent on --rst-accent 7.86 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 10.29 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 4.26 3:1 control border
+ --rst-line-strong on --rst-bg 4.75 3:1 control border
+ --rst-line-strong on --rst-surface-2 4.53 3:1 control border
+ pill neutral fg on its own tint 8.44 4.5:1 text
+ pill positive fg on its own tint 10.18 4.5:1 text
+ pill warning fg on its own tint 9.62 4.5:1 text
+ pill negative fg on its own tint 7.85 4.5:1 text
+ --rst-on-accent on negative fg (fill) 8.99 4.5:1 danger button label
+
+ The tightest pair in the set is --rst-accent on --rst-accent-soft in
+ light, at 4.75:1 against a 4.5:1 floor. Put a token on a surface not
+ listed above and you are guessing; recompute first.
+
+ --rst-line is a hairline divider between rows, not a control border or
+ a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control
+ borders use --rst-line-strong, which does clear 3:1.
+
+ --rst-header-rule is not in the table either, and deliberately so
+ (design doc §6-v2.2): the page header's rule is decoration, not
+ structure — the heading's size, weight and spacing carry the
+ structure — so no contrast floor applies to it and it is not in
+ ui/contrast_test.go's pair table. Do not argue it up to 3:1.
+
+ Pagination chips are the one deliberate exception: they are bordered
+ with --rst-line rather than --rst-line-strong, because a page strip
+ ringed in hard outlines shouts louder than the list it belongs to.
+ 1.4.11 is satisfied without that border — each chip's meaning is
+ carried by its text label at 4.5:1 or better, and the current page
+ adds a filled background, a heavier weight and aria-current on top.
+
+ --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and
+ --rst-overlay are not in the table: they paint a translucent shadow or
+ scrim over whatever sits underneath, not text over a fixed
+ background, so a contrast ratio does not apply. The scrim deepens in
+ dark, matching --rst-shadow-pop's own jump: black reads lighter
+ against a dark backdrop, so the overlay needs more alpha to still
+ read as a dimming layer.
+ ──────────────────────────────────────────────────────────────────── */
+
+:root {
+ color-scheme: light dark;
+
+ /* Type: the family only. Sizes and spacing are structural and stay in
+ tokens.css. */
+ --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+
+ /* Shape: soft, ordinary, unremarkable. */
+ --rst-radius: 8px;
+ --rst-radius-sm: 6px;
+ --rst-radius-pill: 999px;
+
+ /* Surfaces and lines */
+ --rst-bg: light-dark(#ffffff, #111418);
+ --rst-surface: light-dark(#ffffff, #1a1f26);
+ --rst-surface-2: light-dark(#f6f7f8, #15191f);
+ --rst-line: light-dark(#e5e7eb, #2a3038);
+ --rst-line-strong: light-dark(#7f8792, #79828e);
+
+ /* The page header's rule, derived rather than authored: the accent
+ folded into the line at 18%, so a theme that changes its accent
+ gets a matching rule with no second value to keep in step. day's
+ is barely there — a hairline you would call grey until you put it
+ beside plain's. Decorative: it carries no contrast floor. */
+ --rst-header-rule: color-mix(in oklab, var(--rst-accent) 18%, var(--rst-line));
+
+ /* Text */
+ --rst-text: light-dark(#1a1d21, #e8eaed);
+ --rst-text-muted: light-dark(#4b5563, #a8b0ba);
+ --rst-text-faint: light-dark(#656c78, #98a1ac);
+
+ /* Accent */
+ --rst-accent: light-dark(#2464e0, #7ba7ff);
+ --rst-accent-strong: light-dark(#1a4fc0, #a3c0ff);
+ --rst-accent-soft: light-dark(#eef3fe, #17233a);
+ --rst-on-accent: light-dark(#ffffff, #0b1220);
+
+ /* Status tones */
+ --rst-tone-neutral-fg: light-dark(#374151, #c3c9d1);
+ --rst-tone-neutral-bg: light-dark(#eef0f2, #262c34);
+ --rst-tone-positive-fg: light-dark(#166534, #6ee7a8);
+ --rst-tone-positive-bg: light-dark(#dcfce7, #10281c);
+ --rst-tone-warning-fg: light-dark(#854d0e, #f0c46a);
+ --rst-tone-warning-bg: light-dark(#fef3c7, #2c2110);
+ --rst-tone-negative-fg: light-dark(#b91c1c, #f79aa0);
+ --rst-tone-negative-bg: light-dark(#fee2e2, #331819);
+
+ /* Depth */
+ --rst-shadow-pop: 0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5));
+ --rst-shadow-knob: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --rst-shadow-lift: 0 1px 2px rgba(0, 0, 0, 0.08);
+ --rst-overlay: light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6));
+}
+
+/* The whole toggle. Setting color-scheme re-resolves every light-dark()
+ above, so an explicit choice beats the OS in both directions without
+ restating a colour. */
+:root[data-theme="light"] { color-scheme: light; }
+:root[data-theme="dark"] { color-scheme: dark; }
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html
index 755d96f..1ed42de 100644
--- a/internal/meet/templates/index.html
+++ b/internal/meet/templates/index.html
@@ -2,6 +2,9 @@
{{define "content"}}
{{template "page-header" dict "Title" "Meetings"}}
<section>
+<form method="post" action="/meetings/start"><button type="submit" rst-btn="primary lg">Start a call</button></form>
+</section>
+<section>
<h2>New meeting</h2>
<form rst-form method="post" action="/meetings">
{{template "field" dict "ID" "title" "Name" "title" "Label" "Title" "Required" true "Maxlength" "200" "Error" .TitleError "Autofocus" true}}
diff --git a/internal/meet/templates/layout.html b/internal/meet/templates/layout.html
index d09f966..e42184d 100644
--- a/internal/meet/templates/layout.html
+++ b/internal/meet/templates/layout.html
@@ -1,20 +1,42 @@
{{define "layout"}}<!doctype html>
-<html lang="en">
+<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Meet{{end}}</title>
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
+<link rel="stylesheet" href="{{asset "static/theme.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
{{iconAssets}}
<script defer src="{{asset "static/select.js"}}"></script>
</head>
<body>
-<main>
+<div rst-shell-topbar>
+<a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a>
+<header rst-shell-bar>
+<a rst-shell-brand href="/">Meet</a>
+{{/* The account corner is the only thing this shell's tail ever
+ carries — Meet has no nav and no locale menu yet — so the whole
+ menu+tail pair is skipped outright for a signed-out request rather
+ than rendered empty behind a hamburger nobody needs. .Viewer is
+ set only by the page helper (render.go), and only when the request
+ carries a signed-in person. */}}
+{{if .Viewer}}
+<details rst-shell-menu name="rst-shell-menu"><summary>{{icon "menu"}}{{T "rastrillo.ui.shell_menu"}}</summary></details>
+<div rst-shell-tail>
+<div rst-shell-account>
+<span>{{.Viewer.Email}}</span>
+<form method="post" action="/signout"><button type="submit" rst-btn="ghost">Sign out</button></form>
+</div>
+</div>
+{{end}}
+</header>
+<main id="main">
<div rst-page>
{{template "content" .}}
</div>
</main>
+</div>
</body>
</html>
{{end}}
diff --git a/internal/meettest/index_test.go b/internal/meettest/index_test.go
index 1b49c16..762cd81 100644
--- a/internal/meettest/index_test.go
+++ b/internal/meettest/index_test.go
@@ -11,6 +11,10 @@ import (
// links: /static/tokens.<16 hex>.css.
var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`)
+// hashedTheme is the same shape for the vendored theme sheet:
+// /static/theme.<16 hex>.css.
+var hashedTheme = regexp.MustCompile(`/static/theme\.[0-9a-f]{16}\.css`)
+
func TestIndexRenders(t *testing.T) {
c := newHarness(t, "paul@example.com").browser(t)
c.signIn("paul@example.com")
@@ -25,30 +29,39 @@ func TestIndexRenders(t *testing.T) {
// /signin renders the same layout as every other page and needs no
// session, so it is what this test uses to find the fingerprinted
-// stylesheet link now that "/" requires one.
+// stylesheet links now that "/" requires one. Both the structural
+// tokens.css and the vendored theme.css must be there — a layout
+// linking only tokens.css is exactly the unstyled-live-site bug this
+// change fixes.
func TestIndexLinksFingerprintedStylesheet(t *testing.T) {
h := newApp(t)
- href := hashedStylesheet.FindString(get(t, h, "/signin").Body.String())
- if href == "" {
- t.Fatal("index page links no fingerprinted stylesheet")
- }
- rec := get(t, h, href)
- if rec.Code != http.StatusOK {
- t.Fatalf("GET %s: status %d, want 200", href, rec.Code)
- }
- if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
- t.Errorf("GET %s: Cache-Control = %q, want immutable year", href, got)
+ body := get(t, h, "/signin").Body.String()
+ for _, re := range []*regexp.Regexp{hashedStylesheet, hashedTheme} {
+ href := re.FindString(body)
+ if href == "" {
+ t.Fatalf("signed-out page links no fingerprinted asset matching %s", re)
+ }
+ rec := get(t, h, href)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s: status %d, want 200", href, rec.Code)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
+ t.Errorf("GET %s: Cache-Control = %q, want immutable year", href, got)
+ }
}
}
// The bare name keeps working, just never long-cached — so a changed
// file always shows on an ordinary reload.
func TestBareAssetNameStaysFresh(t *testing.T) {
- rec := get(t, newApp(t), "/static/tokens.css")
- if rec.Code != http.StatusOK {
- t.Fatalf("GET /static/tokens.css: status %d, want 200", rec.Code)
- }
- if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
- t.Errorf("Cache-Control = %q, want no-cache", got)
+ h := newApp(t)
+ for _, name := range []string{"/static/tokens.css", "/static/theme.css"} {
+ rec := get(t, h, name)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s: status %d, want 200", name, rec.Code)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
+ t.Errorf("GET %s: Cache-Control = %q, want no-cache", name, got)
+ }
}
}
diff --git a/internal/meettest/meetings_test.go b/internal/meettest/meetings_test.go
index 5744df5..29f5c60 100644
--- a/internal/meettest/meetings_test.go
+++ b/internal/meettest/meetings_test.go
@@ -41,6 +41,31 @@ func TestCreateShowsTheLinkOnceAndTheListNever(t *testing.T) {
}
}
+// TestStartCallShowsTheLinkOnceAndListsItUnderUpcoming is the one-click
+// button above "New meeting": a member POSTs with no form fields at
+// all, and gets the same link-once page a scheduled meeting's creation
+// does, with a title Meet minted itself.
+func TestStartCallShowsTheLinkOnceAndListsItUnderUpcoming(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ rec := c.post("/meetings/start", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("POST /meetings/start: %d\n%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !linkInPage.MatchString(body) {
+ t.Fatalf("no link on the page:\n%s", body)
+ }
+ if !strings.Contains(body, "Call, ") {
+ t.Fatalf("page title does not start \"Call, \":\n%s", body)
+ }
+ rec = c.get("/")
+ if !strings.Contains(rec.Body.String(), "Call, ") {
+ t.Fatal("the list does not show the started call under Upcoming")
+ }
+}
+
func TestCreateRefusesAnEmptyTitleInPlace(t *testing.T) {
x := newHarness(t, "paul@example.com")
c := x.browser(t)
diff --git a/internal/meettest/vendored_test.go b/internal/meettest/vendored_test.go
index 622c204..f108393 100644
--- a/internal/meettest/vendored_test.go
+++ b/internal/meettest/vendored_test.go
@@ -16,8 +16,13 @@ import (
// to re-copy is caught instead of drifting silently. If you edit one
// DELIBERATELY, delete its line below — the file is yours.
func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
+ theme, ok := ui.ThemeCSS("day")
+ if !ok {
+ t.Fatal("ui: no \"day\" theme shipped")
+ }
for name, lib := range map[string][]byte{
"tokens.css": ui.TokensCSS(),
+ "theme.css": theme,
"rastrillo.js": ui.ShimJS(),
"select.js": ui.SelectJS(),
} {