Commit graph

54 commits

Author SHA1 Message Date
21835afebe analyze: send the live toggle lang from the frontend, log resolution
The /api/analyze flow previously read principal.user.lang from the
DB on every request and ignored anything the client might send. That
races the language toggle's PATCH: a user can flip the toggle and
click Generate/Regenerate before the PATCH /api/settings/language
hits the DB, so the analysis is sent with the OLD persisted lang
while the toggle visually reads as the new one. From the user's POV
the analysis comes back in the wrong language.

Frontend portfolio.js now reads the live #lang-toggle data-lang
attribute (the same source the UI itself uses) and includes it in
the /api/analyze body. The dataset attribute is updated optimistically
by cassandraSetLang() before the PATCH fires, so it always reflects
what the user is looking at.

Backend universe.py prefers payload["lang"] when present and falls
back to user.lang otherwise — older clients (scripts, direct curl)
that don't send anything still get the DB-stored preference. The
resolution path is logged so we can confirm in prod which lang
actually drove a given request.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:32:58 +02:00
736d161990 ui: portfolio actions row + AI analysis regenerate
Two small UX changes to the portfolio panel:

1. "Forget this pie" is destructive enough to belong in edit-mode
   only. The button now hides by default and only surfaces when the
   #portfolio-panel.pf-editing class is on the panel (same surface
   that already shows per-row × and the add-position form). The
   element stays in the DOM so the existing click handler keeps
   working without re-mount.

2. "Generate AI analysis" disappears once an analysis exists. In its
   place a small "Regenerate" button is rendered inside the
   collapsible analysis box — in the summary header, right-aligned
   next to the timestamp. The button stops the summary's default
   toggle action so a click regenerates without collapsing the
   panel. runAnalysis() now tolerates either pf-analyze or pf-regen
   as the trigger, and showAnalysis() takes an optional
   onRegenerate callback so callers can wire the button to the
   current pie/enriched closure context. Re-hydration after the
   60s portfolio refresh passes the same callback so the button
   survives a refresh cycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:04:08 +02:00
652995feea ui: log panel bottom-aligns with portfolio via contain:size
Third attempt at fixing the dashboard's right-column alignment, this
time with the structural cause identified explicitly.

Previous attempts (a55168d, 8347c90) changed align-self on #log-panel
to control how the panel filled its grid area. They got the box
edges aligned, but the underlying problem was a different one:
CSS Grid auto-sizes each row by MAX(intrinsic content height across
items in that row). When the log content is taller than indicators +
portfolio combined, the grid grows rows 2-3 to fit it; portfolio
ends up in a stretched row with empty space below the actual content.

The fix is to stop the log's content from contributing to the grid
row sizing at all. `contain: size` on the log panel declares "my
contents do not affect my intrinsic size" — the grid then sizes rows
2-3 from indicators + portfolio alone, and the log stretches to
inhabit that combined height. A flex column inside the panel
(min-height: 0 on every level of the chain) lets .panel-body fill
the remaining height below the header and scroll instead of
overflowing.

