Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ local_backup_*
#docs/
mockups/
docs/superpowers/*
.superpowers/

# ── Local utilities ──
config_backup.py
Expand Down
9 changes: 7 additions & 2 deletions app/ideas/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,16 @@ def inbox():
'purpose': idea.idea_purpose or 'investment',
'state': display_state,
'age_days': idea_ages.get(idea.id, 0),
'thesis': (idea.thesis_summary[:80] + '...') if idea.thesis_summary and len(idea.thesis_summary) > 80 else (idea.thesis_summary or ''),
'thesis': idea.thesis_summary or '',
'notes': idea.initial_notes or '',
'source': idea.source or '',
'action': action,
'edit_url': url_for('ideas.edit_idea', idea_id=idea.id),
})
ideas_json = json.dumps(ideas_data)
# Escape '<' so a thesis/name/notes value containing '</script>' cannot
# terminate the inline <script> block this is rendered into. json.dumps
# does not do this, and the page CSP allows unsafe-inline.
ideas_json = json.dumps(ideas_data).replace('<', '\\u003c')

# Serialize ALL checklists for the evaluation modal (pro users can switch)
checklists_json = json.dumps({
Expand All @@ -190,6 +194,7 @@ def inbox():
} for c in cl.criteria.order_by(KillCriterion.order).all()]
} for cl in all_kill_checklists
})
checklists_json = checklists_json.replace('<', '\\u003c')

response = make_response(render_template('inbox.html',
title="Idea Inbox",
Expand Down
82 changes: 66 additions & 16 deletions app/ideas/templates/inbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ <h5 class="modal-title" id="evaluateModalLabel">
{% include 'components/_tabulator_js.html' %}
{% if idea_count > 0 %}
<script src="{{ url_for('static', filename='js/data-table-component.js') }}"></script>
<script src="{{ url_for('static', filename='js/thesis-hover-card.js') }}"></script>
<script>
const ideasData = {{ ideas_json|safe }};
const checklistsData = {{ checklists_json|safe }};
Expand All @@ -155,6 +156,16 @@ <h5 class="modal-title" id="evaluateModalLabel">
let currentChecklistId = defaultChecklistId;
const savedAnswers = {};

/* ── HTML escaping — formatters build markup by concatenation ── */
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

