<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Voice Calibration — sfpermits.ai</title>
<meta name="csrf-token" content="{{ csrf_token }}">
<script nonce="{{ csp_nonce }}" src="/static/htmx.min.js"></script>
<script nonce="{{ csp_nonce }}">
document.addEventListener('htmx:configRequest', function(e) {
var token = document.querySelector('meta[name="csrf-token"]');
if (token) e.detail.headers['X-CSRFToken'] = token.getAttribute('content');
});
</script>
<style nonce="{{ csp_nonce }}">
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface-2: #252834;
--border: #333749;
--text: #e4e6eb;
--text-muted: #8b8fa3;
--accent: #4f8ff7;
--success: #34d399;
--warning: #fbbf24;
--error: #f87171;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 960px; margin: 0 auto; padding: 0 24px; }
header { border-bottom: 1px solid var(--border); padding: 20px 0; }
header .container { display: flex; align-items: center; justify-content: space-between; }
.logo { font-size: 1.4rem; font-weight: 700; color: var(--accent); text-decoration: none; }
.logo span { color: var(--text-muted); font-weight: 400; }
.back-link { color: var(--text-muted); text-decoration: none; font-size: 0.85rem; }
.back-link:hover { color: var(--text); }
main { padding: 40px 0 120px; }
h1 { font-size: 1.5rem; margin-bottom: 4px; }
.subtitle { color: var(--text-muted); margin-bottom: 8px; font-size: 0.95rem; }
.progress-bar {
margin-bottom: 28px; font-size: 0.85rem; color: var(--text-muted);
display: flex; align-items: center; gap: 12px;
}
.progress-bar .track {
flex: 1; max-width: 240px; height: 6px; background: var(--surface-2);
border-radius: 3px; overflow: hidden;
}
.progress-bar .fill {
height: 100%; background: var(--success); border-radius: 3px;
transition: width 0.3s ease;
}
/* Audience group */
.audience-group { margin-bottom: 12px; }
.audience-group > summary {
cursor: pointer; font-size: 1.1rem; font-weight: 600;
padding: 12px 0; color: var(--text); list-style: none;
display: flex; align-items: center; gap: 10px;
}
.audience-group > summary::-webkit-details-marker { display: none; }
.audience-group > summary::before {
content: "▶"; font-size: 0.7rem; color: var(--text-muted);
transition: transform 0.2s;
}
.audience-group[open] > summary::before { transform: rotate(90deg); }
.audience-count {
font-size: 0.75rem; color: var(--text-muted); font-weight: 400;
}
/* Scenario card */
.scenario-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; margin-bottom: 16px;
margin-left: 16px;
}
.scenario-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px; gap: 8px; flex-wrap: wrap;
}
.scenario-label {
font-weight: 600; font-size: 1rem;
display: flex; align-items: center; gap: 8px;
}
.cal-badge {
font-size: 0.72rem; font-weight: 600;
}
.cal-badge.done { color: var(--success); }
.cal-badge.pending { color: var(--text-muted); }
.context-hint {
font-size: 0.85rem; color: var(--text-muted); font-style: italic;
margin-bottom: 14px;
}
/* Two-column layout */
.columns {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
margin-bottom: 12px;
}
@media (max-width: 700px) {
.columns { grid-template-columns: 1fr; }
}
.col-label {
font-size: 0.78rem; color: var(--text-muted); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px;
}
.template-text {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: 8px; padding: 12px; font-size: 0.85rem;
line-height: 1.6; color: var(--text-muted); white-space: pre-wrap;
max-height: 300px; overflow-y: auto;
}
.user-textarea {
width: 100%; min-height: 200px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 12px; font-family: inherit;
font-size: 0.85rem; line-height: 1.6; resize: vertical;
}
.user-textarea:focus {
outline: none; border-color: var(--accent);
}
/* Actions */
.card-actions {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 6px; padding: 8px 20px; border-radius: 8px;
font-size: 0.85rem; font-weight: 600; cursor: pointer;
text-decoration: none; border: none;
background: var(--accent); color: #fff;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.9; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-outline {
background: transparent; border: 1px solid var(--border);
color: var(--text-muted); font-weight: 500;
}
.btn-outline:hover { border-color: var(--text-muted); }
.card-status { font-size: 0.82rem; margin-left: 4px; }
/* Style notes display */
.style-notes {
margin-top: 12px; padding: 10px 14px;
background: rgba(52,211,153,0.08); border-radius: 8px;
border: 1px solid rgba(52,211,153,0.2);
font-size: 0.82rem; color: var(--text);
}
.style-notes-label {
font-size: 0.72rem; font-weight: 600; color: var(--success);
text-transform: uppercase; margin-bottom: 4px;
}
/* Jump navigation */
.jump-nav {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding: 10px 0;
margin-bottom: 20px;
position: sticky;
top: 0;
z-index: 100;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.jump-pill {
font-size: 0.78rem;
padding: 5px 14px;
border-radius: 16px;
background: var(--surface-2);
color: var(--text-muted);
text-decoration: none;
border: 1px solid var(--border);
white-space: nowrap;
transition: all 0.15s;
}
.jump-pill:hover {
color: var(--text);
border-color: var(--text-muted);
}
.jump-pill.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Fixed footer */
.fixed-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface);
border-top: 1px solid var(--border);
padding: 10px 0;
z-index: 100;
}
</style>
<link rel="stylesheet" href="/static/mobile.css">
</head>
<body>
<header>
<div class="container">
<a href="/" class="logo">sfpermits<span>.ai</span></a>
<a href="/account" class="back-link">← Back to Account</a>
</div>
</header>
<main>
<div class="container">
<h1>Voice Calibration</h1>
<p class="subtitle">
Rewrite these templates in your voice. I'll learn your style for each situation.
</p>
<div class="progress-bar">
<span id="cal-progress">{{ stats.calibrated }} of {{ stats.total }} done</span>
<div class="track">
<div class="fill" style="width: {{ (stats.calibrated / stats.total * 100) if stats.total > 0 else 0 }}%;"></div>
</div>
{% if stats.calibrated == stats.total and stats.total > 0 %}
<span style="color:var(--success);">All done!</span>
{% endif %}
</div>
<div class="jump-nav" id="jump-nav">
{% for aud in audiences %}
{% if grouped.get(aud.key, []) %}
<a href="#aud-{{ aud.key }}" class="jump-pill">{{ aud.label }}</a>
{% endif %}
{% endfor %}
{% if grouped.get("general", []) %}
<a href="#aud-general" class="jump-pill">General</a>
{% endif %}
</div>
{% for aud in audiences %}
{% set aud_cals = grouped.get(aud.key, []) %}
{% if aud_cals %}
<details class="audience-group" id="aud-{{ aud.key }}" {% if loop.first %}open{% endif %}>
<summary>
{{ aud.label }}
<span class="audience-count">
({{ aud_cals | length }} scenario{{ 's' if aud_cals | length != 1 }})
— {{ aud.description }}
</span>
</summary>
{% for cal in aud_cals %}
<div class="scenario-card" id="card-{{ cal.scenario_key }}">
<div class="scenario-header">
<div class="scenario-label">
{{ cal.situation_label }}
<span class="cal-badge {{ 'done' if cal.is_calibrated else 'pending' }}"
id="badge-{{ cal.scenario_key }}">
{% if cal.is_calibrated %}✓ calibrated{% else %}○ not yet{% endif %}
</span>
</div>
</div>
<div class="context-hint">"{{ cal.context_hint }}"</div>
<div class="columns">
<div>
<div class="col-label">Template (generic)</div>
<div class="template-text">{{ cal.template_text }}</div>
</div>
<div>
<div class="col-label">Your version</div>
<textarea class="user-textarea"
id="ta-{{ cal.scenario_key }}"
placeholder="Rewrite this in your voice...">{{ cal.user_text or '' }}</textarea>
</div>
</div>
<div class="card-actions">
<button class="btn"
hx-post="/account/voice-calibration/save"
hx-target="#status-{{ cal.scenario_key }}"
hx-swap="innerHTML"
hx-include="[name='scenario_key_{{ cal.scenario_key }}']"
hx-vals='js:{"scenario_key":"{{ cal.scenario_key }}","user_text":document.getElementById("ta-{{ cal.scenario_key }}").value}'>
Save
</button>
{% if cal.is_calibrated %}
<button class="btn btn-outline"
hx-post="/account/voice-calibration/reset"
hx-target="#status-{{ cal.scenario_key }}"
hx-swap="innerHTML"
hx-vals='{"scenario_key":"{{ cal.scenario_key }}"}'>
Reset
</button>
{% endif %}
<span class="card-status" id="status-{{ cal.scenario_key }}"></span>
</div>
{% if cal.style_notes %}
<div class="style-notes">
<div class="style-notes-label">Extracted Style Notes</div>
{{ cal.style_notes }}
</div>
{% endif %}
</div>
{% endfor %}
</details>
{% endif %}
{% endfor %}
{# General / cross-audience scenarios #}
{% set general_cals = grouped.get("general", []) %}
{% if general_cals %}
<details class="audience-group" id="aud-general">
<summary>
General
<span class="audience-count">
({{ general_cals | length }} scenario{{ 's' if general_cals | length != 1 }})
— Cross-audience templates
</span>
</summary>
{% for cal in general_cals %}
<div class="scenario-card" id="card-{{ cal.scenario_key }}">
<div class="scenario-header">
<div class="scenario-label">
{{ cal.situation_label }}
<span class="cal-badge {{ 'done' if cal.is_calibrated else 'pending' }}"
id="badge-{{ cal.scenario_key }}">
{% if cal.is_calibrated %}✓ calibrated{% else %}○ not yet{% endif %}
</span>
</div>
</div>
<div class="context-hint">"{{ cal.context_hint }}"</div>
<div class="columns">
<div>
<div class="col-label">Template (generic)</div>
<div class="template-text">{{ cal.template_text }}</div>
</div>
<div>
<div class="col-label">Your version</div>
<textarea class="user-textarea"
id="ta-{{ cal.scenario_key }}"
placeholder="Rewrite this in your voice...">{{ cal.user_text or '' }}</textarea>
</div>
</div>
<div class="card-actions">
<button class="btn"
hx-post="/account/voice-calibration/save"
hx-target="#status-{{ cal.scenario_key }}"
hx-swap="innerHTML"
hx-vals='js:{"scenario_key":"{{ cal.scenario_key }}","user_text":document.getElementById("ta-{{ cal.scenario_key }}").value}'>
Save
</button>
{% if cal.is_calibrated %}
<button class="btn btn-outline"
hx-post="/account/voice-calibration/reset"
hx-target="#status-{{ cal.scenario_key }}"
hx-swap="innerHTML"
hx-vals='{"scenario_key":"{{ cal.scenario_key }}"}'>
Reset
</button>
{% endif %}
<span class="card-status" id="status-{{ cal.scenario_key }}"></span>
</div>
{% if cal.style_notes %}
<div class="style-notes">
<div class="style-notes-label">Extracted Style Notes</div>
{{ cal.style_notes }}
</div>
{% endif %}
</div>
{% endfor %}
</details>
{% endif %}
</div>
</main>
<div class="fixed-footer">
<div class="container" style="display:flex;justify-content:space-between;align-items:center;">
<a href="/account" style="color:var(--accent);text-decoration:none;font-size:0.85rem;">← Back to Account</a>
<a href="#" onclick="window.scrollTo({top:0,behavior:'smooth'});return false;" style="color:var(--text-muted);text-decoration:none;font-size:0.82rem;">Back to top</a>
</div>
</div>
<script nonce="{{ csp_nonce }}">
// Highlight active jump pill on scroll
(function() {
var pills = document.querySelectorAll('.jump-pill');
var groups = [];
pills.forEach(function(pill) {
var target = document.querySelector(pill.getAttribute('href'));
if (target) groups.push({pill: pill, el: target});
});
if (!groups.length) return;
function updateActive() {
var scrollTop = window.scrollY + 80;
var active = groups[0];
for (var i = 0; i < groups.length; i++) {
if (groups[i].el.offsetTop <= scrollTop) active = groups[i];
}
pills.forEach(function(p) { p.classList.remove('active'); });
if (active) active.pill.classList.add('active');
}
window.addEventListener('scroll', updateActive, {passive: true});
updateActive();
})();
</script>
</body>
</html>