The 1100px mobile breakpoint undoes the constraint: at that width
the grid restructures to a single column, the log no longer shares
a row with indicators + portfolio, and `contain: size` would just
collapse the panel to zero. There the log expands naturally and
page scroll handles it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:56:11 +02:00
8347c90235 ui: drop log-content's fixed-viewport scroll cap
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>
2026-05-29 12:58:06 +02:00
a55168d20a ui: log panel stretches to portfolio bottom; AI analysis stays expanded
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>
2026-05-29 12:35:10 +02:00
3e1a14f334 ui: flip tone relabel — "Pro" now maps to INTERMEDIATE, not NOVICE
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>
2026-05-29 11:23:52 +02:00
71155a67be ui: rename tone "Novice" → "Pro"; fit tone-toggle to longest option
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>
2026-05-29 11:17:43 +02:00
f57c863145 ui: header toggles expand downward, not sideways
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>
2026-05-29 11:11:46 +02:00
31a8efc27d ui: regroup topbar + unify the three header toggles
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>
2026-05-29 11:00:11 +02:00
1a20f0a15b mobile: tag Qty/Avg cells in JS-rendered portfolio table
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.
2026-05-28 19:13:52 +02:00
6459e8c43d mobile: wrap tabs, trim portfolio + markets bar columns
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).
2026-05-28 19:10:58 +02:00
8ec4ea1c72 mobile: clamp grid items + table cells to viewport width
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.
2026-05-28 19:02:30 +02:00
5ceee96135 mobile: fix drawer stacking + horizontal page overflow
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.
2026-05-28 18:55:04 +02:00
b6da1983d3 mobile: per-view ≤480px rules across the CSS bundle
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.
2026-05-28 18:43:36 +02:00
2b3ea33884 mobile: hamburger drawer (right-side slide-out)
≤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.
2026-05-28 18:36:37 +02:00
355593c4f7 css: split cassandra.css into per-section files
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.
2026-05-28 12:31:29 +02:00
f4d9c9f2ec settings: extract sync + import widget JS to static files
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.
2026-05-27 20:55:49 +02:00
59900f126f css: drop dead selectors (.app-footer, #submit-btn, .form-row, .pf-restore)
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>
2026-05-27 19:22:35 +02:00
e807e58629 ui: fix chat pending class, invented CSS vars, .pf-secondary scope
- 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>
2026-05-27 19:19:42 +02:00
fb71854238 i18n: style the settings select + add a topbar lang toggle
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>
2026-05-27 18:14:23 +02:00
11662c0ea8 portfolio-edit: add a quiet how-to hint inside the composer
A small italic muted line beneath the form explaining the controls:
"Type a symbol, then quantity and cost — or use the calendar to fill
cost from a buy date — then [+] to add. [×] next to an existing row
removes it."

Only renders while the composer itself is visible (i.e. in edit mode),
so it doesn't clutter the dashboard at rest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:27:44 +02:00
6377c929b8 portfolio-edit: form is edit-mode only; submit becomes a + glyph
Two related polishes:

- The add form was auto-shown by the empty-state path so brand-new
  users would see something to act on. That conflicts with the user's
  preference for "Edit always toggles the form, no other path." The
  empty state now shows guidance copy ("click edit to add one")
  instead. exitEditMode always hides the form too.
- The submit "add" word-button is replaced by a square accent-bordered
  + glyph (26×26). Matches the visual weight of the calendar ghost
  next to it but stays in the accent colour so it reads as primary.
  Adds a tiny active-state scale tick for tactile feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:22:13 +02:00
2ffd228976 portfolio-edit: indent add form 12px to match panel-header
Composer had zero horizontal padding so the leading `$` prompt was
flush with the panel border. Match the panel-header's 12px horizontal
inset so the form sits inside the panel's content gutter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:20:20 +02:00
477a47be2c dashboard: drop "held locally · prices via /api/universe" meta line
Subtitle was technical noise that didn't earn its space in the header.
Title alone reads cleaner. Kept the scoped panel-header layout override
in cassandra.css since it's harmless and future-proof against re-adding
header children.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:19:52 +02:00
f997a8adde portfolio-edit: fix unescaped apostrophe in fallback string
A single-quoted string literal "couldn't validate" was breaking the
parse because the apostrophe wasn't escaped. The page logged a syntax
error and none of the edit-mode JS ran. Backslash-escape it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:15:46 +02:00
3de43f55a6 portfolio-edit: fix rogue Edit/Done/Add visibility
Two interlocking bugs surfaced after the design pass:

1. CSS `display: inline-flex` on .pf-edit-btn/.pf-done-btn overrode the
   UA's `[hidden] { display: none }`, so the JS toggling `editBtn.hidden`
   had no visual effect — both buttons rendered side by side.
2. portfolio.js's empty-state path sets `form.hidden = false` but the
   populated-portfolio render path only removed the `pf-empty` class; it
   never reset `form.hidden = true`. So once a user went through the
   empty state, the add form stuck around — leaving the Add button
   visible on a populated dashboard.

Fixes are surgical: add an explicit `[hidden]` rule for the two
header pills, and re-hide the form in `renderPanel` unless edit mode
is currently active (so we don't yank the form out from under an
edit-in-progress).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:09:34 +02:00
bb41ee38f7 portfolio-edit: design pass — terminal-aesthetic inline composer
The previous CSS used invented variable names (--neu-dim, --err, --ok)
that don't exist in the project's design system; the form fell back to
hardcoded hex values and looked disconnected from the rest of the site.

Rebuilt against the real tokens (--border, --dim, --muted, --positive,
--negative, --warning, --accent) and the mono-first 'geopolitical-
terminal aesthetic' the rest of the dashboard uses:

  $ ticker  ✓ 172.40 USD  │  qty  @  cost  USD  📅              add
                                                              ────

- No boxed-form chrome. A dashed bottom rule separates the composer
  from the table below.
- Inputs lose their card-style boxes; they're underline-only with a
  faint accent wash on focus — feels like editing a command line.
- '$' prompt marker, '│' divider, '@' between qty and cost give the
  row a terminal grammar without being twee.
- Submit is a ghost pill in the accent colour; lights to solid only
  when enabled.
- All controls now respond correctly to the light/dark theme toggle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:06:32 +02:00
a9b7d4d8bb portfolio-edit: rebuild form as compact inline strip
Replace the multi-row wizard-style form (Ticker / Qty on row 1, mode
radios on row 2, Date+Cost on row 3) with a single horizontal strip
that sits unobtrusively above the portfolio table. The radio toggle is
gone; a small calendar icon next to the Cost input pops out a date
picker that auto-fills cost on selection and then hides itself.

Same input IDs, so the existing validate/Add/× handlers work unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:02:03 +02:00
70da4cdf84 css: portfolio edit-mode + add-position form styles 2026-05-27 14:56:43 +02:00
9a46a0daec portfolio: render hidden × per row; empty state shows add form
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:55:29 +02:00
84934827b8 portfolio-edit: bought-on-date mode + historical lookup
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:52:52 +02:00
58576a86fc portfolio-edit: add button writes position to localStorage
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:51:45 +02:00
ee6966399c portfolio-edit: ticker validate on blur + duplicate warning
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:50:48 +02:00
f1b242720d portfolio-edit: edit-mode toggle scaffold 2026-05-27 14:49:43 +02:00
b8ebba9503 ui: drop remaining T212-only framing from dashboard + import lede
- portfolio.js empty-state CTA: "Import a T212 CSV" → "Import a portfolio CSV"
- settings.html lede: lead with broker-agnostic copy; relegate the T212
  export path to a smaller secondary line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:41:05 +02:00
ce36ce36fd referrals: close D.3 — both parties get 45 days credit on conversion
The referral feature was half-built: codes captured, banner shown,
counts displayed — but no money flowed when a referred user paid.
The Settings page hard-coded "— (D.3)" for Active credits and the
marketing copy promised "50% off for 3 months" with nothing behind it.

Closing the loop:

- New `convert_referral(session, user)` in referral_service.py looks
  up the user's Referral row, stamps `converted_at` + `credited_at`,
  and extends `credit_until` by 45 days on BOTH the buyer and the
  referrer. Idempotent — replayed webhooks and renewals are no-ops.
  Stacks correctly when the user already has a credit window running
  (anchors at max(now, current_credit_until) like cli.grant_credit).

- Stripe webhook wires this into `_grant_paid`. A captured
  `first_paid_transition = user.tier != "paid"` gate avoids the DB
  lookup on every renewal event; convert_referral's own idempotency
  is the second line of defence.

- `_grant_paid` now takes `session` as its first positional arg so
  the conversion runs inside the same transaction as the tier flip
  and audit-row write. A mid-flight failure rolls everything back
  together — no partial state.

- Settings page replaces the "— (D.3)" placeholder with the live
  count of conversions still inside their 45-day credit window, plus
  a "+N days on your account" hint when the user has any credit of
  their own (referrer bonus, admin grant, or future refund-as-credit).

- Marketing copy on pricing.html + settings.html switches from "50%
  off for 3 months" to "45 days of paid access" — same economic value,
  honest about the actual mechanism (full free access rather than
  discounted billing).

Credit-amount rationale: 50% × 3 months ≈ 1.5 months of free
service ≈ 45 days. Pure-credit delivery is processor-agnostic, needs
no Stripe coupon plumbing, and stacks cleanly across referrals.

7 new tests in test_referral_conversion.py cover the happy path,
idempotency, no-referral no-op, credit stacking, deleted-referrer
survival, end-to-end webhook → credit landing, and the renewal-event
no-double-credit guarantee.

Also bundled: the Restore-button class fix from earlier
(portfolio.js — the cloud-restore "Restore" submit was unstyled and
picked up browser defaults; now uses .settings-btn like the rest of
the action-button family).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:05:29 +02:00
00211fec02 ui: collapsible settings sections + welcome-email + larger auth inputs
Settings page tidy-up driven by user feedback that it had grown too busy:

  - Each section (Import, Invite, Email digests, Cloud sync) is now a
    native <details>/<summary> accordion. Import stays open by default
    because /settings#import is the deep-link target from the dashboard
    CTA; the others collapse so the page lands quiet.
  - Manage subscription is a right-aligned gear-icon button instead of
    a rectangular text button — the descriptive copy moves into the
    tooltip. Frees up the Tier row of visual weight.

Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.

The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.

Tests rewritten to cover the new welcome-email path:
  - first login sends exactly one welcome email
  - returning user gets none
  - SMTP failure does not break the redirect
  - regression guard: returning user who opted out stays opted out

Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:32:59 +02:00
a07fd144ea stripe: per-cadence cooling-off + manage-subscription button
Bundles three related pieces that came out of the operator's first
end-to-end test of the paid flow:

1. Manage subscription button on /settings (paid users with a real
   Stripe sub — i.e. not credit-granted access). POSTs to the existing
   /api/stripe/portal endpoint; Stripe-hosted customer portal handles
   card updates, cancellation, monthly↔annual switch, invoice history.
   Replaces the stale "Paid features unlock with Paddle (D.3) or
   invite credits" hint for free users with a live link to /pricing.

2. Per-cadence cooling-off treatment:

   - **Annual £70**: 14-day free trial via
     subscription_data.trial_period_days=14. No money moves during
     the trial, so the CCR 2013 14-day refund question doesn't arise
     (nothing paid = nothing to refund). Card is still required at
     checkout so Stripe can charge on day 15.

   - **Monthly £7**: bills immediately. A 14-day trial there would
     give away ~50% of cycle one. Instead, /pricing now carries a
     required tick-box above the Subscribe buttons (subscribe stays
     disabled until checked) — by ticking, the user expressly
     consents to begin performance immediately and acknowledges that
     this extinguishes their statutory 14-day right under Reg 36
     CCR 2013. Consent collected on our own page (not via Stripe's
     account-wide consent_collection.terms_of_service) so each
     product can keep its own Terms URL as we add more.

3. T&C §6 clause 1 split into 1a (annual / trial substitute) +
   1b (monthly / Reg 36 waiver via on-page tick-box). Clause 2
   (post-cooling-off cancellation) unchanged.

Settings page shows "Free trial — N days remaining" while the
sub is in `trialing` status, falling back to "Paid subscription
active." once it transitions to active. Countdown is computed
server-side from User.stripe_trial_end_at (new column, migration
0020) populated by the subscription.created/updated webhook from
the Stripe trial_end timestamp; cleared on the trialing→active
transition and on revoke.

Drive-by: fixed a structlog kwarg-name collision on
`log.warning(..., event=event_type, ...)` in both polar_webhook.py
and stripe_billing.py — `event` is structlog's positional event
name and "got multiple values" crashed the user-not-found log
path. Renamed to `event_type=` everywhere it appeared. Caught by
the new trialing-stores-trial-end test.

Tests
- 4 new in test_stripe_billing.py covering monthly (no trial, no
  consent_collection), annual (trial, no consent), trialing stores
  trial_end, trialing→active clears trial_end.
- 1 existing test renamed + reworked for the consent split.
- Full suite: 224 passed, 5 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:06:19 +02:00
2297f9b2ed pricing: land £7/£70 paid tier and make behaviour match
Marketing + behaviour pass to get the site ready for Paddle approval.

Pricing page
- £7/month, £70/year headline (was "Coming soon").
- Bigger tier names (was 11px uppercase mono — looked like chips).
- Real CTAs (button base styles were only scoped to .hero__ctas).
- "Best value" badge + drop-shadow on the Paid card; full-width
  block CTAs that align across both cards.
- "Free vs Paid at a glance" comparison table beneath the cards.
- Compact "Invite a friend — both get 50% off for 3 months"
  callout with the detail explanation behind a <dialog> popup.

Tier copy + behaviour now consistent
- Free strategic-log refresh is every 6 hours, not hourly. New
  read-side filter on /api/log/{latest,by-date} restricts free
  users to logs at boundary hours (00/06/12/18 UTC); paid users
  still see the most recent.
- Follow-up chat is paid-only. /api/chat returns 402 for free;
  the chat sidebar on /log is replaced with a locked aside and
  chat.js no longer loads at all for free users.
- Dashboard meta lines + landing copy softened so they no longer
  promise hourly to everyone.

Future-proofing copy on public pages
- Dropped "free forever" wording (we may close the free tier).
- "Trading 212 CSV" became "broker CSV (Trading 212 today; more
  planned)" on pricing + landing; the actual import UIs stay
  T212-specific.

