본문 바로가기
C.W.K.
Stream
Lesson 06 of 07 · published

Preview와 confirm — Mutate 전 신뢰 얻기

~11 min · preview-confirm, ux, trust, overlay

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"User의 page가 user context. ClipDeck이 손 뻗어 form 채우거나, 영역 highlight 하거나, DOM mutate 하는 순간 user가 일어날 거 보고 깔끔히 back out 할 자격. Lesson 6가 extension action을 놀람 대신 작은 contract로 만드는 preview-and-confirm overlay 패턴."

왜 preview가 필요한가

가만히 읽기만 하는 동작 — 선택 영역 읽기, clip 저장, 화면 찍기 — 에는 미리보기가 필요 없어. user가 시작했고, 결과는 side panel에 뜨고, 페이지는 그대로거든. 반대로 페이지를 건드리는 동작 — 입력창 채우기, 버튼 누르기, 창 닫기, 특정 자리로 스크롤 — 에서는 얘기가 뒤집혀:

  • user는 extension이 하려는지 늘 알 수는 없어. 어느 버튼을 누를지, 무슨 값을 붙일지, 어디로 옮겨 갈지. 미리보기가 그걸 말로 붙여 주는 거야.
  • User가 page를 특정 방식으로 set up 했을 수도 (form 반쯤 채움, mid-scroll). Preview가 그 work 만져지기 전 confirm 이나 back out 순간 줘.
  • 현실의 페이지에는 extension 코드가 미리 헤아리지 못한 구석이 늘 있어. 뭘 할 건지 먼저 보여 주면, user가 확정 전에 '어 저건 아닌데' 를 잡아낼 수 있고.

마찰이 작음 (confirm 위해 click 하나나 Enter tap 하나). 신뢰 이득 거대.

Overlay 패턴

Content script에서 host page에 작은 overlay inject. Overlay가 action 기술, 있으면 target highlight, Confirm / Cancel 제공:

  • 위치: viewport 우상단 floating, 또는 action target 근처 anchor. Never modal — user가 더 context 필요하면 page scroll 가능해야.
  • Content: action의 한 줄 요약 ("이 input에 'service worker eviction' paste"), target element의 bordered preview, 버튼 둘.
  • Lifecycle: SW 나 popup이 action 시작할 때 나타남. Confirm (action 돔), Cancel (action abort), 10 초 timeout (action default abort)에 dismiss.

키보드 affordance

Enter가 confirm. Escape가 cancel. Mount 시 Confirm 버튼 focus, 키보드 경로가 obvious. Default behavior가 90% user가 원하는 거 매칭 — 보통 Confirm — 하지만 user가 안 본 focus 된 버튼에 Enter 쳐서 ambush 느낌 절대 없어야.

Target highlighting

Action이 특정 element 영향 줄 때, 임시 border로 outline 해서 user가 만져질 것 보게:

function highlightElement(el) {
  const original = el.style.outline;
  el.style.outline = '2px solid #1a6bd6';
  el.style.outlineOffset = '2px';
  return () => { el.style.outline = original; };
}

Confirm / cancel 둘 다 unhighlight 가능하게 cleanup function 반환. Outline이 시각적으로 시끄럽지만 layout shift 안 함, user의 위치 mental model 보존.

Z-index 전쟁

실제 페이지들은 z-index를 두고 서로 싸워. 그러니 최댓값을 그냥 박아 버려:

overlay.style.zIndex = '2147483647'; // 2^31 - 1, 최대 int

그리고 page scroll 상관없이 overlay가 viewport 위에 머무르도록 position: fixed 사용. Fixed header 가진 page가 가끔 여전히 occlude. 그러면 자체 top-layer에 있고 z-index 완전 우회하는 :popover element (Chrome 114+) 안에 overlay 렌더.

Shadow root wrap

Page CSS가 overlay에 bleed 하는 거에 추가 안전 위해, overlay container에 shadow root attach 하고 markup + style 그 안에 두기:

const host = document.createElement('div');
host.style.cssText = 'position:fixed;top:0;right:0;z-index:2147483647;';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = `<style>...</style><div>...</div>`;

Closed shadow root라 page script는 host.shadowRoot로 안쪽을 훑을 수 없어. 그래도 UI는 accessibility tree에 남고 composed event는 경계를 건널 수 있어. Closed mode는 캡슐화지 투명 망토가 아니야.

Confirmation Promise

전체 flow를 caller가 await 하는 Promise로 wrap:

const confirmed = await previewAndConfirm({
  summary: 'Paste "' + clip.text.slice(0, 60) + '" into this input',
  target: focusedElement,
  timeoutMs: 10000,
});
if (confirmed) await fillInput(focusedElement, clip.text);

빠져나가는 길은 셋이야. 확인하면 true, 취소하면 false, 시간이 지나도 false. 부르는 쪽은 참/거짓만 보고 갈라지면 돼. event listener를 이리저리 엮을 일도, callback을 겹겹이 쌓을 일도 없어. 화면에 띄운 건 Promise가 풀리기 전에 먼저 걷어 내고.

