diff --git a/website/canvas.html b/website/canvas.html
index 5070920..8d77b4f 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -527,10 +527,7 @@
text-align: center;
}
- /* Feed mode: hide bottom toolbar */
- #canvas.feed-mode ~ #bottom-toolbar {
- display: none;
- }
+ /* Feed mode: hide bottom toolbar (overridden by comprehensive rule below) */
#community-info {
display: none;
@@ -1397,34 +1394,252 @@
overflow-y: auto;
overflow-x: hidden;
touch-action: pan-y;
+ scroll-behavior: smooth;
+ background: var(--rs-bg-page);
}
#canvas.feed-mode #canvas-content {
transform: none !important;
display: flex;
flex-direction: column;
align-items: center;
- gap: 16px;
- padding: 16px 8px 80px;
+ gap: 0;
+ padding: 100px 8px 80px;
width: 100%;
min-height: 100%;
position: relative;
}
+ /* Hide canvas-only UI in feed mode */
+ body:has(#canvas.feed-mode) #toolbar,
+ body:has(#canvas.feed-mode) #bottom-toolbar {
+ display: none !important;
+ }
+ /* Keep corner tools but hide zoom, show only feed toggle */
+ body:has(#canvas.feed-mode) #canvas-corner-tools #zoom-in,
+ body:has(#canvas.feed-mode) #canvas-corner-tools #zoom-out,
+ body:has(#canvas.feed-mode) #canvas-corner-tools #reset-view,
+ body:has(#canvas.feed-mode) #canvas-corner-tools .corner-sep {
+ display: none !important;
+ }
+ /* Target ALL shape types in feed mode */
#canvas.feed-mode folk-shape,
- #canvas.feed-mode [is="folk-shape"] {
+ #canvas.feed-mode [is="folk-shape"],
+ #canvas.feed-mode folk-markdown,
+ #canvas.feed-mode folk-wrapper,
+ #canvas.feed-mode folk-slide,
+ #canvas.feed-mode folk-chat,
+ #canvas.feed-mode folk-obs-note,
+ #canvas.feed-mode folk-rapp,
+ #canvas.feed-mode folk-embed,
+ #canvas.feed-mode folk-drawfast,
+ #canvas.feed-mode folk-prompt,
+ #canvas.feed-mode folk-zine-gen,
+ #canvas.feed-mode folk-workflow-block,
+ #canvas.feed-mode folk-choice-vote,
+ #canvas.feed-mode folk-choice-rank,
+ #canvas.feed-mode folk-choice-spider,
+ #canvas.feed-mode folk-spider-3d,
+ #canvas.feed-mode folk-choice-conviction,
+ #canvas.feed-mode folk-token,
+ #canvas.feed-mode folk-token-mint,
+ #canvas.feed-mode folk-token-ledger,
+ #canvas.feed-mode folk-google-item,
+ #canvas.feed-mode folk-social-post,
+ #canvas.feed-mode folk-calendar,
+ #canvas.feed-mode folk-map,
+ #canvas.feed-mode folk-piano,
+ #canvas.feed-mode folk-splat,
+ #canvas.feed-mode folk-video-chat,
+ #canvas.feed-mode folk-transcription,
+ #canvas.feed-mode folk-image-gen,
+ #canvas.feed-mode folk-video-gen,
+ #canvas.feed-mode folk-blender,
+ #canvas.feed-mode folk-freecad,
+ #canvas.feed-mode folk-kicad,
+ #canvas.feed-mode folk-itinerary,
+ #canvas.feed-mode folk-destination,
+ #canvas.feed-mode folk-budget,
+ #canvas.feed-mode folk-packing-list,
+ #canvas.feed-mode folk-booking,
+ #canvas.feed-mode folk-feed {
position: relative !important;
transform: none !important;
- width: min(100%, 480px) !important;
+ width: 100% !important;
height: auto !important;
- min-height: 200px;
+ min-height: 120px;
flex-shrink: 0;
}
- /* Hide arrows/connections in feed mode */
+ /* Wrapper/canvas shapes get taller min-height */
+ #canvas.feed-mode folk-wrapper,
+ #canvas.feed-mode folk-slide {
+ min-height: 300px;
+ }
+ /* Hide arrows in feed mode */
#canvas.feed-mode folk-arrow {
display: none !important;
}
- /* Hide grid background in feed mode */
- #canvas.feed-mode {
- background: var(--rs-bg-page);
+
+ /* ── Feed card wrappers ── */
+ .feed-card {
+ width: min(100%, 600px);
+ margin: 8px auto;
+ border: 1px solid var(--rs-border, #e2e8f0);
+ border-radius: 12px;
+ background: var(--rs-bg-card, #fff);
+ box-shadow: 0 1px 4px rgba(0,0,0,0.06);
+ overflow: hidden;
+ flex-shrink: 0;
+ }
+ body[data-theme="dark"] .feed-card {
+ background: #1e293b;
+ border-color: #334155;
+ }
+ .feed-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--rs-border, #e2e8f0);
+ background: var(--rs-bg-subtle, #f8fafc);
+ font-size: 13px;
+ min-height: 36px;
+ }
+ body[data-theme="dark"] .feed-card-header {
+ background: #0f172a;
+ border-color: #334155;
+ }
+ .feed-card-icon {
+ font-size: 16px;
+ flex-shrink: 0;
+ width: 20px;
+ text-align: center;
+ }
+ .feed-card-title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+ color: var(--rs-text, #1e293b);
+ }
+ body[data-theme="dark"] .feed-card-title {
+ color: #e2e8f0;
+ }
+ .feed-card-type {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: var(--rs-accent, #6366f1);
+ color: white;
+ flex-shrink: 0;
+ text-transform: capitalize;
+ }
+ .feed-card-body {
+ position: relative;
+ padding: 0;
+ overflow: hidden;
+ }
+
+ /* ── Feed section headers ── */
+ .feed-section-header {
+ width: min(100%, 600px);
+ margin: 20px auto 4px;
+ padding: 8px 4px;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--rs-text-muted, #64748b);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 2px solid var(--rs-accent, #6366f1);
+ flex-shrink: 0;
+ }
+ body[data-theme="dark"] .feed-section-header {
+ color: #94a3b8;
+ border-color: #6366f1;
+ }
+ .feed-section-header:first-child {
+ margin-top: 0;
+ }
+
+ /* ── Feed scroll summary bar ── */
+ #feed-scroll-summary {
+ position: fixed;
+ top: 148px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: none;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 14px;
+ background: white;
+ border-radius: 10px;
+ box-shadow: 0 2px 12px rgba(0,0,0,0.12);
+ z-index: 1002;
+ font-size: 13px;
+ max-width: 90vw;
+ }
+ body[data-theme="dark"] #feed-scroll-summary {
+ background: #1e293b;
+ color: #e2e8f0;
+ }
+ #feed-scroll-summary.visible {
+ display: flex;
+ }
+ .feed-summary-section {
+ font-weight: 600;
+ white-space: nowrap;
+ color: var(--rs-text, #1e293b);
+ }
+ body[data-theme="dark"] .feed-summary-section {
+ color: #e2e8f0;
+ }
+ .feed-summary-counter {
+ color: var(--rs-text-muted, #64748b);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ }
+ .feed-summary-chips {
+ display: flex;
+ gap: 4px;
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+ .feed-summary-chips::-webkit-scrollbar { display: none; }
+ .feed-chip {
+ padding: 2px 8px;
+ border-radius: 6px;
+ background: var(--rs-bg-subtle, #f1f5f9);
+ border: 1px solid var(--rs-border, #e2e8f0);
+ font-size: 12px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background 0.15s, border-color 0.15s;
+ }
+ body[data-theme="dark"] .feed-chip {
+ background: #334155;
+ border-color: #475569;
+ color: #e2e8f0;
+ }
+ .feed-chip:hover {
+ background: var(--rs-accent, #6366f1);
+ color: white;
+ border-color: var(--rs-accent, #6366f1);
+ }
+ .feed-chip.active {
+ background: var(--rs-accent, #6366f1);
+ color: white;
+ border-color: var(--rs-accent, #6366f1);
+ }
+
+ @media (max-width: 768px) {
+ #feed-scroll-summary {
+ top: 100px;
+ left: 8px;
+ right: 8px;
+ transform: none;
+ }
+ #canvas.feed-mode #canvas-content {
+ padding-top: 120px;
+ }
}
#canvas-content.hide-forgotten :state(forgotten) {
@@ -1933,6 +2148,12 @@
+
+
@@ -5702,43 +5923,41 @@
});
// ── Feed Mode ──
+ const FEED_SHAPE_SELECTOR = 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-token-mint, folk-token-ledger, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-feed';
+
let feedMode = false;
let feedSortKey = 'y';
let feedOriginalOrder = [];
+ let feedScrollObserver = null;
const feedToggleBtn = document.getElementById('feed-toggle');
const feedSortBar = document.getElementById('feed-sort-bar');
const feedSortSelect = document.getElementById('feed-sort');
+ const feedScrollSummary = document.getElementById('feed-scroll-summary');
+ const feedSummarySection = feedScrollSummary.querySelector('.feed-summary-section');
+ const feedSummaryCounter = feedScrollSummary.querySelector('.feed-summary-counter');
+ const feedSummaryChips = feedScrollSummary.querySelector('.feed-summary-chips');
- function toggleFeedMode() {
- feedMode = !feedMode;
- canvas.classList.toggle('feed-mode', feedMode);
- feedToggleBtn.classList.toggle('active', feedMode);
- feedSortBar.classList.toggle('hidden', !feedMode);
- if (feedMode) {
- // Save original DOM order before reordering
- feedOriginalOrder = [...canvasContent.children];
- sortFeedShapes(feedSortKey);
- } else {
- // Restore original DOM order
- for (const el of feedOriginalOrder) canvasContent.appendChild(el);
- feedOriginalOrder = [];
- }
+ // Human-readable type name from tag
+ function feedTypeName(tag) {
+ return (tag || '').replace('folk-', '').replace(/-/g, ' ');
}
- function sortFeedShapes(key) {
- const shapes = [...canvasContent.querySelectorAll(
- 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-zine-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking'
- )];
+ // Get label for a shape element
+ function feedShapeLabel(el) {
+ const d = sync.doc?.shapes?.[el.id] || {};
+ return d.title || d.content?.slice(0, 40) || d.tokenName || d.label ||
+ el.getAttribute('title') || el.textContent?.trim().slice(0, 40) || feedTypeName(el.tagName.toLowerCase());
+ }
- shapes.sort((a, b) => {
- // Read from Automerge doc when available
+ // Sort shapes array by key, return sorted array
+ function feedSortArray(shapes, key) {
+ return shapes.sort((a, b) => {
const da = (sync.doc?.shapes?.[a.id]) || {};
const db = (sync.doc?.shapes?.[b.id]) || {};
switch (key) {
case 'y':
return (parseFloat(da.y ?? a.y ?? 0)) - (parseFloat(db.y ?? b.y ?? 0));
case 'created': {
- // Shape IDs encode timestamp: shape--
const ta = parseInt(a.id?.split('-')[1]) || 0;
const tb = parseInt(b.id?.split('-')[1]) || 0;
return ta - tb;
@@ -5750,12 +5969,229 @@
const nameB = db.title || db.content || b.getAttribute('title') || b.textContent?.slice(0, 50) || '';
return nameA.localeCompare(nameB);
}
- default:
- return 0;
+ default: return 0;
}
});
+ }
- for (const s of shapes) canvasContent.appendChild(s);
+ // Compute section groupings for sorted shapes
+ function feedComputeSections(shapes, key) {
+ const sections = []; // [{name, items: [el, ...]}]
+ for (const el of shapes) {
+ const d = sync.doc?.shapes?.[el.id] || {};
+ let sectionName;
+ switch (key) {
+ case 'type': {
+ const tag = el.tagName.toLowerCase();
+ const icon = SHAPE_ICONS[tag] || '';
+ sectionName = icon + ' ' + feedTypeName(tag);
+ break;
+ }
+ case 'created': {
+ const ts = parseInt(el.id?.split('-')[1]) || 0;
+ if (!ts) { sectionName = 'Unknown'; break; }
+ const date = new Date(ts);
+ const now = new Date();
+ const dayDiff = Math.floor((now - date) / 86400000);
+ if (dayDiff === 0) sectionName = 'Today';
+ else if (dayDiff <= 7) sectionName = 'This Week';
+ else if (dayDiff <= 30) sectionName = 'This Month';
+ else sectionName = 'Older';
+ break;
+ }
+ case 'alpha': {
+ const label = (d.title || d.content || el.getAttribute('title') || el.textContent?.slice(0, 1) || '').trim();
+ const first = (label[0] || '#').toUpperCase();
+ sectionName = /[A-Z]/.test(first) ? first : '#';
+ break;
+ }
+ case 'y': {
+ const y = parseFloat(d.y ?? el.y ?? 0);
+ // Compute range from all shapes
+ if (!feedComputeSections._yRange) {
+ const allY = shapes.map(s => parseFloat((sync.doc?.shapes?.[s.id] || {}).y ?? s.y ?? 0));
+ const minY = Math.min(...allY);
+ const maxY = Math.max(...allY);
+ const range = maxY - minY || 1;
+ feedComputeSections._yRange = { minY, range };
+ }
+ const { minY, range } = feedComputeSections._yRange;
+ const pct = (y - minY) / range;
+ if (pct < 0.33) sectionName = 'Top';
+ else if (pct < 0.66) sectionName = 'Middle';
+ else sectionName = 'Bottom';
+ break;
+ }
+ default: sectionName = 'All';
+ }
+ const last = sections[sections.length - 1];
+ if (last && last.name === sectionName) {
+ last.items.push(el);
+ } else {
+ sections.push({ name: sectionName, items: [el] });
+ }
+ }
+ feedComputeSections._yRange = null; // reset
+ return sections;
+ }
+
+ // Build feed DOM: section headers + feed cards
+ function feedBuildDOM(sections) {
+ // Remove any existing feed elements
+ canvasContent.querySelectorAll('.feed-card, .feed-section-header').forEach(el => el.remove());
+
+ let itemIndex = 0;
+ for (const section of sections) {
+ // Section header
+ const header = document.createElement('div');
+ header.className = 'feed-section-header';
+ header.dataset.section = section.name;
+ header.textContent = section.name + ' (' + section.items.length + ')';
+ canvasContent.appendChild(header);
+
+ for (const shape of section.items) {
+ itemIndex++;
+ const card = document.createElement('div');
+ card.className = 'feed-card';
+ card.dataset.feedIndex = itemIndex;
+ card.dataset.section = section.name;
+
+ const cardHeader = document.createElement('div');
+ cardHeader.className = 'feed-card-header';
+
+ const tag = shape.tagName.toLowerCase();
+ const icon = document.createElement('span');
+ icon.className = 'feed-card-icon';
+ icon.textContent = SHAPE_ICONS[tag] || '📄';
+
+ const title = document.createElement('span');
+ title.className = 'feed-card-title';
+ title.textContent = feedShapeLabel(shape);
+
+ const typeBadge = document.createElement('span');
+ typeBadge.className = 'feed-card-type';
+ typeBadge.textContent = feedTypeName(tag);
+
+ cardHeader.append(icon, title, typeBadge);
+
+ const cardBody = document.createElement('div');
+ cardBody.className = 'feed-card-body';
+ cardBody.appendChild(shape);
+
+ card.append(cardHeader, cardBody);
+ canvasContent.appendChild(card);
+ }
+ }
+ }
+
+ // Populate section chips in scroll summary
+ function feedPopulateChips(sections) {
+ feedSummaryChips.innerHTML = '';
+ for (const section of sections) {
+ const chip = document.createElement('span');
+ chip.className = 'feed-chip';
+ chip.textContent = section.name;
+ chip.dataset.section = section.name;
+ chip.addEventListener('click', () => scrollToSection(section.name));
+ feedSummaryChips.appendChild(chip);
+ }
+ }
+
+ // Track scroll position to update summary bar
+ function feedSetupScrollTracking() {
+ feedTeardownScrollTracking();
+ const headers = canvasContent.querySelectorAll('.feed-section-header');
+ const cards = canvasContent.querySelectorAll('.feed-card');
+ const totalItems = cards.length;
+
+ if (!headers.length) return;
+
+ // Use scroll event on the canvas element (it's the scrollable container in feed mode)
+ const scrollHandler = () => {
+ const scrollTop = canvas.scrollTop;
+ const viewportMid = scrollTop + 140; // account for fixed bars
+
+ // Find current section
+ let currentSection = '';
+ for (const h of headers) {
+ if (h.offsetTop <= viewportMid) {
+ currentSection = h.dataset.section;
+ }
+ }
+
+ // Count current item index
+ let currentIndex = 0;
+ for (const c of cards) {
+ if (c.offsetTop <= viewportMid) currentIndex = parseInt(c.dataset.feedIndex);
+ }
+
+ feedSummarySection.textContent = currentSection;
+ feedSummaryCounter.textContent = currentIndex + ' of ' + totalItems;
+
+ // Update active chip
+ feedSummaryChips.querySelectorAll('.feed-chip').forEach(ch => {
+ ch.classList.toggle('active', ch.dataset.section === currentSection);
+ });
+ };
+
+ canvas.addEventListener('scroll', scrollHandler, { passive: true });
+ canvas._feedScrollHandler = scrollHandler;
+ // Initial update
+ scrollHandler();
+ }
+
+ function feedTeardownScrollTracking() {
+ if (canvas._feedScrollHandler) {
+ canvas.removeEventListener('scroll', canvas._feedScrollHandler);
+ canvas._feedScrollHandler = null;
+ }
+ }
+
+ function scrollToSection(sectionName) {
+ const header = canvasContent.querySelector('.feed-section-header[data-section="' + CSS.escape(sectionName) + '"]');
+ if (header) {
+ canvas.scrollTo({ top: header.offsetTop - 130, behavior: 'smooth' });
+ }
+ }
+
+ function toggleFeedMode() {
+ feedMode = !feedMode;
+ canvas.classList.toggle('feed-mode', feedMode);
+ feedToggleBtn.classList.toggle('active', feedMode);
+ feedSortBar.classList.toggle('hidden', !feedMode);
+ feedScrollSummary.classList.toggle('visible', feedMode);
+ if (feedMode) {
+ feedOriginalOrder = [...canvasContent.children];
+ sortFeedShapes(feedSortKey);
+ } else {
+ // Teardown scroll tracking
+ feedTeardownScrollTracking();
+ // Unwrap shapes from feed cards back into canvas-content
+ canvasContent.querySelectorAll('.feed-card-body').forEach(body => {
+ while (body.firstChild) canvasContent.appendChild(body.firstChild);
+ });
+ // Remove feed cards and section headers
+ canvasContent.querySelectorAll('.feed-card, .feed-section-header').forEach(el => el.remove());
+ // Restore original DOM order
+ for (const el of feedOriginalOrder) canvasContent.appendChild(el);
+ feedOriginalOrder = [];
+ feedScrollSummary.classList.remove('visible');
+ }
+ }
+
+ function sortFeedShapes(key) {
+ // If currently wrapped in cards, unwrap first
+ canvasContent.querySelectorAll('.feed-card-body').forEach(body => {
+ while (body.firstChild) canvasContent.appendChild(body.firstChild);
+ });
+ canvasContent.querySelectorAll('.feed-card, .feed-section-header').forEach(el => el.remove());
+
+ const shapes = [...canvasContent.querySelectorAll(FEED_SHAPE_SELECTOR)];
+ feedSortArray(shapes, key);
+ const sections = feedComputeSections(shapes, key);
+ feedBuildDOM(sections);
+ feedPopulateChips(sections);
+ feedSetupScrollTracking();
}
feedToggleBtn.addEventListener('click', toggleFeedMode);