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