Make memory UI dynamic and content-aware

This commit is contained in:
OpenClaw
2026-04-24 22:15:18 +02:00
parent 9fd14f87f7
commit a13d08251d
3 changed files with 307 additions and 278 deletions
+56 -9
View File
@@ -84,26 +84,59 @@ function getEntryCount(content) {
} }
const TAG_STOPWORDS = new Set([ const TAG_STOPWORDS = new Set([
'about', 'after', 'again', 'also', 'been', 'being', 'between', 'could', 'daily', 'does', 'done', 'from', 'have', 'into', 'just', 'like', 'main', 'make', 'maybe', 'memory', 'more', 'most', 'much', 'need', 'note', 'notes', 'only', 'other', 'over', 'really', 'same', 'some', 'still', 'than', 'that', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'today', 'very', 'want', 'were', 'what', 'when', 'with', 'work', 'would', 'your', 'yours', 'jimmi', 'rook', 'said', 'todo', 'todos' 'about', 'after', 'again', 'also', 'been', 'being', 'between', 'could', 'daily', 'does', 'done', 'from', 'have', 'into', 'just', 'like', 'main', 'make', 'maybe', 'memory', 'more', 'most', 'much', 'need', 'note', 'notes', 'only', 'other', 'over', 'really', 'same', 'some', 'still', 'than', 'that', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'today', 'very', 'want', 'were', 'what', 'when', 'with', 'work', 'would', 'your', 'yours', 'jimmi', 'rook', 'said', 'todo', 'todos', 'session', 'update', 'updated', 'using', 'used', 'kind', 'actually', 'awesome', 'maybe', 'seems'
]) ])
const SMART_TAG_RULES = [
{ tag: 'appwrite', patterns: [/appwrite/i, /tablesdb/i, /database/i, /collection/i] },
{ tag: 'agents', patterns: [/openclaw/i, /agent/i, /subagent/i, /codex/i, /claude code/i] },
{ tag: 'design', patterns: [/design/i, /ui\b/i, /visual/i, /theme/i, /scandinavian/i, /gradient/i] },
{ tag: 'memory', patterns: [/memory\.md/i, /memory app/i, /memories/i, /archive/i] },
{ tag: 'infra', patterns: [/cloudflare/i, /systemd/i, /service/i, /deploy/i, /domain/i, /proxy/i] },
{ tag: 'auth', patterns: [/auth/i, /login/i, /session cookie/i, /oauth/i, /permission/i] },
{ tag: 'product', patterns: [/idea/i, /validation/i, /launch/i, /mvp/i, /portfolio/i] },
{ tag: 'kidsstories', patterns: [/kidsstories/i, /stories/i, /child/i] },
{ tag: 'dashboard', patterns: [/dashboard/i, /command center/i, /pixel office/i] },
{ tag: 'family', patterns: [/kids/i, /family/i, /home/i] },
]
const THEME_PROFILES = {
nord: { name: 'Nord', accent: '#8fbcbb', accentSoft: 'rgba(143,188,187,0.18)', accentStrong: '#a3d5d3', glow: 'rgba(143,188,187,0.28)' },
plum: { name: 'Plum', accent: '#b48ead', accentSoft: 'rgba(180,142,173,0.18)', accentStrong: '#d1a9ca', glow: 'rgba(180,142,173,0.28)' },
amber: { name: 'Amber', accent: '#d0a86e', accentSoft: 'rgba(208,168,110,0.18)', accentStrong: '#e5bf8c', glow: 'rgba(208,168,110,0.28)' },
ice: { name: 'Ice', accent: '#81a1c1', accentSoft: 'rgba(129,161,193,0.18)', accentStrong: '#a9c2db', glow: 'rgba(129,161,193,0.28)' },
}
function isProbablyNoiseTag(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value) || /^\d+$/.test(value)
}
function generateTags(content, filename) { function generateTags(content, filename) {
const tags = [] const tags = []
const scores = new Map()
const addScore = (tag, amount) => scores.set(tag, (scores.get(tag) || 0) + amount)
const pushTag = (value) => { const pushTag = (value) => {
const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9+-]+/g, '').trim() const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9+-]+/g, '').trim()
if (!cleaned || cleaned.length < 3 || TAG_STOPWORDS.has(cleaned) || tags.includes(cleaned)) return if (!cleaned || cleaned.length < 3 || TAG_STOPWORDS.has(cleaned) || tags.includes(cleaned) || isProbablyNoiseTag(cleaned)) return
tags.push(cleaned) tags.push(cleaned)
} }
if (filename === 'MEMORY.md') pushTag('core') if (filename === 'MEMORY.md') addScore('core', 5)
for (const rule of SMART_TAG_RULES) {
for (const pattern of rule.patterns) {
const matches = content.match(new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)) || []
if (matches.length) addScore(rule.tag, matches.length * 3)
}
}
for (const line of content.split('\n')) { for (const line of content.split('\n')) {
const trimmed = line.trim() const trimmed = line.trim()
if (trimmed.startsWith('#')) { if (trimmed.startsWith('#')) {
trimmed.replace(/^#+\s*/, '').split(/[^a-zA-Z0-9+-]+/).forEach(pushTag) trimmed.replace(/^#+\s*/, '').split(/[^a-zA-Z0-9+-]+/).forEach((token) => addScore(token.toLowerCase(), 2))
} }
const hashtagMatches = trimmed.match(/#[a-zA-Z0-9+-]+/g) || [] const hashtagMatches = trimmed.match(/#[a-zA-Z0-9+-]+/g) || []
hashtagMatches.forEach((tag) => pushTag(tag.slice(1))) hashtagMatches.forEach((tag) => addScore(tag.slice(1).toLowerCase(), 4))
} }
const frequency = new Map() const frequency = new Map()
@@ -113,16 +146,28 @@ function generateTags(content, filename) {
.replace(/[^a-z0-9+\-\s]/g, ' ') .replace(/[^a-z0-9+\-\s]/g, ' ')
for (const token of normalized.split(/\s+/)) { for (const token of normalized.split(/\s+/)) {
if (!token || token.length < 4 || TAG_STOPWORDS.has(token)) continue if (!token || token.length < 4 || TAG_STOPWORDS.has(token) || isProbablyNoiseTag(token)) continue
frequency.set(token, (frequency.get(token) || 0) + 1) frequency.set(token, (frequency.get(token) || 0) + 1)
} }
Array.from(frequency.entries()) for (const [token, count] of frequency.entries()) {
if (count >= 2) addScore(token, count)
}
Array.from(scores.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 8) .slice(0, 8)
.forEach(([token]) => pushTag(token)) .forEach(([token]) => pushTag(token))
return tags.slice(0, 4) return tags.slice(0, 5)
}
function buildThemeProfile(content = '', tags = []) {
const haystack = `${content} ${(tags || []).join(' ')}`.toLowerCase()
if (/design|theme|visual|scandinavian|style/.test(haystack)) return THEME_PROFILES.plum
if (/infra|deploy|cloudflare|service|proxy|appwrite/.test(haystack)) return THEME_PROFILES.ice
if (/kidsstories|family|home|story/.test(haystack)) return THEME_PROFILES.amber
return THEME_PROFILES.nord
} }
function formatDateLabel(filename) { function formatDateLabel(filename) {
@@ -293,7 +338,9 @@ app.get('/api/meta', async (req, res) => {
return acc return acc
}, { dailyFileCount: 0, totalDailyEntries: 0, totalDailyWords: 0 }) }, { dailyFileCount: 0, totalDailyEntries: 0, totalDailyWords: 0 })
res.json({ mainMemory, dailyFiles, summary }) const theme = buildThemeProfile(mainDoc?.content || '', mainMemory?.tags || [])
res.json({ mainMemory, dailyFiles, summary, theme })
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }) res.status(500).json({ error: error.message })
} }
+146 -229
View File
@@ -6,39 +6,48 @@
background: #101214; background: #101214;
color: #f3f4f6; color: #f3f4f6;
--bg: #101214; --bg: #101214;
--bg-2: #15181c;
--panel: #171a1d; --panel: #171a1d;
--panel-2: #1c2024; --panel-2: #1c2024;
--border: #2a2f35; --border: #2a2f35;
--border-strong: #3a4149; --border-strong: #3a4149;
--text: #f3f4f6; --text: #f3f4f6;
--muted: #9ca3af; --muted: #a5adb7;
--muted-2: #7b828c; --muted-2: #7f8791;
--accent: #d8dde3; --accent: #8fbcbb;
--accent-soft: rgba(143,188,187,0.18);
--accent-strong: #a3d5d3;
--accent-glow: rgba(143,188,187,0.28);
--pointer-x: 18%;
--pointer-y: 12%;
} }
html, body, #root { html, body, #root { min-height: 100%; }
min-height: 100%; body { background: var(--bg); color: var(--text); }
} button, input, textarea { font: inherit; }
button { border: 0; }
body {
background: var(--bg);
color: var(--text);
}
button, input, textarea {
font: inherit;
}
button {
border: 0;
}
.app-shell { .app-shell {
position: relative;
min-height: 100vh; min-height: 100vh;
background: var(--bg); background:
radial-gradient(circle at var(--pointer-x) var(--pointer-y), var(--accent-soft), transparent 26%),
linear-gradient(180deg, #101214 0%, #111419 100%);
overflow: hidden;
}
.dynamic-wash {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at calc(var(--pointer-x) + 10%) calc(var(--pointer-y) + 8%), var(--accent-glow), transparent 16%),
linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.01) 100%);
opacity: 0.9;
} }
.app { .app {
position: relative;
max-width: 1180px; max-width: 1180px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.25rem 4rem; padding: 2rem 1.25rem 4rem;
@@ -68,15 +77,15 @@ button {
.hero h1 { .hero h1 {
margin-top: 0.35rem; margin-top: 0.35rem;
font-size: clamp(2.2rem, 5vw, 4.4rem); font-size: clamp(2.4rem, 5vw, 4.8rem);
font-weight: 600; font-weight: 620;
letter-spacing: -0.06em; letter-spacing: -0.07em;
} }
.subtitle { .subtitle {
margin-top: 0.7rem; margin-top: 0.75rem;
max-width: 38rem; max-width: 42rem;
line-height: 1.5; line-height: 1.55;
color: var(--muted); color: var(--muted);
} }
@@ -93,21 +102,9 @@ button {
.memory-content, .memory-content,
.memory-editor, .memory-editor,
.viewer-header { .viewer-header {
background: var(--panel); background: linear-gradient(180deg, rgba(23,26,29,0.96), rgba(20,23,27,0.96));
border: 1px solid var(--border); border: 1px solid var(--border);
} border-radius: 12px;
.panel,
.file-card,
.result-item,
.memory-content,
.memory-editor,
.viewer-header,
.search-input,
.ghost-btn,
.primary-btn,
.tag-pill {
border-radius: 10px;
} }
.panel, .panel,
@@ -116,44 +113,54 @@ button {
.viewer-header, .viewer-header,
.memory-content, .memory-content,
.memory-editor { .memory-editor {
box-shadow: none; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
.interactive-card,
.ghost-btn,
.primary-btn,
.tag-pill,
.rail-row {
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.interactive-card:hover,
.ghost-btn:hover,
.primary-btn:hover,
.rail-row:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 44%, var(--border));
box-shadow: 0 16px 34px rgba(0,0,0,0.2);
} }
.hero-grid { .hero-grid {
grid-template-columns: 1.35fr 0.9fr 1fr; grid-template-columns: 1.25fr 0.92fr 0.95fr;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.panel { .panel { padding: 1rem; }
padding: 1rem; .accent-panel { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
}
.lead-copy { .lead-copy {
margin-top: 0.75rem; margin-top: 0.75rem;
font-size: 1.05rem; font-size: 1.08rem;
line-height: 1.6; line-height: 1.65;
} }
.lead-meta, .lead-meta,
.file-age, .file-age,
.result-meta { .result-meta,
color: var(--muted-2); .theme-copy { color: var(--muted-2); font-size: 0.82rem; }
font-size: 0.82rem; .lead-meta { margin-top: 0.65rem; }
} .theme-title { margin-top: 0.35rem; font-size: 1.2rem; font-weight: 600; letter-spacing: -0.03em; }
.theme-copy { margin-top: 0.55rem; line-height: 1.55; }
.lead-meta {
margin-top: 0.65rem;
}
.stats-panel {
display: grid;
gap: 0.65rem;
}
.stats-panel { display: grid; gap: 0.65rem; }
.stat-row, .stat-row,
.rail-row,
.result-head, .result-head,
.file-card-top { .file-card-top,
.section-heading,
.compact-heading {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -162,127 +169,85 @@ button {
.stat-row { .stat-row {
padding-bottom: 0.65rem; padding-bottom: 0.65rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255,255,255,0.05);
} }
.stat-row:last-child { padding-bottom: 0; border-bottom: 0; }
.stat-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.stat-row span, .stat-row span,
.recap-line,
.rail-row span,
.file-stats, .file-stats,
.file-snippet, .file-snippet,
.result-snippet, .result-snippet,
.slash-hint { .slash-hint,
color: var(--muted); .recap-line,
} .rail-metrics span { color: var(--muted); }
.stat-row strong, .stat-row strong,
.rail-metrics strong { .rail-metrics strong { font-size: 1.15rem; font-weight: 620; }
font-size: 1.15rem;
font-weight: 600;
}
.recap-panel {
display: grid;
gap: 0.65rem;
}
.main-grid {
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
margin-bottom: 1rem;
}
.section-heading,
.compact-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.cluster-panel,
.search-block,
.main-grid { margin-top: 1rem; }
.main-grid { grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); }
.section-heading h2, .section-heading h2,
.compact-heading h2 { .compact-heading h2 {
margin-top: 0.2rem; margin-top: 0.2rem;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 620;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.rail-list { .rail-list { margin-top: 1rem; display: grid; gap: 0.7rem; }
margin-top: 1rem;
display: grid;
gap: 0.6rem;
}
.rail-row { .rail-row {
width: 100%; width: 100%;
text-align: left; text-align: left;
background: var(--panel-2); background: rgba(28,32,36,0.92);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 11px;
padding: 0.85rem 0.9rem; padding: 0.85rem 0.9rem;
cursor: pointer; cursor: pointer;
} }
.rail-copy { min-width: 0; }
.rail-date, .rail-date,
.file-label, .file-label,
.result-name { .result-name { font-weight: 620; letter-spacing: -0.01em; }
font-weight: 600; .rail-metrics { display: grid; text-align: right; min-width: 8rem; }
letter-spacing: -0.01em; .rail-bar {
} width: 100%;
height: 4px;
.rail-metrics { margin-top: 0.45rem;
display: grid; background: rgba(255,255,255,0.06);
text-align: right; border-radius: 999px;
} overflow: hidden;
}
.composer-form { .rail-bar span {
margin-top: 1rem; display: block;
display: flex; height: 100%;
gap: 0.75rem; border-radius: 999px;
} background: linear-gradient(90deg, var(--accent), var(--accent-strong));
.search-block {
margin-top: 1rem;
}
.search-section {
position: relative;
margin: 1rem 0;
} }
.composer-form { margin-top: 1rem; display: flex; gap: 0.75rem; }
.search-section { position: relative; margin: 1rem 0; }
.search-input, .search-input,
.memory-editor { .memory-editor {
width: 100%; width: 100%;
background: #121518; background: rgba(18,21,24,0.98);
color: var(--text); color: var(--text);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 12px;
outline: none; outline: none;
padding: 0.9rem 1rem; padding: 0.92rem 1rem;
transition: border-color 140ms ease, background 140ms ease;
} }
.memory-editor { .memory-editor {
min-height: 68vh; min-height: 68vh;
resize: vertical; resize: vertical;
line-height: 1.6; line-height: 1.65;
} }
.search-input:focus, .search-input:focus,
.memory-editor:focus { .memory-editor:focus {
border-color: #626973; border-color: color-mix(in srgb, var(--accent) 70%, var(--border-strong));
background: #101316; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-soft) 90%, transparent);
} }
.search-input::placeholder, .search-input::placeholder,
.memory-editor::placeholder { .memory-editor::placeholder { color: var(--muted-2); }
color: var(--muted-2);
}
.search-spinner { .search-spinner {
position: absolute; position: absolute;
right: 1rem; right: 1rem;
@@ -295,50 +260,19 @@ button {
.search-results { .search-results {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem; gap: 0.8rem;
}
.file-card,
.result-item,
.ghost-btn,
.primary-btn {
cursor: pointer;
} }
.file-card, .file-card,
.result-item { .result-item {
padding: 1rem; padding: 1rem;
text-align: left; text-align: left;
transition: border-color 140ms ease, transform 140ms ease; cursor: pointer;
} }
.file-date { color: var(--accent-strong); font-size: 0.82rem; }
.file-card:hover,
.result-item:hover,
.ghost-btn:hover,
.primary-btn:hover,
.back-btn:hover,
.rail-row:hover {
transform: translateY(-1px);
border-color: #505862;
}
.file-date {
color: var(--muted-2);
font-size: 0.82rem;
}
.file-snippet, .file-snippet,
.result-snippet { .result-snippet { margin-top: 0.55rem; line-height: 1.6; }
margin-top: 0.55rem; .file-stats { margin-top: 0.75rem; display: flex; gap: 0.6rem; font-size: 0.82rem; }
line-height: 1.55;
}
.file-stats {
margin-top: 0.75rem;
display: flex;
gap: 0.6rem;
font-size: 0.82rem;
}
.tag-strip { .tag-strip {
margin-top: 0.8rem; margin-top: 0.8rem;
@@ -350,43 +284,46 @@ button {
.tag-pill { .tag-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 1.8rem; min-height: 1.85rem;
padding: 0.22rem 0.55rem; padding: 0.24rem 0.58rem;
background: #111417; background: rgba(255,255,255,0.02);
border: 1px solid var(--border-strong); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
color: #d7dce2; border-radius: 999px;
color: var(--accent-strong);
font-size: 0.75rem; font-size: 0.75rem;
text-transform: lowercase; text-transform: lowercase;
cursor: pointer;
}
.tag-pill.active,
.tag-pill:hover {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
} }
.ghost-btn, .ghost-btn,
.primary-btn, .primary-btn,
.back-btn { .back-btn,
padding: 0.8rem 1rem; .small-btn {
padding: 0.82rem 1rem;
border-radius: 12px;
} }
.ghost-btn, .ghost-btn,
.back-btn { .back-btn {
background: transparent; background: rgba(255,255,255,0.02);
color: var(--text); color: var(--text);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
} }
.primary-btn { .primary-btn {
background: #e8ebef; background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #111315; color: #0d1116;
border: 1px solid #e8ebef; border: 1px solid transparent;
font-weight: 600; font-weight: 620;
}
.primary-btn:disabled {
opacity: 0.55;
cursor: default;
transform: none;
} }
.primary-btn:disabled { opacity: 0.55; cursor: default; transform: none; }
.small-btn { padding: 0.56rem 0.82rem; }
kbd { kbd {
background: #15191d; background: rgba(255,255,255,0.03);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
padding: 0.1rem 0.4rem; padding: 0.1rem 0.4rem;
border-radius: 6px; border-radius: 6px;
@@ -398,7 +335,6 @@ kbd {
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.25rem 3rem; padding: 2rem 1.25rem 3rem;
} }
.viewer-header { .viewer-header {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
@@ -407,53 +343,34 @@ kbd {
padding: 1rem; padding: 1rem;
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
.viewer-title { .viewer-title {
margin-top: 0.2rem; margin-top: 0.2rem;
font-size: 1.35rem; font-size: 1.35rem;
font-weight: 600; font-weight: 620;
letter-spacing: -0.03em; letter-spacing: -0.03em;
} }
.memory-content { .memory-content {
margin-top: 0.8rem; margin-top: 0.8rem;
padding: 1.2rem; padding: 1.2rem;
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.65; line-height: 1.68;
overflow: auto; overflow: auto;
} }
.muted-panel { color: var(--muted); }
.muted-panel {
color: var(--muted);
}
@media (max-width: 980px) { @media (max-width: 980px) {
.hero, .hero,
.hero-grid, .hero-grid,
.main-grid { .main-grid { grid-template-columns: 1fr; }
grid-template-columns: 1fr; .hero-actions { align-items: flex-start; }
}
.hero-actions {
align-items: flex-start;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.app, .app,
.viewer-shell { .viewer-shell { padding-left: 0.9rem; padding-right: 0.9rem; }
padding-left: 0.9rem;
padding-right: 0.9rem;
}
.composer-form, .composer-form,
.viewer-header { .viewer-header { display: grid; grid-template-columns: 1fr; }
grid-template-columns: 1fr;
display: grid;
}
.files-grid, .files-grid,
.search-results { .search-results { grid-template-columns: 1fr; }
grid-template-columns: 1fr; .rail-row { grid-template-columns: 1fr; }
}
} }
+105 -40
View File
@@ -34,27 +34,34 @@ function pickSpark(content) {
return clipText(candidate.replace(/^[-*]\s*/, '')) return clipText(candidate.replace(/^[-*]\s*/, ''))
} }
function TagStrip({ tags = [] }) { function TagStrip({ tags = [], onPick, activeTag }) {
if (!tags.length) return null if (!tags.length) return null
return ( return (
<div className="tag-strip"> <div className="tag-strip">
{tags.map((tag) => ( {tags.map((tag) => (
<span key={tag} className="tag-pill">{tag}</span> <button
key={tag}
type="button"
className={`tag-pill ${activeTag === tag ? 'active' : ''}`}
onClick={onPick ? () => onPick(tag) : undefined}
>
{tag}
</button>
))} ))}
</div> </div>
) )
} }
function FileCard({ file, onClick }) { function FileCard({ file, onClick, onPickTag, activeTag }) {
return ( return (
<button className="file-card" onClick={onClick}> <button className="file-card interactive-card" onClick={onClick}>
<div className="file-card-top"> <div className="file-card-top">
<div className="file-date">{formatDate(file.mtimeMs)}</div> <div className="file-date">{formatDate(file.mtimeMs)}</div>
<div className="file-age">{formatRelative(file.mtimeMs)}</div> <div className="file-age">{formatRelative(file.mtimeMs)}</div>
</div> </div>
<div className="file-label">{file.dateLabel}</div> <div className="file-label">{file.dateLabel}</div>
<div className="file-snippet">{file.tags?.[0] ? `Tagged for ${file.tags[0]}.` : 'Quiet day in the archive.'}</div> <div className="file-snippet">{file.tags?.[0] ? `Leaning ${file.tags[0]}.` : 'Quiet day in the archive.'}</div>
<TagStrip tags={file.tags} /> <TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
<div className="file-stats"> <div className="file-stats">
<span>{file.wordCount.toLocaleString()} words</span> <span>{file.wordCount.toLocaleString()} words</span>
<span>{file.entryCount} entries</span> <span>{file.entryCount} entries</span>
@@ -63,7 +70,7 @@ function FileCard({ file, onClick }) {
) )
} }
function Viewer({ selected, onBack }) { function Viewer({ selected, onBack, activeTag, onPickTag }) {
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -74,9 +81,7 @@ function Viewer({ selected, onBack }) {
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
setSaveState('idle') setSaveState('idle')
const url = isMainMemory const url = isMainMemory ? `${API}/api/main-memory` : `${API}/api/memories/${selected.filename}`
? `${API}/api/main-memory`
: `${API}/api/memories/${selected.filename}`
fetch(url) fetch(url)
.then((r) => r.json()) .then((r) => r.json())
@@ -112,7 +117,7 @@ function Viewer({ selected, onBack }) {
return ( return (
<div className="viewer-shell"> <div className="viewer-shell">
<div className="viewer-header"> <div className="viewer-header panel interactive-card">
<button className="ghost-btn back-btn" onClick={onBack}>Back</button> <button className="ghost-btn back-btn" onClick={onBack}>Back</button>
<div className="viewer-heading"> <div className="viewer-heading">
<div className="viewer-kicker">{isMainMemory ? 'core memory' : 'daily note'}</div> <div className="viewer-kicker">{isMainMemory ? 'core memory' : 'daily note'}</div>
@@ -124,7 +129,7 @@ function Viewer({ selected, onBack }) {
</button> </button>
)} )}
</div> </div>
<TagStrip tags={tags} /> <TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
{loading ? ( {loading ? (
<div className="panel muted-panel">Loading</div> <div className="panel muted-panel">Loading</div>
) : isMainMemory ? ( ) : isMainMemory ? (
@@ -136,28 +141,30 @@ function Viewer({ selected, onBack }) {
) )
} }
function ActivityRail({ files, onSelect }) { function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
if (!files?.length) return null if (!files?.length) return null
const recent = files.slice(0, 8) const recent = files.slice(0, 8)
const maxWords = Math.max(...recent.map((file) => file.wordCount), 1)
return ( return (
<section className="panel rail-panel"> <section className="panel rail-panel interactive-card">
<div className="section-heading"> <div className="section-heading">
<div> <div>
<div className="section-kicker">recent rhythm</div> <div className="section-kicker">recent rhythm</div>
<h2>Eight latest memory days.</h2> <h2>Latest memory days.</h2>
</div> </div>
</div> </div>
<div className="rail-list"> <div className="rail-list">
{recent.map((file) => ( {recent.map((file) => (
<button key={file.filename} className="rail-row" onClick={() => onSelect(file)}> <button key={file.filename} className="rail-row" onClick={() => onSelect(file)}>
<div> <div className="rail-copy">
<div className="rail-date">{file.dateLabel}</div> <div className="rail-date">{file.dateLabel}</div>
<TagStrip tags={file.tags} /> <TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
</div> </div>
<div className="rail-metrics"> <div className="rail-metrics">
<strong>{file.wordCount.toLocaleString()}</strong> <strong>{file.wordCount.toLocaleString()}</strong>
<span>{file.entryCount} entries</span> <span>{file.entryCount} entries</span>
<div className="rail-bar"><span style={{ width: `${(file.wordCount / maxWords) * 100}%` }} /></div>
</div> </div>
</button> </button>
))} ))}
@@ -166,6 +173,17 @@ function ActivityRail({ files, onSelect }) {
) )
} }
function ThemeSwatches({ theme, tags, onPickTag, activeTag }) {
return (
<section className="panel recap-panel interactive-card">
<div className="section-kicker">theme drift</div>
<div className="theme-title">{theme?.name || 'Nord'} mode</div>
<p className="theme-copy">The color temperature now leans with what lives in core memory, not just a fixed skin.</p>
<TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
</section>
)
}
export default function App() { export default function App() {
const [meta, setMeta] = useState(null) const [meta, setMeta] = useState(null)
const [selected, setSelected] = useState(null) const [selected, setSelected] = useState(null)
@@ -177,6 +195,8 @@ export default function App() {
const [reviewCount, setReviewCount] = useState(null) const [reviewCount, setReviewCount] = useState(null)
const [quickLog, setQuickLog] = useState('') const [quickLog, setQuickLog] = useState('')
const [logState, setLogState] = useState('idle') const [logState, setLogState] = useState('idle')
const [activeTag, setActiveTag] = useState('')
const [pointer, setPointer] = useState({ x: 18, y: 12 })
const searchRef = useRef(null) const searchRef = useRef(null)
const refreshMeta = () => { const refreshMeta = () => {
@@ -199,6 +219,14 @@ export default function App() {
refreshMeta() refreshMeta()
}, []) }, [])
useEffect(() => {
const onMove = (e) => {
setPointer({ x: (e.clientX / window.innerWidth) * 100, y: (e.clientY / window.innerHeight) * 100 })
}
window.addEventListener('pointermove', onMove)
return () => window.removeEventListener('pointermove', onMove)
}, [])
useEffect(() => { useEffect(() => {
if (!meta?.dailyFiles?.length) return if (!meta?.dailyFiles?.length) return
const latest = meta.dailyFiles[0] const latest = meta.dailyFiles[0]
@@ -215,6 +243,13 @@ export default function App() {
}) })
}, [meta]) }, [meta])
useEffect(() => {
if (activeTag) {
setQuery(activeTag)
searchRef.current?.focus()
}
}, [activeTag])
useEffect(() => { useEffect(() => {
if (!query.trim() || query.length < 2) { if (!query.trim() || query.length < 2) {
setSearchResults([]) setSearchResults([])
@@ -231,7 +266,7 @@ export default function App() {
setSearchResults([]) setSearchResults([])
} }
setSearching(false) setSearching(false)
}, 220) }, 180)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [query]) }, [query])
@@ -243,6 +278,28 @@ export default function App() {
return { latest, longest } return { latest, longest }
}, [meta]) }, [meta])
const smartClusters = useMemo(() => {
const tags = new Map()
for (const file of meta?.dailyFiles || []) {
for (const tag of file.tags || []) {
tags.set(tag, (tags.get(tag) || 0) + 1)
}
}
return Array.from(tags.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 8)
.map(([tag]) => tag)
}, [meta])
const themeStyle = useMemo(() => ({
'--accent': meta?.theme?.accent || '#8fbcbb',
'--accent-soft': meta?.theme?.accentSoft || 'rgba(143,188,187,0.18)',
'--accent-strong': meta?.theme?.accentStrong || '#a3d5d3',
'--accent-glow': meta?.theme?.glow || 'rgba(143,188,187,0.28)',
'--pointer-x': `${pointer.x}%`,
'--pointer-y': `${pointer.y}%`,
}), [meta, pointer])
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [] }) const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [] })
const submitLog = async (e) => { const submitLog = async (e) => {
@@ -267,17 +324,18 @@ export default function App() {
} }
if (selected) { if (selected) {
return <Viewer selected={selected} onBack={() => setSelected(null)} /> return <Viewer selected={selected} onBack={() => setSelected(null)} activeTag={activeTag} onPickTag={setActiveTag} />
} }
return ( return (
<div className="app-shell"> <div className="app-shell" style={themeStyle}>
<div className="dynamic-wash" aria-hidden="true" />
<div className="app"> <div className="app">
<header className="hero"> <header className="hero">
<div className="hero-copy"> <div className="hero-copy">
<div className="eyebrow">freeclaw memory cloud</div> <div className="eyebrow">freeclaw memory cloud</div>
<h1>Sharper memory.</h1> <h1>Memory with a pulse.</h1>
<p className="subtitle">Quiet archive. Fast search. Auto tags. Less glow, more signal.</p> <p className="subtitle">Theme shifts with core memory. Tags are smarter. The archive should feel alive, not embalmed.</p>
</div> </div>
<div className="hero-actions"> <div className="hero-actions">
<button <button
@@ -292,34 +350,38 @@ export default function App() {
{meta && heroStats && ( {meta && heroStats && (
<section className="hero-grid"> <section className="hero-grid">
<div className="panel lead-panel"> <div className="panel lead-panel interactive-card accent-panel">
<div className="section-kicker">latest spark</div> <div className="section-kicker">latest spark</div>
<div className="lead-copy">{spark || 'Loading…'}</div> <div className="lead-copy">{spark || 'Loading…'}</div>
<div className="lead-meta">{sparkSource || 'recent archive'}</div> <div className="lead-meta">{sparkSource || 'recent archive'}</div>
<TagStrip tags={heroStats.latest.tags} /> <TagStrip tags={heroStats.latest.tags} onPick={setActiveTag} activeTag={activeTag} />
</div> </div>
<div className="panel stats-panel"> <div className="panel stats-panel interactive-card">
<div className="stat-row"><span>daily files</span><strong>{meta.summary.dailyFileCount}</strong></div> <div className="stat-row"><span>daily files</span><strong>{meta.summary.dailyFileCount}</strong></div>
<div className="stat-row"><span>words logged</span><strong>{meta.summary.totalDailyWords.toLocaleString()}</strong></div> <div className="stat-row"><span>words logged</span><strong>{meta.summary.totalDailyWords.toLocaleString()}</strong></div>
<div className="stat-row"><span>core memories</span><strong>{meta.mainMemory?.entryCount ?? 0}</strong></div> <div className="stat-row"><span>core memories</span><strong>{meta.mainMemory?.entryCount ?? 0}</strong></div>
<div className="stat-row"><span>review queue</span><strong>{reviewCount ?? '—'}</strong></div> <div className="stat-row"><span>review queue</span><strong>{reviewCount ?? '—'}</strong></div>
</div> </div>
<div className="panel recap-panel"> <ThemeSwatches theme={meta.theme} tags={meta.mainMemory?.tags} onPickTag={setActiveTag} activeTag={activeTag} />
<div className="section-kicker">pulse</div>
<div className="recap-line">Latest <strong>{heroStats.latest.dateLabel}</strong></div>
<div className="recap-line">Largest <strong>{heroStats.longest.wordCount.toLocaleString()} words</strong></div>
<div className="recap-line">Touched <strong>{formatRelative(heroStats.latest.mtimeMs)}</strong></div>
<TagStrip tags={meta.mainMemory?.tags} />
</div>
</section> </section>
)} )}
<div className="main-grid"> <section className="panel cluster-panel interactive-card">
<ActivityRail files={meta?.dailyFiles} onSelect={openFile} /> <div className="section-heading">
<div>
<div className="section-kicker">smart clusters</div>
<h2>Recurring ideas across the archive.</h2>
</div>
</div>
<TagStrip tags={smartClusters} onPick={setActiveTag} activeTag={activeTag} />
</section>
<section className="panel composer-panel"> <div className="main-grid">
<ActivityRail files={meta?.dailyFiles} onSelect={openFile} onPickTag={setActiveTag} activeTag={activeTag} />
<section className="panel composer-panel interactive-card">
<div className="section-heading compact-heading"> <div className="section-heading compact-heading">
<div> <div>
<div className="section-kicker">quick log</div> <div className="section-kicker">quick log</div>
@@ -328,6 +390,7 @@ export default function App() {
</div> </div>
<form className="composer-form" onSubmit={submitLog}> <form className="composer-form" onSubmit={submitLog}>
<input <input
ref={searchRef}
className="search-input composer-input" className="search-input composer-input"
type="text" type="text"
placeholder="A note, decision, odd clue, or tiny win…" placeholder="A note, decision, odd clue, or tiny win…"
@@ -348,12 +411,14 @@ export default function App() {
<div className="section-heading compact-heading"> <div className="section-heading compact-heading">
<div> <div>
<div className="section-kicker">search</div> <div className="section-kicker">search</div>
<h2>Find threads fast.</h2> <h2>{activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
</div> </div>
{activeTag && (
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setQuery('') }}>Clear tag</button>
)}
</div> </div>
<div className="search-section"> <div className="search-section">
<input <input
ref={searchRef}
className="search-input" className="search-input"
type="text" type="text"
placeholder="Search memory files…" placeholder="Search memory files…"
@@ -368,7 +433,7 @@ export default function App() {
{searchResults.map((result) => ( {searchResults.map((result) => (
<button <button
key={result.filename} key={result.filename}
className="result-item" className="result-item interactive-card"
onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [] })} onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [] })}
> >
<div className="result-head"> <div className="result-head">
@@ -376,7 +441,7 @@ export default function App() {
<div className="result-meta">{result.matchCount} matches</div> <div className="result-meta">{result.matchCount} matches</div>
</div> </div>
<div className="result-snippet">{result.snippet}</div> <div className="result-snippet">{result.snippet}</div>
<TagStrip tags={result.tags} /> <TagStrip tags={result.tags} onPick={setActiveTag} activeTag={activeTag} />
</button> </button>
))} ))}
</div> </div>
@@ -385,7 +450,7 @@ export default function App() {
{searchResults.length === 0 && !searching && query.length < 2 && meta && ( {searchResults.length === 0 && !searching && query.length < 2 && meta && (
<div className="files-grid"> <div className="files-grid">
{meta.dailyFiles.map((file) => ( {meta.dailyFiles.map((file) => (
<FileCard key={file.filename} file={file} onClick={() => openFile(file)} /> <FileCard key={file.filename} file={file} onClick={() => openFile(file)} onPickTag={setActiveTag} activeTag={activeTag} />
))} ))}
</div> </div>
)} )}