/* ── Modal lifecycle: reset button + save answers on every hide ── */
document.getElementById('evaluateModal').addEventListener('hidden.bs.modal', function() {
if (currentEvalIdeaId) {
Expand All @@ -179,8 +190,10 @@ <h5 class="modal-title" id="evaluateModalLabel">
widthGrow: 2,
formatter: function(cell) {
const row = cell.getRow().getData();
const ticker = row.ticker ? '<br><span class="rcl-cell-ticker">' + row.ticker + '</span>' : '';
return '<strong>' + cell.getValue() + '</strong>' + ticker;
const ticker = row.ticker
? '<br><span class="rcl-cell-ticker">' + escapeHtml(row.ticker) + '</span>'
: '';
return '<strong>' + escapeHtml(cell.getValue()) + '</strong>' + ticker;
}
},
{
Expand Down Expand Up @@ -239,9 +252,20 @@ <h5 class="modal-title" id="evaluateModalLabel">
minWidth: 180,
widthGrow: 2,
formatter: function(cell) {
const row = cell.getRow().getData();
const val = cell.getValue();
if (!val) return '<span class="table-cell-muted">&mdash;</span>';
return '<span class="idea-thesis-cell">' + val + '</span>';
const hasDetail = !!(val || row.notes);
if (!hasDetail) return '<span class="table-cell-muted">&mdash;</span>';
// An idea can carry notes without a thesis — still worth opening.
const preview = val || 'Notes only';
// No aria-label: on role="button" it would override
// name-from-content, and the cell's text IS the thesis —
// screen readers must keep announcing it.
return '<span class="idea-thesis-cell has-detail"' +
' data-idea-id="' + row.id + '"' +
' tabindex="0" role="button" aria-expanded="false"' +
' aria-controls="thesis-hover-card">' +
escapeHtml(preview) + '</span>';
}
},
{
Expand All @@ -252,7 +276,7 @@ <h5 class="modal-title" id="evaluateModalLabel">
formatter: function(cell) {
const val = cell.getValue();
if (!val) return '<span class="table-cell-muted">&mdash;</span>';
return '<span class="idea-source-cell">' + val + '</span>';
return '<span class="idea-source-cell">' + escapeHtml(val) + '</span>';
}
},
{
Expand All @@ -267,17 +291,16 @@ <h5 class="modal-title" id="evaluateModalLabel">
var html = '<div class="idea-actions" style="justify-content: flex-end;">';

if (action.type === 'evaluate') {
html += '<button class="idea-action-btn action-kill" onclick="openEvaluateModal(' + row.id + ', \'' + row.name.replace(/'/g, "\\'") + '\', \'' + action.url + '\')">';
html += '<i class="bi ' + action.icon + '"></i> ' + action.label;
html += '<button type="button" class="idea-action-btn action-kill" data-evaluate-id="' + row.id + '">';
html += '<i class="bi ' + escapeHtml(action.icon) + '"></i> ' + escapeHtml(action.label);
html += '</button>';
} else {
var btnClass = 'action-promote';
html += '<a href="' + action.url + '" class="idea-action-btn ' + btnClass + '">';
html += '<i class="bi ' + action.icon + '"></i> ' + action.label;
html += '<a href="' + escapeHtml(action.url) + '" class="idea-action-btn action-promote">';
html += '<i class="bi ' + escapeHtml(action.icon) + '"></i> ' + escapeHtml(action.label);
html += '</a>';
}

html += '<a href="' + row.edit_url + '" class="idea-action-btn action-edit">';
html += '<a href="' + escapeHtml(row.edit_url) + '" class="idea-action-btn action-edit">';
html += '<i class="bi bi-pencil"></i>';
html += '</a>';
html += '</div>';
Expand All @@ -296,6 +319,32 @@ <h5 class="modal-title" id="evaluateModalLabel">
document.getElementById('inbox-state-filter').addEventListener('change', applyFilters);
document.getElementById('inbox-purpose-filter').addEventListener('change', applyFilters);

/* Delegated — Tabulator rebuilds rows on sort/filter/paginate. */
document.getElementById('inbox-table').addEventListener('click', function(e) {
var btn = e.target.closest('[data-evaluate-id]');
if (!btn) return;
var id = parseInt(btn.getAttribute('data-evaluate-id'), 10);
var row = ideasData.filter(function(d) { return d.id === id; })[0];
if (row) openEvaluateModal(row.id, row.name, row.action.url);
});

/* ── Thesis hover card ── */
const ideaMap = {};
ideasData.forEach(function(d) {
ideaMap[d.id] = { thesis: d.thesis, notes: d.notes };
});

const thesisCard = initThesisHoverCard({
container: '#inbox-table',
lookup: function(id) { return ideaMap[id]; }
});

// Sorting, filtering and paging rebuild rows — drop the card rather
// than leave it floating over whatever now occupies that position.
inboxTable.on('renderComplete', function() {
if (thesisCard) thesisCard.close();
});

function applyFilters() {
var searchVal = document.getElementById('inbox-search').value.toLowerCase().trim();
var stateVal = document.getElementById('inbox-state-filter').value;
Expand All @@ -308,6 +357,7 @@ <h5 class="modal-title" id="evaluateModalLabel">
data.name.toLowerCase().indexOf(searchVal) !== -1 ||
(data.ticker && data.ticker.toLowerCase().indexOf(searchVal) !== -1) ||
(data.thesis && data.thesis.toLowerCase().indexOf(searchVal) !== -1) ||
(data.notes && data.notes.toLowerCase().indexOf(searchVal) !== -1) ||
(data.source && data.source.toLowerCase().indexOf(searchVal) !== -1);
return stateMatch && purposeMatch && searchMatch;
});
Expand Down Expand Up @@ -345,7 +395,7 @@ <h5 class="modal-title" id="evaluateModalLabel">
var html = '';
for (var clId in checklistsData) {
var selected = (clId === currentChecklistId) ? ' selected' : '';
html += '<option value="' + clId + '"' + selected + '>' + checklistsData[clId].name + '</option>';
html += '<option value="' + clId + '"' + selected + '>' + escapeHtml(checklistsData[clId].name) + '</option>';
}
selectEl.innerHTML = html;
}
Expand Down Expand Up @@ -378,10 +428,10 @@ <h5 class="modal-title" id="evaluateModalLabel">
html += '<div class="' + cardClass + '" data-criterion-id="' + c.id + '">';
html += ' <div class="eval-criterion-header">';
html += ' <span class="eval-criterion-number">' + (i + 1) + '</span>';
html += ' <span class="eval-criterion-question">' + c.question + '</span>';
html += ' <span class="eval-criterion-question">' + escapeHtml(c.question) + '</span>';
html += ' </div>';
if (c.help_text) {
html += ' <div class="eval-criterion-help"><i class="bi bi-info-circle"></i> ' + c.help_text + '</div>';
html += ' <div class="eval-criterion-help"><i class="bi bi-info-circle"></i> ' + escapeHtml(c.help_text) + '</div>';
}
html += ' <div class="eval-criterion-actions">';
html += ' <label class="eval-toggle eval-toggle-pass">';
Expand All @@ -393,7 +443,7 @@ <h5 class="modal-title" id="evaluateModalLabel">
html += ' <span><i class="bi bi-x-circle"></i> Fail</span>';
html += ' </label>';
html += ' </div>';
html += ' <textarea class="eval-notes" placeholder="Notes (optional)" data-criterion-id="' + c.id + '">' + notesVal + '</textarea>';
html += ' <textarea class="eval-notes" placeholder="Notes (optional)" data-criterion-id="' + c.id + '">' + escapeHtml(notesVal) + '</textarea>';
html += '</div>';
});

