<style nonce="{{ csp_nonce }}">
/* === Results fragment — obsidian design system === */
/* Note: this is an HTMX fragment; CSS custom properties inherited from parent (index.html).
Token aliases here are fallbacks only, in case fragment is rendered standalone. */
:root {
--mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--obsidian: #0a0a0f;
--obsidian-mid: #12121a;
--obsidian-light: #1a1a26;
--glass: rgba(255, 255, 255, 0.04);
--glass-border: rgba(255, 255, 255, 0.06);
--glass-hover: rgba(255, 255, 255, 0.10);
--text-primary: rgba(255, 255, 255, 0.92);
--text-secondary: rgba(255, 255, 255, 0.55);
--text-tertiary: rgba(255, 255, 255, 0.30);
--text-ghost: rgba(255, 255, 255, 0.15);
--accent: #5eead4;
--accent-glow: rgba(94, 234, 212, 0.08);
--accent-ring: rgba(94, 234, 212, 0.30);
--signal-green: #34d399;
--signal-amber: #fbbf24;
--signal-red: #f87171;
--signal-blue: #60a5fa;
--dot-green: #22c55e;
--dot-amber: #f59e0b;
--dot-red: #ef4444;
--radius-sm: 6px;
--radius-md: 12px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
}
/* === Tabs — token underline pattern === */
.results-tabs {
display: flex;
gap: var(--space-6);
border-bottom: 1px solid var(--glass-border);
margin-bottom: var(--space-6);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.results-tabs .tab {
font-family: var(--mono);
font-size: var(--text-sm, 0.875rem);
font-weight: 400;
color: var(--text-tertiary);
background: none;
border: none;
padding: var(--space-3) 0;
cursor: pointer;
position: relative;
transition: color 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.results-tabs .tab:hover { color: var(--text-secondary); }
.results-tabs .tab.active { color: var(--text-primary); }
.results-tabs .tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 1px;
}
/* === Tab panels === */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* === Result card === */
.result-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);
transition: border-color 0.3s;
}
.result-card:hover { border-color: var(--glass-hover); }
/* === Methodology Cards === */
.methodology-card { margin-top: 8px; }
.methodology-card summary {
cursor: pointer;
color: var(--text-secondary);
font-family: var(--mono);
font-size: 0.82em;
font-weight: 400;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 4px;
transition: color 0.2s;
}
.methodology-card summary:hover { color: var(--accent); }
.methodology-card summary::before { content: "ⓘ"; }
.methodology-body {
background: var(--obsidian-light);
border-left: 2px solid var(--accent);
padding: 12px 16px;
margin-top: 6px;
font-family: var(--sans);
font-size: 0.9em;
line-height: 1.6;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.methodology-model {
font-family: var(--mono);
font-weight: 400;
color: var(--text-primary);
margin-bottom: 6px;
font-size: 0.9em;
}
.formula-steps { margin: 8px 0; }
.formula-steps .step {
padding: 2px 0;
color: var(--text-secondary);
font-family: var(--mono);
font-weight: 300;
font-size: 0.85em;
}
.station-breakdown { width: 100%; margin: 8px 0; font-size: 0.9em; border-collapse: collapse; }
.station-breakdown th {
text-align: left;
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 4px 8px;
border-bottom: 1px solid var(--glass-border);
}
.station-breakdown td {
font-family: var(--sans);
color: var(--text-secondary);
padding: 4px 8px;
border-bottom: 1px solid var(--glass-border);
}
.triggers-matched { margin-top: 8px; }
.triggers-matched div { font-family: var(--sans); font-size: 0.85em; color: var(--text-secondary); padding: 2px 0; }
.correction-cats { margin-top: 8px; }
.correction-cats div { font-family: var(--sans); font-size: 0.85em; color: var(--text-secondary); padding: 2px 0; }
.coverage-gaps { color: var(--signal-amber); font-family: var(--sans); font-size: 0.85em; margin-top: 8px; }
.fallback-note { font-family: var(--sans); color: var(--text-tertiary); font-style: italic; font-size: 0.85em; margin-top: 6px; }
.revision-context {
margin-top: 8px;
padding: 8px;
background: rgba(251, 191, 36, 0.06);
border-left: 2px solid var(--signal-amber);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
font-family: var(--sans);
font-size: 0.85em;
}
.methodology-footer {
font-family: var(--mono);
font-weight: 300;
color: var(--text-tertiary);
font-size: 0.78em;
margin-top: 10px;
border-top: 1px solid var(--glass-border);
padding-top: 6px;
}
.methodology-toggle {
margin-bottom: 12px;
font-family: var(--sans);
font-size: 0.85em;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 6px;
}
.methodology-toggle label { cursor: pointer; display: flex; align-items: center; gap: 6px; }
.methodology-toggle input[type="checkbox"] { cursor: pointer; accent-color: var(--accent); }
/* === Cost of Delay overlay === */
.cost-delay-box {
margin-top: 12px;
padding: 12px;
background: rgba(251, 191, 36, 0.06);
border: 1px solid rgba(251, 191, 36, 0.15);
border-radius: var(--radius-sm);
}
.cost-delay-title {
font-family: var(--mono);
font-weight: 400;
color: var(--signal-amber);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cost-delay-meta {
margin-top: 6px;
font-family: var(--sans);
font-size: 0.85em;
color: var(--text-secondary);
}
.cost-delay-table {
width: 100%;
margin-top: 8px;
font-size: 0.85em;
border-collapse: collapse;
}
.cost-delay-table th {
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
text-align: left;
padding: 4px 8px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--glass-border);
}
.cost-delay-table th.right { text-align: right; }
.cost-delay-table td {
font-family: var(--sans);
padding: 4px 8px;
color: var(--text-secondary);
border-bottom: 1px solid var(--glass-border);
}
.cost-delay-table td.right {
text-align: right;
font-family: var(--mono);
font-weight: 300;
}
.cost-delay-table td.value-col {
text-align: right;
font-family: var(--mono);
font-weight: 400;
color: var(--text-primary);
}
.cost-delay-risk {
margin-top: 8px;
font-family: var(--sans);
font-size: 0.85em;
color: var(--signal-amber);
}
/* === Share bar — dark theme === */
.share-bar {
border-top: 1px solid var(--glass-border);
padding: 16px 0;
margin-top: 8px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.share-bar__label {
font-family: var(--sans);
font-size: 0.82rem;
color: var(--text-tertiary);
}
.share-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-family: var(--mono);
font-size: 0.82rem;
font-weight: 400;
cursor: pointer;
border-radius: var(--radius-sm);
transition: border-color 0.2s, color 0.2s, background 0.2s;
text-decoration: none;
}
.share-btn--primary {
background: var(--glass);
color: var(--text-secondary);
border: 1px solid var(--glass-border);
}
.share-btn--primary:hover {
border-color: var(--accent-ring);
color: var(--accent);
}
.share-btn--secondary {
background: none;
color: var(--text-tertiary);
border: 1px solid var(--glass-border);
}
.share-btn--secondary:hover {
border-color: var(--glass-hover);
color: var(--text-secondary);
}
/* === Share email modal — dark theme === */
.share-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
z-index: 90;
}
.share-modal {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 28px;
max-width: 440px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.share-modal h3 {
font-family: var(--sans);
margin: 0 0 8px 0;
font-size: 1.05rem;
font-weight: 400;
color: var(--text-primary);
}
.share-modal p {
font-family: var(--sans);
margin: 0 0 20px 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.share-modal-error {
display: none;
font-family: var(--sans);
color: var(--signal-red);
font-size: 0.85rem;
margin-bottom: 12px;
padding: 8px 12px;
background: rgba(248, 113, 113, 0.08);
border-left: 2px solid var(--signal-red);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.share-modal-input {
width: 100%;
margin-bottom: 8px;
padding: 10px 12px;
font-family: var(--mono);
font-size: 0.875rem;
font-weight: 300;
color: var(--text-primary);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-sizing: border-box;
outline: none;
transition: border-color 0.3s;
}
.share-modal-input:focus { border-color: var(--accent-ring); }
.share-modal-input::placeholder { color: var(--text-tertiary); }
.share-modal-footer {
display: flex;
gap: 10px;
margin-top: 8px;
}
/* === Loading state for similar projects === */
.similar-loading {
text-align: center;
padding: 20px;
font-family: var(--sans);
color: var(--text-tertiary);
}
@media print {
.methodology-card .methodology-body { display: block !important; }
.methodology-card[open] .methodology-body, .methodology-card:not([open]) .methodology-body { display: block !important; }
.methodology-toggle { display: none; }
.share-bar { display: none; }
}
</style>
{% set TAB_META = {
"predict": {"label": "Permits", "panel": "panel-predict"},
"fees": {"label": "Fees", "panel": "panel-fees"},
"timeline": {"label": "Timeline", "panel": "panel-timeline"},
"docs": {"label": "Documents", "panel": "panel-docs"},
"risk": {"label": "Revision Risk", "panel": "panel-risk"},
"team": {"label": "Your Team", "panel": "panel-team"},
"similar": {"label": "Similar Projects", "panel": "panel-similar"},
} %}
{# Map tab keys to anchor IDs for methodology cards #}
{% set ANCHOR_MAP = {
"predict": "method-permits",
"fees": "method-fees",
"timeline": "method-timeline",
"docs": "method-documents",
"risk": "method-risk",
} %}
{% set order = section_order or ["predict", "timeline", "fees", "docs", "risk"] %}
{% if has_team %}
{% set order = order + ["team"] %}
{% endif %}
{# Show methodology toggle (localStorage-persisted) #}
{% if methodology %}
<div class="methodology-toggle">
<label>
<input type="checkbox" id="show-methodology"> Show methodology
</label>
</div>
{% endif %}
<div class="results-tabs">
{% for key in order %}
{% if results.get(key) %}
<button class="tab{% if loop.first %} active{% endif %}" data-panel="{{ TAB_META[key].panel }}">{{ TAB_META[key].label }}</button>
{% endif %}
{% endfor %}
{# === SESSION A: Similar Projects Tab Button (lazy-loaded via HTMX) === #}
<button class="tab" data-panel="panel-similar">Similar Projects</button>
</div>
{% for key in order %}
{% if results.get(key) %}
<div id="{{ TAB_META[key].panel }}" class="tab-panel{% if loop.first %} active{% endif %}">
<div class="result-card">
{{ results[key] | safe }}
</div>
{# === Sprint 58C: Methodology Card === #}
{% set m = (methodology.get(key) if methodology else None) %}
{% if m %}
<details class="methodology-card" id="{{ ANCHOR_MAP.get(key, 'method-' ~ key) }}">
<summary>How we calculated this</summary>
<div class="methodology-body">
{# model name — from nested methodology dict (Agent A) or fallback to tool name #}
{% set model_name = (m.methodology.model if m.methodology is defined and m.methodology is mapping else None) or m.get('tool', '') %}
{% if model_name %}
<p class="methodology-model">{{ model_name }}</p>
{% endif %}
{# formula steps (all tools provide this) #}
{% if m.formula_steps %}
<div class="formula-steps">
{% for step in m.formula_steps %}
<div class="step">{{ step }}</div>
{% endfor %}
</div>
{% endif %}
{# station breakdown — timeline only #}
{% if m.stations %}
<table class="station-breakdown">
<tr><th>Station</th><th>Median</th><th>Reviews</th><th>Trend</th></tr>
{% for s in m.stations %}
<tr>
<td>{{ s.name if s.name is defined else s.get('station_code', '—') }}</td>
<td>~{{ (s.p50_days | round(0) | int) if s.p50_days is defined else '—' }} days</td>
<td>{{ s.sample_count if s.sample_count is defined else s.get('sample_count', '—') }}</td>
<td>{{ s.trend if s.trend is defined else s.get('trend', '—') }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
{# === SESSION C: Cost of Delay overlay === #}
{% if m.cost_impact %}
<div class="cost-delay-box">
<div class="cost-delay-title">Financial Impact of Delay</div>
<div class="cost-delay-meta">
Monthly: ${{ "{:,.0f}".format(m.cost_impact.monthly_carrying_cost|float) }} ·
Weekly: ${{ "{:,.0f}".format(m.cost_impact.weekly_cost|float) }}
</div>
{% if m.cost_impact.scenarios %}
<table class="cost-delay-table">
<tr>
<th>Scenario</th>
<th class="right">Days</th>
<th class="right">Carrying Cost</th>
</tr>
{% for s in m.cost_impact.scenarios %}
<tr>
<td>{{ s.label }}</td>
<td class="right">{{ s.days }}</td>
<td class="value-col">${{ "{:,}".format(s.carrying_cost|int) }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if m.cost_impact.delay_cost is defined and m.cost_impact.delay_cost %}
<div class="cost-delay-risk">
Delay risk: +${{ "{:,}".format(m.cost_impact.delay_cost|int) }} if review takes {{ m.cost_impact.delay_days|int }} extra days
</div>
{% endif %}
</div>
{% endif %}
{# === END SESSION C === #}
{# triggers matched — predict_permits only #}
{% if m.triggers_matched %}
<div class="triggers-matched">
<strong>Project types identified:</strong>
{% for t in m.triggers_matched %}
<div>{{ t.trigger if t.trigger is defined else t.get('trigger', '—') }}
{% if t.form is defined and t.form %} → {{ t.form }}{% endif %}
{% if t.path is defined and t.path %} ({{ t.path }}){% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{# correction categories — revision_risk only #}
{% if m.correction_categories %}
<div class="correction-cats">
<strong>Common correction types:</strong>
{% for c in m.correction_categories %}
<div>{{ c.category if c.category is defined else c.get('category', '—') }}
{% if c.rate is defined and c.rate %}: {{ (c.rate | float * 100) | round(0) | int }}%{% endif %}
{% if c.detail is defined and c.detail %} — {{ c.detail }}{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{# revision context — estimate_fees only #}
{% if m.revision_context and m.revision_context.revision_rate is defined %}
<div class="revision-context">
<strong>Budget risk:</strong>
{{ (m.revision_context.revision_rate | float * 100) | round(0) | int }}% of similar projects see cost revisions.
{% if m.revision_context.budget_ceiling is defined and m.revision_context.budget_ceiling %}
Budget ceiling: ${{ m.revision_context.budget_ceiling | round(0) | int | string | replace(',', '') }}.
{% endif %}
</div>
{% endif %}
{# coverage gaps (only when non-empty) #}
{% if m.coverage_gaps %}
<div class="coverage-gaps">
<strong>Note:</strong>
{% for gap in m.coverage_gaps %}
<span>{{ gap }}</span>{% if not loop.last %} · {% endif %}
{% endfor %}
</div>
{% endif %}
{# fallback note — timeline only #}
{% if m.fallback_note %}
<div class="fallback-note">{{ m.fallback_note }}</div>
{% endif %}
{# footer: data source + sample size + freshness + confidence #}
<div class="methodology-footer">
{% if m.methodology is defined and m.methodology is mapping and m.methodology.data_source is defined %}
Source: {{ m.methodology.data_source }}
{% elif m.data_sources %}
Source: {{ m.data_sources | join(', ') }}
{% endif %}
{% if m.sample_size %} · {{ "{:,}".format(m.sample_size) }} records{% endif %}
{% if m.data_freshness %} · Last updated: {{ m.data_freshness }}{% endif %}
{% if m.confidence %} · Confidence: {{ m.confidence }}{% endif %}
</div>
</div>
</details>
{% endif %}
</div>
{% endif %}
{% endfor %}
{# === SESSION A: Similar Projects Tab Panel (lazy-loaded via HTMX) === #}
<div id="panel-similar" class="tab-panel">
<div class="result-card">
<div id="similar-content"
hx-get="/api/similar-projects?permit_type={{ (analyze_permit_type or '')|urlencode }}&neighborhood={{ (analyze_neighborhood or '')|urlencode }}&cost={{ analyze_cost or '' }}{% if analysis_id %}&analysis_id={{ analysis_id }}{% endif %}"
hx-trigger="intersect once"
hx-swap="innerHTML">
<div class="similar-loading">
Loading similar projects...
</div>
</div>
</div>
</div>
{# === END SESSION A === #}
{% if analysis_id %}
<!-- Share actions bar -->
<div id="share-bar" class="share-bar">
<span class="share-bar__label">Share this analysis:</span>
<!-- Email to team -->
<button id="share-email-btn"
class="share-btn share-btn--primary"
onclick="document.getElementById('share-email-modal').style.display='flex'">
Email to your team
</button>
<!-- Copy share link -->
<button id="share-link-btn"
class="share-btn share-btn--secondary"
onclick="copyShareLink('{{ analysis_id }}')">
Copy share link
</button>
<!-- Copy all text -->
<button id="copy-all-btn"
class="share-btn share-btn--secondary"
onclick="copyAllText()">
Copy all
</button>
</div>
<!-- Email share modal -->
<div id="share-email-modal" class="share-modal-backdrop">
<div class="share-modal">
<h3>Email to your team</h3>
<p>Enter up to 5 email addresses.</p>
<div id="share-email-error" class="share-modal-error"></div>
<div id="email-inputs">
<input type="email" class="share-modal-input" placeholder="colleague@example.com">
<input type="email" class="share-modal-input" placeholder="Add another (optional)">
<input type="email" class="share-modal-input" placeholder="Add another (optional)">
</div>
<div class="share-modal-footer">
<button class="share-btn share-btn--primary" style="flex:1;"
onclick="sendShareEmail('{{ analysis_id }}')">
Send
</button>
<button class="share-btn share-btn--secondary"
onclick="document.getElementById('share-email-modal').style.display='none'">
Cancel
</button>
</div>
</div>
</div>
<script nonce="{{ csp_nonce }}">
function copyShareLink(analysisId) {
const url = window.location.origin + '/analysis/' + analysisId;
navigator.clipboard.writeText(url).then(() => {
const btn = document.getElementById('share-link-btn');
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 2000);
}).catch(() => {
prompt('Copy this link:', window.location.origin + '/analysis/' + analysisId);
});
}
function copyAllText() {
const panels = document.querySelectorAll('.tab-panel .result-card');
let text = '';
panels.forEach(p => {
text += p.innerText + '\n\n---\n\n';
});
navigator.clipboard.writeText(text.trim()).then(() => {
const btn = document.getElementById('copy-all-btn');
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 2000);
}).catch(() => {
alert('Copy failed. Please select the text manually.');
});
}
async function sendShareEmail(analysisId) {
const inputs = document.querySelectorAll('#email-inputs input[type="email"]');
const emails = [];
inputs.forEach(inp => {
const v = inp.value.trim();
if (v) emails.push(v);
});
const errEl = document.getElementById('share-email-error');
errEl.style.display = 'none';
if (emails.length === 0) {
errEl.textContent = 'Please enter at least one email address.';
errEl.style.display = 'block';
return;
}
if (emails.length > 5) {
errEl.textContent = 'Maximum 5 recipients allowed.';
errEl.style.display = 'block';
return;
}
// Basic validation
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const em of emails) {
if (!emailRe.test(em)) {
errEl.textContent = `Invalid email: ${em}`;
errEl.style.display = 'block';
return;
}
}
try {
const resp = await fetch('/analysis/' + analysisId + '/share', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({emails}),
});
const data = await resp.json();
if (data.ok) {
document.getElementById('share-email-modal').style.display = 'none';
const btn = document.getElementById('share-email-btn');
const orig = btn.textContent;
btn.textContent = 'Sent!';
setTimeout(() => { btn.textContent = orig; }, 2500);
} else {
errEl.textContent = data.error || 'Send failed. Please try again.';
errEl.style.display = 'block';
}
} catch (e) {
errEl.textContent = 'Network error. Please try again.';
errEl.style.display = 'block';
}
}
</script>
{% endif %}
{# === Sprint 58C: Methodology toggle script === #}
{% if methodology %}
<script nonce="{{ csp_nonce }}">
(function() {
var toggle = document.getElementById('show-methodology');
if (!toggle) return;
var saved = localStorage.getItem('show-methodology');
if (saved !== null) toggle.checked = saved === 'true';
function applyToggle() {
document.querySelectorAll('.methodology-card').forEach(function(card) {
card.open = toggle.checked;
});
}
toggle.addEventListener('change', function() {
localStorage.setItem('show-methodology', toggle.checked ? 'true' : 'false');
applyToggle();
});
// Apply on load (handles restored state)
if (toggle.checked) {
applyToggle();
}
})();
</script>
{% endif %}
{# === Tab switching for results tabs === #}
<script nonce="{{ csp_nonce }}">
(function() {
// Wire up tab switching for .results-tabs / data-panel
function initResultsTabs() {
var tabs = document.querySelectorAll('.results-tabs .tab');
if (!tabs.length) return;
function activateTab(btn) {
// Deactivate all
tabs.forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) {
p.classList.remove('active');
});
// Activate clicked
btn.classList.add('active');
var panelId = btn.getAttribute('data-panel');
var panel = document.getElementById(panelId);
if (panel) panel.classList.add('active');
}
tabs.forEach(function(btn) {
btn.addEventListener('click', function() { activateTab(btn); });
});
}
// Run immediately (fragment injected into DOM after HTMX swap)
initResultsTabs();
})();
</script>