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; }