<!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"><!-- Remove after beta -->
<title>sfpermits.ai — SF Permit Intelligence</title>
{% include "fragments/head_obsidian.html" %}
<script nonce="{{ csp_nonce }}" src="/static/htmx.min.js"></script>
<style nonce="{{ csp_nonce }}">
/* ── Page body ───────────────────────────────────────────────── */
.page-body {
padding-top: 56px;
padding-bottom: var(--space-20);
}
/* ── Dashboard layout ────────────────────────────────────────── */
.dash-main {
padding: var(--space-8) 0 var(--space-16);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* ── Impersonate banner ──────────────────────────────────────── */
.impersonate-banner {
background: rgba(251, 191, 36, 0.12);
border-bottom: 1px solid rgba(251, 191, 36, 0.3);
color: var(--signal-amber);
text-align: center;
padding: 8px;
font-family: var(--sans);
font-size: var(--text-xs);
}
.impersonate-stop {
background: none;
border: 1px solid rgba(251, 191, 36, 0.3);
color: var(--signal-amber);
padding: 2px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: var(--mono);
font-size: var(--text-xs);
margin-left: 8px;
}
/* ── Search card ─────────────────────────────────────────────── */
.dash-search-card {
padding: var(--space-8);
}
.dash-search-heading {
font-family: var(--sans);
font-size: clamp(1.3rem, 1.0rem + 1.2vw, 2rem);
font-weight: 300;
color: var(--text-primary);
margin-bottom: var(--space-5);
line-height: 1.3;
letter-spacing: -0.01em;
}
.dash-search-heading .accent {
color: var(--accent);
}
/* Search form */
.search-form {
max-width: 100%;
margin: 0 0 var(--space-3);
}
.search-box-wrapper {
display: flex;
gap: 0;
}
.search-box-wrapper .obsidian-input {
flex: 1;
border-radius: var(--radius-md) 0 0 var(--radius-md);
border-right: none;
font-family: var(--mono);
font-size: var(--text-base);
font-weight: 300;
background: var(--obsidian-light);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: var(--space-4) var(--space-5);
outline: none;
transition: border-color 0.2s;
width: 100%;
}
.search-box-wrapper .obsidian-input:focus {
border-color: var(--accent-ring);
z-index: 1;
}
.search-btn {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
padding: var(--space-4) var(--space-6);
width: auto;
min-width: 72px;
flex-shrink: 0;
font-family: var(--mono);
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-secondary);
background: var(--obsidian-light);
border: 1px solid var(--glass-border);
border-left: none;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.search-btn:hover {
color: var(--accent);
border-color: var(--accent-ring);
}
/* Recent searches (inline chips) */
.recent-searches-row {
display: none;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-2);
}
.recent-label {
font-family: var(--mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
font-weight: 400;
flex-shrink: 0;
}
.recent-chip {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
padding: 3px 10px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-chip:hover {
color: var(--text-primary);
border-color: var(--glass-hover);
}
.recent-clear {
background: none;
border: none;
color: var(--text-tertiary);
font-family: var(--mono);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 4px;
margin-left: auto;
flex-shrink: 0;
}
.recent-clear:hover { color: var(--text-secondary); }
/* Search loading + results */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: block; }
.search-loading {
padding: var(--space-6) 0;
text-align: center;
}
.loading-dots {
display: inline-flex;
gap: var(--space-2);
}
.loading-dots span {
width: 6px;
height: 6px;
background: var(--accent);
border-radius: var(--radius-full);
animation: dot-blink 1.4s ease-in-out infinite;
}
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-blink {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1.2); }
}
.loading-text {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
margin-top: var(--space-3);
}
/* Search results markdown output */
#search-results {
margin-top: var(--space-4);
}
.result-card {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-6);
}
.result-card h1 { font-family: var(--sans); font-size: var(--text-lg); font-weight: 400; margin: 0 0 var(--space-4); color: var(--accent); }
.result-card h2 { font-family: var(--sans); font-size: var(--text-base); font-weight: 400; margin: var(--space-5) 0 var(--space-3); color: var(--text-primary); }
.result-card h3 { font-family: var(--sans); font-size: var(--text-sm); font-weight: 400; margin: var(--space-4) 0 var(--space-2); color: var(--text-secondary); }
.result-card p { font-family: var(--sans); font-size: var(--text-sm); color: var(--text-secondary); margin: var(--space-2) 0; line-height: 1.6; }
.result-card ul, .result-card ol { margin: var(--space-2) 0; padding-left: var(--space-6); }
.result-card li { font-family: var(--sans); font-size: var(--text-sm); color: var(--text-secondary); margin: var(--space-1) 0; }
.result-card strong { color: var(--text-primary); }
.result-card a { color: var(--accent); text-decoration: none; }
.result-card a:hover { text-decoration: underline; }
.result-card table {
width: 100%;
border-collapse: collapse;
margin: var(--space-3) 0;
}
.result-card th, .result-card td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--glass-border);
font-size: var(--text-xs);
}
.result-card th {
font-family: var(--mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
font-weight: 400;
}
.result-card td {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.result-card .pass { color: var(--signal-green); }
.result-card .fail { color: var(--signal-red); }
.result-card .warn { color: var(--signal-amber); }
/* ── Onboarding banner (first login) ─────────────────────────── */
.onboarding-banner {
background: var(--accent-glow);
border: 1px solid var(--accent-ring);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.onboarding-banner__text {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.onboarding-banner__text strong {
color: var(--signal-green);
font-weight: 500;
}
.onboarding-banner__dismiss {
background: none;
border: 1px solid rgba(52, 211, 153, 0.4);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
font-family: var(--sans);
font-size: var(--text-xs);
padding: var(--space-1) var(--space-3);
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s;
}
.onboarding-banner__dismiss:hover {
color: var(--text-primary);
border-color: var(--signal-green);
}
/* ── Brief summary card (has watches) ────────────────────────── */
.brief-card {
padding: var(--space-6);
}
.brief-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: var(--space-4);
flex-wrap: wrap;
gap: var(--space-3);
}
.brief-greeting {
font-family: var(--sans);
font-size: var(--text-base);
font-weight: 300;
color: var(--text-secondary);
}
.brief-greeting strong {
font-weight: 400;
color: var(--text-primary);
}
.brief-stats {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
display: flex;
gap: var(--space-4);
}
.brief-stats__val { color: var(--text-secondary); }
/* Action queue rows (from portfolio mockup) */
.aq-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: 10px var(--space-3);
margin: 0 calc(-1 * var(--space-3));
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--glass-border);
text-decoration: none;
}
.aq-item:last-child { border-bottom: none; }
.aq-item:hover { background: var(--glass); }
.aq-item__dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
flex-shrink: 0;
margin-top: 5px;
}
.aq-item__body {
flex: 1;
min-width: 0;
}
.aq-item__headline {
font-family: var(--sans);
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-primary);
}
.aq-item__detail {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: 2px;
}
.aq-item__actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s;
}
.aq-item:hover .aq-item__actions {
max-height: 36px;
opacity: 1;
}
.aq-action {
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
color: var(--text-tertiary);
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 3px 8px;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
text-decoration: none;
}
.aq-action:hover { color: var(--accent); border-color: var(--accent-ring); }
.aq-item__time {
font-family: var(--mono);
font-size: 10px;
flex-shrink: 0;
white-space: nowrap;
margin-top: 2px;
}
.aq-item__time--red { color: var(--signal-red); }
.aq-item__time--amber { color: var(--signal-amber); }
.aq-item__time--green { color: var(--signal-green); }
.aq-item__time--muted { color: var(--text-tertiary); }
.aq-item__arrow {
font-family: var(--mono);
font-size: 12px;
color: var(--text-tertiary);
opacity: 0;
transition: opacity 0.15s;
flex-shrink: 0;
margin-top: 2px;
}
.aq-item:hover .aq-item__arrow { opacity: 1; color: var(--accent); }
/* ── Onboarding card (no watches) ────────────────────────────── */
.onboard-card {
padding: var(--space-8) var(--space-6);
}
.onboard-headline {
font-family: var(--sans);
font-size: var(--text-xl);
font-weight: 300;
color: var(--text-primary);
margin-bottom: var(--space-3);
line-height: 1.4;
}
.onboard-body {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: var(--space-5);
max-width: 520px;
}
.onboard-steps {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.onboard-step {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.onboard-step__num {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.onboard-step__text {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.5;
}
.onboard-step__text strong {
color: var(--text-primary);
font-weight: 400;
}
/* ── Your property card (user has primary address) ────────────── */
.property-card {
padding: var(--space-5) var(--space-6);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.property-card__label {
font-family: var(--mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
margin-bottom: var(--space-1);
}
.property-card__address {
font-family: var(--mono);
font-size: var(--text-base);
font-weight: 300;
color: var(--text-primary);
}
/* ── Recent searches section ──────────────────────────────────── */
.recent-section {
padding: var(--space-5) var(--space-6);
}
.section-label {
font-family: var(--mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-tertiary);
margin-bottom: var(--space-3);
}
.recent-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
}
@media (max-width: 768px) {
.recent-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.recent-grid {
grid-template-columns: 1fr;
}
}
.recent-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
border: 1px solid var(--glass-border);
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
text-decoration: none;
}
.recent-item:hover {
background: var(--glass);
border-color: var(--glass-hover);
}
.recent-item__text {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.recent-item:hover .recent-item__text {
color: var(--text-primary);
}
.recent-item__arrow {
font-family: var(--mono);
font-size: 10px;
color: var(--text-tertiary);
opacity: 0;
transition: opacity 0.12s;
flex-shrink: 0;
}
.recent-item:hover .recent-item__arrow {
opacity: 1;
color: var(--accent);
}
/* ── Ghost CTA (CANON: no filled buttons) ────────────────────── */
.ghost-cta {
font-family: var(--mono);
font-size: var(--text-xs);
font-weight: 400;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.15s;
}
.ghost-cta:hover {
color: var(--accent);
text-decoration: underline;
}
/* ── Freshness indicator ──────────────────────────────────────── */
.freshness {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-3);
}
.freshness-dot {
width: 5px;
height: 5px;
border-radius: var(--radius-full);
background: var(--dot-green);
flex-shrink: 0;
}
/* ── Analyze / Lookup / Plans tool sections ───────────────────── */
.tool-section {
display: none;
}
.tool-section.visible {
display: block;
}
.section-divider {
border-top: 1px solid var(--glass-border);
margin: var(--space-4) 0;
}
.section-title {
font-family: var(--sans);
font-size: var(--text-xl);
font-weight: 300;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.section-subtitle {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
/* ── Form elements (for hidden tool sections) ─────────────────── */
.form-card {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-4);
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
}
@media (min-width: 640px) {
.form-grid {
grid-template-columns: 1fr 1fr;
}
.form-grid .full-width {
grid-column: 1 / -1;
}
}
label {
display: block;
font-family: var(--mono);
font-size: var(--text-xs);
font-weight: 400;
color: var(--text-secondary);
margin-bottom: var(--space-2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
input[type="text"],
input[type="number"],
input[type="date"],
select,
textarea {
width: 100%;
background: var(--obsidian-light);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-family: var(--mono);
font-weight: 300;
transition: border-color 0.2s;
box-sizing: border-box;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent-ring);
}
textarea { resize: vertical; min-height: 100px; }
/* Glass action button (for form submits) */
.glass-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--mono);
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-secondary);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: var(--space-3) var(--space-5);
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
white-space: nowrap;
}
.glass-btn:hover {
color: var(--accent);
border-color: var(--accent-ring);
}
.glass-btn .spinner { display: none; }
form.loading .glass-btn .spinner { display: inline; }
form.loading .glass-btn .label { display: none; }
.glass-btn--full { width: 100%; justify-content: center; }
/* Outline button (scan buttons for plan analysis) */
.outline-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
font-family: var(--mono);
font-size: var(--text-xs);
font-weight: 400;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.outline-btn:hover {
color: var(--accent);
border-color: var(--accent-ring);
}
.outline-btn--active {
color: var(--accent);
border-color: var(--accent-ring);
background: var(--accent-glow);
}
.outline-btn .spinner { display: none; }
.outline-btn.loading .spinner { display: inline; }
.outline-btn.loading .label { display: none; }
/* Presets row */
.presets-label {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.preset-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-5);
}
.preset-chip {
background: var(--glass);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
padding: var(--space-1) var(--space-4);
border-radius: var(--radius-full);
font-family: var(--mono);
font-size: var(--text-xs);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.preset-chip:hover {
border-color: var(--accent-ring);
color: var(--text-primary);
}
/* Priority chips */
.priority-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-5);
}
.priority-chip {
background: var(--glass);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-full);
font-family: var(--mono);
font-size: var(--text-xs);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.priority-chip:hover { border-color: var(--accent-ring); color: var(--text-primary); }
.priority-chip.selected { background: var(--accent-glow); border-color: var(--accent-ring); color: var(--accent); }
/* Experience toggle */
.experience-toggle {
display: flex;
gap: 0;
margin-bottom: var(--space-5);
}
.experience-option {
flex: 1;
padding: var(--space-2) var(--space-4);
font-family: var(--mono);
font-size: var(--text-xs);
text-align: center;
background: var(--glass);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.experience-option:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
.experience-option:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; border-left: none; }
.experience-option:hover { color: var(--text-primary); }
.experience-option.selected { background: var(--accent-glow); border-color: var(--accent-ring); color: var(--accent); }
/* Personalize toggle */
.personalize-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
background: none;
border: none;
color: var(--accent);
font-family: var(--mono);
font-size: var(--text-sm);
cursor: pointer;
padding: var(--space-2) 0;
transition: opacity 0.15s;
}
.personalize-toggle:hover { opacity: 0.8; }
.personalize-toggle .toggle-icon {
font-size: 0.7rem;
transition: transform 0.2s;
}
.personalize-toggle.open .toggle-icon { transform: rotate(90deg); }
.personalize-section { display: none; padding-top: var(--space-4); border-top: 1px solid var(--glass-border); margin-top: var(--space-2); }
.personalize-section.open { display: block; }
/* Team grid */
.team-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
@media (min-width: 640px) {
.team-grid { grid-template-columns: 1fr 1fr 1fr; }
}
/* File upload */
.file-upload-area {
position: relative;
border: 2px dashed var(--glass-border);
border-radius: var(--radius-sm);
padding: var(--space-8) var(--space-5);
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.file-upload-area:hover,
.file-upload-area.drag-over {
border-color: var(--accent-ring);
background: var(--accent-glow);
}
.file-input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.file-upload-label {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
font-family: var(--sans);
color: var(--text-secondary);
pointer-events: none;
font-size: var(--text-sm);
}
.file-hint {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.file-upload-label.has-file { color: var(--signal-green); }
/* Icon size helpers */
.icon-lg { font-size: 2rem; }
.icon-md { font-size: 1.5rem; }
/* Checkbox */
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-3);
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
flex-shrink: 0;
}
/* Plan button tooltip */
.plan-btn-wrap { position: relative; flex: 1; min-width: 80px; }
.plan-btn-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--obsidian-light);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: var(--space-3) var(--space-4);
font-family: var(--sans);
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.5;
width: 220px;
z-index: 20;
pointer-events: none;
}
.plan-btn-wrap:hover .plan-btn-tooltip { display: block; }
@media (max-width: 640px) {
.plan-btn-tooltip { display: none !important; }
}
/* Hourglass spinner */
.hourglass-spinner { font-size: 40px; animation: spin 2s linear infinite; display: inline-block; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* Lookup mode toggle */
.lookup-fields { }
/* ── Footer ───────────────────────────────────────────────────── */
.page-footer {
border-top: 1px solid var(--glass-border);
padding: var(--space-6) 0;
text-align: center;
color: var(--text-tertiary);
font-family: var(--mono);
font-size: var(--text-xs);
}
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 768px) {
.dash-search-card { padding: var(--space-5); }
.brief-card { padding: var(--space-4) var(--space-4); }
.brief-header { flex-direction: column; gap: var(--space-2); }
.brief-stats { gap: var(--space-3); }
.onboard-card { padding: var(--space-6) var(--space-4); }
.property-card { flex-direction: column; align-items: flex-start; }
.recent-section { padding: var(--space-4); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
.reveal { opacity: 1; transform: none; }
}
</style>
</head>
<body class="obsidian">
{% if is_staging %}
<div style="background: var(--signal-amber); color: var(--obsidian); text-align: center; padding: 8px 16px; font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.06em; position: relative; z-index: 9999;">
STAGING ENVIRONMENT — changes here do not affect production
</div>
{% endif %}
{% if session.get('impersonating') %}
<div class="impersonate-banner">
Viewing as {{ session.impersonating }}
<form method="POST" action="/auth/stop-impersonate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="impersonate-stop">Stop</button>
</form>
</div>
{% endif %}
{% set active_page = 'search' %}
{% include 'fragments/nav.html' %}
<div class="page-body">
<div class="obs-container">
<main class="dash-main">
{# ── First-login onboarding banner ─────────────────────────────── #}
{% if session.get('show_onboarding_banner') and not session.get('onboarding_dismissed') %}
<div id="onboarding-banner" class="onboarding-banner">
<div class="onboarding-banner__text">
<strong>Welcome to sfpermits.ai.</strong>
Search any SF address to see its permit history, team, and routing status.
</div>
<button
class="onboarding-banner__dismiss"
hx-post="/onboarding/dismiss"
hx-target="#onboarding-banner"
hx-swap="outerHTML">
Dismiss
</button>
</div>
{% endif %}
{# ── Search card ───────────────────────────────────────────────── #}
<div class="glass-card dash-search-card">
<h1 class="dash-search-heading">
Search <span class="accent">18.4 million</span> San Francisco government records.
</h1>
<form class="search-form"
hx-post="/ask"
hx-target="#search-results"
hx-indicator="#search-loading">
<input type="hidden" name="draft" id="draft-mode" value="0">
<div class="search-box-wrapper">
<input type="text" name="q" id="search-q"
class="obsidian-input"
placeholder="Address, permit number, or any question about SF permits..."
autocomplete="off" autofocus>
<button type="submit" class="search-btn">→</button>
</div>
</form>
{# Primary address quick-search — only shown if set #}
{% if g.user and g.user.primary_street_number and g.user.primary_street_name %}
<div style="margin-top: var(--space-2);">
<button class="recent-chip"
onclick="document.getElementById('search-q').value='{{ g.user.primary_street_number }} {{ g.user.primary_street_name }}'; htmx.trigger(document.querySelector('.search-form'), 'submit');"
title="Check your primary address">
Check {{ g.user.primary_street_number }} {{ g.user.primary_street_name }} →
</button>
</div>
{% endif %}
<div id="recent-searches" class="recent-searches-row">
<span class="recent-label">Recent</span>
<!-- chips injected by JS -->
<button class="recent-clear" onclick="_clearRecentSearches()" title="Clear recent searches">×</button>
</div>
<div id="search-loading" class="htmx-indicator">
<div class="search-loading">
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
<p class="loading-text">Searching permits...</p>
</div>
</div>
<div id="search-results"></div>
</div>
{# ── Brief / Watches card ──────────────────────────────────────── #}
{# State 1: User has watched properties — show brief summary #}
{% if watch_count and watch_count > 0 %}
<div class="glass-card brief-card reveal">
<div class="brief-header">
<div class="brief-greeting">
{% if watch_count == 1 %}
<strong>1 property</strong> in your watchlist.
{% else %}
<strong>{{ watch_count }} properties</strong> in your watchlist.
{% endif %}
{% if changes_count and changes_count > 0 %}
{{ changes_count }} change{{ 's' if changes_count != 1 else '' }} since yesterday.
{% endif %}
</div>
<div class="brief-stats">
<span><span class="brief-stats__val">{{ watch_count }}</span> watching</span>
{% if urgent_count and urgent_count > 0 %}
<span><span class="brief-stats__val" style="color:var(--signal-red);">{{ urgent_count }}</span> urgent</span>
{% endif %}
</div>
</div>
{# Show action items from brief data if any #}
{% if brief_items %}
{% for item in brief_items[:5] %}
<a class="aq-item" href="{{ item.url or '/brief' }}">
<span class="aq-item__dot" style="background: var(--dot-{{ item.severity or 'green' }});"></span>
<div class="aq-item__body">
<div class="aq-item__headline">{{ item.headline }}</div>
{% if item.detail %}
<div class="aq-item__detail">{{ item.detail }}</div>
{% endif %}
<div class="aq-item__actions">
<a href="{{ item.url or '/brief' }}" class="aq-action">View details</a>
{% if item.report_url %}
<a href="{{ item.report_url }}" class="aq-action">Property report</a>
{% endif %}
</div>
</div>
{% if item.time_label %}
<span class="aq-item__time aq-item__time--{{ item.severity or 'muted' }}">{{ item.time_label }}</span>
{% endif %}
<span class="aq-item__arrow">→</span>
</a>
{% endfor %}
{% else %}
{# No specific items — show a simple call to action #}
<div style="padding: var(--space-3) 0; border-bottom: 1px solid var(--glass-border);">
<a href="/brief" class="ghost-cta">Open your morning brief →</a>
</div>
{% endif %}
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: var(--space-3); flex-wrap: wrap; gap: var(--space-3);">
<a href="/brief" class="ghost-cta">Full brief →</a>
<a href="/portfolio" class="ghost-cta">Portfolio view →</a>
</div>
</div>
{# Your property shortcut if primary address set #}
{% if user_report_url %}
<div class="glass-card property-card reveal">
<div>
<div class="property-card__label">Your property</div>
<div class="property-card__address">{{ user_report_address }}</div>
</div>
<a href="{{ user_report_url }}" class="ghost-cta">View property report →</a>
</div>
{% endif %}
{# State 2: No watches — show onboarding card #}
{% else %}
<div class="glass-card onboard-card reveal">
<h2 class="onboard-headline">Watch your first property.</h2>
<p class="onboard-body">
Search any San Francisco address above. When you find a property or permit that matters to you, watch it — and we'll surface changes in your morning brief.
</p>
<div class="onboard-steps">
<div class="onboard-step">
<span class="onboard-step__num">1</span>
<span class="onboard-step__text">
<strong>Search an address</strong> — any SF property, permit number, or question
</span>
</div>
<div class="onboard-step">
<span class="onboard-step__num">2</span>
<span class="onboard-step__text">
<strong>Watch the property</strong> — click Watch on any result to add it to your list
</span>
</div>
<div class="onboard-step">
<span class="onboard-step__num">3</span>
<span class="onboard-step__text">
<strong>Check your brief</strong> — permit changes, new filings, stalled permits — every morning
</span>
</div>
</div>
<div style="display: flex; gap: var(--space-5); flex-wrap: wrap;">
<a href="/brief" class="ghost-cta">View your brief →</a>
{% if user_report_url %}
<a href="{{ user_report_url }}" class="ghost-cta">{{ user_report_address }} →</a>
{% endif %}
<a href="/account" class="ghost-cta">Manage account →</a>
</div>
</div>
{% endif %}
{# ── Recent searches ───────────────────────────────────────────── #}
<div class="glass-card recent-section reveal" id="dash-recent-section" style="display:none;">
<div class="section-label">Recent searches</div>
<div class="recent-grid" id="dash-recent-grid">
<!-- populated by JS -->
</div>
<div style="text-align: right; padding-top: var(--space-3);">
<button class="ghost-cta" onclick="_clearRecentSearches()" style="border: none; background: none; cursor: pointer; font-size: var(--text-xs); font-family: var(--mono);">Clear ×</button>
</div>
</div>
{# ── Freshness ─────────────────────────────────────────────────── #}
<div class="freshness reveal">
<span class="freshness-dot"></span>
Permit data updated nightly · 1.1M+ SF records indexed
</div>
{# ── Hidden tool sections (revealed by search results) ─────────── #}
<!-- Analyze Project Section (hidden by default) -->
<div class="tool-section" id="section-analyze">
<div class="section-divider"></div>
<h2 class="section-title">Analyze a Project</h2>
<p class="section-subtitle">Describe your project and get permit predictions, fee estimates, timelines, document checklists, and revision risk.</p>
<div class="form-card">
<div class="presets-label">Quick start with a sample project:</div>
<div class="preset-chips">
<button class="preset-chip" onclick="loadPreset('kitchen')">Kitchen Remodel</button>
<button class="preset-chip" onclick="loadPreset('adu')">ADU Conversion</button>
<button class="preset-chip" onclick="loadPreset('commercial')">Commercial TI</button>
<button class="preset-chip" onclick="loadPreset('restaurant')">Restaurant</button>
<button class="preset-chip" onclick="loadPreset('historic')">Historic Reno</button>
</div>
<form hx-post="/analyze"
hx-target="#analyze-results"
hx-indicator="#analyze-loading"
class="form-grid">
<div class="full-width">
<label for="description">Project Description</label>
<textarea id="description" name="description"
placeholder="e.g., Gut renovation of residential kitchen, removing a non-bearing wall, relocating gas line, new electrical panel."></textarea>
</div>
<div>
<label for="address">Address (optional)</label>
<input type="text" id="address" name="address" placeholder="e.g., 123 Main St">
</div>
<div>
<label for="neighborhood">Neighborhood</label>
<select id="neighborhood" name="neighborhood">
<option value="">Select neighborhood...</option>
{% for n in neighborhoods %}
{% if n %}
<option value="{{ n }}">{{ n }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label for="cost">Estimated Cost ($)</label>
<input type="number" id="cost" name="cost" placeholder="e.g., 85000" min="0" step="1">
</div>
<div>
<label for="sqft">Square Footage (optional)</label>
<input type="number" id="sqft" name="sqft" placeholder="e.g., 1200" min="0">
</div>
<div>
<label for="carrying_cost">Monthly Carrying Cost ($) <span style="font-size:0.8em;color:var(--text-tertiary);">(optional)</span></label>
<input type="number" id="carrying_cost" name="carrying_cost" placeholder="e.g., 5000" min="0" step="100">
</div>
<!-- Personalization -->
<div class="full-width">
<button type="button" class="personalize-toggle" onclick="togglePersonalize()">
<span class="toggle-icon">▶</span>
<span>Help us give you a better analysis</span>
</button>
</div>
<div class="full-width personalize-section" id="personalize-section">
<div class="section-label" style="margin-bottom: var(--space-2);">What matters most to you?</div>
<div class="priority-chips">
<button type="button" class="priority-chip" data-value="timeline" onclick="toggleChip(this)">Timeline</button>
<button type="button" class="priority-chip" data-value="cost" onclick="toggleChip(this)">Cost</button>
<button type="button" class="priority-chip" data-value="corrections" onclick="toggleChip(this)">Avoiding corrections</button>
<button type="button" class="priority-chip" data-value="requirements" onclick="toggleChip(this)">Understanding requirements</button>
<button type="button" class="priority-chip" data-value="exploring" onclick="toggleChip(this)">Just exploring</button>
</div>
<input type="hidden" name="priorities" id="priorities-input" value="">
<div style="margin-bottom: var(--space-5);">
<label for="target_date">When do you want to start construction?</label>
<input type="date" id="target_date" name="target_date" style="max-width: 240px;">
</div>
<div class="section-label" style="margin-bottom: var(--space-2);">Your project team</div>
<div style="font-family: var(--sans); font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: var(--space-3);">
We'll check their SF permit track record across 1M+ entities.
</div>
<div class="team-grid">
<div>
<label for="contractor_name">General Contractor</label>
<input type="text" id="contractor_name" name="contractor_name" placeholder="Contractor or company">
</div>
<div>
<label for="architect_name">Architect / Engineer</label>
<input type="text" id="architect_name" name="architect_name" placeholder="Architect or engineer">
</div>
<div>
<label for="consultant_name">Land Use Consultant</label>
<input type="text" id="consultant_name" name="consultant_name" placeholder="Land use consultant">
</div>
</div>
<div class="section-label" style="margin-bottom: var(--space-2);">Experience level</div>
<div class="experience-toggle">
<button type="button" class="experience-option" data-value="first_time" onclick="selectExperience(this)">First time filing in SF</button>
<button type="button" class="experience-option" data-value="experienced" onclick="selectExperience(this)">Done this before</button>
</div>
<input type="hidden" name="experience_level" id="experience-input" value="unspecified">
<div>
<label for="additional_context">Anything else we should know?</label>
<textarea id="additional_context" name="additional_context" rows="3"
placeholder="Historic building, existing violations, unusual site conditions..."></textarea>
</div>
</div>
<div class="full-width">
<button type="submit" class="glass-btn glass-btn--full">
<span class="label">Analyze Project</span>
<span class="spinner">Analyzing...</span>
</button>
</div>
</form>
</div>
<div id="analyze-loading" class="htmx-indicator">
<div class="search-loading">
<div class="loading-dots"><span></span><span></span><span></span></div>
<p class="loading-text">Running 5 permit analysis tools...</p>
</div>
</div>
<div id="analyze-results"></div>
</div><!-- /section-analyze -->
<!-- Analyze Plans Section (hidden by default) -->
<div class="tool-section" id="section-analyze-plans">
<div class="section-divider"></div>
<h2 class="section-title">Analyze Plans (AI Vision)</h2>
<p class="section-subtitle">Upload a PDF plan set. Quick Check scans metadata instantly. AI Analysis and Full Analysis use AI vision to annotate your plans with EPR issues, code references, and compliance checks.</p>
{% if upload_error %}
<div style="margin-bottom:var(--space-4);padding:var(--space-3) var(--space-4);background:rgba(248,113,113,0.08);border:1px solid rgba(248,113,113,0.25);border-radius:var(--radius-sm);font-family:var(--sans);font-size:var(--text-sm);color:var(--signal-red);">
{{ upload_error }}
</div>
{% endif %}
<div class="form-card">
<form hx-post="/analyze-plans"
hx-target="#analyze-plans-results"
hx-indicator="#analyze-plans-loading"
hx-encoding="multipart/form-data"
class="form-grid">
<div class="full-width">
<label for="analyze-planfile">PDF Plan Set</label>
<div class="file-upload-area" id="analyze-drop-zone">
<input type="file" id="analyze-planfile" name="planfile" accept=".pdf"
class="file-input" onchange="updateAnalyzeFileLabel(this)">
<div class="file-upload-label" id="analyze-file-label">
<span class="icon-lg">📄</span>
<span>Drag & drop a PDF here, or <strong>click to browse</strong></span>
<span class="file-hint">Max 400 MB — .pdf only</span>
</div>
</div>
</div>
<input type="hidden" name="quick_check" id="quick-check-hidden" value="on">
<input type="hidden" name="analysis_mode" id="analysis-mode-hidden" value="sample">
<div class="full-width">
<button type="button" class="personalize-toggle" id="plan-options-toggle" onclick="togglePlanOptions()">
<span class="toggle-icon">▶</span>
<span>More options</span>
</button>
</div>
<div class="full-width personalize-section" id="plan-options-section">
<div style="display:grid; gap:var(--space-5);">
<div>
<label for="project_description">Project Description</label>
<textarea id="project_description" name="project_description"
placeholder="e.g., Interior remodel of 2-story residential..."
rows="3"></textarea>
</div>
<div>
<label for="permit_type">Permit Type</label>
<select id="permit_type" name="permit_type">
<option value="">-- Select --</option>
<option value="alterations">Alterations</option>
<option value="new_construction">New Construction</option>
<option value="demolition">Demolition</option>
<option value="additions">Additions</option>
</select>
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-3);">
<div>
<label for="property_address">Property Address</label>
<input type="text" id="property_address" name="property_address" placeholder="e.g., 123 Main St">
</div>
<div>
<label for="permit_number_input">Permit Number</label>
<input type="text" id="permit_number_input" name="permit_number" placeholder="e.g., 202401015678">
</div>
</div>
<div>
<label for="submission_stage">Submission Stage</label>
<select id="submission_stage" name="submission_stage">
<option value="">-- Select --</option>
<option value="preliminary">Preliminary Plan Check</option>
<option value="permit">Permit Application (full set)</option>
<option value="resubmittal">Resubmittal (addressing comments)</option>
</select>
</div>
<div>
<label class="checkbox-label">
<input type="checkbox" name="is_addendum" id="analyze-is-addendum">
<span>This is a Site Permit Addendum (allows up to 350 MB)</span>
</label>
</div>
</div>
</div>
<!-- Four analysis buttons -->
<div class="full-width" style="display:flex; gap:var(--space-2); flex-wrap:wrap;">
<div class="plan-btn-wrap">
<button type="submit" class="outline-btn outline-btn--active" id="quick-check-btn" style="width:100%;">
<span class="label">Quick Check</span>
<span class="spinner">Checking...</span>
</button>
<div class="plan-btn-tooltip"><strong>Instant metadata scan</strong><br>File size, page dimensions, fonts, encryption. No AI — results in seconds.</div>
</div>
<div class="plan-btn-wrap">
<button type="button" class="outline-btn" id="compliance-check-btn" style="width:100%;" onclick="submitComplianceCheck(this)">
<span class="label">Compliance</span>
<span class="spinner">Analyzing...</span>
</button>
<div class="plan-btn-tooltip"><strong>AI title block verification</strong><br>Addresses, sheet numbers, professional stamps. ~30 seconds.</div>
</div>
<div class="plan-btn-wrap">
<button type="button" class="outline-btn" id="ai-analysis-btn" style="width:100%;" onclick="submitAIAnalysis(this)">
<span class="label">AI Analysis</span>
<span class="spinner">Analyzing...</span>
</button>
<div class="plan-btn-tooltip"><strong>AI markup on sample pages</strong><br>EPR issues, annotations, code references. ~1-2 minutes.</div>
</div>
<div class="plan-btn-wrap">
<button type="button" class="outline-btn" id="full-analysis-btn" style="width:100%;" onclick="submitFullAnalysis(this)">
<span class="label">Full Analysis</span>
<span class="spinner">Analyzing...</span>
</button>
<div class="plan-btn-tooltip"><strong>AI markup on every page</strong><br>Same as AI Analysis but processes every page. ~3-5 minutes.</div>
</div>
</div>
</form>
</div>
<div id="analyze-plans-loading" class="htmx-indicator">
<div style="text-align: center; padding: var(--space-10);">
<div class="hourglass-spinner">⏳</div>
<p class="loading-text" style="margin-top: var(--space-4);">Running quick metadata check...</p>
<p style="font-family: var(--sans); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: var(--space-2);">This usually takes just a few seconds.</p>
</div>
</div>
<script nonce="{{ csp_nonce }}">
(function() {
function setupAnalyzePlansLoading() {
var form = document.querySelector('form[hx-post="/analyze-plans"]');
var loadingIndicator = document.getElementById('analyze-plans-loading');
if (!form || !loadingIndicator) return;
form.addEventListener('submit', function() {
loadingIndicator.style.display = 'block';
var qcBtn = document.getElementById('quick-check-btn');
if (qcBtn && !qcBtn.disabled) _lockPlanButtons(qcBtn);
setTimeout(function() {
loadingIndicator.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
});
form.addEventListener('htmx:beforeRequest', function() {
loadingIndicator.style.display = 'block';
});
function resetAnalysisBtns() {
['quick-check-btn','compliance-check-btn','ai-analysis-btn','full-analysis-btn'].forEach(function(id) {
var b = document.getElementById(id);
if (!b) return;
b.classList.remove('outline-btn--active');
b.disabled = false;
b.style.opacity = '';
var label = b.querySelector('.label');
var spinner = b.querySelector('.spinner');
if (label) label.style.display = '';
if (spinner) spinner.style.display = '';
});
var qcBtn = document.getElementById('quick-check-btn');
if (qcBtn) qcBtn.classList.add('outline-btn--active');
}
form.addEventListener('htmx:afterSwap', function() {
loadingIndicator.style.display = 'none';
resetAnalysisBtns();
var qcField = document.getElementById('quick-check-hidden');
if (qcField) qcField.value = 'on';
var amField = document.getElementById('analysis-mode-hidden');
if (amField) amField.value = 'sample';
});
form.addEventListener('htmx:responseError', function(evt) {
loadingIndicator.style.display = 'none';
resetAnalysisBtns();
var resultsDiv = document.getElementById('analyze-plans-results');
if (resultsDiv) {
resultsDiv.innerHTML = '<div style="text-align:center;padding:var(--space-8);color:var(--signal-red);">' +
'<p style="font-family:var(--sans);font-size:var(--text-sm);">Analysis request failed. The server may be busy. Please try again.</p></div>';
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupAnalyzePlansLoading);
} else {
setupAnalyzePlansLoading();
}
})();
</script>
<div id="analyze-plans-results"></div>
{% if g.user %}
<div style="text-align:right;margin-top:var(--space-4);">
<a href="/account/analyses" class="ghost-cta">View your analysis history →</a>
</div>
{% endif %}
</div><!-- /section-analyze-plans -->
<!-- Permit Lookup Section (hidden by default) -->
<div class="tool-section" id="section-lookup">
<div class="section-divider"></div>
<h2 class="section-title">Permit Lookup</h2>
<p class="section-subtitle">Search by permit number, address, or parcel to see status, team, inspections, and related permits.</p>
<div class="form-card">
<form hx-post="/lookup"
hx-target="#lookup-results"
hx-indicator="#lookup-loading"
class="form-grid">
<div class="full-width">
<div class="experience-toggle lookup-mode-toggle">
<button type="button" class="experience-option selected" data-mode="number" onclick="selectLookupMode(this)">Permit Number</button>
<button type="button" class="experience-option" data-mode="address" onclick="selectLookupMode(this)">Address</button>
<button type="button" class="experience-option" data-mode="parcel" onclick="selectLookupMode(this)">Block / Lot</button>
</div>
<input type="hidden" name="lookup_mode" id="lookup-mode-input" value="number">
</div>
<div class="full-width lookup-fields" id="lookup-number-fields">
<label for="permit_number">Permit Number</label>
<input type="text" id="permit_number" name="permit_number" placeholder="e.g., 202301015555 or M012345">
</div>
<div class="lookup-fields" id="lookup-address-fields" style="display:none;">
<label for="street_number">Street Number</label>
<input type="text" id="street_number" name="street_number" placeholder="e.g., 123">
</div>
<div class="lookup-fields" id="lookup-address-fields-2" style="display:none;">
<label for="street_name">Street Name</label>
<input type="text" id="street_name" name="street_name" placeholder="e.g., Main">
</div>
<div class="lookup-fields" id="lookup-parcel-fields" style="display:none;">
<label for="block">Block</label>
<input type="text" id="block" name="block" placeholder="e.g., 3512">
</div>
<div class="lookup-fields" id="lookup-parcel-fields-2" style="display:none;">
<label for="lot">Lot</label>
<input type="text" id="lot" name="lot" placeholder="e.g., 001">
</div>
<div class="full-width">
<button type="submit" class="glass-btn glass-btn--full">
<span class="label">Look Up Permit</span>
<span class="spinner">Searching...</span>
</button>
</div>
</form>
</div>
<div id="lookup-loading" class="htmx-indicator">
<div class="search-loading">
<div class="loading-dots"><span></span><span></span><span></span></div>
<p class="loading-text">Searching 1.1M permits across 22 datasets...</p>
</div>
</div>
<div id="lookup-results"></div>
</div><!-- /section-lookup -->
</main>
</div>
</div>
<footer class="page-footer">
<div class="obs-container">
sfpermits.ai — SF permit data from data.sfgov.org
</div>
</footer>
<script nonce="{{ csp_nonce }}">
// ── Preset data for Analyze Project ─────────────────────────────────
var PRESETS = {
kitchen: {
description: "Gut renovation of residential kitchen in Noe Valley, removing a non-bearing wall, relocating gas line, new electrical panel. Budget $85K.",
neighborhood: "Noe Valley",
cost: "85000",
sqft: "",
address: "",
},
adu: {
description: "Convert existing detached garage to ADU with kitchenette and bathroom in the Sunset District. 450 sq ft, new plumbing/electrical, $180K budget.",
neighborhood: "Sunset/Parkside",
cost: "180000",
sqft: "450",
address: "",
},
commercial: {
description: "Office tenant improvement in Financial District, 3,500 sq ft. New walls, HVAC modifications, lighting, ADA-compliant restrooms. Budget $350K.",
neighborhood: "Financial District/South Beach",
cost: "350000",
sqft: "3500",
address: "",
},
restaurant: {
description: "Convert vacant retail space to restaurant with Type I hood, grease interceptor, 49 seats, full commercial kitchen. Mission District, $250K budget.",
neighborhood: "Mission",
cost: "250000",
sqft: "",
address: "",
},
historic: {
description: "Major renovation of Article 10 landmark building in Pacific Heights. Seismic retrofit, new MEP systems, ADA compliance, restore historic facade. 8,000 sq ft, $2.5M budget.",
neighborhood: "Pacific Heights",
cost: "2500000",
sqft: "8000",
address: "",
},
};
function loadPreset(key) {
var p = PRESETS[key];
if (!p) return;
document.getElementById("description").value = p.description;
document.getElementById("address").value = p.address;
document.getElementById("cost").value = p.cost;
document.getElementById("sqft").value = p.sqft;
var sel = document.getElementById("neighborhood");
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === p.neighborhood) {
sel.selectedIndex = i;
break;
}
}
}
// ── Reveal a tool section ────────────────────────────────────────────
function revealSection(name) {
var section = document.getElementById("section-" + name);
if (!section) return;
section.classList.add("visible");
setTimeout(function() {
section.scrollIntoView({ behavior: "smooth", block: "start" });
}, 50);
}
// ── Draft mode ───────────────────────────────────────────────────────
function _enterDraftMode() {
document.getElementById('draft-mode').value = '1';
var q = document.getElementById('search-q');
q.placeholder = 'Paste a client question or email here...';
q.value = '';
q.focus();
}
document.addEventListener('htmx:afterRequest', function(evt) {
var dm = document.getElementById('draft-mode');
if (dm && dm.value === '1') {
dm.value = '0';
document.getElementById('search-q').placeholder =
'Address, permit number, or any question about SF permits...';
}
});
// ── Pre-fill the analyze form from search results ────────────────────
function prefillAnalyze(data) {
if (data.address) document.getElementById("address").value = data.address;
if (data.description) document.getElementById("description").value = data.description;
if (data.estimated_cost) document.getElementById("cost").value = Math.round(data.estimated_cost);
if (data.square_footage) document.getElementById("sqft").value = Math.round(data.square_footage);
if (data.neighborhood) {
var sel = document.getElementById("neighborhood");
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === data.neighborhood) {
sel.selectedIndex = i;
break;
}
}
}
revealSection("analyze");
}
// ── Auto-submit if ?q= present ───────────────────────────────────────
(function() {
var params = new URLSearchParams(window.location.search);
var q = params.get("q");
if (q) {
var input = document.getElementById("search-q");
if (input) {
input.value = q;
setTimeout(function() {
htmx.trigger(document.querySelector(".search-form"), "submit");
}, 100);
}
}
})();
// ── HTMX loading state ───────────────────────────────────────────────
document.body.addEventListener('htmx:beforeRequest', function(evt) {
var elt = evt.detail.elt;
if (elt.tagName === 'FORM' && elt.hasAttribute('hx-indicator')) {
elt.classList.add('loading');
}
});
document.body.addEventListener('htmx:afterRequest', function(evt) {
var elt = evt.detail.elt;
if (elt.tagName === 'FORM' && elt.hasAttribute('hx-indicator')) {
elt.classList.remove('loading');
}
});
// ── Analyze file label ───────────────────────────────────────────────
function updateAnalyzeFileLabel(input) {
var label = document.getElementById("analyze-file-label");
if (input.files && input.files[0]) {
var f = input.files[0];
var sizeMB = (f.size / (1024 * 1024)).toFixed(1);
label.innerHTML = '<span class="icon-md">\u2705</span>' +
'<span><strong>' + f.name + '</strong></span>' +
'<span class="file-hint">' + sizeMB + ' MB</span>';
label.classList.add("has-file");
} else {
label.innerHTML = '<span class="icon-lg">\uD83D\uDCC4</span>' +
'<span>Drag & drop a PDF here, or <strong>click to browse</strong></span>' +
'<span class="file-hint">Max 400 MB — .pdf only</span>';
label.classList.remove("has-file");
}
}
// ── Drag & drop for analyze plans ────────────────────────────────────
var analyzeDropZone = document.getElementById("analyze-drop-zone");
if (analyzeDropZone) {
["dragenter", "dragover"].forEach(function(evt) {
analyzeDropZone.addEventListener(evt, function(e) {
e.preventDefault();
analyzeDropZone.classList.add("drag-over");
});
});
["dragleave", "drop"].forEach(function(evt) {
analyzeDropZone.addEventListener(evt, function(e) {
analyzeDropZone.classList.remove("drag-over");
});
});
}
// ── Plan analysis button management ─────────────────────────────────
function _lockPlanButtons(activeBtn) {
var ids = ['quick-check-btn','compliance-check-btn','ai-analysis-btn','full-analysis-btn'];
ids.forEach(function(id) {
var b = document.getElementById(id);
if (!b) return;
b.disabled = true;
b.classList.remove('outline-btn--active');
if (b !== activeBtn) b.style.opacity = '0.4';
});
activeBtn.classList.add('outline-btn--active');
activeBtn.style.opacity = '1';
var label = activeBtn.querySelector('.label');
var spinner = activeBtn.querySelector('.spinner');
if (label) label.style.display = 'none';
if (spinner) spinner.style.display = 'inline';
}
function _scrollToAnalyzeLoading() {
var el = document.getElementById('analyze-plans-loading');
if (el) setTimeout(function() {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
function submitComplianceCheck(btn) {
_lockPlanButtons(btn);
document.getElementById('quick-check-hidden').value = '';
document.getElementById('analysis-mode-hidden').value = 'compliance';
var form = document.querySelector('form[hx-post="/analyze-plans"]');
if (form) form.requestSubmit();
_scrollToAnalyzeLoading();
}
function submitAIAnalysis(btn) {
_lockPlanButtons(btn);
document.getElementById('quick-check-hidden').value = '';
document.getElementById('analysis-mode-hidden').value = 'sample';
var form = document.querySelector('form[hx-post="/analyze-plans"]');
if (form) form.requestSubmit();
_scrollToAnalyzeLoading();
}
function submitFullAnalysis(btn) {
_lockPlanButtons(btn);
document.getElementById('quick-check-hidden').value = '';
document.getElementById('analysis-mode-hidden').value = 'full';
var form = document.querySelector('form[hx-post="/analyze-plans"]');
if (form) form.requestSubmit();
_scrollToAnalyzeLoading();
}
function togglePlanOptions() {
var btn = document.getElementById("plan-options-toggle");
var section = document.getElementById("plan-options-section");
btn.classList.toggle("open");
section.classList.toggle("open");
}
// ── Personalization section ──────────────────────────────────────────
function togglePersonalize() {
var btn = document.querySelector(".personalize-toggle");
var section = document.getElementById("personalize-section");
btn.classList.toggle("open");
section.classList.toggle("open");
}
function toggleChip(el) {
el.classList.toggle("selected");
var selected = document.querySelectorAll(".priority-chip.selected");
var values = Array.from(selected).map(function(e) { return e.dataset.value; });
document.getElementById("priorities-input").value = values.join(",");
}
function selectExperience(el) {
el.parentElement.querySelectorAll(".experience-option").forEach(function(btn) {
btn.classList.remove("selected");
});
el.classList.add("selected");
document.getElementById("experience-input").value = el.dataset.value;
}
// ── Lookup mode switching ────────────────────────────────────────────
function selectLookupMode(el) {
document.querySelectorAll(".lookup-mode-toggle .experience-option").forEach(function(btn) {
btn.classList.remove("selected");
});
el.classList.add("selected");
var mode = el.dataset.mode;
document.getElementById("lookup-mode-input").value = mode;
document.getElementById("lookup-number-fields").style.display = mode === "number" ? "" : "none";
document.getElementById("lookup-address-fields").style.display = mode === "address" ? "" : "none";
document.getElementById("lookup-address-fields-2").style.display = mode === "address" ? "" : "none";
document.getElementById("lookup-parcel-fields").style.display = mode === "parcel" ? "" : "none";
document.getElementById("lookup-parcel-fields-2").style.display = mode === "parcel" ? "" : "none";
}
// ── Auto-reveal section from URL hash ────────────────────────────────
(function() {
var hash = window.location.hash;
if (hash === '#plan-upload' || hash === '#analyze-plans' || hash === '#section-analyze-plans') {
revealSection('analyze-plans');
}
})();
// ── Recent Searches (localStorage) ───────────────────────────────────
var _RS_KEY = "sf_recent_searches";
var _RS_MAX = 10;
function _saveRecentSearch(q) {
q = (q || "").trim();
if (!q) return;
var list = [];
try { list = JSON.parse(localStorage.getItem(_RS_KEY)) || []; } catch(e) {}
list = list.filter(function(item) { return item.toLowerCase() !== q.toLowerCase(); });
list.unshift(q);
if (list.length > _RS_MAX) list = list.slice(0, _RS_MAX);
localStorage.setItem(_RS_KEY, JSON.stringify(list));
}
function _renderRecentSearches() {
var inlineContainer = document.getElementById("recent-searches");
var dashSection = document.getElementById("dash-recent-section");
var grid = document.getElementById("dash-recent-grid");
var list = [];
try { list = JSON.parse(localStorage.getItem(_RS_KEY)) || []; } catch(e) {}
// Inline chips (inside search card)
if (inlineContainer) {
inlineContainer.querySelectorAll(".recent-chip").forEach(function(el) { el.remove(); });
if (list.length === 0) {
inlineContainer.style.display = "none";
} else {
var clearBtn = inlineContainer.querySelector(".recent-clear");
list.slice(0, 5).forEach(function(q) {
var btn = document.createElement("button");
btn.className = "recent-chip";
btn.textContent = q.length > 28 ? q.substring(0, 26) + "..." : q;
btn.title = q;
btn.onclick = function() {
document.getElementById("draft-mode").value = "0";
var sq = document.getElementById("search-q");
sq.value = q;
htmx.trigger(document.querySelector(".search-form"), "submit");
};
inlineContainer.insertBefore(btn, clearBtn);
});
inlineContainer.style.display = "flex";
}
}
// Dashboard recent section (below brief/onboarding)
if (dashSection && grid) {
if (list.length === 0) {
dashSection.style.display = "none";
} else {
dashSection.style.display = "block";
grid.innerHTML = "";
list.slice(0, 6).forEach(function(q) {
var el = document.createElement("div");
el.className = "recent-item";
el.innerHTML = '<span class="recent-item__text">' + (q.length > 38 ? q.substring(0, 36) + "..." : q) + '</span>' +
'<span class="recent-item__arrow">→</span>';
el.onclick = function() {
document.getElementById("draft-mode").value = "0";
var sq = document.getElementById("search-q");
sq.value = q;
sq.scrollIntoView({ behavior: "smooth", block: "center" });
htmx.trigger(document.querySelector(".search-form"), "submit");
};
grid.appendChild(el);
});
}
}
}
function _clearRecentSearches() {
localStorage.removeItem(_RS_KEY);
_renderRecentSearches();
}
// Save on form submit
document.querySelector(".search-form").addEventListener("htmx:configRequest", function(evt) {
var q = (evt.detail.parameters.q || "").trim();
if (q) {
_saveRecentSearch(q);
setTimeout(_renderRecentSearches, 50);
}
});
// Render on page load
_renderRecentSearches();
// ── Reveal animation ─────────────────────────────────────────────────
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -30px 0px' });
document.querySelectorAll('.reveal').forEach(function(el) { observer.observe(el); });
</script>
{% include 'fragments/feedback_widget.html' %}
<script nonce="{{ csp_nonce }}" src="/static/activity-tracker.js" defer></script>
<script nonce="{{ csp_nonce }}" src="/static/admin-feedback.js" defer></script>
<script nonce="{{ csp_nonce }}" src="/static/admin-tour.js" defer></script>
{% if posthog_key %}
<script async nonce="{{ csp_nonce }}">
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('{{ posthog_key }}', {api_host: '{{ posthog_host }}'});
</script>
{% endif %}
</body>
</html>