Consolidate Boundaries + History under the account; sectioned /account

The inline Boundaries/History panels lived on the home page, so opening them while
scrolled left you stranded. Move everything "yours" behind the account icon:

- Home header slims to: Saved (opens a right-side flyout, signed-in) · shield
  (Boundaries indicator — filled when active — linking to the Boundaries section) ·
  avatar. The inline panels + the home "saved" view are gone.
- /account is now a sectioned hub (left sidebar on desktop, top tabs on mobile),
  OPEN TO EVERYONE with each section self-gating: Profile (sign-in), Saved (sign-in),
  History (device/account), Boundaries (device/account), Admin (admins). This keeps
  Boundaries/History usable without an account (they're device-local) while
  consolidating the UI — and every section loads at the top, fixing the scroll bug.
- Lift Calm Filters and History into shared stores (prefs.svelte.js, history.svelte.js)
  so the home feed (applies/records) and the account page (edits/manages) share one
  source of truth. New SavedFlyout component. Card boundary actions only render when a
  handler is provided.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-04 01:59:53 +00:00
parent d1a4b24627
commit 3924d927aa
10 changed files with 3184 additions and 294 deletions
@@ -155,9 +155,9 @@
</div>
{/if}
</span>
{#if article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
{#if onaction && article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if onaction && article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if onaction && article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
</div>
</div>
</article>
@@ -42,9 +42,9 @@
<section class="panel rise">
<div class="head">
<h2>Your boundaries</h2>
<button class="close" onclick={() => onclose?.()} aria-label="close">done</button>
{#if onclose}<button class="close" onclick={() => onclose?.()} aria-label="close">done</button>{/if}
</div>
<p class="reassure">Kept on this device. Nothing leaves it, and there's no account.</p>
<p class="reassure">Your calm filters — kept on this device, and synced to your account when you're signed in.</p>
<div class="group">
<label class="label" for="avoid">Avoid words or phrases</label>
+26 -23
View File
@@ -1,6 +1,6 @@
<script>
import Avatar from './Avatar.svelte';
let { onBoundaries, onHistory, onaccount, user = null, filtersOn = false } = $props();
let { onSaved, onaccount, user = null, boundariesActive = false } = $props();
</script>
<header class="appbar">
@@ -10,17 +10,19 @@
</a>
<nav class="utils" aria-label="Your controls">
<button class:on={filtersOn} onclick={onBoundaries} title="Your boundaries">
{#if user}
<button class="util" onclick={onSaved} title="Saved articles">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4z"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
<span>Saved</span>
</button>
{/if}
<a class="util shield" class:on={boundariesActive} href="/account?section=boundaries"
title={boundariesActive ? 'Boundaries are on' : 'Your boundaries'}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
<span>Boundaries</span>
</button>
<button onclick={onHistory} title="What you've seen">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" fill="none"
stroke="currentColor" stroke-width="1.8" /><path d="M12 7v5l3.5 2" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>History</span>
</button>
fill={boundariesActive ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.8"
stroke-linejoin="round" /></svg>
</a>
{#if user}
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
<Avatar {user} size={30} />
@@ -39,29 +41,30 @@
position: sticky;
top: 0;
z-index: 20;
/* a whisper of warmth under the bar */
box-shadow: 0 1px 0 rgba(40, 38, 28, 0.02);
}
.bar { display: flex; align-items: center; justify-content: space-between; height: 78px; }
.brand { display: inline-flex; align-items: center; }
.logo { height: 54px; width: auto; display: block; }
.utils { display: flex; gap: 6px; }
.utils button {
.utils { display: flex; align-items: center; gap: 4px; }
.utils .util {
display: inline-flex; align-items: center; gap: 7px;
background: none; border: 1px solid transparent; border-radius: 999px;
padding: 7px 13px; color: var(--muted); font-size: 0.86rem; cursor: pointer;
padding: 7px 12px; color: var(--muted); font-size: 0.86rem; cursor: pointer;
transition: background 0.14s ease, color 0.14s ease;
}
.utils button svg { width: 17px; height: 17px; }
.utils button:hover { background: var(--accent-soft); color: var(--accent-deep); }
.utils button.on { color: var(--accent-deep); }
.utils .signin { border-color: var(--line); color: var(--accent-deep); }
.utils .signin:hover { background: var(--accent-soft); }
.acct { padding: 4px; display: inline-flex; }
.utils .util svg { width: 18px; height: 18px; }
.utils .util:hover { background: var(--accent-soft); color: var(--accent-deep); }
.shield.on { color: var(--accent); }
.acct { background: none; border: none; padding: 4px; display: inline-flex; cursor: pointer; }
.signin {
background: none; border: 1px solid var(--line); border-radius: 999px;
padding: 7px 13px; color: var(--accent-deep); font-size: 0.86rem; cursor: pointer;
}
.signin:hover { background: var(--accent-soft); }
/* On phones the utilities live in the bottom tab bar ("You") instead. */
/* On phones the bottom tab bar handles navigation; keep the bar to the logo. */
@media (max-width: 720px) {
.bar { height: 66px; justify-content: center; }
.utils { display: none; }
@@ -0,0 +1,93 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { savedIds, toggleSave } from '$lib/auth.svelte.js';
import { track } from '$lib/analytics.js';
let { onclose } = $props();
let items = $state([]);
let loading = $state(true);
onMount(async () => {
try {
items = (await getJSON('/api/saved')).items;
} catch {
/* not signed in / transient */
} finally {
loading = false;
}
});
// Filter by the live saved set so an unsave removes it immediately.
let shown = $derived(items.filter((a) => savedIds.has(a.id)));
function onkey(e) {
if (e.key === 'Escape') onclose?.();
}
</script>
<svelte:window onkeydown={onkey} />
<div class="overlay" onclick={onclose} role="presentation">
<aside class="drawer" onclick={(e) => e.stopPropagation()} aria-label="Saved articles">
<div class="head">
<h2>Saved</h2>
<button class="x" onclick={onclose} aria-label="Close">×</button>
</div>
{#if loading}
<p class="muted">Loading…</p>
{:else if shown.length}
<ul>
{#each shown as a (a.id)}
<li>
<a class="t" href={a.url} target="_blank" rel="noopener" onclick={() => track('open', a.id)}>{a.title}</a>
<div class="meta">
<span class="src">{a.source}</span>
<button class="rm" onclick={() => toggleSave(a.id)}>Remove</button>
</div>
</li>
{/each}
</ul>
<a class="all" href="/account?section=saved" onclick={onclose}>Open Saved in account →</a>
{:else}
<p class="muted">Nothing saved yet — tap <strong>Save</strong> on a story to keep it here.</p>
{/if}
</aside>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(10, 22, 38, 0.32);
z-index: 50;
display: flex;
justify-content: flex-end;
}
.drawer {
background: var(--surface);
width: min(380px, 100%);
height: 100%;
box-shadow: -10px 0 30px rgba(40, 38, 28, 0.12);
padding: 20px 22px;
overflow-y: auto;
animation: slide 0.2s ease both;
}
@keyframes slide { from { transform: translateX(20px); opacity: 0.4; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .drawer { animation: none; } }
.head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
.head h2 { font-size: 1.4rem; }
.x { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--muted); cursor: pointer; }
.muted { color: var(--muted); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 12px 0; border-bottom: 1px solid var(--line); }
li:last-of-type { border-bottom: none; }
.t { color: var(--ink); font-weight: 600; font-size: 0.95rem; display: block; }
.t:hover { color: var(--accent-deep); }
.meta { display: flex; align-items: center; justify-content: space-between; margin-top: 5px; }
.src { color: var(--muted); font-size: 0.78rem; }
.rm { background: none; border: none; color: var(--muted); font-size: 0.78rem; text-decoration: underline; cursor: pointer; }
.rm:hover { color: var(--accent-deep); }
.all { display: inline-block; margin-top: 16px; color: var(--accent-deep); font-size: 0.88rem; }
</style>