eleven / messenger Public

web: admin panel fits narrow panes — no side-scroll, stacked rows, autogrow textareas (#1350)

Three phone-size fixes to the Admin app's interface:

- .admin-root gets width:100%: as a flex item with auto margins it
  shrink-to-fit, so one unshrinkable row (a level box's name+buttons
  head) sized the whole page past the pane and the panel side-scrolled.
  That head row also wraps now, with a shrinkable name.
- Label/field rows stack on narrow panes via a container query on the
  root (phones and the Mac's split sidebar stack; the full 480px root
  stays side-by-side — the query measures the content box, hence 420).
- Textareas autogrow (fitHeight: input + ResizeObserver re-fits, with a
  3-line floor), replacing the fixed 3-row description that clipped
  what you wrote; the report-warn textarea gets the same + full width.

Verified in a hermetic harness (real app + stub bridge, chrome-headless-shell
at 402px and 800px): pane scrollWidth == clientWidth at both, desktop keeps
the two-column rows.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 02e5809ab39235d1caa8eb042c0ddb2762a78e12 parent fb9ed1f
2 files changed, +54 −3
  • changelog/2026-07-25-admin-mobile-fit.md +4 −0
  • web/static/apps/admin.js +50 −3
diff --git a/changelog/2026-07-25-admin-mobile-fit.md b/changelog/2026-07-25-admin-mobile-fit.md
new file mode 100644
index 0000000..4110507
--- /dev/null
+++ b/changelog/2026-07-25-admin-mobile-fit.md
@@ -0,0 +1,4 @@
+🤖 The Admin panel now fits phone screens: no more sideways scrolling, the
+settings fields stack neatly on narrow screens instead of squeezing beside
+their labels, and the description box grows as you type so nothing you wrote
+is hidden.
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index b09bde8..ecd8e5e 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -19,6 +19,27 @@ import { changelogParagraphs } from "../format.js";
// follows — the prefix can't drift.
const N = namespace("admin");
+// A textarea that tracks its content height (the composer's behaviour): no
+// inner scrollbar, the field is as tall as what's written. Returns the fit
+// function so callers can re-run it after setting .value programmatically —
+// an input event only fires for typing.
+function fitHeight(ta) {
+ const fit = () => {
+ ta.style.height = "auto";
+ ta.style.height = ta.scrollHeight + 2 + "px"; // +2: the two 1px borders (border-box)
+ };
+ ta.style.resize = "none";
+ ta.style.overflow = "hidden";
+ ta.addEventListener("input", fit);
+ // A width change re-wraps the text (rows stack on narrow panes, panes
+ // resize), so re-fit on any size change; the initial observe callback also
+ // covers first layout. Settles immediately: re-applying the same height
+ // stops the observer cascade.
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(fit).observe(ta);
+ else requestAnimationFrame(fit);
+ return fit;
+}
+
// ── Release notes → the Chat view ─────────────────────────────────────────
// In place of the retired Announcer bot, the Admin app POSTS each new release
// note into this thread as an ordinary encrypted event; chatMessage projects it
@@ -144,7 +165,14 @@ export const app = defineApp({
chatIntro: "Updates about this space are posted here — you'll see the release notes whenever your space updates itself.",
styles: `
-.admin-root { max-width: 480px; margin: 0 auto; padding: 28px 20px 48px; }
+/* width:100% — as a flex item with auto margins the root SHRINK-TO-FITS, so
+ one unshrinkable row used to size the whole page past the pane and the
+ admin panel side-scrolled on phones. Also the container the row rules
+ below query against. */
+.admin-root {
+ max-width: 480px; width: 100%; margin: 0 auto; padding: 28px 20px 48px;
+ container-type: inline-size;
+}
/* The one card on this page: a live miniature of the invitation card — the
actual surface these settings control (branding renders where no key exists
@@ -187,9 +215,21 @@ export const app = defineApp({
flex: 1 1 160px; min-width: 0; font: inherit; padding: 8px 12px;
border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--ink);
}
-.admin-row textarea { resize: vertical; }
+/* Textareas autogrow (the fitHeight helper owns the height); the floor keeps
+ an empty field inviting. lh needs a current browser — without it the floor
+ is just absent and autogrow still works. */
+.admin-row textarea { resize: none; overflow: hidden; min-height: calc(3lh + 18px); }
.admin-row-top { align-items: flex-start; } /* textarea rows: label hugs the top */
.admin-row-top label { padding-top: 8px; }
+/* Narrow pane (phones, the Mac's split sidebar): the label column doesn't fit
+ beside its field — stack each row instead. The switch rows are untouched
+ (their label IS the row; the more-specific rule below keeps them one line).
+ The query measures the root's CONTENT box (its 40px padding excluded), so
+ the full 480px root reads as 440 — 420 keeps desktop side-by-side. */
+@container (max-width: 420px) {
+ .admin-row label { flex: 1 1 100%; }
+ .admin-row-top label { padding-top: 0; }
+}
.admin-row input:focus-visible, .admin-row textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }
.admin-row button { flex: none; }
.admin-status { font-size: 13px; color: var(--muted); min-height: 1.2em; }
@@ -344,6 +384,7 @@ export const app = defineApp({
descInput.rows = 3;
descInput.maxLength = 500;
descInput.placeholder = "What is this space? Members and the people they invite can read this.";
+ const descFit = fitHeight(descInput);
descRow.append(descLabel, descInput);
space.appendChild(descRow);
const paintPreviewDesc = () => {
@@ -606,8 +647,11 @@ export const app = defineApp({
box.dataset.level = level.id;
box.style.cssText = "display:block;padding:10px 0";
const head = document.createElement("div");
- head.style.cssText = "display:flex;align-items:center;gap:8px";
+ // wrap + a shrinkable name: a long level name used to make this row
+ // unshrinkable, which forced the whole page wider than a phone pane.
+ head.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
const name = document.createElement("strong");
+ name.style.cssText = "min-width:0;overflow-wrap:anywhere";
name.textContent = level.name;
const count = document.createElement("span");
count.style.cssText = "font-size:13px;color:var(--muted)";
@@ -1048,9 +1092,11 @@ export const app = defineApp({
openDetail((d) => {
const ta = document.createElement("textarea");
ta.rows = 3;
+ ta.style.width = "100%";
ta.value =
"A report was filed about your behaviour here. Please keep this space welcoming — continued reports can mean removal.";
ta.setAttribute("aria-label", `Warning to ${rep.reported_name}`);
+ fitHeight(ta);
const send = document.createElement("button");
send.textContent = `Send warning to ${rep.reported_name}`;
send.onclick = async () => {
@@ -1425,6 +1471,7 @@ export const app = defineApp({
if (!info) return flash(spaceStatus, "Couldn't load settings — are you an admin?");
nameInput.value = info.name || "";
descInput.value = info.description || "";
+ descFit();
paintPreviewDesc();
emailInput.value = info.email || "";
invCheck.checked = !!info.userInvites;