feat(entries): encrypt template at rest, guided tour, import fixes, cleanup

Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.

- feat: encrypt `template` metadata at rest (template_enc/iv, added to
  ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
  partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
  Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
  re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
  time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
  PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
  toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
  style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-11 18:32:37 +01:00
parent 92ed153bc0
commit a42e4b205d
15 changed files with 381 additions and 2173 deletions
+113
View File
@@ -348,6 +348,119 @@ function closeCheatsheet() {
document.getElementById('cheatsheetModal').classList.add('is-hidden');
}
// ============================================================
// Guided tour ("How it works") — spotlights real UI elements with a
// bubble, no GIFs. Cheaper than baking videos into assets.res and never
// goes stale when the UI changes. Auto-runs once, re-launchable from Settings.
// ============================================================
const TOUR_STEPS = [
{ sel: '#searchInput', title: 'Search',
body: 'Find any entry instantly (Ctrl+K). Anywhere in Windows, press Ctrl+Shift+Q for quick search — copy or autofill without opening the app.' },
{ sel: '#newEntryBtn', title: 'Add entries',
body: 'Create a login, secure note, card, SSH key and more. The ▾ caret picks the type.' },
{ sel: '.view-toggle', title: 'Views',
body: 'Switch between cards, list and table. Your choice is remembered.' },
{ sel: '.sidebar-section[data-section="tools"]', title: 'Tools',
body: 'Authenticator (2FA codes), Vault health score and the password generator live here.' },
{ sel: '#settingsBtn', title: 'Settings',
body: 'WebDAV sync, encrypted auto-backup, autofill hotkeys (Ctrl+Shift+L) and security options.' },
{ sel: '#cheatsheetBtn', title: 'Shortcuts',
body: 'Every keyboard shortcut, anytime — or just press ?.' },
];
let tourIdx = -1;
function tourSeen() {
return (Bridge.active ? null : localStorage.getItem('tourSeen')) === '1';
}
function markTourSeen() {
if (Bridge.active) Bridge.setPref('tourSeen', '1');
localStorage.setItem('tourSeen', '1'); // fast path + fallback
}
// Auto-launch on first unlock. Bridge pref is the source of truth (survives
// the port-rotation localStorage wipe); fall back to localStorage when no Bridge.
async function maybeStartTour() {
let seen = localStorage.getItem('tourSeen') === '1';
if (Bridge.active) {
try { seen = (await Bridge.getPref('tourSeen')) === '1'; } catch (_) {}
}
if (!seen) setTimeout(startTour, 600); // let the app shell settle first
}
function startTour() {
tourIdx = 0;
let bd = document.getElementById('tourBackdrop');
if (!bd) {
bd = el('div', { id: 'tourBackdrop', class: 'tour-backdrop' });
const spot = el('div', { id: 'tourSpot', class: 'tour-spot' });
const bubble = el('div', { id: 'tourBubble', class: 'tour-bubble' });
document.body.append(bd, spot, bubble);
}
window.addEventListener('resize', showTourStep);
showTourStep();
}
function showTourStep() {
// Skip any step whose target isn't in the DOM (feature hidden/disabled).
while (tourIdx < TOUR_STEPS.length &&
!document.querySelector(TOUR_STEPS[tourIdx].sel)) tourIdx++;
if (tourIdx >= TOUR_STEPS.length) return endTour();
const step = TOUR_STEPS[tourIdx];
const target = document.querySelector(step.sel);
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
// Reposition after any scroll settles so the spotlight lands on the rect.
setTimeout(() => positionTour(target, step), 120);
}
function positionTour(target, step) {
const spot = document.getElementById('tourSpot');
const bubble = document.getElementById('tourBubble');
if (!spot || !bubble) return;
const r = target.getBoundingClientRect();
const pad = 6;
spot.style.top = (r.top - pad) + 'px';
spot.style.left = (r.left - pad) + 'px';
spot.style.width = (r.width + pad * 2) + 'px';
spot.style.height = (r.height + pad * 2) + 'px';
const last = tourIdx === TOUR_STEPS.length - 1;
bubble.innerHTML =
'<div class="tour-bubble-title">' + step.title + '</div>' +
'<div class="tour-bubble-body">' + step.body + '</div>' +
'<div class="tour-bubble-foot">' +
'<span class="tour-bubble-count">' + (tourIdx + 1) + ' / ' + TOUR_STEPS.length + '</span>' +
'<span class="tour-bubble-btns">' +
'<button class="btn btn-ghost btn-sm" id="tourSkip">Skip</button>' +
'<button class="btn btn-primary btn-sm" id="tourNext">' +
(last ? 'Done' : 'Next') + '</button>' +
'</span></div>';
bubble.querySelector('#tourSkip').onclick = endTour;
bubble.querySelector('#tourNext').onclick = () => { tourIdx++; showTourStep(); };
// Place the bubble below the target if there's room, else above.
bubble.style.visibility = 'hidden';
bubble.style.top = '0px'; bubble.style.left = '0px';
const bh = bubble.offsetHeight, bw = bubble.offsetWidth;
const gap = 12;
let top = r.bottom + gap;
if (top + bh > window.innerHeight - 8) top = Math.max(8, r.top - gap - bh);
let left = r.left;
if (left + bw > window.innerWidth - 8) left = window.innerWidth - 8 - bw;
bubble.style.top = Math.max(8, top) + 'px';
bubble.style.left = Math.max(8, left) + 'px';
bubble.style.visibility = '';
}
function endTour() {
tourIdx = -1;
window.removeEventListener('resize', showTourStep);
['tourBackdrop', 'tourSpot', 'tourBubble'].forEach(id => {
const n = document.getElementById(id);
if (n) n.remove();
});
markTourSeen();
}
function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput');