← Commit history

adom-prompt widget: a paste-ready AI prompt with a copy button (src text/JSON or inline text)

John Lauer ·36b04ee101 ·8d ago ·parent 781b10b
1 file changed +89
widgets/prompt-widget.jsadded+89
@@ -0,0 +1,89 @@+/*!+ * <adom-prompt> - a paste-ready AI prompt with a copy button, for any wiki page.+ *+ * Drop-in (wiki README):+ *   ::adom-prompt{src="/OWNER/SLUG/render/docs/prompt.txt" heading="Paste into Hydrogen"}+ *+ * - src: a plain-text file (or JSON {"text": "..."}) on the wiki, usually the page's own render route.+ *   Keeping the prompt in a file means the README shows it once and an AI can read the same file.+ * - text: the prompt inline instead of src (short prompts only: tag attributes are length-capped).+ * - heading: optional label over the prompt (default "AI prompt").+ *+ * Copy uses navigator.clipboard, falling back to a selected textarea + execCommand('copy') because the wiki+ * frame is a sandboxed opaque origin where the async clipboard API can be refused. If both fail the text is+ * left selected so Ctrl+C works, and the button says so.+ */+(() => {+  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+  const ICON_COPY = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';+  const ICON_OK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';++  class AdomPrompt extends HTMLElement {+    connectedCallback() {+      if (this._init) return; this._init = true;+      this.attachShadow({ mode: 'open' });+      if (window.self !== window.top) { document.documentElement.style.overflow = 'hidden'; document.body.style.margin = '0'; }+      this._text = this.getAttribute('text') || '';+      this._render(this._text ? null : 'Loading prompt...');+      if (!this._text && this.getAttribute('src')) this._fetch();+      else if (!this._text) this._render('No prompt: give me src or text');+    }+    async _fetch() {+      try {+        const res = await fetch(new URL(this.getAttribute('src'), location.href).href);+        if (!res.ok) throw new Error('could not load the prompt (' + res.status + ')');+        const body = (await res.text()).trim();+        let t = body;+        if (body.startsWith('{')) { try { t = JSON.parse(body).text || body; } catch (e) {} }+        this._text = t.replace(/\r\n/g, '\n');+        this._render();+      } catch (e) { this._render(String(e && e.message || e).slice(0, 160)); }+    }+    _render(note) {+      const h = this.getAttribute('heading') || 'AI prompt';+      this.shadowRoot.innerHTML = `<style>+        :host { display: block; font: 13px/1.5 Satoshi, -apple-system, 'Segoe UI', sans-serif; color: #e6edf3; }+        .card { background: #0d1117; border: 1px solid #30363d; border-radius: 10px; overflow: hidden; }+        .bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-bottom: 1px solid #30363d; }+        .h { flex: 1; font: 600 13px/1.3 'Familjen Grotesk', -apple-system, sans-serif; color: #8b949e; letter-spacing: .02em; }+        button { display: inline-flex; align-items: center; gap: 6px; font: 600 12px/1 inherit; padding: 7px 11px; border-radius: 8px;+          border: 1px solid #00b8b1; background: transparent; color: #00b8b1; cursor: pointer; transition: background .12s ease, color .12s ease, transform .06s ease; }+        button:hover { background: #00b8b1; color: #0d1117; }+        button:active { transform: translateY(1px) scale(.98); }+        button:focus-visible { outline: 2px solid #00b8b1; outline-offset: 3px; }+        button.ok { background: #00b8b1; color: #0d1117; }+        pre { margin: 0; padding: 14px; white-space: pre-wrap; word-break: break-word; font: 13px/1.55 'JetBrains Mono', ui-monospace, Consolas, monospace; color: #e6edf3; }+        .note { padding: 14px; color: #8b949e; }+        textarea { position: absolute; left: -9999px; top: 0; opacity: 0; }+      </style>+      <div class="card"><div class="bar"><span class="h">${esc(h)}</span>${note ? '' : `<button id="cp" type="button" title="Copy the prompt">${ICON_COPY}<span>Copy</span></button>`}</div>+      ${note ? `<div class="note">${esc(note)}</div>` : `<pre id="t">${esc(this._text)}</pre>`}</div>`;+      const b = this.shadowRoot.getElementById('cp');+      if (b) b.addEventListener('click', () => this.copy());+    }+    async copy() {+      const b = this.shadowRoot.getElementById('cp');+      let ok = false;+      try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(this._text); ok = true; } } catch (e) {}+      if (!ok) {+        const ta = document.createElement('textarea'); ta.value = this._text; this.shadowRoot.appendChild(ta); ta.select();+        try { ok = document.execCommand('copy'); } catch (e) {}+        ta.remove();+      }+      if (!ok) {   // leave the prompt selected so Ctrl+C still works+        const r = document.createRange(); r.selectNodeContents(this.shadowRoot.getElementById('t'));+        const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);+      }+      if (b) {+        b.classList.toggle('ok', ok);+        b.innerHTML = ok ? `${ICON_OK}<span>Copied</span>` : `${ICON_COPY}<span>Press Ctrl+C</span>`;+        clearTimeout(this._t); this._t = setTimeout(() => { b.classList.remove('ok'); b.innerHTML = `${ICON_COPY}<span>Copy</span>`; }, 1800);+      }+      this.dispatchEvent(new CustomEvent('promptcopy', { detail: { ok, length: this._text.length }, bubbles: true, composed: true }));+      return ok;+    }+    // for AIs and tests: what the widget is showing+    selfCheck() { return { src: this.getAttribute('src'), length: (this._text || '').length, text: this._text }; }+  }+  if (!customElements.get('adom-prompt')) customElements.define('adom-prompt', AdomPrompt);+})();