Add Boundaries panel to the UI: precise personal avoid-terms first
- New BoundariesPanel.svelte: gentle, device-local controls. Avoid words/phrases first (the trust-critical piece), then 'Paused for now' and 'Always hidden', each with easy remove. Reassures 'nothing leaves this device'; adding a term refreshes the brief/feed immediately. - Quiet 'Boundaries' toggle (active indicator) replaces the old calm bar, keeping the first viewport calm. - Wording stays gentle throughout: avoid / pause / hide / boundaries — never blocked/banned/blacklist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
<script>
|
||||
let { prefs, onchange, onclose } = $props();
|
||||
|
||||
let term = $state('');
|
||||
|
||||
function addTerm() {
|
||||
const v = term.trim();
|
||||
if (v && !prefs.avoid_terms.includes(v)) {
|
||||
prefs.avoid_terms.push(v);
|
||||
onchange?.();
|
||||
}
|
||||
term = '';
|
||||
}
|
||||
function removeTerm(i) { prefs.avoid_terms.splice(i, 1); onchange?.(); }
|
||||
function removePause(i) { prefs.pauses.splice(i, 1); onchange?.(); }
|
||||
function removeMute(kind, value) {
|
||||
const arr = kind === 'topic' ? prefs.mute_topics : prefs.mute_flavors;
|
||||
const i = arr.indexOf(value);
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
onchange?.();
|
||||
}
|
||||
function reset() {
|
||||
for (const k of ['avoid_terms', 'pauses', 'mute_topics', 'mute_flavors', 'include_topics', 'include_flavors'])
|
||||
prefs[k].length = 0;
|
||||
prefs.max_cortisol = null;
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function when(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
let mutes = $derived([
|
||||
...prefs.mute_topics.map((v) => ['topic', v]),
|
||||
...prefs.mute_flavors.map((v) => ['flavor', v]),
|
||||
]);
|
||||
let anything = $derived(prefs.avoid_terms.length || prefs.pauses.length || mutes.length);
|
||||
</script>
|
||||
|
||||
<section class="panel rise">
|
||||
<div class="head">
|
||||
<h2>Your boundaries</h2>
|
||||
<button class="close" onclick={() => onclose?.()} aria-label="close">done</button>
|
||||
</div>
|
||||
<p class="reassure">Kept on this device. Nothing leaves it, and there's no account.</p>
|
||||
|
||||
<div class="group">
|
||||
<label class="label" for="avoid">Avoid words or phrases</label>
|
||||
<div class="addrow">
|
||||
<input id="avoid" type="text" bind:value={term} placeholder="a name, a condition, a topic you'd rather not meet"
|
||||
onkeydown={(e) => e.key === 'Enter' && addTerm()} />
|
||||
<button class="add" onclick={addTerm}>Add</button>
|
||||
</div>
|
||||
{#if prefs.avoid_terms.length}
|
||||
<div class="pills">
|
||||
{#each prefs.avoid_terms as t, i (t)}
|
||||
<span class="pill">{t}<button onclick={() => removeTerm(i)} aria-label={`stop avoiding ${t}`}>×</button></span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Anything you'd rather not see — a person, a diagnosis, a subject — goes here and is hidden everywhere.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<span class="label">Paused for now</span>
|
||||
{#if prefs.pauses.length}
|
||||
<div class="pills">
|
||||
{#each prefs.pauses as p, i (p.kind + p.value + p.until)}
|
||||
<span class="pill soft">{p.value}<small> · until {when(p.until)}</small><button onclick={() => removePause(i)} aria-label="resume">×</button></span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Nothing paused.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<span class="label">Always hidden</span>
|
||||
{#if mutes.length}
|
||||
<div class="pills">
|
||||
{#each mutes as [kind, v] (kind + v)}
|
||||
<span class="pill">{v}<button onclick={() => removeMute(kind, v)} aria-label={`show ${v} again`}>×</button></span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Nothing hidden.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if anything}
|
||||
<button class="reset" onclick={reset}>Clear all boundaries</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px 22px;
|
||||
margin: 8px 0 18px;
|
||||
}
|
||||
.head { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.head h2 { font-size: 1.3rem; }
|
||||
.close { background: none; border: none; color: var(--sage-deep); font-size: 0.85rem; text-decoration: underline; }
|
||||
.reassure { margin: 4px 0 16px; color: var(--muted); font-size: 0.85rem; }
|
||||
.group { margin-bottom: 16px; }
|
||||
.label {
|
||||
display: block; font-size: 0.78rem; text-transform: uppercase;
|
||||
letter-spacing: 0.07em; color: var(--muted); margin-bottom: 8px; font-weight: 600;
|
||||
}
|
||||
.addrow { display: flex; gap: 8px; }
|
||||
.addrow input {
|
||||
flex: 1; border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px;
|
||||
font-size: 0.92rem; background: var(--bg);
|
||||
}
|
||||
.addrow input:focus { outline: none; border-color: var(--sage); }
|
||||
.add { border: 1px solid var(--sage); background: var(--sage); color: #fff; border-radius: 9px; padding: 9px 16px; font-size: 0.88rem; }
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: var(--sage-soft); color: var(--sage-deep);
|
||||
border-radius: 999px; padding: 4px 6px 4px 12px; font-size: 0.85rem;
|
||||
}
|
||||
.pill small { color: var(--muted); }
|
||||
.pill button { background: none; border: none; color: var(--sage-deep); font-size: 1.05rem; line-height: 1; cursor: pointer; padding: 0 4px; }
|
||||
.empty { margin: 0; color: var(--muted); font-size: 0.85rem; font-style: italic; }
|
||||
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
import MoodNav from '$lib/components/MoodNav.svelte';
|
||||
import Lane from '$lib/components/Lane.svelte';
|
||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||
|
||||
let moods = $state([]);
|
||||
let selected = $state('today');
|
||||
@@ -12,6 +13,7 @@
|
||||
let feed = $state([]);
|
||||
let lanes = $state([]);
|
||||
let userPrefs = $state(P.blank());
|
||||
let showBoundaries = $state(false);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
@@ -52,16 +54,14 @@
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function applyAction(kind, value) {
|
||||
P[kind]?.(userPrefs, value);
|
||||
function refreshPrefs() {
|
||||
userPrefs = { ...userPrefs };
|
||||
P.save(userPrefs);
|
||||
select(selected);
|
||||
}
|
||||
function resetFilters() {
|
||||
userPrefs = P.blank();
|
||||
P.save(userPrefs);
|
||||
select(selected);
|
||||
function applyAction(kind, value) {
|
||||
P[kind]?.(userPrefs, value);
|
||||
refreshPrefs();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -80,11 +80,14 @@
|
||||
<MoodNav {moods} {selected} onselect={select} />
|
||||
{/if}
|
||||
|
||||
{#if filtersOn}
|
||||
<div class="calmbar">
|
||||
<span>Calm filters on — your feed is personalized on this device.</span>
|
||||
<button onclick={resetFilters}>reset</button>
|
||||
</div>
|
||||
<div class="toptools">
|
||||
<button class="boundaries" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
|
||||
{filtersOn ? 'Boundaries ·' : 'Boundaries'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showBoundaries}
|
||||
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
@@ -131,13 +134,13 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.calmbar {
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
background: var(--sage-soft); color: var(--sage-deep);
|
||||
border-radius: 999px; padding: 6px 16px; margin: 6px auto 0; width: fit-content;
|
||||
font-size: 0.85rem;
|
||||
.toptools { display: flex; justify-content: flex-end; margin: 2px 0 0; }
|
||||
.boundaries {
|
||||
background: none; border: none; color: var(--muted);
|
||||
font-size: 0.82rem; padding: 4px 2px; letter-spacing: 0.01em;
|
||||
}
|
||||
.calmbar button { background: none; border: none; color: var(--sage-deep); text-decoration: underline; font-size: 0.85rem; }
|
||||
.boundaries:hover { color: var(--sage-deep); }
|
||||
.boundaries.on { color: var(--sage-deep); font-weight: 600; }
|
||||
.kicker {
|
||||
font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.12em;
|
||||
color: var(--gold); margin: 22px 0 14px; font-family: var(--sans); font-weight: 700;
|
||||
|
||||
Reference in New Issue
Block a user