Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion artifacts/four-cuts-1.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; } }
Expand Down
11 changes: 6 additions & 5 deletions src/assets/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve card space for the expanded action group

On linked cards, the three-button group is absolutely positioned over the card without adding any top or right padding to the underlying anchor. At the grid's narrow column sizes, this substantially wider group occupies the same top row as status badges and chapter numbers, obscuring those elements and making their covered portion unclickable as a card link. Reserve layout space in .card or place the group outside the card's content area.

Useful? React with 👍 / 👎.

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; }
Expand Down Expand Up @@ -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; } }
101 changes: 85 additions & 16 deletions src/assets/js/site.js
Original file line number Diff line number Diff line change
@@ -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 ===
Expand Down Expand Up @@ -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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize the Four Cuts field titles

The Four Cuts exportable measures identify themselves with .m-title elements rather than h1h3, summary, or strong, so fieldTitle() falls back to the page title for every measure. As a result, all four distinct measure exports receive the same filename, PDF heading, and accessible action labels, making downloaded files indistinguishable except for browser-added numeric suffixes. Include the artifact's .m-title and .ref-title vocabulary when resolving field titles.

Useful? React with 👍 / 👎.

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) {
Expand All @@ -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' }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve field structure in Markdown downloads

For fields containing links, headings, or lists, textForCopy(field) returns only rendered innerText, so the downloaded .md discards all Markdown structure and link destinations. This is especially visible for linked project, article, and chapter cards: their export contains the label and description but no URL back to the linked resource. Serialize the field semantics into Markdown rather than placing the clipboard-oriented plain-text representation in a Markdown-named blob.

Useful? React with 👍 / 👎.

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>body{max-width:50rem;margin:2rem auto;padding:0 1rem;color:#111;font:12pt/1.5 system-ui,sans-serif}h1{font-family:Georgia,serif;font-size:20pt;line-height:1.2}pre{white-space:pre-wrap;font:inherit}</style></head><body><h1>${escapeHtml(title)}</h1><pre>${escapeHtml(textForCopy(field))}</pre></body></html>`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid duplicating field headings in PDF exports

When the exported field contains an h1, h2, h3, summary, or strong, fieldTitle(field) extracts that text, but textForCopy(field) still includes the same element. The generated print view therefore renders the heading once as the synthetic <h1> and again at the beginning of <pre>, so most panel, card, and page-head PDF exports contain a visibly duplicated title. Exclude the selected heading from the body or omit the synthetic heading.

Useful? React with 👍 / 👎.

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';
Expand All @@ -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);
25 changes: 14 additions & 11 deletions tests/site.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 }) => {
Expand Down
Loading