Terms
- Renamed Terms of Service -> Terms and Conditions (Paddle
  expectation), bumped last-updated to 2026-05-26.
- New §6 Refunds covering the 14-day cooling off, post-window
  cancellation, termination-by-us refunds, statutory rights, and
  how to request a refund.
- Renumbered §7-§14 and fixed the disclaimer link labels.

Tests
- 6 new tests in tests/test_chat_and_log_gates.py cover the
  chat 402 + the boundary-hour filter on both log endpoints.
- Full suite: 205 passed, 5 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:34:37 +02:00
77b867c924 landing: lift screenshots off the page; align feature thumbnails
Two fixes after a visual review:

1. Drop-shadow on every `.shot` so screenshots read as raised elements,
   not part of the page chrome. Soft slate shadow in light mode; deeper
   pure-black shadow + accent-tinted glow on hover in dark mode (the slate
   shadow disappears against the near-black background). The hover state
   also nudges the card up 1px so the lift is felt, not just seen.

2. Vertical alignment of the news / macro / hourly thumbnails inside the
   three feature cards. The cards are already equal-height (grid row,
   default stretch); now `.feature-card__body { flex-grow: 1 }` plus
   `display:flex; flex-direction:column` on the card pushes the body
   text to fill the column, which lands every thumbnail at the same y
   regardless of how much copy sits above it. Fixed 18px gap between
   body text and thumbnail.
