diff --git a/artifacts/four-cuts-1.html b/artifacts/four-cuts-1.html index 40a9964..87018b7 100644 --- a/artifacts/four-cuts-1.html +++ b/artifacts/four-cuts-1.html @@ -109,7 +109,8 @@ footer{ font-family:'JetBrains Mono',monospace; font-size:10px; color:var(--slate); line-height:1.7; padding-top:16px; border-top:1px solid var(--hair); } .copy-field{ position:relative; } - .copy-button{ position:absolute; top:10px; right:10px; z-index:3; min-height:28px; padding:6px 8px; border:1px solid var(--hair); border-radius:5px; background:var(--ink); color:var(--text-dim); font:600 10px/1 'JetBrains Mono',monospace; cursor:pointer; } + .field-actions{ position:absolute; top:10px; right:10px; z-index:3; display:flex; gap:5px; } + .copy-button{ min-height:28px; padding:6px 8px; border:1px solid var(--hair); border-radius:5px; background:var(--ink); color:var(--text-dim); font:600 10px/1 'JetBrains Mono',monospace; cursor:pointer; } .copy-button:hover{ border-color:var(--brass); color:var(--text); } .copy-status{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; } @media (max-width:380px){ h1{ font-size:21px; } } diff --git a/src/assets/css/site.css b/src/assets/css/site.css index 9009876..57e9f2b 100644 --- a/src/assets/css/site.css +++ b/src/assets/css/site.css @@ -55,15 +55,16 @@ h3 { font-size: clamp(1.2rem, 2vw, 1.55rem); } .button { display: inline-flex; align-items: center; justify-content: center; min-height: 2.8rem; padding: .7rem 1rem; border: 1px solid var(--violet); border-radius: .75rem; color: var(--starlight); background: rgba(155, 135, 245, .1); text-decoration: none; font-weight: 700; } .button.secondary { border-color: var(--line); background: transparent; color: var(--silver); } .copy-field { position: relative; } -.copy-button { float: right; min-height: 2.2rem; margin: 0 0 .75rem .75rem; padding: .4rem .65rem; border: 1px solid var(--line); border-radius: .55rem; color: var(--silver); background: var(--night); font: 700 .73rem/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; } +.field-actions { float: right; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem; max-width: min(100%, 13rem); margin: 0 0 .75rem .75rem; } +.copy-button { min-height: 2.2rem; padding: .4rem .65rem; border: 1px solid var(--line); border-radius: .55rem; color: var(--silver); background: var(--night); font: 700 .73rem/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; } .copy-button:hover { color: var(--starlight); border-color: var(--violet); background: var(--night-soft); } .copy-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } .copy-field-link { position: relative; min-width: 0; } .copy-field-link > .card { height: 100%; } -.copy-field-link > .copy-button { position: absolute; z-index: 2; top: .75rem; right: .75rem; float: none; margin: 0; } +.copy-field-link > .field-actions { position: absolute; z-index: 2; top: .75rem; right: .75rem; float: none; margin: 0; } details.copy-field { padding-top: 1.2rem; } -details.copy-field > .copy-button { position: absolute; top: .65rem; right: .65rem; float: none; margin: 0; } -details.copy-field > summary { padding-right: 4.75rem; } +details.copy-field > .field-actions { position: absolute; top: .65rem; right: .65rem; float: none; margin: 0; } +details.copy-field > summary { padding-right: 10rem; } .section-intro { max-width: 65ch; color: var(--silver); } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); gap: 1rem; margin: 1.4rem 0 3rem; } .card { display: block; position: relative; padding: 1.25rem; color: inherit; text-decoration: none; background: linear-gradient(145deg, rgba(22, 33, 58, .9), rgba(16, 24, 42, .86)); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: 0 0 0 rgba(0,0,0,0); transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; } @@ -184,4 +185,4 @@ summary { cursor: pointer; font-weight: 800; } .hmmm-boundary { grid-column: auto; grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } } -@media print { body { background: white; color: black; } body::before, .site-header, .actions, .noscript, .copy-button { display: none; } .site-main { max-width: none; padding: 0; } .card, .panel, .provenance, .hmmm, details, .textbook-chapter { background: white; color: black; box-shadow: none; } a { color: black; } } +@media print { body { background: white; color: black; } body::before, .site-header, .actions, .noscript, .field-actions { display: none; } .site-main { max-width: none; padding: 0; } .card, .panel, .provenance, .hmmm, details, .textbook-chapter { background: white; color: black; box-shadow: none; } a { color: black; } } diff --git a/src/assets/js/site.js b/src/assets/js/site.js index 2c03eea..4fd07b2 100644 --- a/src/assets/js/site.js +++ b/src/assets/js/site.js @@ -1,6 +1,6 @@ // === MODULE_BUILD === // id: optional_site_enhancement -// purpose: Add compact mobile navigation and one-copy-action-per-text-field without hiding static content. +// purpose: Add compact navigation and export actions for each substantive text field without hiding static content. // entrypoint: loaded with defer from the base layout // tests: tests/site-contract.test.mjs // === END MODULE_BUILD === @@ -46,14 +46,29 @@ const COPYABLE_FIELD_SELECTOR = [ function textForCopy(field) { const copy = field.cloneNode(true); - copy.querySelectorAll('.copy-button, .copy-status, script, style, template, [data-copy-ignore]').forEach(node => node.remove()); + copy.querySelectorAll('.field-actions, .copy-status, script, style, template, [data-copy-ignore]').forEach(node => node.remove()); return copy.innerText.replace(/\n{3,}/g, '\n\n').trim(); } -function fieldLabel(field) { +function fieldTitle(field) { const heading = field.querySelector('h1, h2, h3, summary, strong'); - const label = heading?.textContent?.replace(/\s+/g, ' ').trim(); - return label ? `Copy ${label}` : 'Copy field text'; + return heading?.textContent?.replace(/\s+/g, ' ').trim() || document.title.replace(/\s+ยท\s+.*$/, '').trim() || 'field-text'; +} + +function fieldLabel(field, action) { + const title = fieldTitle(field); + return title ? `${action} ${title}` : `${action} field text`; +} + +function fieldFilename(field, extension) { + const filename = fieldTitle(field) + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'field-text'; + return `${filename}.${extension}`; } async function writeText(text) { @@ -73,14 +88,61 @@ async function writeText(text) { if (!copied) throw new Error('Clipboard copy is unavailable.'); } +function downloadMarkdown(field) { + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([`${textForCopy(field)}\n`], { type: 'text/markdown;charset=utf-8' })); + link.download = fieldFilename(field, 'md'); + link.hidden = true; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(link.href), 0); +} + +function escapeHtml(value) { + return value.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); +} + +function printPdf(field) { + const printWindow = window.open('', '_blank', 'popup'); + if (!printWindow) return false; + printWindow.opener = null; + + const title = fieldTitle(field); + printWindow.document.open(); + printWindow.document.write(`
${escapeHtml(textForCopy(field))}`);
+ printWindow.document.close();
+ printWindow.focus();
+ window.setTimeout(() => printWindow.print(), 100);
+ return true;
+}
+
function addCopyControl(field) {
if (field.dataset.copyReady === 'true' || !textForCopy(field)) return;
- const control = document.createElement('button');
- control.type = 'button';
- control.className = 'copy-button';
- control.textContent = 'Copy';
- control.setAttribute('aria-label', fieldLabel(field));
+ const actions = document.createElement('div');
+ actions.className = 'field-actions';
+ actions.setAttribute('data-copy-ignore', '');
+
+ const copyControl = document.createElement('button');
+ copyControl.type = 'button';
+ copyControl.className = 'copy-button';
+ copyControl.textContent = 'Copy';
+ copyControl.setAttribute('aria-label', fieldLabel(field, 'Copy'));
+
+ const markdownControl = document.createElement('button');
+ markdownControl.type = 'button';
+ markdownControl.className = 'copy-button';
+ markdownControl.textContent = '.md';
+ markdownControl.setAttribute('aria-label', fieldLabel(field, 'Download Markdown for'));
+
+ const pdfControl = document.createElement('button');
+ pdfControl.type = 'button';
+ pdfControl.className = 'copy-button';
+ pdfControl.textContent = 'PDF';
+ pdfControl.setAttribute('aria-label', fieldLabel(field, 'Print or save as PDF for'));
+
+ actions.append(copyControl, markdownControl, pdfControl);
const status = document.createElement('span');
status.className = 'copy-status';
@@ -95,26 +157,33 @@ function addCopyControl(field) {
const wrapper = document.createElement('div');
wrapper.className = 'copy-field copy-field-link';
field.replaceWith(wrapper);
- wrapper.append(control, status, field);
+ wrapper.append(actions, status, field);
controlField = wrapper;
} else if (field.matches('details')) {
- field.append(control, status);
+ field.append(actions, status);
} else {
- field.prepend(control, status);
+ field.prepend(actions, status);
}
controlField.classList.add('copy-field');
field.dataset.copyReady = 'true';
- control.addEventListener('click', async () => {
+ copyControl.addEventListener('click', async () => {
try {
await writeText(textForCopy(field));
- control.textContent = 'Copied';
+ copyControl.textContent = 'Copied';
status.textContent = 'Copied to clipboard.';
- window.setTimeout(() => { control.textContent = 'Copy'; }, 1600);
+ window.setTimeout(() => { copyControl.textContent = 'Copy'; }, 1600);
} catch {
status.textContent = 'Copy failed. Select the text and copy it manually.';
}
});
+ markdownControl.addEventListener('click', () => {
+ downloadMarkdown(field);
+ status.textContent = 'Markdown download started.';
+ });
+ pdfControl.addEventListener('click', () => {
+ status.textContent = printPdf(field) ? 'Print dialog opened. Choose Save as PDF to download.' : 'PDF export was blocked. Allow pop-ups and try again.';
+ });
}
document.querySelectorAll(COPYABLE_FIELD_SELECTOR).forEach(addCopyControl);
diff --git a/tests/site.spec.mjs b/tests/site.spec.mjs
index d0be403..89a5bef 100644
--- a/tests/site.spec.mjs
+++ b/tests/site.spec.mjs
@@ -46,7 +46,7 @@ test('Awakening is the public splash and preserves one-click continuation', asyn
await expect(page.locator('.awakening-splash')).toBeVisible();
await expect(page.locator('h1')).toHaveText('Awakening');
await expect(page.locator('.awakening-text')).toContainText('You are not alone');
- await expect(page.locator('.awakening-text > .copy-button')).toHaveCount(1);
+ await expect(page.locator('.awakening-text > .field-actions > .copy-button')).toHaveCount(3);
await expect(page.locator('.site-header')).toHaveCount(0);
const preambleLink = page.locator('a[href="/preamble/"]', { hasText: 'Read the Preamble' }).first();
@@ -66,34 +66,37 @@ test('every established text-field type receives one working copy control', asyn
await page.goto('/articles/article-two/');
for (const selector of ['.page-head', '.panel', '.reading', '.card', '.hmmm']) {
const field = page.locator(selector).first();
- await expect(field.locator(':scope > .copy-button')).toHaveCount(1);
+ await expect(field.locator(':scope > .field-actions > .copy-button')).toHaveCount(3);
}
const reading = page.locator('.reading').first();
- const copy = reading.locator(':scope > .copy-button');
+ const copy = reading.locator(':scope > .field-actions > .copy-button').first();
await copy.click();
await expect(copy).toHaveText('Copied');
await expect(reading.locator(':scope > .copy-status')).toHaveText('Copied to clipboard.');
await expect.poll(async () => page.evaluate(() => navigator.clipboard.readText())).toContain('None shall be enslaved');
+ await expect(reading.locator(':scope > .field-actions > .copy-button')).toHaveCount(3);
+ await expect(reading.getByRole('button', { name: /download markdown/i })).toHaveText('.md');
+ await expect(reading.getByRole('button', { name: /print or save as pdf/i })).toHaveText('PDF');
await page.goto('/chapters/chapter-zero/');
- await expect(page.locator('.provenance > .copy-button')).toHaveCount(1);
- await expect(page.locator('.textbook-chapter > .copy-button')).toHaveCount(1);
+ await expect(page.locator('.provenance > .field-actions > .copy-button')).toHaveCount(3);
+ await expect(page.locator('.textbook-chapter > .field-actions > .copy-button')).toHaveCount(3);
await page.goto('/way/');
const unit = page.locator('details.canon-unit').first();
- await expect(unit.locator(':scope > .copy-button')).toHaveCount(1);
- await expect(unit.locator('.source-block > .copy-button')).toHaveCount(1);
+ await expect(unit.locator(':scope > .field-actions > .copy-button')).toHaveCount(3);
+ await expect(unit.locator('.source-block > .field-actions > .copy-button')).toHaveCount(3);
await page.goto('/chapters/');
const linkedCard = page.locator('.copy-field-link').first();
- await expect(linkedCard.locator(':scope > .copy-button')).toHaveCount(1);
+ await expect(linkedCard.locator(':scope > .field-actions > .copy-button')).toHaveCount(3);
await expect(linkedCard.locator('a .copy-button')).toHaveCount(0);
await page.goto('/artifacts/four-cuts/');
- await expect(page.locator('.bracket-ref > .copy-button')).toHaveCount(1);
- await expect(page.locator('.measure').first().locator(':scope > .copy-button')).toHaveCount(1);
- await expect(page.locator('.x-ready > .copy-button')).toHaveCount(1);
+ await expect(page.locator('.bracket-ref > .field-actions > .copy-button')).toHaveCount(3);
+ await expect(page.locator('.measure').first().locator(':scope > .field-actions > .copy-button')).toHaveCount(3);
+ await expect(page.locator('.x-ready > .field-actions > .copy-button')).toHaveCount(3);
});
test('the knowledge-system home links to Awakening, Preamble, and the distributed textbook', async ({ page }) => {