Active mutation이 preview 자격. Action을 plain language로 보여 주고, target outline, keyboard affordance 가진 Confirm / Cancel 제공, 10 초 후 default Cancel. 작은 마찰, 거대한 신뢰 이득.
미리보기를 건너뛰어도 되는 때. user가 이미 눈으로 보고 있는 대상에 대해, 헷갈릴 데가 없는 동작을 직접 불렀다면 (고른 텍스트에 Ctrl+Shift+K를 눌러 clip을 저장한다든지) 확인창 대신 끝나고 1 초짜리 알림만 띄워도 돼. 무거운 확인창은 user의 의도만으로 대상이 뻔하지 않은 동작에vyweight overlay 예약.

Code

content.js — Promise 반환하는 full previewAndConfirm helper·javascript
// content.js — shadow root + 키보드 nav 가진 previewAndConfirm overlay
function previewAndConfirm({ summary, target, timeoutMs = 10000 }) {
  return new Promise((resolve) => {
    const host = document.createElement("div");
    host.style.cssText = "position:fixed;top:16px;right:16px;z-index:2147483647;";
    document.body.appendChild(host);
    const shadow = host.attachShadow({ mode: "closed" });
    shadow.innerHTML = `
      <style>
        .panel { background: #fff; color: #222; border: 1px solid #1a6bd6; border-radius: 6px;
          padding: 12px 14px; box-shadow: 0 4px 14px rgba(0,0,0,0.15); font-family: system-ui, sans-serif;
          font-size: 13px; min-width: 280px; max-width: 360px; }
        .summary { margin: 0 0 10px; line-height: 1.4; }
        .row { display: flex; gap: 8px; justify-content: flex-end; }
        button { padding: 4px 10px; font: inherit; border-radius: 4px; cursor: pointer; }
        .confirm { background: #1a6bd6; color: #fff; border: 1px solid #1a6bd6; }
        .cancel { background: #fff; color: #1a6bd6; border: 1px solid #1a6bd6; }
      </style>
      <div class="panel" role="dialog" aria-modal="false" aria-labelledby="clipdeck-preview-summary">
        <p id="clipdeck-preview-summary" class="summary"></p>
        <div class="row">
          <button class="cancel">Cancel</button>
          <button class="confirm">Confirm</button>
        </div>
      </div>`;
    shadow.querySelector(".summary").textContent = summary;

    let cleanupTarget = () => {};
    if (target) {
      const original = target.style.outline;
      target.style.outline = "2px solid #1a6bd6";
      target.style.outlineOffset = "2px";
      cleanupTarget = () => { target.style.outline = original; };
    }

    const teardown = (result) => {
      cleanupTarget();
      host.remove();
      clearTimeout(timer);
      window.removeEventListener("keydown", onKey, true);
      resolve(result);
    };
    const timer = setTimeout(() => teardown(false), timeoutMs);
    const onKey = (e) => {
      if (e.key === "Escape") { e.preventDefault(); teardown(false); }
      if (e.key === "Enter" && shadow.activeElement === shadow.querySelector(".confirm")) {
        e.preventDefault(); teardown(true);
      }
    };
    window.addEventListener("keydown", onKey, true);
    shadow.querySelector(".confirm").addEventListener("click", () => teardown(true));
    shadow.querySelector(".cancel").addEventListener("click", () => teardown(false));
    shadow.querySelector(".confirm").focus();
  });
}
content.js — preview, 다음 confirm 시 fillInput·javascript
// content.js — example usage: preview 와 함께 focused input 에 clip paste
async function pasteClipWithPreview(clip) {
  const target = document.activeElement;
  if (!target || target === document.body) return;
  const ok = await previewAndConfirm({
    summary: `Paste \u201C${clip.text.slice(0, 60)}\u2026\u201D into this input`,
    target,
  });
  if (!ok) return;
  // Lesson 5 의 CD_TOOLS.fillInput
  await CD_TOOLS.fillInput({
    selector: generateSelector(target),
    value: clip.text,
  });
}

External links

Exercise

첫 번째 code block (previewAndConfirm)을 clipdeck/content.js에 추가. Page 아무거나의 content script DevTools console에서 previewAndConfirm({ summary: 'Test action on this body', target: document.body }) 호출. Overlay가 우상단 나타남. document.body가 파란 outline. Enter 누르기 — Promise true로 resolve. 재시도, Escape 누르기 — false로 resolve. timeoutMs: 3000 으로 시도하고 interact 안 함 — 3 초 후 Promise false로 resolve. Overlay CSS가 page에 bleed 안 함 (shadow root이 그거 방지) 확인하고 공격적 z-index가 page가 렌더하는 어떤 것 위 유지하는지 확인.
Hint
Helper는 mount 뒤 Confirm에 focus를 줘. Preview가 떠 있는 동안 Escape는 어디서든 취소하지만, Enter는 Confirm button 자체가 focus를 가진 때만 승인해. 그래서 host page input에서 Enter를 쳐도 mutation이 승인되지 않아. Dialog에 accessible label이 있고 closed shadow root여도 accessibility tree에 남는지 확인해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.