From 8ffa540815fd6b2a12865af0f7707ae48f33652b Mon Sep 17 00:00:00 2001 From: grunion-ai Date: Mon, 28 Sep 2026 15:58:22 -0700 Subject: [PATCH] editor: the duplicate button duplicates slides only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With rows selected on the canvas the button duplicated those rows, offset 16px; otherwise it duplicated the slide. The same click meant two things depending on the selection. It now duplicates the current slide on the canvas whatever rows are selected (the selection clears) and the selected slides in the contact sheet. Its tooltip and aria-label read "Duplicate slide". Rows are copied with ⌘C and ⌘V. ⌘D has no handler (retired for this button, which the gate asserts); the contact sheet paragraph in docs/editor.md no longer lists it. Gate: test/hud.test.mjs, test/gate.test.mjs. Deck and library html rebuilt. Fixes #80 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + deck.html | 7 +++---- docs/editor.md | 4 ++-- library.html | 7 +++---- template.html | 7 +++---- test/gate.test.mjs | 2 +- test/hud.test.mjs | 25 +++++++++++++------------ 7 files changed, 26 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c81a5f6..09ad4f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - **F toggles full screen from the contact sheet.** Issue #83: on the sheet F did nothing and the full-screen button was disabled, because `sheetUi()` disabled `#fs` with the navigator and `sheetKey()` swallowed every key it did not handle. Full screen changes how the deck is shown, not which slide is current, so the button stays live on the sheet and a bare F (no meta, ctrl or alt) calls `fs()` there. Presenting starts from the picked slide (the first selected thumbnail), by key or by button; presenting with the sheet open, F leaves full screen straight from the sheet. Previous and next stay disabled there. The shortcuts popover and `docs/editor.md` say so. Deck and library html rebuilt. Gate: test/hud.test.mjs (Chromium and WebKit), test/gate.test.mjs. - **⌘C and ⌘V copy and paste the rows selected on the canvas.** Issue #79: the only clipboard was the contact sheet's, which holds slides, and the canvas keydown returned on any modifier before a clipboard branch, so ⌘C and ⌘V on a selection did nothing. ⌘C now copies the selected rows to a clipboard of their own (the sheet's slide clipboard is unchanged) and ⌘V pastes them onto the current slide: 16px down and right on the slide they came from (16px more on each paste), where they sat on any other slide, which is how a row moves between slides. The copies become the selection and ⌘Z takes the paste back. Pasted rows drop their ids and are stamped fresh where they land. A master row, an override or a slot row copies as the row you see, so a slide that hides the master still shows the copy. A connector keeps an end only on a row copied with it, pointed at the copy; a routed connector without both of its rows stays behind. While text is being edited, and in an input, the keys stay the browser's own copy and paste. decklet has no groups (test/group.test.mjs holds that), so there is no group to keep together. `docs/editor.md` and the shortcuts popover say so. The template's added comments are kept short: `library.html` sits within 200 bytes of its 752 KB budget. Deck and library html rebuilt. Gate: test/editor.test.mjs (Chromium and WebKit), test/gate.test.mjs. - **The text toolbar fits a phone and stays off the rows around the one you edit.** A toolbar review at 82d0e8d found three defects at 375px with a coarse pointer: the role segment did not wrap, so Label and Stat sat past the screen edge; the 44px button floor stretched the 16px swatches into 16x44 ovals; and the toolbar always sat above the edited row, over the Title and Supertitle. Under 480px wide the toolbar's segments wrap. Under `(pointer: coarse)` a swatch is a 44x44 tap target around a round 20px dot. `placeTb()` places the toolbar above the row unless that covers another text row or leaves the viewport, and then places it below; the same rule stops a Body edit on the desktop from hiding a two-line Title's second line. Deck and library html rebuilt. Gate: test/touch.test.mjs. Fixes #170. +- **The duplicate button duplicates slides only.** Issue #80: with rows selected on the canvas the button duplicated those rows, offset 16px, and otherwise the slide, so the same click meant two things depending on the selection. It now duplicates the current slide on the canvas whatever rows are selected (the selection clears) and the selected slides in the contact sheet, as before. Its tooltip and aria-label read "Duplicate slide". Rows are copied with ⌘C and ⌘V (#79), which also works across slides. There is no ⌘D: the key was retired for this button, and the contact sheet paragraph in `docs/editor.md` no longer lists it. Deck and library html rebuilt. Gate: test/hud.test.mjs, test/gate.test.mjs. ## 0.14.0 ### Fork C — templates, media, entities and assets (K21–K26) diff --git a/deck.html b/deck.html index b2b627c..5924a5e 100644 --- a/deck.html +++ b/deck.html @@ -261,7 +261,7 @@ - +
@@ -1598,12 +1598,11 @@ helpbtn.onclick=()=>help(!helpmenu.classList.contains('open')); helpbtn.onmouseenter=()=>{helpT=setTimeout(()=>help(true),350)};helpbtn.onmouseleave=()=>clearTimeout(helpT); document.addEventListener('mousedown',e=>{if(!e.target.closest('#addwrap'))menu(false);if(!e.target.closest('#helpwrap'))help(false);if(!e.target.closest('#spellmenu,#spellwrap'))spellClose()}); -// duplicate: selected rows (offset 16px, the copies become the selection) · else the slide, after itself · in the sheet, the selected slides +// duplicate: slides only (rows copy with ⌘C ⌘V): the current slide, after itself · in the sheet, the selected slides $('dup').onclick=()=>{commitEdit();menu(false); if(!sheet.hidden){snap();const src=[...gsel].sort((a,b)=>a-b);if(!src.length)return;const at=src[src.length-1]+1;const cp=src.map(n=>{const c=dup(deck.slides[n]);delete c.id;return c});deck.slides.splice(at,0,...cp);gsel=new Set(cp.map((_,x)=>at+x));ganchor=i=at;save();sheetRender();return} snap();const s=slide(); - if(sel.size){const ns=[...sel].map(k=>typeof k==='number'?k:fork(k.slice(2)));const cp=ns.map(n=>{const o=s.els[n],c=dup(o);delete c.id;moveBy(c,o,16,16);return c});const at=s.els.length;s.els.push(...cp);sel.clear();cp.forEach((_,x)=>sel.add(at+x));save();render();return} - const c=dup(s);delete c.id;deck.slides.splice(i+1,0,c);i=i+1;save();render()}; + const c=dup(s);delete c.id;deck.slides.splice(i+1,0,c);i=i+1;sel.clear();save();render()}; // fileHtml(): THIS document with the CURRENT model and log baked into their literals, chrome state stripped — the // file an agent reads (bin/edits.mjs, create --from) and the copy that opens anywhere without browser storage. function fileHtml(){ diff --git a/docs/editor.md b/docs/editor.md index bdf2eca..35cb14a 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -4,7 +4,7 @@ ## The HUD -**HUD (the full set, left to right), four groups after the spacer, a hairline between groups; every control names itself and its key on hover (`data-tip`, instant — never the OS title delay):** prev / next (chevrons; ← →) · **save:** the save button (its own button, a Lucide save glyph the size of every other control: calm = nothing waiting · a green dot on its corner = autosave writes the file (a hosted deck, or a file linked with ⌘S) and the file has every edit; it goes the moment an edit registers and returns when the write is confirmed, and a deck that saves only to this browser never shows it · amber with a count badge = saved in this browser, N edits not in the file yet · an amber **Not saving** pill = nothing persists beyond this tab (the tab route, below) · a pulse while a write is in flight · a lock and an agent's name = the deck is checked out on a served page (read-only until checkin; see Hosting); a click or tap saves the file now — the ⌘S door on a phone, and the only Save-a-copy door: where the file cannot be written, ⌘S and this button both download a copy) · **edit:** `+` (Text / Box / Slide) · duplicate (copy-plus icon: the selected rows, offset 16px, the copies become the selection · else the slide, after itself · in the sheet, the selected slides) · grid + guides + snap (grip icon; **G**; off by default, dims when off: a 16px dot grid over the slide and this slide's layout lines — slot edges, the content margin, the canvas mid-lines — of which only the line being held shows; a drag, a resize or a connector nib snaps to a line within 10px, else to the grid; a circle snaps by its centre; a connector end also snaps to the eight 45° rays out of its other end; never in present, print, the sheet, the PDF or the saved file) · spellcheck (spell-check icon; on by default, dims when off: the words the build's dictionary refused — nspell + dictionary-en at create, optional — are marked on every slide through the CSS Highlight API with a wavy underline **and** a faint red wash, so they show in Chrome, in Safari, which paints no text-decoration inside a highlight pseudo, and in an embedded pane alike; the browser's own checker adds the row you type in; never present, print or PDF) · the red badge on that button is a button of its own: it opens a panel of every flagged word on this slide, each with the suggestions the build baked into the file — a pick replaces every occurrence of that word on the slide, keeping its case and its markup, through the ordinary edit path (so it lands in the file and in `bin/edits.mjs`), and **Ignore in this deck** appends the word to the model's `spell.ignore` and repaints. A click on a marked word while you are editing its row opens the same panel anchored to it and fixes that one occurrence; Escape closes it and hands focus back. The badge counts this slide live as you type, on the contact sheet every thumbnail wears its own count at the top-right, and zero shows nothing · **file:** PDF (⤓) · **view:** contact sheet (grid icon; **C** or Esc) · full screen (**F**) · shortcuts (ⓘ; a three-column popover: slides · rows · file; ⌘P and ⌘D are not shortcuts — the PDF and duplicate buttons are the only doors) · **Report a bug** (bug icon; no key — a report is never one keystroke away; hidden from the present-mode peek pill so it clears the page counter; opens a native dialog: four tiles — Looks wrong · Won't respond · Save or PDF · Crashed — and a 300-character description; **Open mail app** is an anchor whose `mailto:decklet@grunion.ai` is rebuilt on every change, **Copy report** puts the same text on the clipboard; subject `[ISSUE] decklet · · `; body: the engine version, format, slide size and count, current slide, mode, the browser UA, the deck id, viewport · dpr · colour scheme, then the category's probes — counts and fixed words only: rows by kind and clipped rows for Looks wrong, pointer · selection · autosave for Won't respond, autosave · file access · versions · pdf route for Save or PDF, the last three uncaught errors (scrubbed) · uptime · drawn rows for Crashed — then the description; never a word, note, alt, token, image, link, path or edit of the deck; the reporter reads and sends it, the address is receive-only). +**HUD (the full set, left to right), four groups after the spacer, a hairline between groups; every control names itself and its key on hover (`data-tip`, instant — never the OS title delay):** prev / next (chevrons; ← →) · **save:** the save button (its own button, a Lucide save glyph the size of every other control: calm = nothing waiting · a green dot on its corner = autosave writes the file (a hosted deck, or a file linked with ⌘S) and the file has every edit; it goes the moment an edit registers and returns when the write is confirmed, and a deck that saves only to this browser never shows it · amber with a count badge = saved in this browser, N edits not in the file yet · an amber **Not saving** pill = nothing persists beyond this tab (the tab route, below) · a pulse while a write is in flight · a lock and an agent's name = the deck is checked out on a served page (read-only until checkin; see Hosting); a click or tap saves the file now — the ⌘S door on a phone, and the only Save-a-copy door: where the file cannot be written, ⌘S and this button both download a copy) · **edit:** `+` (Text / Box / Slide) · duplicate (copy-plus icon: the current slide, after itself, whatever rows are selected · in the sheet, the selected slides; rows copy with ⌘C / ⌘V) · grid + guides + snap (grip icon; **G**; off by default, dims when off: a 16px dot grid over the slide and this slide's layout lines — slot edges, the content margin, the canvas mid-lines — of which only the line being held shows; a drag, a resize or a connector nib snaps to a line within 10px, else to the grid; a circle snaps by its centre; a connector end also snaps to the eight 45° rays out of its other end; never in present, print, the sheet, the PDF or the saved file) · spellcheck (spell-check icon; on by default, dims when off: the words the build's dictionary refused — nspell + dictionary-en at create, optional — are marked on every slide through the CSS Highlight API with a wavy underline **and** a faint red wash, so they show in Chrome, in Safari, which paints no text-decoration inside a highlight pseudo, and in an embedded pane alike; the browser's own checker adds the row you type in; never present, print or PDF) · the red badge on that button is a button of its own: it opens a panel of every flagged word on this slide, each with the suggestions the build baked into the file — a pick replaces every occurrence of that word on the slide, keeping its case and its markup, through the ordinary edit path (so it lands in the file and in `bin/edits.mjs`), and **Ignore in this deck** appends the word to the model's `spell.ignore` and repaints. A click on a marked word while you are editing its row opens the same panel anchored to it and fixes that one occurrence; Escape closes it and hands focus back. The badge counts this slide live as you type, on the contact sheet every thumbnail wears its own count at the top-right, and zero shows nothing · **file:** PDF (⤓) · **view:** contact sheet (grid icon; **C** or Esc) · full screen (**F**) · shortcuts (ⓘ; a three-column popover: slides · rows · file; ⌘P and ⌘D are not shortcuts — the PDF and duplicate buttons are the only doors) · **Report a bug** (bug icon; no key — a report is never one keystroke away; hidden from the present-mode peek pill so it clears the page counter; opens a native dialog: four tiles — Looks wrong · Won't respond · Save or PDF · Crashed — and a 300-character description; **Open mail app** is an anchor whose `mailto:decklet@grunion.ai` is rebuilt on every change, **Copy report** puts the same text on the clipboard; subject `[ISSUE] decklet · · `; body: the engine version, format, slide size and count, current slide, mode, the browser UA, the deck id, viewport · dpr · colour scheme, then the category's probes — counts and fixed words only: rows by kind and clipped rows for Looks wrong, pointer · selection · autosave for Won't respond, autosave · file access · versions · pdf route for Save or PDF, the last three uncaught errors (scrubbed) · uptime · drawn rows for Crashed — then the description; never a word, note, alt, token, image, link, path or edit of the deck; the reporter reads and sends it, the address is receive-only). This manifest is a contract: the gate compares it against the template, so the HUD cannot gain or lose a control without this line changing. While presenting, the HUD peeks back as a centred pill above the bottom edge — never over the page counter in the corner. On the contact sheet the HUD stays, pinned above the thumbnails, with prev/next disabled (the sheet is the navigator) and full screen live: F or the button presents from the picked slide and `+` adding a slide after the current one. Drag to move (one row — a connector travels whole, both ends and every control point; nothing else comes with it), ⌘-click to multi-select, drag from empty canvas for a marquee that takes every row it wholly contains (⇧ adds to the selection), double-click to retype, corner nib to resize, a connector's **point nibs** move one end (or a curve's control point) while a drag on its shaft moves it whole, ⌘C / ⌘V to copy and paste the selected rows (onto this slide 16px down and right, onto any other slide where they sat; the copies become the selection and get ids of their own; a master row copies as the row you see; a connector keeps an end only on a row copied with it, and a routed connector without both of its rows stays behind; text being edited keeps the browser's own copy and paste), ⌘Z to undo (persists across reloads, a paste included), ⌘B / ⌘I / ⌘U mark a text selection or a whole selected row. Selecting text shows a floating toolbar: role segment, **B / I / U / S̶ / link** (marks never change size; on a row whose role the style already sets bold, 600 or heavier, **B** is disabled with the tip "Bold · the role is already bold" and ⌘B does nothing; the link takes http, https or mailto — an empty field unlinks), the deck's own colours as swatches, "Apply to all slides". @@ -14,7 +14,7 @@ This manifest is a contract: the gate compares it against the template, so the H ## The contact sheet -**Contact sheet:** live thumbnails 3-across; click / ⌘ / shift select, double-click opens, grab-and-drag reorders (mouse or touch — the other cells slide aside), ⌫ deletes (never the last), ⌘C ⌘V ⌘D ⌘Z, C or Esc closes, F presents from the picked slide. It also opens in present mode, and there F leaves full screen from the sheet. Hold a dragged thumbnail near the sheet's top or bottom edge and the sheet scrolls, faster the closer you get; Escape cancels the drag. +**Contact sheet:** live thumbnails 3-across; click / ⌘ / shift select, double-click opens, grab-and-drag reorders (mouse or touch — the other cells slide aside), ⌫ deletes (never the last), ⌘C ⌘V ⌘Z, C or Esc closes, F presents from the picked slide. It also opens in present mode, and there F leaves full screen from the sheet. Hold a dragged thumbnail near the sheet's top or bottom edge and the sheet scrolls, faster the closer you get; Escape cancels the drag. ## PDF diff --git a/library.html b/library.html index 2069e58..60134c1 100644 --- a/library.html +++ b/library.html @@ -260,7 +260,7 @@
- +
@@ -1597,12 +1597,11 @@ helpbtn.onclick=()=>help(!helpmenu.classList.contains('open')); helpbtn.onmouseenter=()=>{helpT=setTimeout(()=>help(true),350)};helpbtn.onmouseleave=()=>clearTimeout(helpT); document.addEventListener('mousedown',e=>{if(!e.target.closest('#addwrap'))menu(false);if(!e.target.closest('#helpwrap'))help(false);if(!e.target.closest('#spellmenu,#spellwrap'))spellClose()}); -// duplicate: selected rows (offset 16px, the copies become the selection) · else the slide, after itself · in the sheet, the selected slides +// duplicate: slides only (rows copy with ⌘C ⌘V): the current slide, after itself · in the sheet, the selected slides $('dup').onclick=()=>{commitEdit();menu(false); if(!sheet.hidden){snap();const src=[...gsel].sort((a,b)=>a-b);if(!src.length)return;const at=src[src.length-1]+1;const cp=src.map(n=>{const c=dup(deck.slides[n]);delete c.id;return c});deck.slides.splice(at,0,...cp);gsel=new Set(cp.map((_,x)=>at+x));ganchor=i=at;save();sheetRender();return} snap();const s=slide(); - if(sel.size){const ns=[...sel].map(k=>typeof k==='number'?k:fork(k.slice(2)));const cp=ns.map(n=>{const o=s.els[n],c=dup(o);delete c.id;moveBy(c,o,16,16);return c});const at=s.els.length;s.els.push(...cp);sel.clear();cp.forEach((_,x)=>sel.add(at+x));save();render();return} - const c=dup(s);delete c.id;deck.slides.splice(i+1,0,c);i=i+1;save();render()}; + const c=dup(s);delete c.id;deck.slides.splice(i+1,0,c);i=i+1;sel.clear();save();render()}; // fileHtml(): THIS document with the CURRENT model and log baked into their literals, chrome state stripped — the // file an agent reads (bin/edits.mjs, create --from) and the copy that opens anywhere without browser storage. function fileHtml(){ diff --git a/template.html b/template.html index 5dc30f7..685ca2b 100644 --- a/template.html +++ b/template.html @@ -260,7 +260,7 @@
- +
@@ -1597,12 +1597,11 @@ helpbtn.onclick=()=>help(!helpmenu.classList.contains('open')); helpbtn.onmouseenter=()=>{helpT=setTimeout(()=>help(true),350)};helpbtn.onmouseleave=()=>clearTimeout(helpT); document.addEventListener('mousedown',e=>{if(!e.target.closest('#addwrap'))menu(false);if(!e.target.closest('#helpwrap'))help(false);if(!e.target.closest('#spellmenu,#spellwrap'))spellClose()}); -// duplicate: selected rows (offset 16px, the copies become the selection) · else the slide, after itself · in the sheet, the selected slides +// duplicate: slides only (rows copy with ⌘C ⌘V): the current slide, after itself · in the sheet, the selected slides $('dup').onclick=()=>{commitEdit();menu(false); if(!sheet.hidden){snap();const src=[...gsel].sort((a,b)=>a-b);if(!src.length)return;const at=src[src.length-1]+1;const cp=src.map(n=>{const c=dup(deck.slides[n]);delete c.id;return c});deck.slides.splice(at,0,...cp);gsel=new Set(cp.map((_,x)=>at+x));ganchor=i=at;save();sheetRender();return} snap();const s=slide(); - if(sel.size){const ns=[...sel].map(k=>typeof k==='number'?k:fork(k.slice(2)));const cp=ns.map(n=>{const o=s.els[n],c=dup(o);delete c.id;moveBy(c,o,16,16);return c});const at=s.els.length;s.els.push(...cp);sel.clear();cp.forEach((_,x)=>sel.add(at+x));save();render();return} - const c=dup(s);delete c.id;deck.slides.splice(i+1,0,c);i=i+1;save();render()}; + const c=dup(s);delete c.id;deck.slides.splice(i+1,0,c);i=i+1;sel.clear();save();render()}; // fileHtml(): THIS document with the CURRENT model and log baked into their literals, chrome state stripped — the // file an agent reads (bin/edits.mjs, create --from) and the copy that opens anywhere without browser storage. function fileHtml(){ diff --git a/test/gate.test.mjs b/test/gate.test.mjs index 05c2d95..b2170bb 100644 --- a/test/gate.test.mjs +++ b/test/gate.test.mjs @@ -1447,7 +1447,7 @@ live('live: arrows nudge a selection 1px (⇧ 10px), a connector travels whole, }); test('nudge shares the drag translation path and is named in the shortcuts popover', () => { assert.match(tpl, /const moveBy=\(el,o,dx,dy\)=>/, 'one moveBy helper'); - assert.equal((tpl.match(/moveBy\(/g) || []).length, 4, 'drag, nudge, duplicate and row paste all call it — no second translation path'); + assert.equal((tpl.match(/moveBy\(/g) || []).length, 3, 'drag, nudge and row paste all call it — no second translation path'); assert.match(tpl, /nudge 1px · ⇧<\/kbd> 10px/); }); diff --git a/test/hud.test.mjs b/test/hud.test.mjs index 3b349f5..db8a274 100644 --- a/test/hud.test.mjs +++ b/test/hud.test.mjs @@ -105,22 +105,23 @@ for (const engine of ['chromium', 'webkit']) live(`hud: F and ⛶ toggle full sc }, {timeout: 50000}); }); -live('hud: duplicate copies the selected rows offset 16px and selects the copies; with nothing selected it copies the slide', async () => { - const b = await pw.chromium.launch(); const p = await open(b); +// Issue #80: the button duplicated the selected rows when rows were selected, and the slide otherwise, so the same click +// meant two things depending on the canvas. It duplicates slides only: the current slide on the canvas, the selected slides +// in the sheet. Rows are copied with ⌘C / ⌘V (#79). +test('hud: the duplicate button is named for the slide', () => { + assert.match(hud, /