fix(skills): unlock UX — lock icon top-right, save reset, confirm z-index

Three issues from manual smoke-testing the unlock flow:

1. Confirm dialog rendered behind the edit-skill modal. Both
   overlays sat at z-index 600, and confirm-overlay is earlier in
   the DOM than edit-template-overlay — so DOM order put the parent
   modal on top of its own confirm. Bumped confirm-overlay to 650
   (still below toasts at 700) since confirm dialogs are launched
   FROM other overlays and need to sit above them.

2. Save button stayed disabled (or non-functional) after unlock.
   submitEditTemplate disables etm-submit on click and re-enables in
   .finally, but a stale disabled=true survives the mutate-in-place
   re-render that runs after unlock. Always reset
   submitBtn.disabled = false in showEditTemplateModal so the
   re-render path can never inherit a stuck disabled state.

3. UX redesign — moved the unlock affordance from a "Customize…"
   button at the bottom of the footer to a 🔒 icon button at the
   top-right of the modal. The lock glyph is the universal "this is
   locked, click to unlock" affordance and reads more clearly than
   a footer button next to Cancel/Save. font-variant-emoji: text
   keeps it monochrome on browsers that support it (instrument-panel
   aesthetic) with graceful fallback to coloured emoji elsewhere.
   admin-modal-skill h2 reserves padding-right so a long title can
   never collide with the absolute-positioned button.

Cleanup: removed the now-unused .modal-secondary and
.modal-buttons-spacer rules; the bottom etm-unlock button + flex
spacer are gone from the modal footer.
This commit is contained in:
Patrick Buckley
2026-05-08 14:26:08 -07:00
parent 73bf80e495
commit cb5fa1bed1
3 changed files with 69 additions and 41 deletions
+18 -15
View File
@@ -1446,16 +1446,23 @@ function showEditTemplateModal(tmplId) {
originBadge.style.display = "none";
}
}
var unlockBtn = document.getElementById("etm-unlock");
if (unlockBtn) {
unlockBtn.style.display = isReadonly ? "" : "none";
unlockBtn.dataset.skillId = tmplId;
unlockBtn.dataset.skillName = tmpl.name || "";
var lockBtn = document.getElementById("etm-lock-btn");
if (lockBtn) {
// Inline-flex (not "") so display: none doesn't bleed into a
// browser-default block reflow on re-show.
lockBtn.style.display = isReadonly ? "inline-flex" : "none";
lockBtn.dataset.skillId = tmplId;
lockBtn.dataset.skillName = tmpl.name || "";
}
var submitBtn = document.getElementById("etm-submit");
if (submitBtn) {
submitBtn.style.display = "";
submitBtn.textContent = isReadonly ? "Save Config" : "Save";
// Always reset to enabled — submitEditTemplate disables this on click
// and re-enables in .finally, but a stale disabled=true survives a
// mutate-in-place re-render (e.g. after unlock) and would leave the
// button non-functional otherwise.
submitBtn.disabled = false;
}
// Spec/content fields: locked for installed skills (preserve source fidelity).
// Point screen readers at the origin badge so the "why is this disabled?"
@@ -1538,7 +1545,7 @@ function hideEditTemplateModal() {
}
function unlockSkill() {
var btn = document.getElementById("etm-unlock");
var btn = document.getElementById("etm-lock-btn");
if (!btn) return;
var skillId = btn.dataset.skillId;
var skillName = btn.dataset.skillName || "this skill";
@@ -1557,11 +1564,8 @@ function unlockSkill() {
}
function _performUnlockSkill(skillId) {
var btn = document.getElementById("etm-unlock");
if (btn) {
btn.disabled = true;
btn.textContent = "Customizing…";
}
var btn = document.getElementById("etm-lock-btn");
if (btn) btn.disabled = true;
authFetch("/v1/api/admin/skills/" + skillId + "/unlock", {
method: "POST",
})
@@ -1585,10 +1589,9 @@ function _performUnlockSkill(skillId) {
showToast(e.message || "Unlock failed");
})
.finally(function () {
if (btn) {
btn.disabled = false;
btn.textContent = "Customize…";
}
// Re-enable in case the re-render didn't happen (error path); the
// success path hides the button anyway via showEditTemplateModal.
if (btn) btn.disabled = false;
});
}
+11 -9
View File
@@ -3298,6 +3298,17 @@
class="admin-modal admin-modal-wide admin-modal-skill"
>
<h2 id="edit-template-title">Edit Skill</h2>
<button
id="etm-lock-btn"
class="skill-lock-btn"
type="button"
style="display: none"
onclick="unlockSkill()"
aria-label="Customize skill (detach from upstream and unlock editing)"
title="Customize — detach from upstream and unlock editing"
>
<span aria-hidden="true">🔒</span>
</button>
<div
id="etm-origin-badge"
class="skill-origin-badge"
@@ -3579,15 +3590,6 @@
<button class="modal-cancel" onclick="hideEditTemplateModal()">
Cancel
</button>
<button
id="etm-unlock"
class="modal-secondary"
style="display: none"
onclick="unlockSkill()"
>
Customize…
</button>
<span class="modal-buttons-spacer" aria-hidden="true"></span>
<button
id="etm-submit"
class="modal-submit"
+40 -17
View File
@@ -1855,6 +1855,11 @@
.admin-modal-skill {
padding: 28px 28px 24px;
}
/* Reserve space for the absolute-positioned lock button (top-right) so a
long title can never collide with it. */
.admin-modal-skill > h2 {
padding-right: 44px;
}
.skill-spec-body {
display: grid;
@@ -2085,37 +2090,47 @@ textarea.skill-content-area {
cursor: not-allowed;
filter: none;
}
.modal-secondary {
/* Sized to content so the primary action (Save) keeps a stable footprint
whether or not Customize is rendered. The spacer below pushes Save right. */
padding: 9px 14px;
/* Lock-icon affordance in the top-right of an installed (readonly) skill's
edit modal — clicking detaches the skill from upstream so spec fields
become editable. Reserved for the modal it lives in; not a generic
button class. */
.skill-lock-btn {
position: absolute;
top: 14px;
right: 14px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
color: var(--fg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font: inherit;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
font-size: 14px;
/* Render the lock glyph as text where supported (keeps the monochrome
instrument-panel aesthetic instead of a coloured emoji). Browsers
that don't support font-variant-emoji fall back gracefully. */
font-variant-emoji: text;
cursor: pointer;
transition: background 0.15s;
transition: background 0.15s, border-color 0.15s;
z-index: 2;
}
.modal-secondary:hover {
.skill-lock-btn:hover {
background: var(--bg-highlight);
border-color: var(--accent);
color: var(--accent);
}
.modal-secondary:focus-visible {
.skill-lock-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.modal-secondary:disabled {
.skill-lock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.modal-buttons-spacer {
flex: 1;
min-width: 0;
}
#create-user-overlay,
#create-token-overlay,
@@ -2155,6 +2170,14 @@ textarea.skill-content-area {
justify-content: center;
z-index: 600;
}
/* Confirm dialogs are launched FROM other overlays (e.g. unlock-skill from
the edit-template modal). They share z-index 600, so DOM order picks the
winner — and confirm-overlay is earlier in the DOM, so it would render
underneath. Bump it above the per-feature overlays but keep it below
toasts (z-index 700). */
#confirm-overlay {
z-index: 650;
}
/* Token display (show-once) */
.token-created-warning {
@@ -4008,7 +4031,7 @@ textarea.skill-content-area {
.admin-action-btn,
.modal-cancel,
.modal-submit,
.modal-secondary {
.skill-lock-btn {
transition: none;
}
.admin-modal input,