2026-05-26 00:29:51 +02:00
4ded3632e9 landing: add product screenshots — hero shot, feature thumbnails, lightbox
Five PNGs at app/static/images/ (renamed from screenshot dumps):
- dashboard.png         — full dashboard hero shot, sits below the hero CTAs
- news-feed.png         — feature-card thumbnail: auto-tagged news feed
- indicators-read.png   — feature-card thumbnail: per-group AI commentary
- strategic-log.png     — feature-card thumbnail: hourly strategic log text
- chat-with-log.png     — "More views" gallery: ask follow-ups against a log

Every screenshot is a <button class="shot"> with data-full + data-caption;
click opens an HTML5 <dialog>-based lightbox. <dialog> handles focus trap,
ESC-to-close, inert background; the backdrop click closes too. Images use
loading="lazy" so the lightbox-only ones don't block first paint.

CSS appended to cassandra.css: .shot, .shot-hero, .shots-grid, .shot__caption,
and .shot-modal (+ ::backdrop). All colours pull from the existing palette
vars so light and dark themes stay coherent.

Total image weight: ~950 KB across all five — acceptable for a marketing
landing page with lazy-loaded thumbnails.
2026-05-26 00:27:02 +02:00
a7d657e1b4 beta: header chip flagged by BETA_MODE config (default on) 2026-05-25 22:42:19 +02:00
5c7cc4c6aa sync: detect orphaned blobs (pepper rotation) + fix AESGCM arg order
Adds an 8-byte HKDF fingerprint of the current pepper to portfolio_sync
rows. On fetch, a mismatch surfaces as 410 Gone (distinct from genuine
GCM corruption → 500), and the UI silently cleans up the dead row and
shows a soft "please re-import" notice instead of a confusing PIN
re-prompt. Legacy rows (pepper_fp NULL) are probed optimistically and
backfilled on success.

