diff --git a/docs/superpowers/plans/2026-05-27-manual-portfolio-composition.md b/docs/superpowers/plans/2026-05-27-manual-portfolio-composition.md
new file mode 100644
index 0000000..b909c16
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-manual-portfolio-composition.md
@@ -0,0 +1,1434 @@
+# Manual Portfolio Composition Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add dashboard-native portfolio editing: an EDIT button toggles inline edit mode with an add-position form (live ticker validation + avg-cost or bought-on-date toggle) and per-row delete; brand-new users see the same form in the empty state instead of being sent to /settings.
+
+**Architecture:** Two new paid-only endpoints (`/api/ticker/validate`, `/api/ticker/historical`) thinly wrap the existing `fetch_yahoo` to give the JS the data it needs. A new `portfolio_edit.js` owns edit-mode toggle + form behaviour and writes positions via the existing `window.CassandraPortfolio.savePie` API. `portfolio.js` is touched only to (a) emit × buttons hidden by CSS and (b) replace the empty-state CTA. No server-side portfolio persistence; localStorage stays authoritative.
+
+**Tech Stack:** FastAPI · `httpx.AsyncClient` · vanilla JS · Jinja2 templates · existing `app/services/market.fetch_yahoo` · existing `app/services/ticker_universe.upsert_tickers`
+
+**Spec:** `docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md`
+
+---
+
+## File Structure
+
+**Create:**
+- `app/routers/ticker_validate.py` — both new endpoints (validate + historical) + the `fetch_yahoo_historical` helper.
+- `tests/test_ticker_validate.py` — backend tests.
+- `app/static/js/portfolio_edit.js` — edit-mode + add-position form behaviour.
+
+**Modify:**
+- `app/main.py` — register the new router (one new line).
+- `app/templates/dashboard.html` — EDIT button in panel header; hidden add-position form; new `
+
+```
+
+Replace with:
+
+```html
+
+
+ Portfolio
+ held locally · prices via /api/universe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
loading…
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add app/templates/dashboard.html
+git commit -m "dashboard: scaffold portfolio edit-mode markup"
+```
+
+## Context for this task
+
+- No tests for this — pure markup. JS will hide/show via class toggle.
+- The pencil SVG path is the standard "edit" icon (Feather-style).
+- All form fields are present in the DOM at all times; `portfolio_edit.js` shows/hides via CSS class on `#portfolio-panel`.
+
+---
+
+### Task 6: portfolio_edit.js — edit-mode toggle
+
+**Files:**
+- Create: `app/static/js/portfolio_edit.js`
+
+- [ ] **Step 1: Create the scaffold with edit-mode toggle**
+
+Create `app/static/js/portfolio_edit.js`:
+
+```javascript
+/* Dashboard-native portfolio editing.
+ *
+ * Owns: the EDIT button toggle, the add-position form behaviour
+ * (ticker validation on blur, qty/cost inputs, date-mode historical
+ * lookup, Add click), and per-row delete via event delegation.
+ *
+ * Reads/writes the portfolio via window.CassandraPortfolio.loadPie /
+ * savePie / mountAndRender — the same surface portfolio.js exposes
+ * for the CSV-import preview.
+ */
+(function () {
+ 'use strict';
+
+ const panel = document.getElementById('portfolio-panel');
+ const editBtn = document.getElementById('pf-edit-btn');
+ const doneBtn = document.getElementById('pf-done-btn');
+ const form = document.getElementById('pf-add-form');
+ if (!panel || !editBtn || !doneBtn || !form) return;
+
+ function enterEditMode() {
+ panel.classList.add('pf-editing');
+ form.hidden = false;
+ editBtn.hidden = true;
+ doneBtn.hidden = false;
+ editBtn.setAttribute('aria-pressed', 'true');
+ document.getElementById('pf-add-ticker').focus();
+ }
+
+ function exitEditMode() {
+ panel.classList.remove('pf-editing');
+ // Form stays visible when the pie is empty (empty-state UX handled
+ // by portfolio.js setting the pf-empty class on the panel).
+ if (!panel.classList.contains('pf-empty')) {
+ form.hidden = true;
+ }
+ editBtn.hidden = false;
+ doneBtn.hidden = true;
+ editBtn.setAttribute('aria-pressed', 'false');
+ }
+
+ editBtn.addEventListener('click', enterEditMode);
+ doneBtn.addEventListener('click', exitEditMode);
+})();
+```
+
+- [ ] **Step 2: Smoke-test in a browser**
+
+Restart the app (manual prod step — request user approval), navigate to the dashboard, click EDIT. Expected: form appears, focus lands in the ticker input; Done button appears; Edit button hides. Click Done: reverse.
+
+(No automated test; vanilla JS, no JS test framework in this repo.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/static/js/portfolio_edit.js
+git commit -m "portfolio-edit: edit-mode toggle scaffold"
+```
+
+---
+
+### Task 7: portfolio_edit.js — ticker validation on blur
+
+**Files:**
+- Modify: `app/static/js/portfolio_edit.js` (append to the existing IIFE before the closing `})();`)
+
+- [ ] **Step 1: Add validation logic**
+
+Inside the IIFE in `app/static/js/portfolio_edit.js`, before the final `})();`, append:
+
+```javascript
+ // ---- Ticker validation on blur -------------------------------------
+
+ const tickerInput = document.getElementById('pf-add-ticker');
+ const tickerStatus = document.getElementById('pf-add-ticker-status');
+ const costCurrencyEl = document.getElementById('pf-add-cost-currency');
+ const submitBtn = document.getElementById('pf-add-submit');
+ const warningEl = document.getElementById('pf-add-warning');
+
+ let validated = null; // {symbol, price, currency, as_of} or null
+
+ function setStatus(el, text, kind) {
+ el.textContent = text;
+ el.className = 'pf-add-status' + (kind ? ' pf-add-status--' + kind : '');
+ }
+
+ function updateSubmitState() {
+ const qty = parseFloat(document.getElementById('pf-add-qty').value);
+ const cost = parseFloat(document.getElementById('pf-add-cost').value);
+ submitBtn.disabled = !(
+ validated && qty > 0 && cost > 0 && isFinite(qty) && isFinite(cost)
+ );
+ }
+
+ function clearDuplicateWarning() {
+ warningEl.hidden = true;
+ warningEl.textContent = '';
+ }
+
+ function showDuplicateWarning(existing) {
+ warningEl.hidden = false;
+ warningEl.textContent =
+ `Already in your portfolio (${existing.qty} shares @ ` +
+ `${existing.avg_cost.toFixed(2)}). Adding will create a duplicate row.`;
+ }
+
+ async function validateTicker() {
+ const raw = tickerInput.value.trim().toUpperCase();
+ if (!raw) {
+ validated = null;
+ setStatus(tickerStatus, '', '');
+ costCurrencyEl.textContent = '';
+ clearDuplicateWarning();
+ updateSubmitState();
+ return;
+ }
+ setStatus(tickerStatus, 'checking…', 'pending');
+ try {
+ const r = await fetch('/api/ticker/validate?symbol=' + encodeURIComponent(raw));
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ const j = await r.json();
+ if (j.ok) {
+ validated = j;
+ setStatus(
+ tickerStatus,
+ '✓ ' + j.price.toFixed(2) + ' ' + (j.currency || ''),
+ 'ok',
+ );
+ costCurrencyEl.textContent = j.currency || '';
+ // Duplicate detection.
+ const pie = window.CassandraPortfolio.loadPie();
+ const existing = pie && (pie.positions || []).find(
+ p => (p.yahoo_ticker || '').toUpperCase() === j.symbol
+ );
+ if (existing) showDuplicateWarning(existing);
+ else clearDuplicateWarning();
+ } else {
+ validated = null;
+ setStatus(tickerStatus, '✗ ' + (j.error || 'not recognised'), 'err');
+ costCurrencyEl.textContent = '';
+ clearDuplicateWarning();
+ }
+ } catch (e) {
+ validated = null;
+ setStatus(tickerStatus, '✗ couldn’t validate — try again', 'err');
+ costCurrencyEl.textContent = '';
+ clearDuplicateWarning();
+ }
+ updateSubmitState();
+ }
+
+ tickerInput.addEventListener('blur', validateTicker);
+ document.getElementById('pf-add-qty').addEventListener('input', updateSubmitState);
+ document.getElementById('pf-add-cost').addEventListener('input', updateSubmitState);
+```
+
+- [ ] **Step 2: Smoke-test in browser**
+
+Restart app. EDIT → type `AAPL`, tab out → expect green check with price + currency. Type bogus → red error. Add button stays disabled.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/static/js/portfolio_edit.js
+git commit -m "portfolio-edit: ticker validate on blur + duplicate warning"
+```
+
+---
+
+### Task 8: portfolio_edit.js — Add button + localStorage merge
+
+**Files:**
+- Modify: `app/static/js/portfolio_edit.js` (append inside the IIFE)
+
+- [ ] **Step 1: Add the submit handler**
+
+Append (inside the same IIFE):
+
+```javascript
+ // ---- Add button → localStorage merge -------------------------------
+
+ function resetForm() {
+ tickerInput.value = '';
+ document.getElementById('pf-add-qty').value = '';
+ document.getElementById('pf-add-cost').value = '';
+ document.getElementById('pf-add-date').value = '';
+ validated = null;
+ setStatus(tickerStatus, '', '');
+ costCurrencyEl.textContent = '';
+ clearDuplicateWarning();
+ updateSubmitState();
+ tickerInput.focus();
+ }
+
+ function addPosition() {
+ if (submitBtn.disabled) return;
+ const qty = parseFloat(document.getElementById('pf-add-qty').value);
+ const cost = parseFloat(document.getElementById('pf-add-cost').value);
+ const sym = validated.symbol;
+
+ const pie = window.CassandraPortfolio.loadPie() || {
+ pie_name: null,
+ base_currency: 'GBP',
+ positions: [],
+ totals: {invested: 0, value: 0, result: 0},
+ warnings: [],
+ };
+ pie.positions = pie.positions || [];
+ pie.positions.push({
+ yahoo_ticker: sym,
+ t212_slice: sym, // shared shape with CSV path
+ name: validated.name || sym,
+ qty: qty,
+ avg_cost: cost,
+ currency: validated.currency || 'USD',
+ });
+ window.CassandraPortfolio.savePie(pie);
+ window.CassandraPortfolio.mountAndRender();
+ resetForm();
+ }
+
+ submitBtn.addEventListener('click', addPosition);
+
+ // Submit on Enter from any input within the form.
+ form.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && !submitBtn.disabled) {
+ e.preventDefault();
+ addPosition();
+ }
+ });
+```
+
+- [ ] **Step 2: Smoke-test in browser**
+
+EDIT → enter AAPL + qty 100 + cost 150.25 → Add. Row should appear in the table. Form clears, ticker input refocused. Refresh page; row persists (localStorage).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/static/js/portfolio_edit.js
+git commit -m "portfolio-edit: add button writes position to localStorage"
+```
+
+---
+
+### Task 9: portfolio_edit.js — Bought-on-date mode + historical lookup
+
+**Files:**
+- Modify: `app/static/js/portfolio_edit.js` (append inside the IIFE)
+
+- [ ] **Step 1: Add cost-mode toggle + historical lookup**
+
+Append:
+
+```javascript
+ // ---- Cost mode toggle + historical lookup --------------------------
+
+ const dateField = document.getElementById('pf-add-date-field');
+ const dateInput = document.getElementById('pf-add-date');
+ const dateStatus = document.getElementById('pf-add-date-status');
+ const costInput = document.getElementById('pf-add-cost');
+
+ function onModeChange() {
+ const mode = document.querySelector(
+ 'input[name="pf-cost-mode"]:checked'
+ ).value;
+ if (mode === 'date') {
+ dateField.hidden = false;
+ costInput.readOnly = true;
+ costInput.placeholder = 'auto-filled from date';
+ } else {
+ dateField.hidden = true;
+ costInput.readOnly = false;
+ costInput.placeholder = '150.25';
+ setStatus(dateStatus, '', '');
+ }
+ }
+
+ document.querySelectorAll('input[name="pf-cost-mode"]').forEach(r =>
+ r.addEventListener('change', onModeChange)
+ );
+
+ async function fetchHistorical() {
+ if (!validated) {
+ setStatus(dateStatus, 'enter a valid ticker first', 'err');
+ return;
+ }
+ const d = dateInput.value;
+ if (!d) {
+ setStatus(dateStatus, '', '');
+ costInput.value = '';
+ updateSubmitState();
+ return;
+ }
+ setStatus(dateStatus, 'looking up…', 'pending');
+ try {
+ const url = '/api/ticker/historical?symbol=' +
+ encodeURIComponent(validated.symbol) +
+ '&date=' + encodeURIComponent(d);
+ const r = await fetch(url);
+ if (r.status === 400) {
+ const j = await r.json().catch(() => ({detail: 'invalid date'}));
+ setStatus(dateStatus, '✗ ' + (j.detail || 'invalid date'), 'err');
+ costInput.value = '';
+ updateSubmitState();
+ return;
+ }
+ const j = await r.json();
+ if (j.ok) {
+ costInput.value = j.close.toFixed(2);
+ const tag = (j.actual_date && j.actual_date !== d)
+ ? '✓ from ' + j.actual_date
+ : '✓';
+ setStatus(dateStatus, tag, 'ok');
+ } else {
+ setStatus(dateStatus, '✗ ' + (j.error || 'no data'), 'err');
+ costInput.value = '';
+ }
+ } catch (e) {
+ setStatus(dateStatus, '✗ couldn’t fetch — try again', 'err');
+ costInput.value = '';
+ }
+ updateSubmitState();
+ }
+
+ dateInput.addEventListener('blur', fetchHistorical);
+```
+
+- [ ] **Step 2: Smoke-test in browser**
+
+EDIT → AAPL validated → switch radio to "Bought on date" → cost field becomes read-only → pick a Friday → cost field auto-fills. Pick a Saturday → cost fills with previous Friday's close, "✓ from YYYY-MM-DD" tag visible. Pick today's date → fills with today's close. Pick a future date → red "date cannot be in the future".
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/static/js/portfolio_edit.js
+git commit -m "portfolio-edit: bought-on-date mode + historical lookup"
+```
+
+---
+
+### Task 10: portfolio.js — render × buttons + empty-state form
+
+**Files:**
+- Modify: `app/static/js/portfolio.js` (two changes: `renderEmpty` and the row-render code)
+
+- [ ] **Step 1: Identify the row-render call site**
+
+```bash
+grep -n "
` rows for each position. (The skeleton is likely a `rows.map(...)` or string-concat. Locate it; this is where we add a per-row × cell.)
+
+- [ ] **Step 2: Modify each row to include an × delete cell**
+
+Inside the row-render loop, append a new `
` to each row's HTML:
+
+```javascript
+'
' +
+ '' +
+'
'
+```
+
+(Use whichever loop variable holds the index. If positions are iterated with `.map((p, i) => ...)`, `i` is the index. The × cell goes at the END of each `
`.)
+
+Also add a matching empty `
` to the table header row so column counts line up.
+
+- [ ] **Step 3: Replace the empty-state render**
+
+Find `function renderEmpty(mount)` (~line 180). Replace its body:
+
+```javascript
+ function renderEmpty(mount) {
+ var notice = window._cassandraPortfolioBackupExpired
+ ? '
Your encrypted cloud backup expired. ' +
+ 'Please re-upload your portfolio to refresh it.' +
+ '
'
+ : '';
+ var panel = document.getElementById('portfolio-panel');
+ if (panel) panel.classList.add('pf-empty');
+ mount.innerHTML =
+ '