Visual/IA pass: brand mark, real header, mobile bottom tabs, topic browse

- Logo mark: SVG rising-dots wave (sage dots + warm gold peak = 'upbeat bytes'),
  used as favicon/PWA icon and in the header.
- Header: full-width app bar — mark + mixed-type wordmark (Upbeat serif ink /
  Bytes sans sage) on the left, housed Boundaries/History utility cluster on the
  right (desktop). No more floating text links.
- Mobile: fixed bottom tab bar (Today / Browse / You); utilities move into a
  'You' sheet. One-handed, modern, calm.
- Browse: moods stay the primary front door; added a quiet 'Explore by topic'
  section (existing topics) below the content — selecting a topic loads its feed.
- Layout trimmed (header now in-page, full width); footer keeps clearance for the
  bottom bar.

Phase A of the consensus pass; Phase B (add technology + learning topics and
reclassify) is next. Live site untouched until publish.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-01 17:28:25 +00:00
parent 86975d599b
commit c6d37039a8
5 changed files with 294 additions and 150 deletions
@@ -0,0 +1,45 @@
<script>
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
// discovery, You = personal controls (Boundaries, History).
let { active = 'today', onToday, onBrowse, onYou } = $props();
</script>
<nav class="bottomnav" aria-label="Primary">
<button class:active={active === 'today'} onclick={onToday}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>Today</span>
</button>
<button class:active={active === 'browse'} onclick={onBrowse}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
<span>Browse</span>
</button>
<button class:active={active === 'you'} onclick={onYou}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M5 20c0-3.6 3.1-5.5 7-5.5s7 1.9 7 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>You</span>
</button>
</nav>
<style>
.bottomnav { display: none; }
@media (max-width: 720px) {
.bottomnav {
display: flex;
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 30;
background: var(--surface);
border-top: 1px solid var(--line);
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
box-shadow: 0 -2px 14px rgba(40, 38, 28, 0.05);
}
.bottomnav button {
flex: 1;
display: flex; flex-direction: column; align-items: center; gap: 3px;
background: none; border: none; padding: 6px 0; cursor: pointer;
color: var(--muted); font-size: 0.7rem; letter-spacing: 0.02em;
}
.bottomnav button svg { width: 23px; height: 23px; }
.bottomnav button.active { color: var(--sage-deep); }
.bottomnav button.active svg { stroke: var(--sage); }
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script>
let { onBoundaries, onHistory, filtersOn = false } = $props();
</script>
<header class="appbar">
<div class="container bar">
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
<svg class="mark" viewBox="0 0 40 30" aria-hidden="true">
<path d="M3 24 Q15 21 23 11 T37 5" fill="none" stroke="var(--sage)" stroke-width="2.2"
opacity="0.3" stroke-linecap="round" />
<circle cx="3" cy="24" r="2.6" fill="var(--sage)" />
<circle cx="13" cy="19" r="2.8" fill="var(--sage)" />
<circle cx="23" cy="12" r="3" fill="var(--sage)" />
<circle cx="35" cy="5" r="3.9" fill="var(--gold)" />
</svg>
<span class="word"><span class="up">Upbeat</span><span class="by">Bytes</span></span>
</a>
<nav class="utils" aria-label="Your controls">
<button class:on={filtersOn} onclick={onBoundaries} title="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>
</nav>
</div>
</header>
<style>
.appbar {
background: var(--surface);
border-bottom: 1px solid var(--line);
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: 64px; }
.brand { display: inline-flex; align-items: center; gap: 11px; }
.mark { height: 28px; width: auto; display: block; }
.word { display: inline-flex; align-items: baseline; gap: 0.28em; letter-spacing: -0.01em; }
.up { font-family: var(--serif); font-weight: 600; font-size: 1.5rem; color: var(--ink); }
.by {
font-family: var(--sans); font-weight: 700; font-size: 1.42rem; color: var(--sage);
letter-spacing: -0.02em;
}
.utils { display: flex; gap: 6px; }
.utils button {
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;
transition: background 0.14s ease, color 0.14s ease;
}
.utils button svg { width: 17px; height: 17px; }
.utils button:hover { background: var(--sage-soft); color: var(--sage-deep); }
.utils button.on { color: var(--sage-deep); }
/* On phones the utilities live in the bottom tab bar ("You") instead. */
@media (max-width: 720px) {
.bar { height: 58px; justify-content: center; }
.utils { display: none; }
.up { font-size: 1.35rem; }
.by { font-size: 1.28rem; }
}
</style>
+5 -25
View File
@@ -3,16 +3,7 @@
let { children } = $props();
</script>
<header class="site">
<div class="container">
<a class="brand" href="/">Upbeat <span>Bytes</span></a>
<p class="tagline">Calm, constructive news worth your attention — and nothing that isn't.</p>
</div>
</header>
<main class="container">
{@render children()}
</main>
{@render children()}
<footer class="site">
<div class="container">
@@ -21,21 +12,6 @@
</footer>
<style>
header.site {
text-align: center;
padding: 38px 0 22px;
background: linear-gradient(180deg, var(--surface), var(--bg));
border-bottom: 1px solid var(--line);
}
.brand {
font-family: var(--serif);
font-size: 2.1rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.brand span { color: var(--sage); }
.tagline { margin: 6px 0 0; color: var(--muted); font-size: 0.98rem; }
main.container { padding-top: 18px; padding-bottom: 40px; min-height: 60vh; }
footer.site {
text-align: center;
color: var(--muted);
@@ -44,4 +20,8 @@
border-top: 1px solid var(--line);
}
footer.site a { color: var(--sage-deep); }
/* room for the mobile bottom tab bar */
@media (max-width: 720px) {
footer.site { padding-bottom: calc(34px + 64px + env(safe-area-inset-bottom)); }
}
</style>
+164 -122
View File
@@ -2,32 +2,33 @@
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import Header from '$lib/components/Header.svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import MoodNav from '$lib/components/MoodNav.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
let moods = $state([]);
let selected = $state('today');
let topics = $state([]);
let selected = $state('today'); // 'today' | a mood key | a topic key
let brief = $state(null);
let feed = $state([]);
let userPrefs = $state(P.blank());
let showBoundaries = $state(false);
let showHistory = $state(false);
let showYou = $state(false); // mobile "You" sheet
let loading = $state(true);
let error = $state('');
// Device-local memory (no account): persisted in localStorage so it survives
// a refresh. `seen` stops Replace recycling; `dismissed` (replaced-away) is
// excluded from the brief so swaps stick; `history` keeps everything seen
// recoverable. A reader can wipe it all from the History panel.
// Device-local memory (no account), persisted in localStorage.
const SEEN_KEY = 'goodnews:seen';
const DISMISSED_KEY = 'goodnews:dismissed';
const HISTORY_KEY = 'goodnews:history';
const BRIEF_VIEW_KEY = 'goodnews:brief_view'; // {date, items} — the reader's curated brief
const BRIEF_VIEW_KEY = 'goodnews:brief_view';
const HISTORY_CAP = 200;
let seenIds = new Set(); // handler-only, not rendered — no reactivity needed
let dismissed = $state(new Set()); // read in the template (Clear button) — must be reactive
let seenIds = new Set();
let dismissed = $state(new Set());
let history = $state([]);
function persistSession() {
@@ -54,21 +55,34 @@
dismissed = new Set();
history = [];
persistSession();
P.saveJSON(BRIEF_VIEW_KEY, null); // drop the pinned brief so it re-composes fresh
P.saveJSON(BRIEF_VIEW_KEY, null);
showHistory = false;
select(selected, true);
}
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
let filtersOn = $derived(P.active(userPrefs));
let current = $derived(moods.find((m) => m.key === selected));
let viewLabel = $derived(selected === 'today' ? 'Highlights from Today' : (current?.label ?? ''));
let viewSubtitle = $derived(
selected === 'today' ? (brief?.brief_date ?? '') : (current?.description ?? '')
let currentMood = $derived(moods.find((m) => m.key === selected));
let currentTopic = $derived(topics.find((t) => t.key === selected));
let viewLabel = $derived(
selected === 'today' ? 'Highlights from Today' : (currentMood?.label ?? cap(currentTopic?.key) ?? '')
);
let viewSubtitle = $derived(
selected === 'today'
? (brief?.brief_date ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
);
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
// The filter for the current view: a mood's preset, a topic include, or none.
function viewFilter(key = selected) {
if (key === 'today') return {};
const m = moods.find((x) => x.key === key);
if (m) return m.filter ?? {};
return { include_topics: [key] }; // a topic
}
function mergedParam() {
const merged = P.merge(userPrefs, current?.filter ?? {});
return P.param(merged);
return P.param(P.merge(userPrefs, viewFilter()));
}
async function loadToday(fresh) {
@@ -76,9 +90,6 @@
const ex = Array.from(dismissed).join(',');
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
// Keep the reader's curated view (swaps + hero) across plain refreshes — but
// only while the server's brief is unchanged. When genuinely fresh data
// arrives (generated_at advances), the server wins and the pin is dropped.
const sameServerBrief =
view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
@@ -92,15 +103,13 @@
async function select(key, fresh = false) {
selected = key;
showYou = false;
error = '';
try {
// Today = the day's highlights (hero + six). Other moods reveal that
// category only when chosen.
if (key === 'today') {
await loadToday(fresh);
} else {
const mood = moods.find((m) => m.key === key);
const q = P.param(P.merge(userPrefs, mood?.filter ?? {}));
const q = P.param(P.merge(userPrefs, viewFilter(key)));
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
remember(feed);
@@ -114,7 +123,7 @@
function refreshPrefs() {
userPrefs = { ...userPrefs };
P.save(userPrefs);
select(selected, true); // boundaries changed — re-fetch so they apply
select(selected, true);
}
function applyAction(kind, value) {
P[kind]?.(userPrefs, value);
@@ -131,7 +140,6 @@
const list = selected === 'today' ? brief?.items : feed;
if (!list) return;
const isHero = selected === 'today' && list[0]?.id === article.id;
// Exclude everything seen (persisted), so Replace never cycles back.
const exclude = Array.from(seenIds).join(',');
const q = mergedParam();
const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
@@ -146,8 +154,6 @@
flash("That's everything fresh for now — nothing new to swap in.");
return;
}
// Remember the swap so it sticks across refreshes (the dismissed story is
// excluded from future briefs; it stays recoverable in History).
dismissed.add(article.id);
seenIds.add(article.id);
remember([repl]);
@@ -157,8 +163,6 @@
if (i >= 0) {
brief.items[i] = repl;
brief = { ...brief, items: [...brief.items] };
// Pin the swap against the current server brief; it holds until fresh
// server data arrives (then the server's version takes over).
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, items: brief.items });
}
} else {
@@ -170,6 +174,13 @@
}
}
function browse() {
showYou = false;
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
if (selected !== 'today') select('today').then(go);
else go();
}
onMount(async () => {
userPrefs = P.load();
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
@@ -177,121 +188,145 @@
history = P.loadJSON(HISTORY_KEY, []);
try {
moods = await getJSON('/api/moods');
topics = (await getJSON('/api/categories')).topics;
await select('today');
} catch (e) {
error = 'Could not reach goodNews.';
error = 'Could not reach Upbeat Bytes.';
}
loading = false;
});
</script>
{#if moods.length}
<MoodNav {moods} {selected} onselect={select} />
{/if}
<Header onBoundaries={() => (showBoundaries = !showBoundaries)} onHistory={() => (showHistory = !showHistory)} {filtersOn} />
<div class="toptools">
<button class="link" class:on={history.length} onclick={() => (showHistory = !showHistory)}>History</button>
<button class="link" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
{filtersOn ? 'Boundaries ·' : 'Boundaries'}
</button>
</div>
<main class="container">
{#if moods.length}
<MoodNav {moods} {selected} onselect={select} />
{/if}
{#if showBoundaries}
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
{/if}
{#if showBoundaries}
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
{/if}
{#if showHistory}
<section class="panel rise">
<div class="phead">
<h2>What you've seen</h2>
<button class="close" onclick={() => (showHistory = false)}>done</button>
</div>
<p class="reassure">Everything you've seen here, including stories you swapped away — so a swap sticks and stays recoverable. Kept on this device only (no account, nothing sent). (Saved history & favorites come with sign-in, later.)</p>
{#if history.length}
<ul class="hist">
{#each history as a (a.id)}
<li>
<a href={a.url} target="_blank" rel="noopener">{a.title}</a>
<span class="hsrc">{a.source}</span>
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing yet — your seen stories will appear here.</p>
{/if}
{#if history.length || dismissed.size}
<button class="reset" onclick={clearSession}>Clear what I've seen (start fresh)</button>
{/if}
</section>
{/if}
{#if notice}
<p class="notice rise">{notice}</p>
{/if}
{#if loading}
<p class="muted center pad">Gathering the good news…</p>
{:else if error}
<p class="muted center pad">{error}</p>
{:else}
{#key selected}
<header class="view-head rise">
<h1>{viewLabel}</h1>
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
</header>
{#if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
{#if brief.items.length > 1}
<div class="grid rest">
{#each brief.items.slice(1) as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
{/each}
</div>
{/if}
</section>
<p class="endcap rise">✦ that's the good news for today ✦</p>
{:else}
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
{/if}
{:else if feed.length}
<div class="grid rise">
{#each feed as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
{/each}
{#if showHistory}
<section class="panel rise">
<div class="phead">
<h2>What you've seen</h2>
<button class="close" onclick={() => (showHistory = false)}>done</button>
</div>
{:else}
<p class="muted center pad">Nothing in this mood right now — try another, or ease a boundary.</p>
<p class="reassure">Everything you've seen here, including stories you swapped away — so a swap sticks and stays recoverable. Kept on this device only (no account, nothing sent).</p>
{#if history.length}
<ul class="hist">
{#each history as a (a.id)}
<li><a href={a.url} target="_blank" rel="noopener">{a.title}</a><span class="hsrc">{a.source}</span></li>
{/each}
</ul>
{:else}
<p class="empty">Nothing yet — your seen stories will appear here.</p>
{/if}
{#if history.length || dismissed.size}
<button class="reset" onclick={clearSession}>Clear what I've seen (start fresh)</button>
{/if}
</section>
{/if}
{#if showYou}
<section class="panel rise youmenu">
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
</button>
<button class="yourow" onclick={() => { showYou = false; showHistory = true; }}>
<span>What you've seen</span>{#if history.length}<span class="dot">{history.length}</span>{/if}
</button>
</section>
{/if}
{#if notice}<p class="notice rise">{notice}</p>{/if}
{#if loading}
<p class="muted center pad">Gathering the good news…</p>
{:else if error}
<p class="muted center pad">{error}</p>
{:else}
{#key selected}
<header class="view-head rise">
<h1>{viewLabel}</h1>
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
</header>
{#if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
{#if brief.items.length > 1}
<div class="grid rest">
{#each brief.items.slice(1) as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
{/each}
</div>
{/if}
</section>
<p class="endcap rise">✦ that's the good news for today ✦</p>
{:else}
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
{/if}
{:else if feed.length}
<div class="grid rise">
{#each feed as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
{/each}
</div>
{:else}
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
{/if}
{/key}
{#if topics.length}
<section id="explore" class="explore">
<h2>Explore by topic</h2>
<div class="chips">
{#each topics as t (t.key)}
<button class="chip" class:active={selected === t.key} onclick={() => select(t.key)}>{cap(t.key)}</button>
{/each}
</div>
</section>
{/if}
{/key}
{/if}
{/if}
</main>
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={() => (showYou = !showYou)} />
<style>
.toptools { display: flex; justify-content: flex-end; gap: 18px; margin: 2px 0 0; }
.link {
background: none; border: none; color: var(--muted);
font-size: 0.82rem; padding: 4px 2px; letter-spacing: 0.01em;
}
.link:hover { color: var(--sage-deep); }
.link.on { color: var(--sage-deep); font-weight: 600; }
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
.view-head { margin: 20px 0 20px; }
.view-head h1 {
font-size: clamp(2.1rem, 5.5vw, 2.8rem);
line-height: 1.05;
}
.view-head { margin: 18px 0 18px; }
.view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
.view-head::after {
content: ''; display: block; width: 46px; height: 3px;
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
}
/* History panel */
/* Explore by topic — a quiet secondary discovery section, not a nav row. */
.explore { margin: 48px 0 8px; padding-top: 26px; border-top: 1px solid var(--line); }
.explore h2 {
font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--muted); font-family: var(--sans); font-weight: 700; margin: 0 0 12px;
}
.explore .chips { display: flex; flex-wrap: wrap; gap: 8px; }
.explore .chip {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; cursor: pointer; transition: all 0.14s ease;
text-transform: capitalize;
}
.explore .chip:hover { border-color: var(--sage); color: var(--sage-deep); }
.explore .chip.active { background: var(--sage); border-color: var(--sage); color: #fff; }
/* Panels (Boundaries handled by its own component; History + You here) */
.panel {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 20px 22px; margin: 8px 0 18px;
box-shadow: var(--shadow); padding: 20px 22px; margin: 12px 0 6px;
}
.phead { display: flex; align-items: baseline; justify-content: space-between; }
.phead h2 { font-size: 1.3rem; }
@@ -307,10 +342,17 @@
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; }
.reset:hover { color: var(--sage-deep); }
.youmenu .yourow {
width: 100%; display: flex; align-items: center; justify-content: space-between;
background: none; border: none; border-bottom: 1px solid var(--line);
padding: 14px 2px; font-size: 1rem; color: var(--ink); cursor: pointer; text-align: left;
}
.youmenu .yourow:last-child { border-bottom: none; }
.youmenu .dot { background: var(--sage-soft); color: var(--sage-deep); border-radius: 999px; padding: 1px 9px; font-size: 0.78rem; }
.notice {
text-align: center; color: var(--sage-deep); background: var(--sage-soft);
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content;
font-size: 0.86rem;
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
}
.rest { margin-top: 18px; }
.center { text-align: center; }
+6 -3
View File
@@ -1,5 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#2f7d5b"/>
<circle cx="32" cy="29" r="11" fill="#faf6ee"/>
<path d="M14 44 q12 9 24 3 q9 -4 12 1" stroke="#faf6ee" stroke-width="3.4" fill="none" stroke-linecap="round"/>
<rect width="64" height="64" rx="15" fill="#2f7d5b"/>
<path d="M13 45 Q26 41 35 26 T53 14" fill="none" stroke="#faf6ee" stroke-width="3" opacity="0.32" stroke-linecap="round"/>
<circle cx="13" cy="45" r="3.6" fill="#faf6ee"/>
<circle cx="25" cy="37" r="4" fill="#faf6ee"/>
<circle cx="38" cy="26" r="4.4" fill="#faf6ee"/>
<circle cx="51" cy="14" r="6" fill="#e8c87a"/>
</svg>

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 449 B