diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js
index 7d3378e..88d360c 100644
--- a/internal/webapp/static/app.js
+++ b/internal/webapp/static/app.js
@@ -101,9 +101,9 @@ async function loadProjects() {
add.className = "nav-add";
add.title = "New project";
add.textContent = "+";
- add.onclick = () => {
- const name = prompt("New project name:");
- if (name) createProject(name.trim());
+ add.onclick = async () => {
+ const name = await modalPrompt("New project", "Project name", "", "Create");
+ if (name) createProject(name);
};
head.appendChild(add);
nav.appendChild(head);
@@ -303,7 +303,7 @@ async function showOrgAdmin(org) {
row.appendChild(sel);
const rm = el(row, "button", "ai-del", "Remove");
rm.onclick = async () => {
- if (!confirm("Remove " + m.email + " from " + org.name + "?")) return;
+ if (!(await modalConfirm("Remove member", "Remove " + m.email + " from " + org.name + "?", "Remove", true))) return;
try { await api("DELETE", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email)); toast("Removed."); await loadOrgs(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
@@ -324,14 +324,14 @@ async function showOrgAdmin(org) {
el(row, "span", "ai-main", p.name);
const rn = el(row, "button", "ai-btn", "Rename");
rn.onclick = async () => {
- const name = prompt("Rename project:", p.name);
- if (!name || name.trim() === p.name) return;
- try { await api("PATCH", "api/projects/" + p.id, { name: name.trim() }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); }
+ const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
+ if (!name || name === p.name) return;
+ try { await api("PATCH", "api/projects/" + p.id, { name }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
const del = el(row, "button", "ai-del", "Delete");
del.onclick = async () => {
- if (!confirm("Delete project “" + p.name + "”? Its files stay in storage but it's removed from the hub.")) return;
+ if (!(await modalConfirm("Delete project", "Delete “" + p.name + "”? Its files stay in storage, but it's removed from the hub.", "Delete", true))) return;
try {
await api("DELETE", "api/projects/" + p.id);
toast("Deleted “" + p.name + "”.");
@@ -371,7 +371,7 @@ async function showOrgAdmin(org) {
el(row, "span", "ai-tag", meta);
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
- if (!confirm("Revoke this invite link? Anyone still holding it won't be able to join.")) return;
+ if (!(await modalConfirm("Revoke invite", "Revoke this invite link? Anyone still holding it won't be able to join.", "Revoke", true))) return;
try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
@@ -395,7 +395,7 @@ async function showOrgAdmin(org) {
el(row, "span", "ai-tag", meta);
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
- if (!confirm("Revoke the public link to “" + sh.path + "”? Anyone with the URL will lose access.")) return;
+ if (!(await modalConfirm("Revoke share link", "Revoke the public link to “" + sh.path + "”? Anyone with the URL will lose access.", "Revoke", true))) return;
try { await api("DELETE", "api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
@@ -421,6 +421,64 @@ async function api(method, url, body) {
return r.status === 204 ? {} : r.json();
}
+/* In-app modal prompt (text input) — replaces native prompt(). Resolves to
+ the trimmed string, or null if cancelled. */
+function modalPrompt(title, label, value, okLabel) {
+ return new Promise((resolve) => {
+ const back = document.createElement("div");
+ back.className = "modal-back";
+ back.innerHTML = `
`;
+ back.querySelector("h3").textContent = title;
+ back.querySelector(".modal-label").textContent = label || "";
+ const input = back.querySelector(".modal-input");
+ input.value = value || "";
+ back.querySelector('[data-a="ok"]').textContent = okLabel || "OK";
+ const done = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
+ const onKey = (e) => { if (e.key === "Escape") done(null); if (e.key === "Enter") done(input.value.trim() || null); };
+ back.querySelector('[data-a="cancel"]').onclick = () => done(null);
+ back.querySelector('[data-a="ok"]').onclick = () => done(input.value.trim() || null);
+ back.onclick = (e) => { if (e.target === back) done(null); };
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(back);
+ input.focus();
+ input.select();
+ });
+}
+
+/* In-app confirm — replaces native confirm(). `danger` styles the confirm
+ button as destructive. Resolves true/false. */
+function modalConfirm(title, message, confirmLabel, danger) {
+ return new Promise((resolve) => {
+ const back = document.createElement("div");
+ back.className = "modal-back";
+ back.innerHTML = ``;
+ back.querySelector("h3").textContent = title;
+ back.querySelector(".modal-msg").textContent = message || "";
+ back.querySelector('[data-a="ok"]').textContent = confirmLabel || "Confirm";
+ const done = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
+ const onKey = (e) => { if (e.key === "Escape") done(false); if (e.key === "Enter") done(true); };
+ back.querySelector('[data-a="cancel"]').onclick = () => done(false);
+ back.querySelector('[data-a="ok"]').onclick = () => done(true);
+ back.onclick = (e) => { if (e.target === back) done(false); };
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(back);
+ back.querySelector('[data-a="ok"]').focus();
+ });
+}
+
/* clipboard copy that never throws on a non-HTTPS origin (where
navigator.clipboard is undefined). Returns true on success. */
async function copyText(text) {
@@ -619,9 +677,12 @@ function renderNode(n) {
}
li.appendChild(renderChildren(n.children || []));
if (collapsed.has(n.path)) li.classList.add("collapsed");
+ row.setAttribute("aria-expanded", String(!collapsed.has(n.path)));
row.onclick = () => {
li.classList.toggle("collapsed");
- li.classList.contains("collapsed") ? collapsed.add(n.path) : collapsed.delete(n.path);
+ const isCollapsed = li.classList.contains("collapsed");
+ isCollapsed ? collapsed.add(n.path) : collapsed.delete(n.path);
+ row.setAttribute("aria-expanded", String(!isCollapsed));
};
} else {
flatFiles.push({ path: n.path, name: n.name });
diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css
index ae77c44..fc0a504 100644
--- a/internal/webapp/static/style.css
+++ b/internal/webapp/static/style.css
@@ -7,10 +7,11 @@
--border: #363636;
--text: #dadada;
--text-dim: #a6a6a6; /* AA on --bg and --bg-side */
- --text-faint: #8a8a8a; /* raised from #6e6e6e → ~4.7:1 on --bg */
+ --text-faint: #969696; /* AA on both --bg (5.0) and --bg-side #262626 (4.6) */
--accent: #a882ff;
--accent-bright: #c9b3ff; /* accent text on the tinted --bg-active (AA) */
--accent-dim: #6a48e0; /* darker so white labels reach 4.5:1 */
+ --accent-press: #5a3bc9; /* button hover/press — white stays ≥4.5:1 */
--code-bg: #2a2a2a;
/* radius scale: controls / cards / overlays */
--r-ctl: 6px;
@@ -146,7 +147,7 @@ body {
text-decoration: none;
border: none; cursor: pointer; font-family: inherit;
}
-.btn:hover { background: var(--accent); }
+.btn:hover { background: var(--accent-press); }
#content { flex: 1; overflow-y: auto; padding: 28px 48px 80px; }
#history-btn, #share-btn { border: none; cursor: pointer; font: inherit; font-size: 12.5px; background: var(--bg-active); color: var(--text); }
#history-btn:hover, #share-btn:hover { color: #fff; background: var(--bg-hover); }
@@ -218,6 +219,7 @@ body {
font-size: 13.5px;
}
#palette-results li.selected { background: var(--bg-active); color: var(--text); }
+#palette-results li.selected .picon, #palette-results li.selected .pkind { color: #c4c4c4; }
#palette-results li .picon { width: 18px; flex: none; text-align: center; color: var(--text-faint); }
#palette-results li .plabel { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#palette-results li .plabel b { color: var(--accent); font-weight: 600; }
@@ -261,7 +263,7 @@ body {
.ob-row input { flex: 1; padding: 8px 11px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; font-size: 13.5px; outline: none; }
.ob-row input:focus { border-color: var(--accent-dim); }
.pbtn { border: none; cursor: pointer; font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px; background: var(--accent-dim); color: #fff; white-space: nowrap; }
-.pbtn:hover { background: var(--accent); }
+.pbtn:hover { background: var(--accent-press); }
/* ---- admin panels (org, pending) ---- */
.admin { max-width: 720px; }
@@ -296,6 +298,12 @@ body {
.modal p { margin: 0 0 16px; font-size: 13.5px; color: var(--text-dim); }
.modal-url { font: 12px var(--mono, ui-monospace, Menlo, monospace); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; color: var(--text-dim); word-break: break-all; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
+.modal-label { display: block; font-size: 12.5px; color: var(--text-dim); margin: 0 0 6px; }
+.modal-msg { margin: 0 0 16px; font-size: 13.5px; color: var(--text-dim); }
+.modal-input { width: 100%; box-sizing: border-box; padding: 9px 11px; border-radius: var(--r-ctl); border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; font-size: 14px; margin-bottom: 16px; outline: none; }
+.modal-input:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
+.danger-btn { border: none; cursor: pointer; font: inherit; font-size: 13px; padding: 7px 16px; border-radius: var(--r-ctl); background: #b3382e; color: #fff; }
+.danger-btn:hover { background: #c94336; }
/* ---- toast ---- */
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--bg-active); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px 18px; font-size: 13.5px; box-shadow: 0 8px 30px rgba(0,0,0,.5); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 200; }
@@ -332,6 +340,11 @@ body {
/* Sidebar header controls also meet the 44px target on touch. */
.icon-btn2, #signout, .adminbar { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
#vault { padding: 8px 12px; }
+ /* The list rows are the primary tap targets — size them to 44px too. */
+ #tree li > .row, #projects .row { min-height: 44px; }
+ #invite-btn { min-height: 44px; padding: 0 16px; }
+ .nav-add { min-width: 44px; min-height: 44px; }
+ .dir-history { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
.markdown, .admin, .onboard, .history { max-width: 100%; }
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
.ob-row { flex-direction: column; }