Expand All @@ -405,7 +455,7 @@ <h5 class="modal-title" id="evaluateModalLabel">
html += ' </button>';
html += ' <div class="eval-quick-kill-form" id="quick-kill-form">';
html += ' <label>Why are you killing this idea?</label>';
html += ' <textarea class="eval-quick-kill-reason" id="quick-kill-reason" placeholder="e.g. CEO just resigned, regulatory risk too high, lost conviction...">' + savedQuickKill + '</textarea>';
html += ' <textarea class="eval-quick-kill-reason" id="quick-kill-reason" placeholder="e.g. CEO just resigned, regulatory risk too high, lost conviction...">' + escapeHtml(savedQuickKill) + '</textarea>';
html += ' <button class="eval-quick-kill-btn" id="quick-kill-btn" onclick="submitQuickKill()">';
html += ' <i class="bi bi-x-circle"></i> Kill Idea';
html += ' </button>';
Expand Down
140 changes: 138 additions & 2 deletions app/static/css/modules/_idea-inbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,42 @@
background: var(--success-500);
}

/* Thesis preview */
/* Thesis preview — also the trigger for the hover card */
.idea-thesis-cell {
/* display:block makes the whole cell a hover target. It also activates
overflow/text-overflow, which do not apply to inline boxes — so the
ellipsis is now drawn here, at the true cell width. No max-width: the
Thesis column uses widthGrow:2, and a fixed cap would truncate short of
the column edge and leave dead space. */
display: block;
font-size: 0.8125rem;
color: var(--gray-500);
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

/* Only cells that actually have a thesis or notes read as interactive. */
.idea-thesis-cell.has-detail {
cursor: help;
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: var(--gray-400);
text-underline-offset: 3px;
}

.idea-thesis-cell.has-detail:hover,
.idea-thesis-cell.has-detail.is-active {
color: var(--gray-900);
text-decoration-color: var(--accent-color);
}

.idea-thesis-cell.has-detail:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 2px;
}

/* Age cell */
.idea-age {
font-family: 'Space Mono', monospace;
Expand Down Expand Up @@ -558,3 +584,113 @@
opacity: 0.5;
cursor: not-allowed;
}

/* ── THESIS HOVER CARD ───────────────────────────── */
/*
Mounted on <body>, not inside the cell — Tabulator sets overflow:hidden on
.tabulator-cell, which would clip it. z-index sits below Bootstrap's modal
backdrop (1050) so the card can never float over the evaluate modal.
*/

.thesis-card {
position: fixed;
z-index: 1041; /* above the topbar/companion rail (1040), below the modal backdrop (1050) */
width: 360px;
max-width: calc(100vw - 2rem);
background: #fff;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: 0 12px 32px rgba(17, 24, 39, 0.16),
0 2px 6px rgba(17, 24, 39, 0.06);
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: opacity 0.12s ease, transform 0.12s ease;
}

.thesis-card.is-open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}

.thesis-card.is-pinned {
border-color: var(--accent-color);
box-shadow: 0 12px 32px rgba(45, 106, 79, 0.20),
0 0 0 3px rgba(45, 106, 79, 0.10);
}

.thesis-card .tc-scroll {
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain; /* don't chain to the page at the end of the scroll */
padding: 0.9rem 1rem;
}

.thesis-card .tc-scroll:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: -2px;
}

.thesis-card .tc-sec + .tc-sec {
margin-top: 0.85rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border-light);
}

.thesis-card .tc-label {
margin: 0 0 0.35rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--gray-400);
}

.thesis-card .tc-body {
margin: 0;
font-size: 0.8125rem;
line-height: 1.65;
color: var(--gray-700);
white-space: pre-wrap; /* capture-time line breaks are meaningful */
overflow-wrap: anywhere; /* a pasted URL must not force a sideways scrollbar */
}

.thesis-card .tc-foot {
display: none; /* JS switches to flex when pinned or overflowing */
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem 0.55rem;
border-top: 1px solid var(--border-light);
background: var(--gray-50);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}

.thesis-card .tc-hint {
font-size: 0.6875rem;
color: var(--gray-500);
}

.thesis-card .tc-close {
padding: 0.25rem 0.55rem;
border: 1px solid var(--border-light);
border-radius: 6px;
background: #fff;
font-size: 0.6875rem;
font-weight: 600;
color: var(--gray-700);
cursor: pointer;
}

.thesis-card .tc-close:hover {
background: var(--gray-100);
}

@media (prefers-reduced-motion: reduce) {
.thesis-card,
.thesis-card.is-open {
transition: none;
transform: none;
}
}
Loading
Loading