From 8655092d297e736f197178899bd79560df0b1175 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 7 Sep 2026 22:43:57 -0700 Subject: [PATCH] =?UTF-8?q?HUD=20in=20four=20groups,=20every=20control=20n?= =?UTF-8?q?amed=20on=20hover;=20versions=20wears=20the=20autosave=20dot;?= =?UTF-8?q?=20a=20duplicate=20button;=20C=20=3D=20sheet,=20G=20=3D=20guide?= =?UTF-8?q?s;=20=E2=8C=98P/=E2=8C=98D=20retired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the spacer: save state (versions + autosave badge, menu opens with the full status and a Save ⌘S row) · edit (+, duplicate, guides, spellcheck) · file (save a copy, PDF) · view (contact sheet, full screen, shortcuts), a hairline between groups. Tooltips are instant data-tip labels with the key; the OS title delay is gone. The ⓘ popover is three columns: slides · rows · file. The sheet's ⌘D handler is removed; the duplicate button is the door. Gate: test/hud.test.mjs; SKILL.md manifest and CHANGELOG move with it; deck.html rebuilt. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + SKILL.md | 4 +- deck.html | 110 +++++++++++++++++++++++++------------------ template.html | 110 +++++++++++++++++++++++++------------------ test/editor.test.mjs | 2 +- test/gate.test.mjs | 52 ++++++++++---------- test/hud.test.mjs | 83 ++++++++++++++++++++++++++++++++ 7 files changed, 241 insertions(+), 121 deletions(-) create mode 100644 test/hud.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 12239af..df8524b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog ## Unreleased +- **The HUD is four groups, and every control names itself.** After the spacer: **save state** (versions wearing the autosave dot as a badge — green the file has everything, amber saved in this browser, red nothing persists; click opens the versions menu, whose first row is the full save status and a `Save ⌘S` button) · a hairline · **edit** (`+`, a new **duplicate** button — the selected rows offset 16px and re-selected, else the slide after itself, in the sheet the selected slides — guides, spellcheck) · **file** (save a copy when storage is blocked, PDF) · **view** (contact sheet, full screen, shortcuts). Hovering any control shows its name and its key at once (`data-tip`; the OS `title` delay is gone). Keys: **C** opens the contact sheet (was G), **G** toggles guides + snap; ⌘P and ⌘D leave the shortcut list — the PDF and duplicate buttons are the doors. The ⓘ popover is three columns by what a key acts on: slides · rows · file. Gate: test/hud.test.mjs; the SKILL.md HUD manifest moved with it. - **Grid + guides + snap in the editor.** A HUD toggle (grip icon, off by default, per browser, never in the model) overlays a 16px dot grid on the canvas — one dot per intersection — and makes the slide's layout lines (its slot edges, the content margin, the canvas mid-lines, each rounded onto the lattice) magnetic: a drag, the resize nib or a connector nib snaps to a line within 10px (a held line keeps its grip 4px further), else to the grid; only the line being held shows, while it is held. A circle snaps by its centre. A connector end also snaps to the eight 45° rays out of its other end with the same reach and hold, sliding along the ray to a guide or grid line. Never in present, print, the contact sheet, the ⤓ PDF or the saved file. A block grab now `preventDefault`s, so moving a row no longer selects the text under the pointer. - **The nine Modern templates are also layouts.** `bento-grid`, `image-hero-overlay`, `image-split`, `annotated-shot`, `three-up-cards`, `dashboard-composite`, `table-insight`, `proof-strip` and `team-grid` join the layout library as slot maps under a new `modern` group (31 layouts): paint and media slots carry the frame, the slide binds the words, so a deck can use them as chrome with its own rows instead of a filled template. Paint and media slots may bleed to the canvas edge; type stays inside the margins. Gate: test/layouts.test.mjs (every layout renders, parity holds). - **`logic-tree` leaves the template library** (58 ship); the examples now name `three-up-cards`. The `image-split` button label binds to its button with `valign` like `closing-cta`. diff --git a/SKILL.md b/SKILL.md index b4a4111..bda0fc0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -107,8 +107,8 @@ Fix the model, not the output. Re-run until `VERIFY PASS`. Attach `verify-out/re ### Step 6 — hand-off notes for the human editor Say, in this order: 1. Where the file is and that it opens from disk in any browser, no install, no network. -2. **HUD (the full set, left to right):** prev / next (chevrons) · autosave dot (green = the file has everything · amber = saved in this browser, N edits not in the file yet · red = nothing persists; click = ⌘S) · `+` (Text / Box / Slide) · contact sheet (grid icon; G or Esc) · spellcheck (spell-check icon; on by default, dims when off) · grid + guides + snap (grip icon, a 3×3 dot lattice; **off by default**, dims when off: a 16px dot grid over the slide — one dot per intersection — and this slide's layout lines — its slot edges, the content margin and the canvas mid-lines, every one seated on the grid — of which only the line being held shows, while it is held; a drag, a resize or a connector nib snaps to a layout line within 10px (a held line keeps its grip 4px further), 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, same reach and hold measured across the ray — per browser, never in the model, never in present, print or the PDF) · save a copy (copy icon — only shown when the browser blocks storage) · versions (history icon: pin this version, restore an earlier one) · PDF (file-down icon) · fullscreen (maximize icon; F) · shortcuts (question mark). The icons are Lucide shapes with movingicons.dev motion — each playing once on load and once per hover, never on a loop. - +2. **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 state:** versions (history icon wearing the autosave dot as a badge: green = the file has everything · amber = saved in this browser, N edits not in the file yet · red = nothing persists; click opens the versions menu, whose first row is `Save ⌘S` · pin · restore) · **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 browser's own — it marks the row you type in, never present, print or PDF) · **file:** save a copy (only when the browser blocks storage) · 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). + 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/present disabled (the sheet is the navigator) and `+` adding a slide after the current one. Drag to move (a connector travels whole — both ends and every control point; a `group` travels whole and wears one dashed box), ⌘-click to multi-select (or to take one member of a group alone), 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, ⌘Z to undo (persists across reloads), ⌘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; the link takes http, https or mailto — an empty field unlinks), the deck's own colours as swatches, "Apply to all slides". 3. **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. It also opens in present mode. 4. **PDF:** links become real `/Link` annotations, so a LinkedIn document post is clickable. the PDF button downloads a **true slide-sized PDF written inside the file** — no library, no server. Each slide is rasterised from its live DOM (SVG `foreignObject` → canvas at 2× → JPEG) onto a W×H pt page, so a 16:9 deck is a 16:9 PDF with no letterboxing; fonts must be local (they are — the deck loads none). Verified in Chromium; **Safari's `foreignObject` path is unconfirmed** — if rasterising throws or the canvas is tainted, the PDF button falls back to `print()`. **⌘P is the paper path:** one page per slide, each with its own background, on a **named** page size — Letter (A4 for `document-a4`) — because Safari ignores pixel `@page` sizes; the slide is zoomed to the printable width. Choose "Save as PDF" there for a paper-shaped file. diff --git a/deck.html b/deck.html index 5db9173..1b2f2c6 100644 --- a/deck.html +++ b/deck.html @@ -61,11 +61,14 @@ /* inline marks change weight/style/position/colour — never size: sub/sup shift the baseline only */ .el sub,.el sup{font-size:inherit;line-height:0;vertical-align:sub}.el sup{vertical-align:super} /* autosave dot: green ✓ persisted · red edits lost on refresh · amber pulse saving */ -#autosave{width:9px;height:9px;border-radius:50%;background:#3fb950;box-shadow:0 0 6px #3fb95088;position:relative;flex:none} +#autosave{width:9px;height:9px;border-radius:50%;background:#3fb950;box-shadow:0 0 0 2px var(--bg),0 0 6px #3fb95088;position:absolute;top:-4px;right:-4px;flex:none} #autosave::after{content:'✓';position:absolute;inset:0;font:bold 7px/9px sans-serif;color:#0b2e14;text-align:center} -/* hover label = status + last save time (data-tip mirrors title, readable without the OS tooltip delay) */ -#autosave::before{content:attr(data-tip);display:none;position:absolute;left:50%;transform:translateX(-50%);bottom:calc(100% + 10px);white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:4px 8px;color:var(--fg);font:12px ui-monospace,Menlo,monospace;z-index:13} -#autosave:hover::before{display:block} +/* hover label on every HUD control: its name · its key (data-tip, instant — the OS title delay hid the names) */ +#hud [data-tip]{position:relative} +#hud [data-tip]::before{content:attr(data-tip);display:none;position:absolute;left:50%;transform:translateX(-50%);bottom:calc(100% + 10px);white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:4px 8px;color:var(--fg);font:12px ui-monospace,Menlo,monospace;z-index:13;pointer-events:none} +#hud [data-tip]:hover::before{display:block} +#hud [data-tip]:last-child::before{left:auto;right:0;transform:none} +#hud [aria-expanded="true"]::before{display:none!important} /* an open menu is the label */ #autosave[data-state=bad]{background:#f85149;box-shadow:0 0 6px #f8514988}#autosave[data-state=bad]::after{content:''} #autosave[data-state=busy]{background:#d29922;box-shadow:0 0 6px #d2992288;animation:asave .6s ease-in-out infinite alternate}#autosave[data-state=busy]::after{content:''} @keyframes asave{from{opacity:.5}to{opacity:1}} @@ -81,15 +84,16 @@ .mi-chevron-left path { transition: all 0.2s ease-in; } .mi-chevron-left .chevron { transform: translateX(-3px); }.mi-chevron-right path { transition: all 0.2s ease-in; } .mi-chevron-right .chevron { transform: translateX(3px); }.mi-plus .plus { overflow: visible; } .mi-plus .horizontal, .mi-plus .vertical { stroke-dasharray: 14; stroke-dashoffset: 0; transition: stroke-dashoffset 0.15s ease-out; } .mi-plus .plus.animate .horizontal { opacity: 0; animation: mi-plus-lineAnimation 0.3s ease-out forwards; } .mi-plus .plus.animate .vertical { opacity: 0; animation: mi-plus-lineAnimation 0.3s ease-out 0.25s forwards; /* Start shortly before horizontal ends */ } @keyframes mi-plus-lineAnimation { 0% { opacity: 0; stroke-dashoffset: 14; } 15% { opacity: 1; stroke-dashoffset: 14; } 100% { opacity: 1; stroke-dashoffset: 0; } }.mi-layout-grid .layout-grid-icon { overflow: visible; } .mi-layout-grid .layout-grid-icon.animate .top-right-panel { opacity: 0; animation: mi-layout-grid-fadeInTop 0.35s ease-out forwards; } .mi-layout-grid .layout-grid-icon.animate .bottom-right-panel { opacity: 0; animation: mi-layout-grid-fadeInRight 0.35s ease-out 0.1s forwards; } .mi-layout-grid .layout-grid-icon.animate .bottom-left-panel { opacity: 0; animation: mi-layout-grid-fadeInBottom 0.35s ease-out 0.2s forwards; } .mi-layout-grid .layout-grid-icon.animate .top-left-panel { opacity: 0; animation: mi-layout-grid-fadeInLeft 0.35s ease-out 0.3s forwards; } @keyframes mi-layout-grid-fadeInLeft { 0%, 50% { opacity: 0; transform: translateX(-10px); } 80% { opacity: 0.8; transform: translateX(2px); } 100% { opacity: 1; transform: translateX(0); } } @keyframes mi-layout-grid-fadeInRight { 0%, 50% { opacity: 0; transform: translateX(10px); } 80% { opacity: 0.8; transform: translateX(-2px); } 100% { opacity: 1; transform: translateX(0); } } @keyframes mi-layout-grid-fadeInTop { 0%, 50% { opacity: 0; transform: translateY(-10px); } 80% { opacity: 0.8; transform: translateY(2px); } 100% { opacity: 1; transform: translateY(0); } } @keyframes mi-layout-grid-fadeInBottom { 0%, 50% { opacity: 0; transform: translateY(10px); } 80% { opacity: 0.8; transform: translateY(-2px); } 100% { opacity: 1; transform: translateY(0); } }.mi-copy .copy-rect, .mi-copy .copy-path { transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); } .mi-copy .copy-rect.animate { transform: translate(-3px, -3px); } .mi-copy .copy-path.animate { transform: translate(3px, 3px); } .mi-copy svg { overflow: visible; }.mi-file-down g.animate path { transform: translateY(2px); transition: transform 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6); } .mi-file-down g path { transform: translateY(0); transition: transform 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6); }.mi-maximize path { transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .mi-maximize .bottom-right { transform: translate(2px, 2px); } .mi-maximize .bottom-left { transform: translate(-2px, 2px); } .mi-maximize .top-right { transform: translate(2px, -2px); } .mi-maximize .top-left { transform: translate(-2px, -2px); } .mi-maximize svg { overflow: visible; }.mi-circle-question-mark .animate-icon { transition: transform 0.5s ease-in-out; } .mi-circle-question-mark .animate-path { transition: transform 0.5s ease-in-out; transform-origin: center; animation: mi-circle-question-mark-rotateAnimation 0.5s ease-in-out; } @keyframes mi-circle-question-mark-rotateAnimation { 0% { transform: rotate(0deg); } 20% { transform: rotate(-10deg); } 40% { transform: rotate(10deg); } 60% { transform: rotate(-10deg); } 100% { transform: rotate(0deg); } }.mi-grip circle { transform-origin: center; transform-box: fill-box; } .mi-grip .dots.animate circle { animation: mi-grip-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both; } .mi-grip .dots.animate circle:nth-child(2), .mi-grip .dots.animate circle:nth-child(4) { animation-delay: 0.05s; } .mi-grip .dots.animate circle:nth-child(3), .mi-grip .dots.animate circle:nth-child(5), .mi-grip .dots.animate circle:nth-child(7) { animation-delay: 0.1s; } .mi-grip .dots.animate circle:nth-child(6), .mi-grip .dots.animate circle:nth-child(8) { animation-delay: 0.15s; } .mi-grip .dots.animate circle:nth-child(9) { animation-delay: 0.2s; } @keyframes mi-grip-pop { 0% { transform: scale(0); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }.mi-spell-check .tick { stroke-dasharray: 10; stroke-dashoffset: 0; } .mi-spell-check .tick.animate { animation: mi-spell-check-draw 0.4s ease-out forwards; } @keyframes mi-spell-check-draw { 0% { stroke-dashoffset: 10; opacity: 0; } 20% { opacity: 1; } 100% { stroke-dashoffset: 0; opacity: 1; } } @media (prefers-reduced-motion:reduce){.mi svg,.mi svg *{animation:none!important;transition:none!important}} /* "+" micro-menu: the add controls (Text / Box / Slide) nest under one button; opens upward, click-away closes */ -#addwrap,#helpwrap{position:relative} +#addwrap,#helpwrap,#verswrap{position:relative} #addmenu,#helpmenu{display:none;position:absolute;bottom:calc(100% + 6px);flex-direction:column;gap:4px;padding:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;z-index:11;white-space:nowrap} #addmenu{left:0}#addmenu.open{display:flex} -#versmenu{display:none;position:absolute;bottom:calc(100% + 6px);right:0;flex-direction:column;gap:4px;padding:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;z-index:11;white-space:nowrap;max-height:60vh;overflow:auto}#versmenu.open{display:flex} -#versmenu .v{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:2px 4px}#versmenu .v button{padding:2px 8px} +#versmenu{display:none;position:absolute;bottom:calc(100% + 6px);left:0;flex-direction:column;gap:4px;padding:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;z-index:11;white-space:normal;width:max-content;max-width:min(480px,calc(100vw - 32px));max-height:60vh;overflow:auto}#versmenu.open{display:flex} +#versmenu .v{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:2px 4px}#versmenu .v button{padding:2px 8px;white-space:nowrap} #autosave[data-state=local]{background:#d29922;box-shadow:0 0 6px #d2992288}#autosave[data-state=local]::after{content:''} /* ⓘ popover: the ONE home for shortcut hints (the HUD itself stays free of hint text); click or hover opens, click-away / Esc closes */ -#helpmenu{right:0;padding:8px 10px;line-height:1.7}#helpmenu.open{display:flex} -#helpmenu kbd{font:inherit;color:var(--accent);display:inline-block;min-width:9em} +#helpmenu{right:0;padding:10px 14px;line-height:1.7;flex-direction:row;gap:20px;white-space:normal;width:max-content;max-width:calc(100vw - 32px)}#helpmenu.open{display:flex} +#helpmenu .col{flex:1 1 0;min-width:200px;max-width:320px}#helpmenu .ph{color:var(--fg);letter-spacing:.08em;text-transform:uppercase;font-size:11px;margin-bottom:4px}#helpmenu .dim{opacity:.7;margin-top:6px} +#helpmenu kbd{font:inherit;color:var(--accent);display:inline-block;margin-right:.4em} /* floating mini toolbar: role segment + inline marks + Apply to all slides. No font pickers — roles ARE the type system */ #tb{position:fixed;z-index:12;display:flex;flex-wrap:wrap;align-items:center;max-width:calc(100vw - 8px);gap:4px;padding:4px;background:var(--card);border:1px solid var(--line);border-radius:8px;font:12px ui-monospace,Menlo,monospace} #tb[hidden]{display:none} @@ -163,45 +167,53 @@
- - + +
- +
+ + +
+
- +
- - - - - - - + + + +
+ +
- + +
- +
@@ -336,7 +348,7 @@ // The tooltip (title + hover label) always carries the last confirmed save time. const hms=d=>d?d.toTimeString().slice(0,8):''; const BLOCKED='Not saved — this browser blocks storage for local files (Safari does, on file://). Press ⌘S to save a copy you can reopen, or open this deck in Chrome.'; -const autosave=(st,msg)=>{const a=$('autosave');if(!a)return;a.dataset.state=st;const txt=msg||{ok:fileAt?'Saved to file · '+hms(fileAt):'Autosaved · '+hms(lastSavedAt),local:'Saved in this browser · '+unsynced+' edit'+(unsynced===1?'':'s')+' not in the file yet — ⌘S writes them',bad:CANSTORE?'Not saved — edits will be lost on refresh'+(lastSavedAt?' (last saved '+hms(lastSavedAt)+')':''):BLOCKED,busy:'Saving…'}[st];a.title=txt;a.dataset.tip=txt;a.setAttribute('aria-label',txt)}; +const autosave=(st,msg)=>{const a=$('autosave');if(!a)return;a.dataset.state=st;const txt=msg||{ok:fileAt?'Saved to file · '+hms(fileAt):'Autosaved · '+hms(lastSavedAt),local:'Saved in this browser · '+unsynced+' edit'+(unsynced===1?'':'s')+' not in the file yet — ⌘S writes them',bad:CANSTORE?'Not saved — edits will be lost on refresh'+(lastSavedAt?' (last saved '+hms(lastSavedAt)+')':''):BLOCKED,busy:'Saving…'}[st];a.dataset.full=txt;const b=$('vers');b.dataset.tip='Versions · '+{ok:fileAt?'saved to file '+hms(fileAt):'autosaved '+hms(lastSavedAt),local:'saved in this browser, not in the file',bad:'not saved',busy:'saving…'}[st];b.setAttribute('aria-label',b.dataset.tip);a.setAttribute('aria-label',txt);if($('versmenu').classList.contains('open'))versRender()}; // save: every mutation lands here. base = the deck as last saved; the diff since then is appended to the log (stamped with // the file rev it was made on — an entry with no rev has not reached a file yet), then model + log + versions persist, then // the linked file is rewritten when there is one. `unsynced` = entries no file carries yet: the dot turns amber until ⌘S. @@ -837,7 +849,6 @@ snap();del.forEach(n=>deck.slides.splice(n,1));i=Math.min(del[del.length-1],deck.slides.length-1);gsel=new Set([i]);ganchor=i;save();sheetRender();return true} if(mod&&k==='c'&&gsel.size){clip=[...gsel].sort((a,b)=>a-b).map(n=>dup(deck.slides[n]));return true} if(mod&&k==='v'&&clip){snap();const at=Math.max(...gsel,-1)+1;deck.slides.splice(at,0,...clip.map(dup));gsel=new Set(clip.map((_,x)=>at+x));i=at;save();sheetRender();return true} - if(mod&&k==='d'&&gsel.size){snap();const src=[...gsel].sort((a,b)=>a-b),at=src[src.length-1]+1;deck.slides.splice(at,0,...src.map(n=>dup(deck.slides[n])));gsel=new Set(src.map((_,x)=>at+x));i=at;save();sheetRender();return true} if(mod&&k==='z'){undo();gsel=new Set([Math.min(i,deck.slides.length-1)]);sheetRender();return true} return false; } @@ -856,8 +867,10 @@ snap();[...sel].filter(k=>typeof k!=='number').forEach(k=>(slide().hide=slide().hide||[]).push(k.slice(2))); [...sel].filter(k=>typeof k==='number').sort((a,b)=>b-a).forEach(n=>slide().els.splice(n,1));sel.clear();save();render(); } + if(e.metaKey||e.ctrlKey||e.altKey)return; // plain letters only: ⌘C stays the browser's copy if(e.key.toLowerCase()==='f')fs(); - if(e.key.toLowerCase()==='g')sheetOpen(); + if(e.key.toLowerCase()==='g'){commitEdit();setSnap(!SNAP)} + if(e.key.toLowerCase()==='c')sheetOpen(); }); // arrow nudge: EVERY selected row travels by the same fixed delta (1px, ⇧ 10px) so a group stays rigid — the keyboard // twin of drag through moveBy: connector far ends travel too, master rows fork first. One undo snapshot per burst (an 800ms pause starts a new one). @@ -957,9 +970,9 @@ const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([Uint8Array.from(out,ch=>ch.charCodeAt(0))],{type:'application/pdf'}));a.download=document.title.replace(/[\/:*?"<>|]+/g,'-')+'.pdf';a.click(); } $('pdf').onclick=()=>exportPdf().catch(()=>print()); -const setSpell=on=>{SPELL=on;store.set(SKEY,on?'1':'0');const b=$('spell');b.classList.toggle('off',!on);b.setAttribute('aria-pressed',on);b.title='Spellcheck ('+(on?'on':'off')+')';b.setAttribute('aria-label',b.title);render()}; +const setSpell=on=>{SPELL=on;store.set(SKEY,on?'1':'0');const b=$('spell');b.classList.toggle('off',!on);b.setAttribute('aria-pressed',on);b.dataset.tip='Spellcheck · '+(on?'on':'off');b.setAttribute('aria-label',b.dataset.tip);render()}; $('spell').onclick=()=>{commitEdit();setSpell(!SPELL)};if(!SPELL)setSpell(false); -const setSnap=on=>{SNAP=on;store.set(GKEY,on?'1':'0');const b=$('snap');b.setAttribute('aria-pressed',on);b.title='Layout guides + snap ('+(on?'on':'off')+')';b.setAttribute('aria-label',b.title);render()}; +const setSnap=on=>{SNAP=on;store.set(GKEY,on?'1':'0');const b=$('snap');b.setAttribute('aria-pressed',on);b.dataset.tip='Guides + snap · '+(on?'on':'off')+' · G';b.setAttribute('aria-label',b.dataset.tip);render()}; $('snap').onclick=()=>{commitEdit();setSnap(!SNAP)};if(SNAP)setSnap(true); canvas.lang=deck.lang||'en'; // the dictionary the browser picks; deck.lang is optional model data $('savecopy').onclick=()=>saveCopy(); // only visible when the browser blocks storage — then it is the only way to keep an edit @@ -974,13 +987,18 @@ 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('#versmenu,#vers'))vers(false)}); -$('autosave').onclick=()=>saveFile(); // the dot is the other door to ⌘S: link the file, write it +// duplicate: selected rows (offset 16px, the copies become the selection) · else the 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()}; // fileHtml(): THIS document with the CURRENT model, log and versions 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(){ commitEdit();const d=document.documentElement.cloneNode(true),q=s=>d.querySelector(s); d.removeAttribute('style');q('body').removeAttribute('style');q('body').className='';d.querySelectorAll('#hud :disabled').forEach(b=>b.disabled=false);['#canvas','#grid','#print','#versmenu'].forEach(s=>q(s).innerHTML=''); - q('#snap').setAttribute('aria-pressed','false');q('#snap').title='Layout guides + snap (off)';q('#snap').setAttribute('aria-label',q('#snap').title); // guides are browser state: a copy opens with them off + q('#snap').setAttribute('aria-pressed','false');q('#snap').dataset.tip='Guides + snap · off · G';q('#snap').setAttribute('aria-label',q('#snap').dataset.tip); // guides are browser state: a copy opens with them off q('#tb').hidden=q('#sheet').hidden=true;q('#addmenu').classList.remove('open');q('#helpmenu').classList.remove('open');q('#versmenu').classList.remove('open'); const J=v=>JSON.stringify(v).replace(/<\/script/gi,'<\\/script'),sc=q('script'); sc.textContent=sc.textContent @@ -1030,9 +1048,9 @@ function restore(n){const v=versions[n];if(!v)return;commitEdit();pin('before restore');snap();const rev=deck.rev;deck=JSON.parse(JSON.stringify(v.deck));deck.rev=rev;sel.clear();i=Math.min(i,deck.slides.length-1);save();render();vers(false)} const versmenu=$('versmenu'),versbtn=$('vers'); const vers=open=>{versmenu.classList.toggle('open',open);versbtn.setAttribute('aria-expanded',open);if(open)versRender()}; -function versRender(){versmenu.innerHTML=versions.length?[...versions].map((v,n)=>[v,n]).reverse().map(([v,n])=>`
${new Date(v.t).toLocaleString()} · ${v.by} · ${(v.label||'').replace(/[<>&]/g,'')}
`).join('')+`
now
`:`
No versions yet
`} +function versRender(){versmenu.innerHTML='
'+($('autosave').dataset.full||'')+'
'+(versions.length?[...versions].map((v,n)=>[v,n]).reverse().map(([v,n])=>`
${new Date(v.t).toLocaleString()} · ${v.by} · ${(v.label||'').replace(/[<>&]/g,'')}
`).join('')+`
now
`:`
No versions yet
`)} versbtn.onclick=()=>vers(!versmenu.classList.contains('open')); -versmenu.onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.pin){pin('pin');return}restore(+b.dataset.restore)}; +versmenu.onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.save){vers(false);saveFile();return}if(b.dataset.pin){pin('pin');return}restore(+b.dataset.restore)}; // add text + box: new text is born Body — or binds to a free slot of the slide's layout (inheriting its role + geometry) const TPL={ text:{x:Math.round(W*.4),y:Math.round(H*.46),w:Math.round(W*.31),role:'Body',text:'New text — double-click to edit'}, diff --git a/template.html b/template.html index a1e8b33..9ede6b2 100644 --- a/template.html +++ b/template.html @@ -61,11 +61,14 @@ /* inline marks change weight/style/position/colour — never size: sub/sup shift the baseline only */ .el sub,.el sup{font-size:inherit;line-height:0;vertical-align:sub}.el sup{vertical-align:super} /* autosave dot: green ✓ persisted · red edits lost on refresh · amber pulse saving */ -#autosave{width:9px;height:9px;border-radius:50%;background:#3fb950;box-shadow:0 0 6px #3fb95088;position:relative;flex:none} +#autosave{width:9px;height:9px;border-radius:50%;background:#3fb950;box-shadow:0 0 0 2px var(--bg),0 0 6px #3fb95088;position:absolute;top:-4px;right:-4px;flex:none} #autosave::after{content:'✓';position:absolute;inset:0;font:bold 7px/9px sans-serif;color:#0b2e14;text-align:center} -/* hover label = status + last save time (data-tip mirrors title, readable without the OS tooltip delay) */ -#autosave::before{content:attr(data-tip);display:none;position:absolute;left:50%;transform:translateX(-50%);bottom:calc(100% + 10px);white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:4px 8px;color:var(--fg);font:12px ui-monospace,Menlo,monospace;z-index:13} -#autosave:hover::before{display:block} +/* hover label on every HUD control: its name · its key (data-tip, instant — the OS title delay hid the names) */ +#hud [data-tip]{position:relative} +#hud [data-tip]::before{content:attr(data-tip);display:none;position:absolute;left:50%;transform:translateX(-50%);bottom:calc(100% + 10px);white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:4px 8px;color:var(--fg);font:12px ui-monospace,Menlo,monospace;z-index:13;pointer-events:none} +#hud [data-tip]:hover::before{display:block} +#hud [data-tip]:last-child::before{left:auto;right:0;transform:none} +#hud [aria-expanded="true"]::before{display:none!important} /* an open menu is the label */ #autosave[data-state=bad]{background:#f85149;box-shadow:0 0 6px #f8514988}#autosave[data-state=bad]::after{content:''} #autosave[data-state=busy]{background:#d29922;box-shadow:0 0 6px #d2992288;animation:asave .6s ease-in-out infinite alternate}#autosave[data-state=busy]::after{content:''} @keyframes asave{from{opacity:.5}to{opacity:1}} @@ -81,15 +84,16 @@ .mi-chevron-left path { transition: all 0.2s ease-in; } .mi-chevron-left .chevron { transform: translateX(-3px); }.mi-chevron-right path { transition: all 0.2s ease-in; } .mi-chevron-right .chevron { transform: translateX(3px); }.mi-plus .plus { overflow: visible; } .mi-plus .horizontal, .mi-plus .vertical { stroke-dasharray: 14; stroke-dashoffset: 0; transition: stroke-dashoffset 0.15s ease-out; } .mi-plus .plus.animate .horizontal { opacity: 0; animation: mi-plus-lineAnimation 0.3s ease-out forwards; } .mi-plus .plus.animate .vertical { opacity: 0; animation: mi-plus-lineAnimation 0.3s ease-out 0.25s forwards; /* Start shortly before horizontal ends */ } @keyframes mi-plus-lineAnimation { 0% { opacity: 0; stroke-dashoffset: 14; } 15% { opacity: 1; stroke-dashoffset: 14; } 100% { opacity: 1; stroke-dashoffset: 0; } }.mi-layout-grid .layout-grid-icon { overflow: visible; } .mi-layout-grid .layout-grid-icon.animate .top-right-panel { opacity: 0; animation: mi-layout-grid-fadeInTop 0.35s ease-out forwards; } .mi-layout-grid .layout-grid-icon.animate .bottom-right-panel { opacity: 0; animation: mi-layout-grid-fadeInRight 0.35s ease-out 0.1s forwards; } .mi-layout-grid .layout-grid-icon.animate .bottom-left-panel { opacity: 0; animation: mi-layout-grid-fadeInBottom 0.35s ease-out 0.2s forwards; } .mi-layout-grid .layout-grid-icon.animate .top-left-panel { opacity: 0; animation: mi-layout-grid-fadeInLeft 0.35s ease-out 0.3s forwards; } @keyframes mi-layout-grid-fadeInLeft { 0%, 50% { opacity: 0; transform: translateX(-10px); } 80% { opacity: 0.8; transform: translateX(2px); } 100% { opacity: 1; transform: translateX(0); } } @keyframes mi-layout-grid-fadeInRight { 0%, 50% { opacity: 0; transform: translateX(10px); } 80% { opacity: 0.8; transform: translateX(-2px); } 100% { opacity: 1; transform: translateX(0); } } @keyframes mi-layout-grid-fadeInTop { 0%, 50% { opacity: 0; transform: translateY(-10px); } 80% { opacity: 0.8; transform: translateY(2px); } 100% { opacity: 1; transform: translateY(0); } } @keyframes mi-layout-grid-fadeInBottom { 0%, 50% { opacity: 0; transform: translateY(10px); } 80% { opacity: 0.8; transform: translateY(-2px); } 100% { opacity: 1; transform: translateY(0); } }.mi-copy .copy-rect, .mi-copy .copy-path { transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); } .mi-copy .copy-rect.animate { transform: translate(-3px, -3px); } .mi-copy .copy-path.animate { transform: translate(3px, 3px); } .mi-copy svg { overflow: visible; }.mi-file-down g.animate path { transform: translateY(2px); transition: transform 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6); } .mi-file-down g path { transform: translateY(0); transition: transform 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6); }.mi-maximize path { transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .mi-maximize .bottom-right { transform: translate(2px, 2px); } .mi-maximize .bottom-left { transform: translate(-2px, 2px); } .mi-maximize .top-right { transform: translate(2px, -2px); } .mi-maximize .top-left { transform: translate(-2px, -2px); } .mi-maximize svg { overflow: visible; }.mi-circle-question-mark .animate-icon { transition: transform 0.5s ease-in-out; } .mi-circle-question-mark .animate-path { transition: transform 0.5s ease-in-out; transform-origin: center; animation: mi-circle-question-mark-rotateAnimation 0.5s ease-in-out; } @keyframes mi-circle-question-mark-rotateAnimation { 0% { transform: rotate(0deg); } 20% { transform: rotate(-10deg); } 40% { transform: rotate(10deg); } 60% { transform: rotate(-10deg); } 100% { transform: rotate(0deg); } }.mi-grip circle { transform-origin: center; transform-box: fill-box; } .mi-grip .dots.animate circle { animation: mi-grip-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both; } .mi-grip .dots.animate circle:nth-child(2), .mi-grip .dots.animate circle:nth-child(4) { animation-delay: 0.05s; } .mi-grip .dots.animate circle:nth-child(3), .mi-grip .dots.animate circle:nth-child(5), .mi-grip .dots.animate circle:nth-child(7) { animation-delay: 0.1s; } .mi-grip .dots.animate circle:nth-child(6), .mi-grip .dots.animate circle:nth-child(8) { animation-delay: 0.15s; } .mi-grip .dots.animate circle:nth-child(9) { animation-delay: 0.2s; } @keyframes mi-grip-pop { 0% { transform: scale(0); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }.mi-spell-check .tick { stroke-dasharray: 10; stroke-dashoffset: 0; } .mi-spell-check .tick.animate { animation: mi-spell-check-draw 0.4s ease-out forwards; } @keyframes mi-spell-check-draw { 0% { stroke-dashoffset: 10; opacity: 0; } 20% { opacity: 1; } 100% { stroke-dashoffset: 0; opacity: 1; } } @media (prefers-reduced-motion:reduce){.mi svg,.mi svg *{animation:none!important;transition:none!important}} /* "+" micro-menu: the add controls (Text / Box / Slide) nest under one button; opens upward, click-away closes */ -#addwrap,#helpwrap{position:relative} +#addwrap,#helpwrap,#verswrap{position:relative} #addmenu,#helpmenu{display:none;position:absolute;bottom:calc(100% + 6px);flex-direction:column;gap:4px;padding:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;z-index:11;white-space:nowrap} #addmenu{left:0}#addmenu.open{display:flex} -#versmenu{display:none;position:absolute;bottom:calc(100% + 6px);right:0;flex-direction:column;gap:4px;padding:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;z-index:11;white-space:nowrap;max-height:60vh;overflow:auto}#versmenu.open{display:flex} -#versmenu .v{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:2px 4px}#versmenu .v button{padding:2px 8px} +#versmenu{display:none;position:absolute;bottom:calc(100% + 6px);left:0;flex-direction:column;gap:4px;padding:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;z-index:11;white-space:normal;width:max-content;max-width:min(480px,calc(100vw - 32px));max-height:60vh;overflow:auto}#versmenu.open{display:flex} +#versmenu .v{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:2px 4px}#versmenu .v button{padding:2px 8px;white-space:nowrap} #autosave[data-state=local]{background:#d29922;box-shadow:0 0 6px #d2992288}#autosave[data-state=local]::after{content:''} /* ⓘ popover: the ONE home for shortcut hints (the HUD itself stays free of hint text); click or hover opens, click-away / Esc closes */ -#helpmenu{right:0;padding:8px 10px;line-height:1.7}#helpmenu.open{display:flex} -#helpmenu kbd{font:inherit;color:var(--accent);display:inline-block;min-width:9em} +#helpmenu{right:0;padding:10px 14px;line-height:1.7;flex-direction:row;gap:20px;white-space:normal;width:max-content;max-width:calc(100vw - 32px)}#helpmenu.open{display:flex} +#helpmenu .col{flex:1 1 0;min-width:200px;max-width:320px}#helpmenu .ph{color:var(--fg);letter-spacing:.08em;text-transform:uppercase;font-size:11px;margin-bottom:4px}#helpmenu .dim{opacity:.7;margin-top:6px} +#helpmenu kbd{font:inherit;color:var(--accent);display:inline-block;margin-right:.4em} /* floating mini toolbar: role segment + inline marks + Apply to all slides. No font pickers — roles ARE the type system */ #tb{position:fixed;z-index:12;display:flex;flex-wrap:wrap;align-items:center;max-width:calc(100vw - 8px);gap:4px;padding:4px;background:var(--card);border:1px solid var(--line);border-radius:8px;font:12px ui-monospace,Menlo,monospace} #tb[hidden]{display:none} @@ -163,45 +167,53 @@
- - + +
- +
+ + +
+
- +
- - - - - - - + + + +
+ +
- + +
- +
@@ -336,7 +348,7 @@ // The tooltip (title + hover label) always carries the last confirmed save time. const hms=d=>d?d.toTimeString().slice(0,8):''; const BLOCKED='Not saved — this browser blocks storage for local files (Safari does, on file://). Press ⌘S to save a copy you can reopen, or open this deck in Chrome.'; -const autosave=(st,msg)=>{const a=$('autosave');if(!a)return;a.dataset.state=st;const txt=msg||{ok:fileAt?'Saved to file · '+hms(fileAt):'Autosaved · '+hms(lastSavedAt),local:'Saved in this browser · '+unsynced+' edit'+(unsynced===1?'':'s')+' not in the file yet — ⌘S writes them',bad:CANSTORE?'Not saved — edits will be lost on refresh'+(lastSavedAt?' (last saved '+hms(lastSavedAt)+')':''):BLOCKED,busy:'Saving…'}[st];a.title=txt;a.dataset.tip=txt;a.setAttribute('aria-label',txt)}; +const autosave=(st,msg)=>{const a=$('autosave');if(!a)return;a.dataset.state=st;const txt=msg||{ok:fileAt?'Saved to file · '+hms(fileAt):'Autosaved · '+hms(lastSavedAt),local:'Saved in this browser · '+unsynced+' edit'+(unsynced===1?'':'s')+' not in the file yet — ⌘S writes them',bad:CANSTORE?'Not saved — edits will be lost on refresh'+(lastSavedAt?' (last saved '+hms(lastSavedAt)+')':''):BLOCKED,busy:'Saving…'}[st];a.dataset.full=txt;const b=$('vers');b.dataset.tip='Versions · '+{ok:fileAt?'saved to file '+hms(fileAt):'autosaved '+hms(lastSavedAt),local:'saved in this browser, not in the file',bad:'not saved',busy:'saving…'}[st];b.setAttribute('aria-label',b.dataset.tip);a.setAttribute('aria-label',txt);if($('versmenu').classList.contains('open'))versRender()}; // save: every mutation lands here. base = the deck as last saved; the diff since then is appended to the log (stamped with // the file rev it was made on — an entry with no rev has not reached a file yet), then model + log + versions persist, then // the linked file is rewritten when there is one. `unsynced` = entries no file carries yet: the dot turns amber until ⌘S. @@ -837,7 +849,6 @@ snap();del.forEach(n=>deck.slides.splice(n,1));i=Math.min(del[del.length-1],deck.slides.length-1);gsel=new Set([i]);ganchor=i;save();sheetRender();return true} if(mod&&k==='c'&&gsel.size){clip=[...gsel].sort((a,b)=>a-b).map(n=>dup(deck.slides[n]));return true} if(mod&&k==='v'&&clip){snap();const at=Math.max(...gsel,-1)+1;deck.slides.splice(at,0,...clip.map(dup));gsel=new Set(clip.map((_,x)=>at+x));i=at;save();sheetRender();return true} - if(mod&&k==='d'&&gsel.size){snap();const src=[...gsel].sort((a,b)=>a-b),at=src[src.length-1]+1;deck.slides.splice(at,0,...src.map(n=>dup(deck.slides[n])));gsel=new Set(src.map((_,x)=>at+x));i=at;save();sheetRender();return true} if(mod&&k==='z'){undo();gsel=new Set([Math.min(i,deck.slides.length-1)]);sheetRender();return true} return false; } @@ -856,8 +867,10 @@ snap();[...sel].filter(k=>typeof k!=='number').forEach(k=>(slide().hide=slide().hide||[]).push(k.slice(2))); [...sel].filter(k=>typeof k==='number').sort((a,b)=>b-a).forEach(n=>slide().els.splice(n,1));sel.clear();save();render(); } + if(e.metaKey||e.ctrlKey||e.altKey)return; // plain letters only: ⌘C stays the browser's copy if(e.key.toLowerCase()==='f')fs(); - if(e.key.toLowerCase()==='g')sheetOpen(); + if(e.key.toLowerCase()==='g'){commitEdit();setSnap(!SNAP)} + if(e.key.toLowerCase()==='c')sheetOpen(); }); // arrow nudge: EVERY selected row travels by the same fixed delta (1px, ⇧ 10px) so a group stays rigid — the keyboard // twin of drag through moveBy: connector far ends travel too, master rows fork first. One undo snapshot per burst (an 800ms pause starts a new one). @@ -957,9 +970,9 @@ const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([Uint8Array.from(out,ch=>ch.charCodeAt(0))],{type:'application/pdf'}));a.download=document.title.replace(/[\/:*?"<>|]+/g,'-')+'.pdf';a.click(); } $('pdf').onclick=()=>exportPdf().catch(()=>print()); -const setSpell=on=>{SPELL=on;store.set(SKEY,on?'1':'0');const b=$('spell');b.classList.toggle('off',!on);b.setAttribute('aria-pressed',on);b.title='Spellcheck ('+(on?'on':'off')+')';b.setAttribute('aria-label',b.title);render()}; +const setSpell=on=>{SPELL=on;store.set(SKEY,on?'1':'0');const b=$('spell');b.classList.toggle('off',!on);b.setAttribute('aria-pressed',on);b.dataset.tip='Spellcheck · '+(on?'on':'off');b.setAttribute('aria-label',b.dataset.tip);render()}; $('spell').onclick=()=>{commitEdit();setSpell(!SPELL)};if(!SPELL)setSpell(false); -const setSnap=on=>{SNAP=on;store.set(GKEY,on?'1':'0');const b=$('snap');b.setAttribute('aria-pressed',on);b.title='Layout guides + snap ('+(on?'on':'off')+')';b.setAttribute('aria-label',b.title);render()}; +const setSnap=on=>{SNAP=on;store.set(GKEY,on?'1':'0');const b=$('snap');b.setAttribute('aria-pressed',on);b.dataset.tip='Guides + snap · '+(on?'on':'off')+' · G';b.setAttribute('aria-label',b.dataset.tip);render()}; $('snap').onclick=()=>{commitEdit();setSnap(!SNAP)};if(SNAP)setSnap(true); canvas.lang=deck.lang||'en'; // the dictionary the browser picks; deck.lang is optional model data $('savecopy').onclick=()=>saveCopy(); // only visible when the browser blocks storage — then it is the only way to keep an edit @@ -974,13 +987,18 @@ 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('#versmenu,#vers'))vers(false)}); -$('autosave').onclick=()=>saveFile(); // the dot is the other door to ⌘S: link the file, write it +// duplicate: selected rows (offset 16px, the copies become the selection) · else the 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()}; // fileHtml(): THIS document with the CURRENT model, log and versions 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(){ commitEdit();const d=document.documentElement.cloneNode(true),q=s=>d.querySelector(s); d.removeAttribute('style');q('body').removeAttribute('style');q('body').className='';d.querySelectorAll('#hud :disabled').forEach(b=>b.disabled=false);['#canvas','#grid','#print','#versmenu'].forEach(s=>q(s).innerHTML=''); - q('#snap').setAttribute('aria-pressed','false');q('#snap').title='Layout guides + snap (off)';q('#snap').setAttribute('aria-label',q('#snap').title); // guides are browser state: a copy opens with them off + q('#snap').setAttribute('aria-pressed','false');q('#snap').dataset.tip='Guides + snap · off · G';q('#snap').setAttribute('aria-label',q('#snap').dataset.tip); // guides are browser state: a copy opens with them off q('#tb').hidden=q('#sheet').hidden=true;q('#addmenu').classList.remove('open');q('#helpmenu').classList.remove('open');q('#versmenu').classList.remove('open'); const J=v=>JSON.stringify(v).replace(/<\/script/gi,'<\\/script'),sc=q('script'); sc.textContent=sc.textContent @@ -1030,9 +1048,9 @@ function restore(n){const v=versions[n];if(!v)return;commitEdit();pin('before restore');snap();const rev=deck.rev;deck=JSON.parse(JSON.stringify(v.deck));deck.rev=rev;sel.clear();i=Math.min(i,deck.slides.length-1);save();render();vers(false)} const versmenu=$('versmenu'),versbtn=$('vers'); const vers=open=>{versmenu.classList.toggle('open',open);versbtn.setAttribute('aria-expanded',open);if(open)versRender()}; -function versRender(){versmenu.innerHTML=versions.length?[...versions].map((v,n)=>[v,n]).reverse().map(([v,n])=>`
${new Date(v.t).toLocaleString()} · ${v.by} · ${(v.label||'').replace(/[<>&]/g,'')}
`).join('')+`
now
`:`
No versions yet
`} +function versRender(){versmenu.innerHTML='
'+($('autosave').dataset.full||'')+'
'+(versions.length?[...versions].map((v,n)=>[v,n]).reverse().map(([v,n])=>`
${new Date(v.t).toLocaleString()} · ${v.by} · ${(v.label||'').replace(/[<>&]/g,'')}
`).join('')+`
now
`:`
No versions yet
`)} versbtn.onclick=()=>vers(!versmenu.classList.contains('open')); -versmenu.onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.pin){pin('pin');return}restore(+b.dataset.restore)}; +versmenu.onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.save){vers(false);saveFile();return}if(b.dataset.pin){pin('pin');return}restore(+b.dataset.restore)}; // add text + box: new text is born Body — or binds to a free slot of the slide's layout (inheriting its role + geometry) const TPL={ text:{x:Math.round(W*.4),y:Math.round(H*.46),w:Math.round(W*.31),role:'Body',text:'New text — double-click to edit'}, diff --git a/test/editor.test.mjs b/test/editor.test.mjs index e277658..3908632 100644 --- a/test/editor.test.mjs +++ b/test/editor.test.mjs @@ -188,7 +188,7 @@ live('versions: pin keeps a snapshot, restore brings it back (and pins the state await p.evaluate(() => pin('first')); assert.equal(await p.evaluate(() => versions.length), 1); await p.evaluate(() => { snap(); slide().els[0].text = 'Changed'; save(); }); await p.evaluate(() => document.getElementById('vers').click()); - assert.equal(await p.evaluate(() => document.querySelectorAll('#versmenu .v').length), 2, 'one version + the pin row'); + assert.equal(await p.evaluate(() => document.querySelectorAll('#versmenu .v').length), 3, 'the save row + one version + the pin row'); await p.evaluate(() => document.querySelector('#versmenu [data-restore="0"]').click()); assert.equal(await p.evaluate(() => slide().els[0].text), 'One', 'restored'); assert.deepEqual(await p.evaluate(() => versions.map(v => v.label)), ['first', 'before restore']); diff --git a/test/gate.test.mjs b/test/gate.test.mjs index e382c67..aec11b0 100644 --- a/test/gate.test.mjs +++ b/test/gate.test.mjs @@ -82,7 +82,7 @@ test('roles are the type system: eight complete roles in the template, locked ke const m = modelOf(tpl); assert.deepEqual(Object.keys(m.styles.roles), ROLES); for (const r of Object.values(m.styles.roles)) for (const p of ['font', 'size', 'weight', 'lh', 'color']) assert.ok(r[p] != null, p); // the template's own roles carry lh - assert.match(tpl, /data-cmd="bold"[\s\S]*data-cmd="italic"[\s\S]*data-cmd="underline"[\s\S]*data-cmd="strikeThrough"/); assert.doesNotMatch(tpl, /data-cmd="(subscript|superscript)"/, 'no sub/sup buttons'); assert.doesNotMatch(tpl, /tb-fill|dataset\.fill|data-fill|deckBgs/, 'no box-fill feature'); assert.match(tpl, /#tb \.seg\{display:inline-flex;align-items:center;gap:2px;padding:2px 4px 2px 0;/, 'segments are one centred line box with symmetric top/bottom padding'); assert.match(tpl, /#tb \.sw\{width:16px;height:16px;padding:0;margin:0;vertical-align:middle;/, 'swatches symmetric'); assert.match(tpl, /select text<\/kbd> → roles · B I U S̶ · link · color<\/div>/, 'popover toolbar line'); assert.doesNotMatch(tpl.match(/
\s*<\/div>/)[0], /fill/i, 'no "fill" in the popover'); + assert.match(tpl, /data-cmd="bold"[\s\S]*data-cmd="italic"[\s\S]*data-cmd="underline"[\s\S]*data-cmd="strikeThrough"/); assert.doesNotMatch(tpl, /data-cmd="(subscript|superscript)"/, 'no sub/sup buttons'); assert.doesNotMatch(tpl, /tb-fill|dataset\.fill|data-fill|deckBgs/, 'no box-fill feature'); assert.match(tpl, /#tb \.seg\{display:inline-flex;align-items:center;gap:2px;padding:2px 4px 2px 0;/, 'segments are one centred line box with symmetric top/bottom padding'); assert.match(tpl, /#tb \.sw\{width:16px;height:16px;padding:0;margin:0;vertical-align:middle;/, 'swatches symmetric'); assert.match(tpl, /double-click<\/kbd> edit text · select text → roles · B I U S̶ · link · color<\/div>/, 'popover toolbar line'); assert.doesNotMatch(tpl.match(/
\s*<\/div>/)[0], /fill/i, 'no "fill" in the popover'); assert.match(tpl, /\.el sub,\.el sup\{font-size:inherit;line-height:0/, 'sub/sup never change size'); assert.match(tpl, /const LOCK=\['font','size','lh','ls'\]/); assert.match(tpl, /if\(r\[k\]==null\|\|LOCK\.includes\(k\)\)r\[k\]=t\[k\]/, 'role always wins the locked keys'); assert.doesNotMatch(tpl, / { - assert.match(tpl, /