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(title)}

${escapeHtml(title)}

${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 }) => {