Merge branch 'refactor/task-15b-copy-prompt'
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user