Merge branch 'refactor/task-15b-copy-prompt'
verify-and-publish / gate (push) Successful in 4m37s
verify-and-publish / publish (push) Has been skipped

This commit is contained in:
Marcos Paulo
2026-09-05 17:51:54 +00:00
2 changed files with 262 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
---
// CopyPrompt — copy-to-clipboard island for full-guide. One per button.
//
// Click reads `#${target}`'s textContent, copies it via
// `navigator.clipboard.writeText`, and falls back to a `document.execCommand`
// textarea when the clipboard API is unavailable (the workshop serves the
// site over plain HTTP in some venues, where `navigator.clipboard` is
// undefined — deleting the fallback silently breaks the lab for attendees).
// Both paths are kept on purpose; this is the contract the legacy app.js
// copyPrompt function uses for the three full-guide prompts.
//
// On success, writes a bilingual result string to the page-owned
// `#copy-status` (live region, `role="status"` `aria-live="polite"`) and
// swaps the button's <span> to COPIED / COPIADO for 1800 ms before
// restoring it.
//
// Language comes from `document.documentElement.lang` — set today by
// app.js's `applyLanguage` and tomorrow by task 15c's language-toggle
// island. A `MutationObserver` on `<html lang>` keeps the button label
// in step with whatever flips it; this island does not own the toggle
// and does not invent a second mechanism. Task 15c and 15d should align
// on this — see the task report.
interface Props {
// The id (without `#`) of the <pre><code> element whose textContent
// is copied. Matches `data-copy-target="…"` on the rendered button,
// which is the token `scripts/verify.mjs` asserts in the legacy
// `full-guide/index.html`.
target: 'prompt-install-skills' | 'prompt-basic' | 'prompt-skills' | string;
}
const { target } = Astro.props;
---
<button class="copy-prompt-button" data-copy-target={target} data-copy-prompt-button type="button">
<span data-copy-prompt-label>COPY</span>
<i aria-hidden="true"></i>
</button>
<script is:inline>
// CopyPrompt island — click handler.
// Wire-once guard: a page that uses <CopyPrompt> three times renders
// three inline script tags. Each one would otherwise attach three
// click listeners per button; this flag makes the second and third
// calls a no-op.
(function () {
if (window.__copyPromptWired) return;
window.__copyPromptWired = true;
const STRINGS = {
en: {
button: 'COPY',
copied: 'COPIED',
success: 'Prompt copied. Paste it into a fresh agent session.',
failure: 'Copy unavailable. Select the text manually.',
},
pt: {
button: 'COPIAR',
copied: 'COPIADO',
success: 'Prompt copiado. Cole em uma nova sessão de agente.',
failure: 'Não foi possível copiar. Selecione o texto manualmente.',
},
};
function currentLang() {
return document.documentElement.lang === 'pt-BR' ? 'pt' : 'en';
}
function labels() {
return STRINGS[currentLang()];
}
function setStatus(copied) {
const status = document.querySelector('#copy-status');
if (!status) return;
const l = labels();
status.textContent = copied ? l.success : l.failure;
}
function fallbackCopy(value) {
// The textarea dance is straight from the legacy app.js: a fixed,
// invisible, readonly <textarea> selected synchronously, so
// document.execCommand('copy') sees a real selection. Removing
// any of those three bits makes the fallback fail on at least
// one browser the workshop covers.
const helper = document.createElement('textarea');
helper.value = value;
helper.setAttribute('readonly', '');
helper.style.position = 'fixed';
helper.style.opacity = '0';
document.body.appendChild(helper);
helper.select();
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
}
helper.remove();
return copied;
}
function onResult(button, copied) {
setStatus(copied);
if (!copied) return;
const l = labels();
const span = button.querySelector('span');
if (span) span.textContent = l.copied;
button.classList.add('copied');
// 1800ms matches the legacy copyPrompt. Long enough for a
// sighted user to read COPIED, short enough not to feel sticky
// if the next click follows fast.
window.setTimeout(function () {
button.classList.remove('copied');
if (span) span.textContent = labels().button;
}, 1800);
}
function copyPrompt(button) {
const id = button.dataset.copyTarget;
if (!id) return;
const target = document.querySelector('#' + id);
if (!target) return;
const value = target.textContent || '';
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === 'function'
) {
// navigator.clipboard.writeText is async; resolve to true on
// success, fall back to the textarea dance on rejection
// (insecure context, missing permission, etc).
navigator.clipboard.writeText(value).then(
function () {
onResult(button, true);
},
function () {
onResult(button, fallbackCopy(value));
},
);
return;
}
onResult(button, fallbackCopy(value));
}
function syncLabels() {
const l = labels();
const buttons = document.querySelectorAll('[data-copy-prompt-button]');
for (let i = 0; i < buttons.length; i++) {
const btn = buttons[i];
// Mid-feedback buttons (1800ms COPIED state) keep their
// swapped label until the timer fires; otherwise the toggle
// would visibly flicker the COPIED text back to the default.
if (btn.classList.contains('copied')) continue;
const span = btn.querySelector('span');
if (span) span.textContent = l.button;
}
}
const buttons = document.querySelectorAll('[data-copy-prompt-button]');
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener(
'click',
(function (button) {
return function () {
copyPrompt(button);
};
})(buttons[i]),
);
}
// Pick up external language changes (task 15c's toggle) without
// owning the toggle itself. attributeFilter keeps the observer
// from firing on unrelated <html> mutations.
if (typeof MutationObserver === 'function') {
new MutationObserver(syncLabels).observe(document.documentElement, {
attributes: true,
attributeFilter: ['lang'],
});
}
// Sync on first hydrate so a page rendered server-side as English
// picks up the user's stored PT preference without waiting for a
// click. app.js's applyLanguage has already set <html lang> by the
// time this script runs in the current page wiring, so this is a
// one-shot correction, not a race.
syncLabels();
})();
</script>
<style>
/* Component-scoped. The legacy full-guide/styles.css carries the
full visual treatment (button border, hover, `.copied` state,
etc.); 15d ports that wholesale when it migrates the page. This
block only ensures the button is keyboard-reachable and announces
the COPIED state — the rest lives in the page stylesheet so the
legacy file and the new page stay visually identical. */
.copy-prompt-button {
cursor: pointer;
}
.copy-prompt-button:focus-visible {
outline: 3px solid var(--gold);
outline-offset: 2px;
}
</style>
@@ -0,0 +1,57 @@
---
// ReadingProgress — scroll-position bar. One instance per page.
//
// Renders the same markup the legacy `full-guide/index.html` line 13
// carried: `<div class="reading-progress" aria-hidden="true"><span></span></div>`.
// The scroll listener is registered with `{ passive: true }`, matching
// app.js line 406 and the rule in `.agents/rules/animation.md` that a
// passive listener is required for any handler bound to a frequently
// fired event like scroll. Animating `width` here is fine — the bar
// is a 3px element at the very top of the viewport, so layout cost
// is negligible compared to the alternative of subscribing to
// IntersectionObserver and computing progress from a single sentinel
// per section.
//
// Language: this island does not read language. It has no string
// labels and the same progress semantics apply to EN and PT.
interface Props {
// Optional override selector. Defaults to `.reading-progress span`
// to match the legacy app.js query, in case a page renders the bar
// with a different class name.
target?: string;
}
const { target = '.reading-progress span' } = Astro.props;
---
<div class="reading-progress" aria-hidden="true">
<span data-reading-progress-span></span>
</div>
<script is:inline define:vars={{ selector: target }}>
// ReadingProgress island — passive scroll listener.
// Wire-once guard mirrors CopyPrompt: a page that renders the bar
// twice would otherwise stack two scroll handlers on `window`.
(function () {
if (window.__readingProgressWired) return;
window.__readingProgressWired = true;
const span = document.querySelector(selector);
if (!span) return;
function update() {
const height = document.documentElement.scrollHeight - window.innerHeight;
span.style.width = (height > 0 ? (window.scrollY / height) * 100 : 0) + '%';
}
// `{ passive: true }` is non-negotiable. The legacy app.js binds
// this listener passive, and a non-passive scroll handler on the
// top edge of the document is exactly the kind of input latency
// that fails INP. See `.agents/rules/animation.md`.
window.addEventListener('scroll', update, { passive: true });
// Set initial state — important when the user navigates with
// #hash deep links and lands partway down a long page.
update();
})();
</script>