design(round 2): 44px list rows, AA residuals, in-app modals

Closes the remaining design-review gaps (design-only; no flow changes).

Accessibility (the failing objective gate + residuals):
- Mobile 44px now reaches the PRIMARY tap targets: file tree rows,
  project rows, the org "Manage" button, new-project +, and folder-history
  all sized to 44px min (round 1 had only reached the header/sidebar icons).
- Residual sub-AA small text fixed: --text-faint #8a8a8a→#969696 (AA on the
  #262626 sidebar, measured 5.1); selected palette row's kind/icon lifted
  to #c4c4c4 (6.6); filled-button hover moved to --accent-press #5a3bc9 so
  white labels stay ≥4.5 on hover (7.2, was 2.9).
- Directory rows expose aria-expanded, updated on collapse toggle.

UX (the main remaining seam):
- Native prompt()/confirm() replaced with in-app modal components
  (modalPrompt / modalConfirm, destructive variant) for new project,
  rename, delete, remove member, and revoke invite/share — so every
  decision shares one visual language with the share modal and toasts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
Snow Lee
2026-07-08 23:48:59 -07:00
co-authored by Claude Fable 5
parent 442232bab2
commit 927d77c22b
2 changed files with 88 additions and 14 deletions
+72 -11
View File
@@ -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 = `<div class="modal">
<h3></h3>
<label class="modal-label"></label>
<input class="modal-input" type="text" autocomplete="off">
<div class="modal-actions">
<button class="ai-btn" data-a="cancel">Cancel</button>
<button class="pbtn" data-a="ok"></button>
</div></div>`;
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 = `<div class="modal">
<h3></h3>
<p class="modal-msg"></p>
<div class="modal-actions">
<button class="ai-btn" data-a="cancel">Cancel</button>
<button class="${danger ? "danger-btn" : "pbtn"}" data-a="ok"></button>
</div></div>`;
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 });
+16 -3
View File
@@ -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; }