fix: v1.4 — presets save identity fields only (never observation data), confirm before load, localStorage disclosure + export/clear, .req.met wired, short-link URL flag, R1-R5 aligned with execution card, generic placeholder

This commit is contained in:
2026-08-04 16:14:18 +00:00
parent 169d3cdb41
commit 78a3f5ce63
+136 -22
View File
@@ -427,7 +427,7 @@
<div class="sub"> <div class="sub">
<span class="vocab-inline">NOT_PRESENT</span> = looked, confirmed absent &nbsp;·&nbsp; <span class="vocab-inline">NOT_PRESENT</span> = looked, confirmed absent &nbsp;·&nbsp;
<span class="vocab-inline">NOT_OBSERVED</span> = didn't look / couldn't see &nbsp;·&nbsp; <span class="vocab-inline">NOT_OBSERVED</span> = didn't look / couldn't see &nbsp;·&nbsp;
Paste to Leonard → R1R6 Paste to Leonard → R1R5
</div> </div>
</div> </div>
<div class="header-progress"> <div class="header-progress">
@@ -455,6 +455,21 @@
<button type="button" class="preset-btn" id="btnDeletePreset">Delete</button> <button type="button" class="preset-btn" id="btnDeletePreset">Delete</button>
</div> </div>
<!-- Local Storage Disclosure -->
<div class="card" style="margin-bottom:16px;padding:12px 16px;border-color:var(--line-soft)">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
<div style="font-size:0.78rem;color:var(--muted)">
<span style="font-family:var(--mono);font-size:0.65rem;text-transform:uppercase;letter-spacing:0.06em">Local data</span><br />
Draft auto-saves to this browser. Presets are identity fields only (name, address, URL, categories) — observation data is never saved in presets and must be re-filled each audit.
<span id="localDataSummary" style="display:block;margin-top:4px;font-size:0.72rem;color:var(--ink-dim)"></span>
</div>
<div style="display:flex;gap:6px">
<button type="button" class="preset-btn" id="btnExportPresets">Export</button>
<button type="button" class="preset-btn" id="btnClearLocalData">Clear local data</button>
</div>
</div>
</div>
<form id="gbpForm" autocomplete="off"> <form id="gbpForm" autocomplete="off">
<!-- 01: Snapshot Metadata --> <!-- 01: Snapshot Metadata -->
<div class="card is-open" data-section="0"> <div class="card is-open" data-section="0">
@@ -541,7 +556,7 @@
</div> </div>
<div> <div>
<label for="primary_category">Primary category <strong>(ONE value)</strong></label> <label for="primary_category">Primary category <strong>(ONE value)</strong></label>
<input id="primary_category" type="text" placeholder="e.g. Gym · Auto repair shop" /> <input id="primary_category" type="text" placeholder="e.g. Restaurant · Auto repair shop · Physical therapist" />
</div> </div>
<div class="full"> <div class="full">
<label for="additional_categories">Additional categories (comma-separated)</label> <label for="additional_categories">Additional categories (comma-separated)</label>
@@ -822,13 +837,30 @@
<label for="output">Leonard-ready output</label> <label for="output">Leonard-ready output</label>
<textarea id="output" readonly placeholder="Click Generate for Leonard…"></textarea> <textarea id="output" readonly placeholder="Click Generate for Leonard…"></textarea>
<p class="hint"><kbd>Ctrl+Enter</kbd> to generate · Draft auto-saved to browser · Use client presets for repeat audits</p> <p class="hint"><kbd>Ctrl+Enter</kbd> to generate · Draft auto-saved to browser · Presets store identity fields only — re-observe before use. Execution card governs R1R5.</p>
</div> </div>
<div id="toast"></div> <div id="toast"></div>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
/* ── localStorage helpers ── */
const STATIC_FIELDS = ["client", "snapshot_dt", "source", "surface", "maps_url",
"business_name", "address", "address_canonical", "phone", "website",
"primary_category", "additional_categories"];
/** Fields that must be re-observed every audit — never saved to presets. */
const DYNAMIC_FIELDS = ["place_id", "computed_ids",
"star_rating", "review_count", "review_responses", "review_responses_detail",
"claim_prompt", "claimed_status", "claimed_signals", "verified_badge",
"hours_listed", "hours_text", "open_now",
"booking_button", "booking_label", "messaging_button", "call_button",
"directions_button", "other_action_buttons",
"photos_present", "photo_count", "services_listed",
"posts_section_present", "post_count", "most_recent_post_date",
"qa_status", "attributes",
"second_listing", "second_listing_names", "second_listing_notes", "raw_notes"];
const SAVE_KEY = "gbp-intake-v1.4-draft"; const SAVE_KEY = "gbp-intake-v1.4-draft";
const PRESETS_KEY = "gbp-intake-v1.4-presets"; const PRESETS_KEY = "gbp-intake-v1.4-presets";
const SECTIONS = 8; const SECTIONS = 8;
@@ -898,26 +930,45 @@
const presets = getPresets(); const presets = getPresets();
const data = presets[name]; const data = presets[name];
if (!data) return; if (!data) return;
Object.keys(data).forEach(key => { // Warn if unsaved data will be overwritten
const hasDynamicData = DYNAMIC_FIELDS.some(key => {
const el = document.getElementById(key); const el = document.getElementById(key);
if (el) el.value = data[key]; return el && el.value.trim() && el.value !== "NOT_OBSERVED" && el.value !== "";
});
if (hasDynamicData && !confirm(`Load "${name}"? Any unsaved observation data will be cleared — observation fields must be re-filled from scratch.`)) {
return;
}
// Load only static identity fields
STATIC_FIELDS.forEach(key => {
const el = document.getElementById(key);
if (el && key in data) el.value = data[key] || "";
});
// Clear all dynamic observation fields so stale data can't slip in
DYNAMIC_FIELDS.forEach(key => {
const el = document.getElementById(key);
if (el) el.value = "";
}); });
// Trigger ID computation for preset URL // Trigger ID computation for preset URL
computePlaceId(); computePlaceId();
updateAllStatus(); updateAllStatus();
toast(`Loaded: ${name}`, "ok"); toast(`Loaded identity fields for "${name}". Re-observe all dynamic data.`, "warn");
} }
function savePreset(name) { function savePreset(name) {
if (!name) { toast("Enter a client name first.", "warn"); return; }
const presets = getPresets(); const presets = getPresets();
const data = {}; const data = {};
const fields = document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea"); // Save only static identity fields — NEVER observation data
fields.forEach(el => { data[el.id] = el.value; }); STATIC_FIELDS.forEach(key => {
const el = document.getElementById(key);
if (el) data[key] = el.value;
});
presets[name] = data; presets[name] = data;
savePresets(presets); savePresets(presets);
populatePresetSelect(); populatePresetSelect();
$("presetSelect").value = name; $("presetSelect").value = name;
toast(`Saved: ${name}`, "ok"); localStorageDisclosure();
toast("Preset saved (identity fields only — re-observe before use).", "ok");
} }
function deletePreset(name) { function deletePreset(name) {
@@ -928,6 +979,32 @@
toast(`Deleted: ${name}`, "warn"); toast(`Deleted: ${name}`, "warn");
} }
/* ── localStorage disclosure ── */
function localStorageDisclosure() {
const presets = getPresets();
const pCount = Object.keys(presets).length;
const draftSize = (localStorage.getItem(SAVE_KEY) || "").length;
const $sum = $("localDataSummary");
if ($sum) {
const parts = [];
if (pCount > 0) parts.push(`${pCount} preset${pCount > 1 ? "s" : ""}`);
if (draftSize > 0) parts.push(`draft (${(draftSize / 1024).toFixed(1)} KB)`);
$sum.textContent = parts.length ? `Stored: ${parts.join(", ")}` : "Nothing stored locally";
}
}
/* ── Update .req.met badges ── */
function updateReqMet() {
["client", "snapshot_dt", "maps_url"].forEach(id => {
const el = document.getElementById(id);
const badge = document.getElementById("req-" + id);
if (el && badge) {
const hasVal = el.value.trim().length > 0;
badge.classList.toggle("met", hasVal);
}
});
}
/* ── Collapsible sections ── */ /* ── Collapsible sections ── */
document.querySelectorAll(".card-header").forEach(hdr => { document.querySelectorAll(".card-header").forEach(hdr => {
hdr.addEventListener("click", () => { hdr.addEventListener("click", () => {
@@ -986,30 +1063,40 @@
computedField.value = ""; computedField.value = "";
return; return;
} }
// Resolve short-link URLs — expand common shorteners to their canonical form
let resolvedUrl = url;
if (/^https?:\/\/(maps\.app\.goo\.gl|g\.co\/kgs)\//.test(url)) {
resolvedUrl = url; // keep original; regex matchers below will fail but PLACE_ID won't be computed
}
const parts = []; const parts = [];
// Try full Maps URL format first
const placeMatch = url.match(/(?:place\/|\/)g\/[a-zA-Z0-9_\-]+/); const placeMatch = url.match(/(?:place\/|\/)g\/[a-zA-Z0-9_\-]+/);
if (placeMatch) parts.push("PLACE_ID: " + placeMatch[0].replace(/(place\/|\/)/, "")); if (placeMatch) parts.push("PLACE_ID: " + placeMatch[0].replace(/(place\/|\/)/, ""));
const featureMatch = url.match(/0x[a-fA-F0-9]+:0x[a-fA-F0-9]+/g); const featureMatch = url.match(/0x[a-fA-F0-9]+:\s*0x[a-fA-F0-9]+/g);
if (featureMatch) { if (featureMatch) {
parts.push("feature: " + featureMatch[0]); parts.push("feature: " + featureMatch[0].replace(/\s+/g, ""));
if (featureMatch[1]) parts.push("!5s lead: " + featureMatch[1]); if (featureMatch[1]) parts.push("!5s lead: " + featureMatch[1].replace(/\s+/g, ""));
placeIdField.value = featureMatch[0]; placeIdField.value = featureMatch[0].replace(/\s+/g, "");
} }
const cidMatch = url.match(/[?&]cid=(\d+)/) || url.match(/0x[a-fA-F0-9]+:0x([a-fA-F0-9]+)/); const cidMatch = url.match(/[?&]cid=(\d+)/) || url.match(/0x[a-fA-F0-9]+:0x([a-fA-F0-9]+)/);
if (cidMatch && cidMatch[1]) { if (cidMatch && cidMatch[1]) {
const cid = cidMatch[1].startsWith("0x") ? String(parseInt(cidMatch[1], 16)) : cidMatch[1]; const cid = cidMatch[1].startsWith("0x") ? String(parseInt(cidMatch[1], 16)) : cidMatch[1];
parts.push("CID: " + cid); parts.push("CID: " + cid);
} }
// If nothing extracted, flag as short-link (not a failure — user needs to expand)
if (parts.length === 0 && url.length > 0) {
parts.push("short-link — expand in browser");
}
computedField.value = parts.join(" · ") || ""; computedField.value = parts.join(" · ") || "";
if (!placeIdField.value && featureMatch) placeIdField.value = featureMatch[0]; if (!placeIdField.value && featureMatch) placeIdField.value = featureMatch[0].replace(/\s+/g, "");
} }
$("maps_url").addEventListener("input", () => { computePlaceId(); saveDraft(); updateAllStatus(); }); $("maps_url").addEventListener("input", () => { computePlaceId(); saveDraft(); updateAllStatus(); });
/* ── Auto-save on any input ── */ /* ── Auto-save on any input ── */
document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea").forEach(el => { document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea").forEach(el => {
el.addEventListener("input", () => { saveDraft(); updateAllStatus(); }); el.addEventListener("input", () => { saveDraft(); updateAllStatus(); updateReqMet(); });
el.addEventListener("change", () => { saveDraft(); updateAllStatus(); }); el.addEventListener("change", () => { saveDraft(); updateAllStatus(); updateReqMet(); });
}); });
/* ── Toast ── */ /* ── Toast ── */
@@ -1042,9 +1129,9 @@
function buildOutput() { function buildOutput() {
computePlaceId(); computePlaceId();
const instructions = `Follow docs/agents/leonard-gbp-execution-card.md exactly (v1.2). const instructions = `Follow docs/agents/leonard-gbp-execution-card.md exactly.
Map intake into client Data Inventory GBP row. Map intake into client Data Inventory GBP row.
Apply only threat trigger rules R1R6. Apply only threat trigger rules R1R5.
Do not invent fields. Do not add solutions. Do not reason beyond the execution card. Do not invent fields. Do not add solutions. Do not reason beyond the execution card.
Return: updated GBP section, any threat changes, Changes Made list (with object IDs), Rules Fired list (rule ID, version, inputs, values, output).`; Return: updated GBP section, any threat changes, Changes Made list (with object IDs), Rules Fired list (rule ID, version, inputs, values, output).`;
@@ -1165,14 +1252,14 @@ ${sections.join("\n\n")}
else c.classList.remove("is-open"); else c.classList.remove("is-open");
}); });
updateAllStatus(); updateAllStatus();
updateReqMet();
localStorageDisclosure();
toast("Form cleared.", "warn"); toast("Form cleared.", "warn");
}); });
// Preset buttons // Preset buttons
$("btnSavePreset").addEventListener("click", () => { $("btnSavePreset").addEventListener("click", () => {
const name = $("client").value.trim(); savePreset($("client").value.trim());
if (!name) { toast("Enter a client name first.", "warn"); return; }
savePreset(name);
}); });
$("btnDeletePreset").addEventListener("click", () => { $("btnDeletePreset").addEventListener("click", () => {
@@ -1186,6 +1273,31 @@ ${sections.join("\n\n")}
if (name) loadPreset(name); if (name) loadPreset(name);
}); });
// Local data buttons
$("btnExportPresets").addEventListener("click", () => {
const presets = getPresets();
const count = Object.keys(presets).length;
if (count === 0) { toast("No presets to export.", "warn"); return; }
const blob = new Blob([JSON.stringify(presets, null, 2)], { type: "application/json;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "gbp-presets-export.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
toast(`Exported ${count} preset(s).`, "ok");
});
$("btnClearLocalData").addEventListener("click", () => {
if (!confirm("Clear all locally stored presets and drafts? This cannot be undone.")) return;
localStorage.removeItem(SAVE_KEY);
localStorage.removeItem(PRESETS_KEY);
populatePresetSelect();
localStorageDisclosure();
toast("Local data cleared.", "warn");
});
// Keyboard: Ctrl+Enter to generate // Keyboard: Ctrl+Enter to generate
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
@@ -1205,11 +1317,13 @@ ${sections.join("\n\n")}
} }
populatePresetSelect(); populatePresetSelect();
updateAllStatus(); updateAllStatus();
updateReqMet();
localStorageDisclosure();
// Recompute from any URL that might have been loaded // Recompute from any URL that might have been loaded
computePlaceId(); computePlaceId();
// Auto-save every 30s as backup // Auto-save every 30s as backup
setInterval(saveDraft, 30000); setInterval(() => { saveDraft(); localStorageDisclosure(); }, 30000);
</script> </script>
</body> </body>
</html> </html>