C.W.K.
Stream
Lesson 06 of 07 · published

Preview 와 confirm — Mutate 전 신뢰 얻기

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

Level 0Extension 입덕
0 XP0/54 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 가 필요한가

Passive action (selection read, clip 저장, screenshot) 엔 preview 필요 없음 — user 가 시작, 결과 가 side panel 에 도착, page 에 변경 없음. Active action (input auto-fill, button click, modal dismiss, scroll into view) 엔 방정식 flip:

  • User 가 항상 extension 이 하려는지 — click target / paste value / scroll 대상 element — 알 수 없음. Preview 가 이름 붙임.
  • User 가 page 를 특정 방식으로 set up 했을 수도 (form 반쯤 채움, mid-scroll). Preview 가 그 work 만져지기 전 confirm 이나 back out 순간 줘.
  • Real-world page 가 extension 코드가 anticipate 못 하는 edge case 가짐. 계획된 action 을 surface 하는 preview 가 user 가 commit 전 obvious 잘못 잡게.

마찰이 작음 (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 전쟁

실제 page 가 싸우는 z-index stack 가짐. Absolute max 사용:

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 가 그 안에 querySelector 못 한다는 뜻. Overlay 가 page 자체 analytics / accessibility 도구 / event listener 에 invisible — observable 안 해야 할 extension UI 에 유용.

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

세 exit: confirmed (true), cancelled (false), timeout (false). Caller 가 boolean 으로 branch — event listener 춤 없음, callback hell 없음. Overlay teardown 이 Promise resolve 전 일어남.

Active mutation 이 preview 자격. Action 을 plain language 로 보여 주고, target outline, keyboard affordance 가진 Confirm / Cancel 제공, 10 초 후 default Cancel. 작은 마찰, 거대한 신뢰 이득.
Preview 건너뛸 수 있을 때. User 가 이미 보는 specific target 가진 ambiguous 아닌 action 부르면 (선택 텍스트에 Ctrl+Shift+K → clip 저장), preview 가 confirmation 전 아닌 사후 1 초 toast confirmation 가능. Target 이 user intent 에서 이미 obvious 안 한 action 에 heavyweight 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="alertdialog">
        <p 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 === "Enter") { e.preventDefault(); teardown(true); }
      if (e.key === "Escape") { e.preventDefault(); teardown(false); }
    };
    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
Overlay 나타나는데 키보드 focus 안 잡으면, .confirm 버튼 selector 가 missing 일 수도 — shadow.querySelector(".confirm") 이 non-null element 반환하고 AND focus() 호출이 shadow root 렌더 후 도는지 확인. Page 의 input 에서 Enter 누르는 게 실수로 Confirm trigger 하면, keydown listener 의 true capture phase 가 의도된 거 하지만 — target 이 overlay 바깥인 event 무시하도록 제한 원할 수도. Persistent overlay 가진 실제 page (이미 fixed 우상단 notification 쓰는 web app) 엔 시각적 overlap 안 하는지 테스트; 그러면 fixed corner 대신 target element 에 overlay anchor."

Progress

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

댓글 0

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

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