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">
<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;
Paste to Leonard → R1R6
Paste to Leonard → R1R5
</div>
</div>
<div class="header-progress">
@@ -455,6 +455,21 @@
<button type="button" class="preset-btn" id="btnDeletePreset">Delete</button>
</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">
<!-- 01: Snapshot Metadata -->
<div class="card is-open" data-section="0">
@@ -541,7 +556,7 @@
</div>
<div>
<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 class="full">
<label for="additional_categories">Additional categories (comma-separated)</label>
@@ -822,13 +837,30 @@
<label for="output">Leonard-ready output</label>
<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 id="toast"></div>
<script>
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 PRESETS_KEY = "gbp-intake-v1.4-presets";
const SECTIONS = 8;
@@ -898,26 +930,45 @@
const presets = getPresets();
const data = presets[name];
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);
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
computePlaceId();
updateAllStatus();
toast(`Loaded: ${name}`, "ok");
toast(`Loaded identity fields for "${name}". Re-observe all dynamic data.`, "warn");
}
function savePreset(name) {
if (!name) { toast("Enter a client name first.", "warn"); return; }
const presets = getPresets();
const data = {};
const fields = document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea");
fields.forEach(el => { data[el.id] = el.value; });
// Save only static identity fields — NEVER observation data
STATIC_FIELDS.forEach(key => {
const el = document.getElementById(key);
if (el) data[key] = el.value;
});
presets[name] = data;
savePresets(presets);
populatePresetSelect();
$("presetSelect").value = name;
toast(`Saved: ${name}`, "ok");
localStorageDisclosure();
toast("Preset saved (identity fields only — re-observe before use).", "ok");
}
function deletePreset(name) {
@@ -928,6 +979,32 @@
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 ── */
document.querySelectorAll(".card-header").forEach(hdr => {
hdr.addEventListener("click", () => {
@@ -986,30 +1063,40 @@
computedField.value = "";
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 = [];
// Try full Maps URL format first
const placeMatch = url.match(/(?:place\/|\/)g\/[a-zA-Z0-9_\-]+/);
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) {
parts.push("feature: " + featureMatch[0]);
if (featureMatch[1]) parts.push("!5s lead: " + featureMatch[1]);
placeIdField.value = featureMatch[0];
parts.push("feature: " + featureMatch[0].replace(/\s+/g, ""));
if (featureMatch[1]) parts.push("!5s lead: " + featureMatch[1].replace(/\s+/g, ""));
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]+)/);
if (cidMatch && cidMatch[1]) {
const cid = cidMatch[1].startsWith("0x") ? String(parseInt(cidMatch[1], 16)) : cidMatch[1];
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(" · ") || "";
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(); });
/* ── Auto-save on any input ── */
document.querySelectorAll("#gbpForm input, #gbpForm select, #gbpForm textarea").forEach(el => {
el.addEventListener("input", () => { saveDraft(); updateAllStatus(); });
el.addEventListener("change", () => { saveDraft(); updateAllStatus(); });
el.addEventListener("input", () => { saveDraft(); updateAllStatus(); updateReqMet(); });
el.addEventListener("change", () => { saveDraft(); updateAllStatus(); updateReqMet(); });
});
/* ── Toast ── */
@@ -1042,9 +1129,9 @@
function buildOutput() {
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.
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.
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");
});
updateAllStatus();
updateReqMet();
localStorageDisclosure();
toast("Form cleared.", "warn");
});
// Preset buttons
$("btnSavePreset").addEventListener("click", () => {
const name = $("client").value.trim();
if (!name) { toast("Enter a client name first.", "warn"); return; }
savePreset(name);
savePreset($("client").value.trim());
});
$("btnDeletePreset").addEventListener("click", () => {
@@ -1186,6 +1273,31 @@ ${sections.join("\n\n")}
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
document.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
@@ -1205,11 +1317,13 @@ ${sections.join("\n\n")}
}
populatePresetSelect();
updateAllStatus();
updateReqMet();
localStorageDisclosure();
// Recompute from any URL that might have been loaded
computePlaceId();
// Auto-save every 30s as backup
setInterval(saveDraft, 30000);
setInterval(() => { saveDraft(); localStorageDisclosure(); }, 30000);
</script>
</body>
</html>