Also fixes a latent bug in unwrap(): AESGCM.decrypt args were swapped
(ct, nonce instead of nonce, ct), so restore-from-cloud always failed
even when the pepper was correct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:49:11 +02:00
f1903e1e61 public: landing + pricing + legal pages, apex-ready, lawyer-reviewed
Adds the unauthenticated surface that's needed to invite outsiders:

  - Landing (/) — dual-purpose root: dashboard for logged-in users,
    landing for everyone else. New maybe_current_user soft-auth helper
    in app/auth.py supports it without disturbing the per-route
    require_token deps on /news, /log, /upload, /settings.
  - About, Pricing, Disclaimer, Terms, Privacy — own router
    (app/routers/public.py), no auth dep, shared public_base layout
    (brand link, thin nav, footer with legal links + ICO ref + date).
  - Editorial positioning: news aggregator with a macro brain; tagline
    "Understand markets. Don't gamble on them."; anti-trading-as-gambling
    stance carried through About and Landing.

Legal pass following an independent lawyer-style review:

  - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22
    automated-decision line; explicit consent for sessionStorage sync
    key (PECR); 30-day IP-log retention; Art. 21 objection right;
    Children clause; Art. 33/34 breach-notification clause;
    international-transfer mechanism (IDTA + UK Addendum). ICO
    registration ZC098928 surfaced at the top.
  - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove
    advice-shaped wording ("what would invalidate the posture" gone);
    added italic carve-out citing FSMA / FCA COBS.
  - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34)
    commentator safe-harbour; "qualifies the Terms" line; hallucination
    wording fixed.
  - Terms: cl.4 explicit AI-training prohibition + harassment line;
    cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright
    claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension +
    pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights
    carve-out from the liability cap; cl.11 right to close account on
    material change; cl.12 non-exclusive jurisdiction + UK consumer
    local courts.

