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
13 files changed,
+342
−34
docs/superpowers/copy/2026-09-08-slice-1-strings.md+9 −0internal/meet/app.go+1 −0internal/meet/auth.go+1 −1internal/meet/handlers_join.go+2 −2internal/meet/handlers_meetings.go+34 −11internal/meet/icons/icons.go+1 −0internal/meet/render.go+20 −1internal/meet/static/theme.css+187 −0internal/meet/templates/index.html+3 −0internal/meet/templates/layout.html+24 −2internal/meettest/index_test.go+30 −17internal/meettest/meetings_test.go+25 −0internal/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(), |
| } { |