diff --git a/.gitignore b/.gitignore index 18a10cf..df314a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ __pycache__/ *.py[cod] .venv/ +node_modules/ *.egg-info/ data/*.sqlite3 data/*.sqlite3-* +data/*.db logs/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 38b974f..01f347e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "goodnews-web", "version": "0.1.0", + "dependencies": { + "three": "^0.184.0" + }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", @@ -1540,6 +1543,11 @@ "node": ">=18" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8fe8bf3..666116a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,5 +17,8 @@ "svelte": "^5.1.0", "vite": "^6.0.0", "vitest": "^4.1.8" + }, + "dependencies": { + "three": "^0.184.0" } } diff --git a/frontend/src/lib/EmojiPicker.svelte b/frontend/src/lib/EmojiPicker.svelte new file mode 100644 index 0000000..3f30733 --- /dev/null +++ b/frontend/src/lib/EmojiPicker.svelte @@ -0,0 +1,98 @@ + + +
No emoji match โ{q}โ.
+ {/if} + {:else} + {#if recent.length} +Loading todayโs Bloomโฆ
+ {:else} + {#if isFree}Free Play ยท {isWild ? 'Wild Bloom' : 'Center Circle'}
{/if} + + +๐ธ Full Bloom โ you found today's whole bloom. Lovely.
+ {/if} + + {#if found.length} +Lovely โ you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}
+ {/if} + {/if} +{viewSubtitle}
{/if}โฆ that's the good news for today โฆ
You're caught up for now.
+ {#if ritual.total} +Today's calm set
++ {ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} ยท fresh set tomorrow ยท make it yours +
+Tomorrow's brief is headed to your inbox โ
{:else} @@ -592,6 +661,8 @@ {:else}โฆ you're all caught up โฆ
{/if} + {:else if selected === 'search'} +No articles found for โ{searchQuery}โ. Try a different word, or a source name like โNatureโ.
{:else if selected === 'following'}{#if auth.user}Nothing here yet โ open a source or a grouping and tap Follow to fill this lane with what you care about. @@ -660,6 +731,21 @@ .viewback:hover { border-color: var(--accent); } .viewback svg { width: 16px; height: 16px; display: block; } .vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; } + .searchtoggle { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; + background: none; border: 1px solid var(--line); color: var(--accent-deep); border-radius: 999px; + cursor: pointer; transition: border-color 0.14s ease, background 0.14s ease; } + .searchtoggle:hover { border-color: var(--accent); } + .searchtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; } + .searchtoggle svg { width: 17px; height: 17px; display: block; } + .searchbar { display: flex; gap: 8px; margin: 0 0 18px; } + .searchbar input { flex: 1; min-width: 0; font: inherit; font-size: 1rem; padding: 10px 14px; + border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); } + .searchbar input:focus { outline: none; border-color: var(--accent); } + .searchgo { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0 18px; + font: inherit; font-weight: 600; cursor: pointer; } + .searchgo:hover { background: var(--accent-deep); } + .searchclear { background: none; border: 1px solid var(--line); color: var(--muted); border-radius: 10px; + padding: 0 14px; font: inherit; cursor: pointer; } .followbtn { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; background: none; border: 1px solid var(--accent); color: var(--accent-deep); @@ -713,6 +799,36 @@ .endcap .digestcta:hover { background: var(--accent-deep); } .endcap .digestcta:disabled { opacity: 0.6; cursor: default; } + /* Daily Ritual โ "today's calm set". Gentle, non-instrumental: a soft check + for what's been enjoyed, no streak, no pressure to finish the rest. */ + .calmset { + margin: 20px auto 4px; max-width: 320px; padding-top: 16px; + border-top: 1px solid var(--line); font-style: normal; font-family: var(--label); + } + .cs-head { + margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em; + font-size: 0.66rem; font-weight: 600; color: var(--accent-deep); + } + .cs-items { + list-style: none; margin: 0; padding: 0; display: flex; gap: 16px; + justify-content: center; flex-wrap: wrap; + } + .cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); } + .cs-item a { color: inherit; text-decoration: none; } + .cs-item a:hover { color: var(--accent-deep); } + .cs-item.done { color: var(--ink); } + .cs-mark { + width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line); + flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease; + } + .cs-item.done .cs-mark { + background: var(--accent); border-color: var(--accent); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-size: 13px; background-repeat: no-repeat; background-position: center; + } + .cs-foot { margin: 12px 0 0; font-size: 0.82rem; color: var(--muted); } + .cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; } + /* "Since you last visited" โ a calm welcome-back cue on Highlights */ .welcomeback { display: flex; align-items: center; gap: 12px; justify-content: space-between; diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 0cfcde4..497d7b8 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -4,6 +4,7 @@ import { getJSON, postJSON } from '$lib/api.js'; import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js'; import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js'; + import { RITUAL_ITEMS, ritualKeys, DEFAULT_KEYS } from '$lib/ritual.js'; import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js'; import { track } from '$lib/analytics.js'; import { openFeedback } from '$lib/feedback.svelte.js'; @@ -25,9 +26,24 @@ { key: 'following', label: 'Following' }, { key: 'history', label: 'History' }, { key: 'lanes', label: 'Lanes' }, + { key: 'calmset', label: 'Calm set' }, { key: 'boundaries', label: 'Boundaries' }, ]; + // Calm set: which daily items make up the reader's gentle daily ritual. + let calmEnabled = $derived(new Set(ritualKeys(prefs.data.ritual))); + function toggleCalmItem(key) { + const cur = new Set(ritualKeys(prefs.data.ritual)); + cur.has(key) ? cur.delete(key) : cur.add(key); + const keys = RITUAL_ITEMS.map((i) => i.key).filter((k) => cur.has(k)); + // null = "follow the curated default" (only when the selection IS the default); + // otherwise store the explicit chosen list (frozen โ future games won't auto-join). + const isDefault = keys.length === DEFAULT_KEYS.length && DEFAULT_KEYS.every((k) => cur.has(k)); + prefs.data.ritual = isDefault ? null : keys; + prefs.data = { ...prefs.data }; + persistPrefs(); + } + let follows = $state([]); let followsReady = $state(false); async function loadFollowsList() { @@ -112,6 +128,25 @@
Loadingโฆ
{/if} + {:else if section === 'calmset'} +The gentle daily loop shown on Highlights and Play โ a finite "today's set" + you can finish and feel caught up. Choose what fills yours, like building a workout. No + pressure, no streaks; uncheck anything you'd rather not see there.
+Your calm set is empty โ it won't show until you add something.
{/if} +{c._err}
{/if} @@ -659,6 +852,29 @@ {/if} + {#if rejectedCandidates.length} +{c._err}
{/if} +{healthy} healthy ยท {resting} resting ยท {flagged} flagged ยท {paused} paused ยท {retired} retired ยท {sources.length} total
No feedback yet.
{/if} {:else if section === 'games'} -Acceptance is broad (every valid dictionary word) โ these are player + โshould this count?โ reports. Approve allows the word everywhere + (takes effect immediately, no deploy); Block hides it; Dismiss + clears the report without a rule.
+ {#if bloomReports.length} +No pending word reports.
+ {/if} + +Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter words in the guess dictionary qualify, so the daily answer is always solvable. Removals take effect for future puzzles and can be restored any time.
@@ -1082,6 +1337,109 @@ {/each} {:else}No custom themes yet โ the daily rotation uses the built-in ones.
{/if} + + {:else if section === 'publish'} +Build a queue of share-worthy stories, write the blurb in your own voice, and open it in X. Posts your /a/ share link (your card, drives to UB, still credits the source). Opening X can't confirm a post โ you mark Posted.
Build error: {pubError}
{/if} + + {#if !pubItems.length && !pubBuilding} +Queue is empty โ hit โBuild queueโ to gather candidates.
+ {/if} + +{item.rationale}
{/if} + {#if item.talking_points?.length} +Angle: {item.angle}
{/if} + + {#if item.suggested_handles?.length} +{item._err}
{/if} + {#if item._confirm} +If you tweaked the post inside X, edit the text above to match before confirming โ it's saved as your final wording.
+ +A small calm thing after the brief. One of each a day โ no rush, no score to beat but your own.
+ {#if date && ritual.total} +Today's calm set
+{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} ยท fresh set tomorrow ยท make it yours
+A calm corner โ drop in with UB
+Visit ยท no scores, just quiet
+{game === 'word' ? 'Pick your length.' : 'Pick your size.'}
+{game === 'word' ? 'Pick your length.' : game === 'wordsearch' ? 'Pick your size.' : game === 'match' ? 'Pick a format, then todayโs board or free play.' : 'Play todayโs shared puzzle, or graze freely.'}
Todayโs board โ shared daily
+ {#each MATCH_TIERS as [t, name, desc] (t)} + + {/each} +Free play โ fresh boards anytime
+ {#each MATCH_TIERS as [t, name, desc] (t)} + + {/each} + {:else if game === 'bloom'} + + + + {:else if game === 'word'}