Two changes to the LLM call path that together close the
chain-of-thought leakage surface:
1. _call_provider accepts an optional `response_format` (forwarded to
the OpenAI-shaped API — DeepSeek and OpenRouter both honour
{"type": "json_object"}). Threaded through call_llm so callers can
force structured output without monkey-patching the body. The
indicator-summary job will use this next: it'll require the model
to emit {"read": "..."} and parse the field, making prose outside
the JSON object physically impossible to publish.
2. Empty `content` no longer falls back to the `reasoning` field.
`reasoning` is the model's internal scratchpad — "Let's see...",
half-formed math, planning notes. We had a fallback that surfaced
it when content was null, but the field is intended for debugging
the model, not for publication. After the 2026-05-29 valuation
read leaked into production, the fallback is gone: an empty
content row now raises so the caller retries or skips, and the
previous good row remains visible. Test updated to assert this
safer behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dashboard's log panel now stretches in the grid to bottom-align
with the portfolio (a55168d), but .log-content still carried
max-height: calc(100vh - 240px) + overflow-y: auto from an older
layout. That produced an inner scrollbar inside the panel AND left
visible dead space below the scrolling region. Removing the cap lets
the panel grid handle the height and the page scroll handle very long
logs; no more nested scroll region.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two small fixes to the dashboard right column based on user feedback:
1. layout.css — drop align-self:start from #log-panel.
The panel previously shrank to its content, leaving the right-hand
column visually shorter than the indicators+portfolio stack on the
left. Removing the override lets the grid stretch the panel to the
full row span so the two columns now bottom-align. The log content
still sits at the top of the panel; any extra height is empty
padding inside the box.
2. portfolio.js — re-hydrate AI analysis expanded.
The 60s auto-refresh rebuilds the portfolio mount and re-attaches
the previously-generated analysis from localStorage, but the
<details> element was re-attached with open:false — collapsing it
under the user's cursor every minute. Users reasonably perceived
that as "the analysis disappeared". Hydrate as open:true so the
body stays visible; the user can still click the summary to
collapse manually within a refresh window.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Strategic Log Archive panel header used to show two engineery
badges sourced from server config:
new logs use: tone intermediate analysis speculative
Both were misleading:
- The tone badge described the SERVER's generator setting, not the
user's reading preference — confusingly disconnected from the
Novice | Pro toggle in the topbar that actually controls what AI
panels render.
- The analysis flag is always SPECULATIVE in production, so the badge
carried no information.
Drop the "new logs use:" prefix and the analysis badge. The tone badge
now mirrors the user's toggle: NOVICE → "novice", INTERMEDIATE → "pro"
(same data values; just the display label flips, matching the header
relabel from 3e1a14f).
Wiring lives in base.html: a new cassandraSyncToneBadge(tone) helper
updates the #tone-badge element when present. Called from
DOMContentLoaded (so the initial badge picks up the localStorage tone)
and from cassandraSetTone (so toggling the header updates the badge
live, without a page refresh).
current_tone / current_analysis are removed from _log_page_context —
log.html was the only consumer and neither key is referenced now.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous commit's i18n explanatory comment included the snippet
{% if user_lang == 'it' %} as illustration — but Jinja parses the
whole template, including content inside JS // comments, so that
literal got picked up as a real (unclosed) tag and every page rendered
with a TemplateSyntaxError. Rewrite the comment without the literal
Jinja syntax.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The strategic log content already refreshes via HTMX on lang-changed
(server-side translation lookup), but the chat sidebar's static labels
— title, hint, helper lede, textarea placeholder, Send button — were
baked into the HTML by Jinja at page render and only updated after a
full reload.
Add a tiny client-side i18n dictionary (CASSANDRA_I18N) plus
applyI18n(lang) in base.html. cassandraSetLang() now calls
applyI18n(newLang) right after the language PATCH succeeds and before
firing the HTMX triggers, so labels swap in step with the AI content.
Convention: <element data-i18n="key">…</element> sets textContent;
<input data-i18n-placeholder="key" …> sets .placeholder. Initial
render still goes through the existing {% if user_lang == 'it' %}
Jinja blocks so there's no flash of English on page load for IT users
— applyI18n is a no-op until the toggle is clicked.
Only the chat sidebar has bindings today. Adding more labels later is
a matter of dropping a key into the dict and tagging the element.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three connected fixes after the user spotted the 2026-05-28 IT log
cutting off mid-sentence:
1. translation: bump max_tokens 4000 → 8000.
call_llm()'s default cap was 4000, which is what the English log
generator itself uses as its ceiling. Italian expands roughly 15-25 %
over English in tokens, so any near-cap English source produced an
IT translation that hit finish_reason=length and returned a
truncated body — silently, because _call_provider() only raises when
content is fully empty. The strategic_log_translations table has
dozens of rows where completion_tokens landed at exactly 4000 with
content well under half the source length. 8000 gives ample
headroom for any of the five LANGUAGES we ship (en/it/es/fr/de).
2. log.html: localise the chat sidebar strings.
user_lang was already passed into the template by pages.py, so an
inline {% if user_lang == 'it' %} keeps it simple. Covers the
"Ask Cassandra" title, the "grounded on…" hint, the helper lede,
the textarea placeholder, and the Send button label.
3. chat endpoint: append respond_in_clause(user.lang) to the system
prompt. The chat conversation can now happen in IT — the model's
first reply lands in the right language even when the user's first
turn is short.
scripts/backfill_truncated_translations.py: one-off cleanup utility.
Scans strategic_log_translations for rows whose translated content is
< 70 % of the English source (the truncation signal — IT *expands*
beyond English, so a shorter translation is always suspect), deletes
them, and re-translates via the now-uncapped service. Supports --date,
--since, --all and --dry-run. The 2026-05-28 fan-out has already been
re-translated (13/13 rows). Other historical dates still hold older
truncations; the user can decide whether to backfill those (the script
is idempotent).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverses the polarity of 71155a6 to match the actual semantics:
- "Novice" stays labelled "Novice" → glossary tooltips, plainer prose.
- "Intermediate" is relabelled "Pro" → terse, assumes fluency, no
hand-holding. This is the mode an expert reader wants, so the "Pro"
badge actually fits.
Backend tone values (NOVICE, INTERMEDIATE) are unchanged — no API,
prompt, or stored-preference impact. Only the display strings flip.
Also drops the .tone-toggle button min-width: 10em override added in
71155a6. With "Intermediate" gone from the visible label, the longest
remaining label is "Novice" (6 chars), which fits the shared 5.5em
just like the theme and language toggles.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-visible relabel only. Backend tone value stays NOVICE — no API
contract change, no migration on stored user.digest_tone, the
glossary/plain-prose depth of analysis is unchanged. The marketing
intent is that "Pro" reads better than "Novice" on the dashboard
header; landing/pricing/privacy copy still uses the word "Novice" in
flowing prose, so leaving those alone keeps the existing explanations
coherent until they get a copy pass.
Toggle width: the popup expansion (positioned left:0/right:0) is
sized by the container, which previously sized to the active button.
When "Pro" was active the popup was too narrow to fit "Intermediate".
Bumped .tone-toggle button min-width to 10em so both buttons reserve
enough room for the longest label regardless of which one is active.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hovering a toggle (tone, theme, language) previously revealed the
non-active option inline next to the active one, which widened the
toggle and pushed its neighbours sideways. Now the non-active option
appears as a popup ABSOLUTELY POSITIONED below the active one — the
toggle's in-flow footprint stays exactly one button wide and tall, so
the other two toggles next to it never move when the user mouses over
one of them.
Mechanism: inside @media (hover: hover) the container becomes
position:relative and every button defaults to display:none. The
:hover/:focus-within rule renders all options as position:absolute
under the container. Specificity (.X[data=Y] btn[data=Y]) on the
active-button rule then pins the active option back into the static
flow at the top, so only the non-active end(s) up absolute — popup
grows downward only. margin-top:-1px makes the popup's top border
overlap the container's bottom border for a single shared edge.
z-index:60 sits above the markets bar (z-50). Touch devices keep
both options side-by-side (the @media gate); the mobile drawer keeps
both visible too.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Header layout was visibly broken on desktop after the mobile-drawer
change: flex space-between distributed brand, BETA, tone-toggle, nav
and header-right across the bar, so BETA drifted away from the brand
wordmark and the tone-toggle landed in the middle of the row.
Markup: brand + BETA are now wrapped in .header-left so they ride
together. The tone-toggle moves back inside .header-right next to
theme + lang where it logically belongs. CSS: the header switches to
grid (1fr auto 1fr) on desktop, which truly centres the nav regardless
of side-group widths. The mobile @media block reverts to flex so the
hamburger + slide-out drawer still work.
Toggle redesign (tone, theme, language):
- The single-button theme widget becomes a Light | Dark segmented
control matching the other two so all three read as one cluster.
cassandraToggleTheme is replaced by cassandraSetTheme(theme), the
toggle's data-theme attribute is synced on page load.
- All three share one CSS rule set: same padding, font, border, and
a min-width so the active-only width matches the expanded width
(no layout jump on hover).
- On hover-capable devices each toggle collapses to just the active
option; hovering (or keyboard focus-within) reveals both. Touch
devices keep both visible — the @media (hover: hover) gate handles
that and the mobile-drawer block overrides it explicitly so the
drawer-stacked controls remain full-width with both options shown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User reported phone still showing old behaviour (Qty/Avg portfolio
columns visible) even though the server-side JS had been updated.
Root cause: every <link>/<script> URL was a plain
/static/css/foo.css with no query string, so mobile Chrome served
the file from its HTTP cache rather than refetching it.
Adds a process-startup timestamp to the Jinja environment as
ASSET_VERSION (computed once when templates_env is imported). Every
<link>/<script> reference now appends `?v={{ ASSET_VERSION }}` so a
container restart bumps the URL and the browser refetches. 38 URLs
across 8 templates updated via sed; tests still pass.
Side benefit: future CSS/JS edits no longer require users to hard-
refresh.
The portfolio table is rendered client-side in portfolio.js (not by
the partials/portfolio.html Jinja template, which is unused for this
view). The previous commit's mobile-hide class made it into the
template but never reached the actual DOM. Adding the class to the
JS-emitted <th> and <td> strings so .dense .mobile-hide { display:
none } actually picks them up at ≤480px.
Three pieces of phone-side feedback:
1. Indicator group tabs wrap onto multiple rows instead of
horizontal-scrolling — every group is visible at a glance. Each
button keeps its own bottom border so wrapped rows stay
visually delimited; the container's bottom border is removed.
2. Portfolio holdings table hides Qty and Avg columns on mobile via
the mobile-hide class (same mechanism as the indicator table).
Remaining columns are the actionable ones: Ticker, Name, Last,
P/L, %.
3. Markets bar at the bottom compacts to one row per chip —
dot + code + change% only. The state word ("open" / "closed")
is implied by the dot colour; the index label, price, and
until-time are dropped on mobile. Grid columns drop their 220px
floor so the full set fits the viewport without horizontal
scroll (previously the bar scrolled within itself).
User reported the page rendering at ~3x viewport width on Android
Chrome with overflow-x:hidden clipping off most of the content.
Root cause: CSS grid items default to min-width:min-content, and the
indicator table inside the indicators panel has white-space:nowrap
cells. A long Symbol/Label value forces the table wider than its
panel; the panel propagates that minimum width up the grid; the grid
expands the .app-main; .app-main pushes the page wider than the
viewport. overflow-x:hidden then just chops the right portion off.
Fix has three parts:
1. .app and .app-main get min-width:0 and max-width:100vw so the
shell can't be wider than the viewport regardless of descendants.
2. Every direct child of .app-main (each panel) gets min-width:0
on mobile so individual panels can shrink past their min-content.
3. table.dense drops white-space:nowrap on text cells at ≤480px —
long symbols wrap to two lines instead of forcing the table wide.
Numeric cells keep nowrap (negative percentages reading as
"−12\n.34%" would be unreadable).
Also adds an overflow-x:auto fallback on .panel-body pre/code so
any code block in AI output scrolls within the panel instead of
blowing the page out.
Two related bugs reported on phone:
1. Drawer was unclickable — backdrop covered it. Root cause: the
.app-header (position:sticky, z-index:50) creates a stacking
context, so the drawer inside it had its z-index:100 clamped to
"above other things inside the header" but NOT above siblings of
the header. The backdrop at root-level z:90 then sat over the
drawer subtree.
Fix: when body.drawer-open, raise .app-header z-index to 110
so its entire descendant tree (drawer included) draws above the
z:90 backdrop. The page body under the header stays dimmed.
2. Horizontal scrolling on the dashboard. Root cause: the bottom
markets bar used `grid-template-columns: repeat(auto-fit,
minmax(220px, 1fr))`, which at 4+ markets blows out to 880px+ and
forces the page wider than the viewport.
Fix: on ≤480px the markets bar becomes a horizontally scrolling
flex strip with min-width:160px per chip — page stays narrow,
user swipes the bar to see more markets.
Also added overflow-x:hidden to html/body as a defensive net against
the fixed off-screen drawer creating overflow on Safari iOS.
Adds the @media (max-width: 480px) blocks specified in the design:
- dashboard.css: indicator table hides the 'mobile-hide'-tagged
columns (Label, Ccy, 1y, anchor, as-of), keeping Symbol / Price /
1d / 1m. Cell padding + font shrink. Group-tab buttons get a
bigger touch target.
- panels.css: header padding tightens, scroll-body max-height drops
to 60vh so log/news stay above the fold in the stacked layout.
- portfolio.css: overall grid keeps 2 cols (already at 640px) with
tighter gap; action buttons wrap; composer input goes full-width.
- log-chat.css: chat bubbles edge-to-edge, input row stacks, font-
size:14px on form fields to avoid iOS Safari zoom-on-focus.
- news.css: row collapses to age | (title / source) — source moves
under the title. Tag filter strip wraps.
- settings.css: form rows stack (label above input). Import picker
becomes single-column. Buttons full-width.
- auth.css: card padding tightens to free up vertical space when the
iOS keyboard is up. font-size:14px on inputs.
- public.css: hero headline clamp() lower bound drops to 22px; CTAs
stack full-width; pricing tier-grid stacks.
indicators.html: tagged the secondary cells with .mobile-hide rather
than relying on positional nth-child — the anchor column is
conditional and would have shifted positions.
336 tests still pass.
≤480px gets a hamburger button in the topbar and a fixed slide-out
panel from the right edge (width min(82vw, 320px)). The topbar keeps
only brand + tone toggle + hamburger visible; nav and the
header-right widgets (theme, lang, user menu, version meta) move
into the drawer.
Markup change: nav and .header-right are now wrapped in
.mobile-drawer, which is display:contents on desktop (no layout
effect) and a fixed translateX panel on mobile. The user-menu
dropdown chip hides on mobile and its links surface flat inside the
drawer.
JS: ~50 lines of vanilla. Tap hamburger / backdrop / ESC / swipe-
right-on-drawer all close. Clicking a nav link inside the drawer
closes it after the navigation kicks off so the panel doesn't linger
on the next page.
CSS: per-file @media block at the bottom of layout.css per the
agreed-upon organisation.
Pass `currency` to Stripe checkout for first-time buyers so Stripe
picks the matching `currency_options` rate configured on the Price
in the Dashboard (multi-currency Prices: one Price, per-currency
unit_amount). Operator configures the rates on existing Prices
prod_UaZ0xCpCboUGCN/price_*; this commit is the application-side
signal.
Currency precedence: explicit request body > Cloudflare cf-ipcountry
header > Accept-Language locale > GBP fallback. Only honoured when
the user has no stripe_customer_id yet — Stripe locks currency to
the customer record at first checkout, so existing customers keep
their original currency (they can switch via the portal).
Adds 4 tests: sniffed currency on new customer, body override beats
sniff, currency omitted for existing customer, and unit-tests for
the sniffing fallback chain.
Previously translate_log_for_active_languages and
translate_summary_for_active_languages added every successful
translation to the session and called session.commit() once at the
end. A single bad row (DB error, constraint violation, encoding
mismatch) rolled back the whole batch — losing all the languages that
had succeeded.
Wrap each row in session.begin_nested() so a per-row failure only
loses that one row. Track succeeded/failed counts and log them at the
end — escalating to error if zero succeeded out of N attempted, so
total failure surfaces in monitoring instead of just N warning lines.
DeepSeek's native API returns prompt_tokens/completion_tokens but not
`usage.cost`. OpenRouter returns both. Result: with DeepSeek-direct as
primary (current default), every LogResult.cost_usd was None — and
every downstream cost ledger row (AICall, StrategicLog,
IndicatorSummary, translation tables) stored None instead of the real
spend.
Added a per-model rate table and fallback computation in _call_provider:
when the upstream omits cost, multiply tokens by the table rates. If the
upstream DOES return cost, keep it (authoritative). Falls back to None
if both the upstream and the table miss.
deepseek-v4-flash rates: \$0.07/M input, \$0.28/M output (per DeepSeek).
Splits the 2571-line cassandra.css into ten focused stylesheets:
tokens (palette + fonts), layout (chrome), panels, dashboard,
portfolio, log-chat, auth, settings, news, public. base.html and
public_base.html load only what they need; auth pages (login,
verify, unsubscribe confirm) load tokens + layout + auth.
Brand drift-detection test repointed at tokens.css (where the
palette now lives). 291 tests still pass.
api.py was 933 lines mixing four distinct concerns: indicators +
news + strategic log (the JSON/HTMX API proper), the chat endpoint
+ its three private helpers (~200 lines), and the two HTML-only ops
endpoints /markets-bar + /health (~150 lines).
Extracted:
- app/routers/chat.py — POST /api/chat + _latest_quotes_by_group_chat,
_thesis_headlines_for_chat, _month_spend
- app/routers/ops.py — GET /api/markets-bar + GET /api/health +
_fmt_price helper
Both new routers use the same dependencies=[Depends(require_token)]
as api.py and are mounted at the /api prefix in app/main.py.
URL surface is byte-identical with no externally-visible change.
api.py shrinks to ~620 lines focused on indicators+news+log+settings.
Helpers shared with the original api.py (_md_to_html, _resolve_tone_param)
are imported from app.routers.api where needed in chat.py to avoid
duplication.
Also updated tests/test_chat_and_log_gates.py to mount chat_router
in its local test app, since /api/chat now lives there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
email_service.py was 428 lines covering three different concerns:
SMTP transport, OTP/welcome rendering (tightly coupled — same brand
template + theme), and digest rendering (a totally different shape
of email, different layout, different copy cadence). The two halves
changed at different cadences and made the file noisy to navigate.
Extracted render_digest_email + _DIGEST_HTML_TEMPLATE +
_strip_html_to_text to app/services/digest_email.py. SMTP transport
and the OTP/welcome surface stay in email_service.py.
Import sites updated: email_digest_job and test_email_render now
import render_digest_email from digest_email. The OTP/welcome
import sites (auth router, branding tests, test_email_service) are
untouched.
No behaviour change — pure relocation. Templates byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
openrouter.py was 790 lines mixing two orthogonal concerns:
- Prompt engineering (build_system_prompt, build_summary_*,
build_chat_*, build_daily_digest_*, etc.) — ~400 lines, changes
weekly as PROMPT_VERSION bumps
- LLM transport (call_llm, _provider_chain, _call_provider, retry
+ fallback machinery) — ~250 lines, rarely changes
Extracted the prompt-engineering surface to app/services/llm_prompts.py.
Transport stays in openrouter.py (consistent with the filename — the
OpenRouter URL is the transport's anchor).
All import sites (jobs, routers, services, tests) split their
multi-import lines into two: prompt-things from llm_prompts, transport
from openrouter. PROMPT_VERSION constant, _TONE_ALIASES, _resolve_tone,
and SYSTEM_PROMPT moved with the prompt functions.
No behaviour change — pure relocation. Function signatures, body, and
naming all preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three recently-added tables (strategic_log_translations,
indicator_summary_translations, csv_format_templates) drifted from
the codebase's existing naming convention:
- llm_model -> model
- llm_cost_usd -> cost_usd
- content_md -> content (on the two translation tables; csv_format
doesn't have a content field)
Also added prompt_tokens and completion_tokens to the three tables;
they were silently dropped at write time despite LogResult exposing
them.
All writer call sites (ai_log_job, indicator_summary_job,
llm_csv_parser) and reader call sites (api.py localized helpers)
updated to match. Tests realigned.
Migration 0025 uses batch_alter_table for SQLite compatibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two largest inline <script> blocks in settings.html — the cloud
sync modal/management UI (~145 lines) and the import widget wiring
(~245 lines) — moved to app/static/js/settings-sync.js and
settings-import.js respectively, included via <script src="..."
defer> at the bottom of the template.
Where the inline code referenced Jinja vars or {% if %} guards,
those values are now passed via data-* attributes on the relevant
DOM elements (or via window.cassandra* config objects for structured
data) and read in the static JS.
Smaller blocks (Stripe portal, digest prefs, language select,
invite copy) stay inline — each <40 lines and easier to follow
next to their markup. settings.html drops from 758 lines to roughly
half that.
The theme toggle's onclick attribute held a 140-character inline
IIFE that was hard to read amongst the other named-function
handlers in the same header. Promoted it to cassandraToggleTheme()
alongside cassandraSetTone / cassandraSetLang.
The HTMX log endpoints in api.py do their own localization via
_localized_content; the pages.py helper was added during the
initial localization wiring but was bypassed once HTMX rendering
landed. No call sites remain.
The /log page renders its content asynchronously by hitting
/api/log/latest?as=html and /api/log/by-date/{day}?as=html via HTMX.
Both endpoints returned StrategicLog.content (English) verbatim,
ignoring the new StrategicLogTranslation table entirely. The
_resolve_log_content helper I added to pages.py earlier was wired
into the page handlers themselves but never reached for HTMX swaps,
so Italian users only ever saw English content despite their
lang='it' preference being persisted and translations being
generated correctly.
Fix: add a _localized_content helper in api.py that looks up the
matching translation row for the requesting principal's lang.
_log_partial_payload gains a content_override arg; both HTMX
endpoints (log_latest, log_by_date) compute the override and pass
it through. JSON paths (?as= other than html) remain English to
avoid changing the public API contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related schema fixes from the code review:
- users.lang gets a single-column index. The ai_log_job and
email_digest_job both SELECT DISTINCT on this column every cycle;
even at low cardinality an index is the right shape.
- quotes_daily.symbol widened to VARCHAR(128) to match quotes.symbol
(widened back in 0005). Long Eurostat/ONS symbols would silently
truncate during rollup otherwise.
Models updated to match (User.lang gains index=True, QuoteDaily.symbol
goes to String(128)).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- pyproject already sets asyncio_mode=auto, so async def tests are
collected as async automatically. Removed the redundant decorator
from four files (test_i18n, test_llm_csv_parser, test_ticker_validate,
test_localization_integration); the bare async def is enough.
- StrategicLogTranslation.log_id used the _PK autoincrement type for
a non-PK FK column. Replaced with a portable BigInteger that emits
Integer on SQLite and BigInteger elsewhere — matches the migration's
sa.BigInteger() declaration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- indicator_summary_job.py imported its own copies of _month_spend and
_latest_quotes_by_group; _market_context.py already exposes these.
Switched to the canonical imports. Also fixed _market_context's
latest_quotes_by_group to actually filter null prices (it claimed to
in its docstring but lacked the WHERE clause).
- api.py duplicated REFERENCE_LINE as CHAT_REFERENCE_LINE — same string,
two sources of truth. Now imports REFERENCE_LINE.
- Chat endpoint used the deprecated `call_openrouter` alias and passed
an explicit `model=` that bypassed the provider chain. Switched to
`call_llm` with default model selection, then removed the alias.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stale comments referencing completed migrations:
- universe.py "remain live until step 10 of Phase G" — endpoints gone
- api.py "Portfolio endpoints moved to universe.py" — empty block
- csv_import.py "persist_pie removed in Phase G" — historical context
Dead Settings fields (all confirmed unreferenced by app code):
- CASSANDRA_PORT — port is hardcoded in docker-compose / uvicorn cmd
- POLAR_API_KEY — Polar was replaced by Stripe
- CASSANDRA_MOCK — env var still set by tests as a sentinel; the
Settings field itself was never read
- CASSANDRA_BASE_CURRENCY — "GBP" hardcoded inline elsewhere
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The .app-footer rule was kept "for /api/health" but the health page
doesn't reference it. #submit-btn and .form-row were leftovers from
the removed upload page. .pf-restore had a class attribute in
portfolio.js but no CSS rule — dropped the class attribute too.
Also removed the @media (prefers-color-scheme: dark) block — the
dashboard JS always sets data-theme so the media query was unreachable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- chat.js: pending indicator class was wrong (.pending instead of
chat-msg--pending) so the … waiting message never got italic/dim
- settings.html + cassandra.css: three invented CSS vars (--panel-bg,
--ok, --surface-1) had hardcoded fallbacks that broke dark mode;
replaced with real tokens (--surface, --positive)
- cassandra.css: .pf-secondary was scoped to .pf-actions but used
standalone in 4 places (sync modal, disable-sync, import cancel,
forget-pie button) — hoisted to a top-level selector
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues addressed:
1. The /settings language <select> was unstyled — .settings-select and
.settings-status classes didn't exist, so the dropdown rendered
with full native browser chrome and clashed visually with the rest
of the panel. Added a terminal-aesthetic select: transparent
background, 1px var(--border), custom chevron via crossed
linear-gradients, accent border on focus/hover. Disabled options
(ES/FR/DE 'coming soon') render in --dim.
2. Added a compact EN | IT pill in the topbar next to the theme
toggle, mirroring the .tone-toggle visual rhythm. Shown only when
a user is signed in (admins skipped). Optimistic UI: clicking
flips the pill immediately, PATCHes /api/settings/language, and
reverts on failure. On /log specifically the page reloads so the
user sees the localized version of the strategic log right away.
The /settings dropdown still surfaces all five languages (with ES/FR/DE
disabled) for visibility; the topbar pill keeps to the two active
languages to stay compact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds module-level _resolve_log_content(session, log_id, lang) helper
to app/routers/pages.py: looks up StrategicLogTranslation by (log_id,
lang) when lang != 'en'; falls back silently to the English original
when no translation row exists yet (the expected case for the first
hour after a new language activates, or when translation fails for a
specific log).
log_page / log_page_day pull cu.user.lang and thread it through
_log_page_context so the template renders the right variant.
Two tests cover both branches.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>