eleven / messenger Public

web: make App Studio app list responsive on narrow viewports (#1324)

James Adam 347dfbe9a47b9e63ca6a35d5076d942b4e820ea8 parent f18d7f0
2 files changed, +35 −13
  • changelog/2026-07-24-app-studio-mobile.md +8 −0
  • web/static/apps/appstudio.js +27 −13
diff --git a/changelog/2026-07-24-app-studio-mobile.md b/changelog/2026-07-24-app-studio-mobile.md
new file mode 100644
index 0000000..aeebc9a
--- /dev/null
+++ b/changelog/2026-07-24-app-studio-mobile.md
@@ -0,0 +1,8 @@
+๐Ÿค– App Studio now fits a phone screen. Each app in the list used to keep its
+title and its whole row of buttons โ€” Open, Upgrade, Edit code, History,
+Download, Remove โ€” on one line, which shoved everything off the right edge on a
+narrow screen: titles broke apart letter by letter ("Strea/ks") and the
+buttons and description ran past where you could read or tap them. The buttons
+now sit in their own group that wraps neatly below the app's name when there
+isn't room beside it, so the whole page reads and works on a narrow viewport
+while staying a single line on wider ones.
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js
index 8ae45ac..6fabe39 100644
--- a/web/static/apps/appstudio.js
+++ b/web/static/apps/appstudio.js
@@ -114,13 +114,21 @@ export const app = defineApp({
.studio h2 { margin: 0; font-size: 18px; }
.studio h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.studio .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
-.studio .row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border); }
-.studio .row .g { font-size: 20px; width: 28px; text-align: center; }
-.studio .row .meta { flex: 1 1 auto; min-width: 0; }
-.studio .row .meta .t { font-weight: 600; overflow-wrap: anywhere; }
+/* The row wraps: glyph + title hold the first line; the action cluster drops
+ to its own line and wraps among itself when the pane is too narrow to keep
+ it inline โ€” so nothing overflows off-screen and the title is never crushed
+ letter-by-letter. On a wide pane the meta flex-grows and pushes the actions
+ to the right, keeping the familiar single-line look. */
+.studio .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 12px 0; border-bottom: 1px solid var(--border); }
+.studio .row .g { font-size: 22px; width: 28px; text-align: center; flex: 0 0 auto; align-self: flex-start; }
+/* A solid basis keeps the title readable: on a narrow pane the actions wrap
+ below rather than squeezing meta until "Streaks" stacks into "Strea/ks". */
+.studio .row .meta { flex: 1 1 200px; min-width: 0; }
+.studio .row .meta .t { font-weight: 600; overflow-wrap: break-word; }
.studio .row .meta .by { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
+.studio .row .actions { display: flex; flex-wrap: wrap; gap: 8px; }
.studio .bot-badge { display: inline-flex; align-items: center; color: var(--muted); }
-.studio .row button { cursor: var(--ui-cursor, pointer); background: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 10px; color: var(--ink); font-size: 13px; }
+.studio .row button { cursor: var(--ui-cursor, pointer); background: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 12px; color: var(--ink); font-size: 13px; }
.studio .row button.danger { color: var(--danger); border-color: var(--danger); }
.studio .row button.icon-btn { display: inline-flex; align-items: center; padding: 5px 9px; }
.studio .link-btn { align-self: flex-start; background: none; border: none; color: var(--link); cursor: var(--ui-cursor, pointer); font-size: 13px; padding: 0; }
@@ -194,12 +202,13 @@ export const app = defineApp({
const sub = new Date(v.at * 1000).toLocaleString() + (v.byMe ? " ยท by you" : "") + (v.based_on ? ` ยท based on version ${v.based_on}` : "");
meta.appendChild(el("div", "by", sub));
row.appendChild(meta);
+ const actions = el("div", "actions");
if (v.source) {
const dl = el("button", "icon-btn");
dl.innerHTML = ICON_DOWNLOAD;
dl.title = "Download this version";
dl.onclick = () => downloadSource(v.source, `${historyTitle}-v${v.version}`);
- row.appendChild(dl);
+ actions.appendChild(dl);
}
if (ENABLE_MEMBER_APPS && !v.current && v.source) {
const revert = el("button", "primary", "Revert to this");
@@ -217,8 +226,9 @@ export const app = defineApp({
historyData = null;
rerender();
};
- row.appendChild(revert);
+ actions.appendChild(revert);
}
+ row.appendChild(actions);
panel.appendChild(row);
}
}
@@ -281,10 +291,13 @@ export const app = defineApp({
}
meta.appendChild(by);
row.appendChild(meta);
+ // Buttons live in their own cluster so they wrap as a unit below the
+ // title on a narrow pane instead of spilling off the right edge.
+ const actions = el("div", "actions");
if (m.name) {
const open = el("button", null, "Open");
open.onclick = () => openMemberApp(m.name);
- row.appendChild(open);
+ actions.appendChild(open);
}
if (a.mine && a.source) {
// A legacy-format app (manifest predates the places vocabulary)
@@ -306,7 +319,7 @@ export const app = defineApp({
// apps_changed reloads to apply, same as a revert deploy.
up.textContent = "Upgraded โœ“";
};
- row.appendChild(up);
+ actions.appendChild(up);
}
// Edit code leads to the (gated) editor โ€” with member apps off it
// hides with it; History, Download, and Remove stay open.
@@ -318,7 +331,7 @@ export const app = defineApp({
editingName = m.name || "";
rerender();
};
- row.appendChild(edit);
+ actions.appendChild(edit);
}
const hist = el("button", null, "History");
hist.onclick = () => {
@@ -327,12 +340,12 @@ export const app = defineApp({
historyData = null;
rerender();
};
- row.appendChild(hist);
+ actions.appendChild(hist);
const dl = el("button", "icon-btn");
dl.innerHTML = ICON_DOWNLOAD;
dl.title = "Download source";
dl.onclick = () => downloadSource(a.source, m.name || a.id);
- row.appendChild(dl);
+ actions.appendChild(dl);
const del = el("button", "danger", "Remove");
del.onclick = async () => {
if (!confirm(`Remove โ€œ${m.title || m.name}โ€? This deletes the app for everyone.`)) return;
@@ -346,8 +359,9 @@ export const app = defineApp({
}
refreshList();
};
- row.appendChild(del);
+ actions.appendChild(del);
}
+ row.appendChild(actions);
listBody.appendChild(row);
}
};