Code-side enforcement of the Privacy claim:

  - openrouter.py: outbound OpenRouter calls now carry
    X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request
    flag; the Privacy page discloses this caveat verbatim.

Apex domain prep:

  - branding.APP_URL flipped to https://read.markets (was app.). DNS for
    the apex already resolves; pending operator NPM step is a cert that
    covers the bare apex + a 301 from app.read.markets. No hard-coded
    subdomain references remain in code (verified with grep).

Nav + chrome:

  - app dropdown gains Pricing / Terms / Privacy / Disclaimer links.
  - login.html gains a small legal-links footer for the
    highest-leverage moment to surface them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:08:02 +02:00
b98d8d003c ui: aggregated read on top, hide stale rows, wire /log tone toggle; prompts v8
- dashboard grid: explicit "header" area as the first row so the
    aggregated read panel renders at the top instead of being
    auto-placed after the named areas.
  - indicators: hide rows flagged stale (older than the group's
    freshness threshold). Server still computes stale_symbols;
    rendering can be re-enabled by removing the
    `{% if not is_stale %}` wrapper in indicators.html.
  - /log: add tone-changed to #log-content's hx-trigger and include
    it in cassandraSetTone's selector list — toggling Novice /
    Intermediate on the Log page was previously a no-op.
  - prompts: bump PROMPT_VERSION 7→8. Strengthen the rational-vs-
    irrational framing in the strategic-log system prompt from
    aspirational to mandatory ("a paragraph without both lenses must
    be rewritten"). Require the same lens in the per-group summary,
    cross-asset aggregate, and portfolio commentary overrides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:36:04 +02:00
f326b41a08 sync: encrypted cloud backup for portfolios + settings UX rework
Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:15:54 +02:00
89632e9937 ui: light theme by default (dark is opt-in)
Swaps the role of `:root` (now light) and the data-theme attribute
(now `[data-theme="dark"]`) in cassandra.css, flips the localStorage
fallback from 'dark' to 'light' in base/login/verify templates, and
updates the theme-toggle label and the branding-consistency test
selectors to match.

Existing users with cassandra.theme=dark in localStorage still see
dark — their explicit preference wins. Only first-time visitors and
users with no stored preference shift to light.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:51:23 +01:00
9759080134 phase D milestones 1+2: referral system + paid-access gate
Lays the billing-prep spine before Paddle lands in D.3.

D.1 — referrals
- users.referral_code: unique 8-char URL-safe code (alphabet excludes the
  ambiguous 0/O/1/I/L). Generated lazily on first /settings hit so existing
  accounts pick one up without a backfill migration.
- users.referred_by_user_id + new referrals audit table (referrer,
  referred, created_at, converted_at, credited_at). converted_at /
  credited_at stay null until D.3 fills them via the Paddle webhook.
- POST /login accepts ?ref=<code>; the code rides on the signed
  pending-verify cookie so it survives the GET → POST → /verify hop.
- /settings page: email, tier badge, referral code chip + invite link
  with one-click copy, pending/converted/active-credits stats grid.
  Settings nav link added to the top bar.

Reward shape: when the referred user makes their first paid Paddle
subscription, both they and the referrer get 50% off for 3 months.
(D.3 wires the actual credit application via the Paddle webhook.)

D.2 — paid-access gate
- users.credit_until: timestamp until which a free-tier account has
  paid-tier access. Null = no credit. Populated by admin CLI now and the
  D.3 webhook later.
- app.services.access exposes paid_status(user) → PaidStatus dataclass
  (active / source / expires_at / days_remaining), is_paid_active() with
  admin-bearer-token bypass, and a require_paid FastAPI dependency that
  raises 402 Payment Required for free-tier callers.
- POST /api/analyze (portfolio AI commentary) gated behind require_paid.
- Settings page surfaces credit window when active ("free · credit · N
  day(s) remaining (expires YYYY-MM-DD)") and the upgrade hint when not.
- Admin CLI: python -m app.cli {grant-credit,revoke-credit,show-status}.
  grant-credit is idempotent — extends from max(now, current expiry) so
  re-running the command never erodes an existing grant.

Migrations 0013 (referrals) and 0014 (credit_until). Tests cover the
paid-status truth table, code generation + normalisation, CLI argument
parsing, and the pending-cookie ref roundtrip (29 new tests).
2026-05-21 23:25:35 +01:00
2013bfa8cc news: auto-tag headlines + market-aware cadence + filter UI
- Move news_job from hourly to 3x/hour (cron 10,30,50), with a CadencePolicy
  gate that throttles to active hours (07-21 UTC weekdays at 20 min), off-hours
  (3 h), weekends (6 h). Keeps the daytime feed fresh without spamming RSS
  sources overnight.
- Tag each headline on ingestion via DeepSeek (BATCH_SIZE=25, max_tokens=4000,
  json.JSONDecoder().raw_decode + per-row regex recovery for resilient parsing).
  Vocabulary: 16 tags including new EU / USA / AI / Conflict. NULL tags are
  picked up automatically on the next news_job run, so back-tagging is implicit
  rather than a separate migration step.
- Tag UI: pill bar above the feed with off → include → exclude cycle on click;
  shift-click jumps straight to exclude. State persists in localStorage and is
  injected into /api/news requests via htmx:configRequest. Per-row chips sit to
  the right of the headline (new 5-column grid: age | source | title | tags |
  UTC) so vertical density stays high.
- Strategic log header bug: model was hallucinating "(Updated 21:30 UTC)" in
  future tense. Bumped PROMPT_VERSION 6→7, added explicit ban on time-of-day
  clauses, and supply the actual current UTC time in the user prompt so the
  model has no need to invent one.

Migration 0012 adds headlines.tags (JSON, nullable). Tests cover vocabulary
integrity, validation/normalisation, and the JSON-recovery parser (17 tests).
2026-05-21 23:25:03 +01:00
6e7f57c6b2 phase G: data minimisation + passwordless auth + DeepSeek-first LLM
Server no longer holds portfolios. Holdings live in the browser
(localStorage); the server publishes an anonymous ticker_universe and a
gzipped /api/universe payload identical for every authenticated user, so
access patterns can't betray which tickers a user holds. AI commentary
is generated ephemerally from the browser-supplied pie and the cost
ledger row records no positions. Migrations 0009-0011 added the
universe table and dropped positions / portfolio_snapshots /
portfolios.

Authentication is now e-mail OTP only. Migration 0010 dropped
password_hash and email_verified (every active session is by
construction proof of email control). The /signup endpoint is gone;
signup and login share a single email-entry page. Email rendering is
HTML+plain-text multipart with a shared brand palette (app/branding.py)
asserted in sync with the CSS by a drift-detection test.

LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com)
with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and
indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE)
per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION
bumped to 6 with an educational anti-TA / anti-gambling stance baked
into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX,
yield curve, HY OAS, etc.) with JS-positioned tooltips that survive
viewport edges and sticky bars. Model name and tokens hidden from the
user UI; still recorded in StrategicLog.model and AICall for admin.

Layout adds a sticky top nav, a sticky bottom markets bar (one chip per
exchange with status LED + headline index + 1d change), and
Phase H feedback reporting is queued in tasks/todo.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:16:57 +01:00