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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user