feat: add ViewHistory for in-app back navigation, rename rWork to rTasks

Add shared ViewHistory<V> utility class that provides a proper navigation
stack for rApps with hierarchical views. Replaces hardcoded data-back
targets with stack-based back navigation across 10 rApps: rtrips, rmaps,
rtasks, rforum, rphotos, rvote, rnotes, rinbox, rschedule, rcart.

Rename rWork module to rTasks — directory, component (folk-tasks-board),
CSS, exports, domains, and all cross-module references updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 14:04:13 -07:00
parent 9de37d7405
commit 31b088543e
70 changed files with 2041 additions and 380 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@ Thumbs.db
.env .env
.env.local .env.local
.env.*.local .env.*.local
open-notebook.env
# Bun # Bun
bun.lockb bun.lockb

View File

@ -264,7 +264,7 @@ redirects to the unified server with subdomain-based space routing.
| **rCal** | rcal.online | Spatio-temporal calendar with map + lunar overlay | | **rCal** | rcal.online | Spatio-temporal calendar with map + lunar overlay |
| **rMaps** | rmaps.online | Geographic mapping & location hierarchy | | **rMaps** | rmaps.online | Geographic mapping & location hierarchy |
| **rTrips** | rtrips.online | Trip planning with itineraries | | **rTrips** | rtrips.online | Trip planning with itineraries |
| **rWork** | rwork.online | Task boards & project management | | **rTasks** | rtasks.online | Task boards & project management |
| **rSchedule** | rschedule.online | Persistent cron-based job scheduling with email, webhooks & briefings | | **rSchedule** | rschedule.online | Persistent cron-based job scheduling with email, webhooks & briefings |
### Communication ### Communication
@ -332,7 +332,7 @@ The canvas is the primary interface. Modules render as shapes that can
be positioned, connected, resized, and composed spatially. This enables be positioned, connected, resized, and composed spatially. This enables
non-linear knowledge work: a funding proposal (rFunds) sits next to non-linear knowledge work: a funding proposal (rFunds) sits next to
the vote (rVote), the budget spreadsheet (rData), and the project the vote (rVote), the budget spreadsheet (rData), and the project
plan (rWork) — all on one canvas, all synced in real-time. plan (rTasks) — all on one canvas, all synced in real-time.
### Offline-First ### Offline-First
Every interaction works offline. Changes queue locally and merge Every interaction works offline. Changes queue locally and merge

View File

@ -197,16 +197,16 @@ services:
traefik.http.routers.rmaps-sa.entrypoints: web traefik.http.routers.rmaps-sa.entrypoints: web
traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000" traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000"
# ── rWork ── # ── rTasks ──
rwork-standalone: rtasks-standalone:
<<: *standalone-base <<: *standalone-base
container_name: rwork-standalone container_name: rtasks-standalone
command: ["bun", "run", "modules/rwork/standalone.ts"] command: ["bun", "run", "modules/rtasks/standalone.ts"]
labels: labels:
<<: *traefik-enabled <<: *traefik-enabled
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`) traefik.http.routers.rtasks-sa.rule: Host(`rtasks.online`)
traefik.http.routers.rwork-sa.entrypoints: web traefik.http.routers.rtasks-sa.entrypoints: web
traefik.http.services.rwork-sa.loadbalancer.server.port: "3000" traefik.http.services.rtasks-sa.loadbalancer.server.port: "3000"
# ── rTrips ── # ── rTrips ──
rtrips-standalone: rtrips-standalone:

View File

@ -49,6 +49,7 @@ services:
- INFISICAL_AI_PROJECT_SLUG=claude-ops - INFISICAL_AI_PROJECT_SLUG=claude-ops
- INFISICAL_AI_SECRET_PATH=/ai - INFISICAL_AI_SECRET_PATH=/ai
- LISTMONK_URL=https://newsletter.cosmolocal.world - LISTMONK_URL=https://newsletter.cosmolocal.world
- NOTEBOOK_API_URL=http://open-notebook:5055
depends_on: depends_on:
rspace-db: rspace-db:
condition: service_healthy condition: service_healthy
@ -88,10 +89,10 @@ services:
- "traefik.http.routers.rspace-rvote.entrypoints=web" - "traefik.http.routers.rspace-rvote.entrypoints=web"
- "traefik.http.routers.rspace-rvote.priority=120" - "traefik.http.routers.rspace-rvote.priority=120"
- "traefik.http.routers.rspace-rvote.service=rspace-online" - "traefik.http.routers.rspace-rvote.service=rspace-online"
- "traefik.http.routers.rspace-rwork.rule=Host(`rwork.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rwork.online`)" - "traefik.http.routers.rspace-rtasks.rule=Host(`rtasks.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rtasks.online`)"
- "traefik.http.routers.rspace-rwork.entrypoints=web" - "traefik.http.routers.rspace-rtasks.entrypoints=web"
- "traefik.http.routers.rspace-rwork.priority=120" - "traefik.http.routers.rspace-rtasks.priority=120"
- "traefik.http.routers.rspace-rwork.service=rspace-online" - "traefik.http.routers.rspace-rtasks.service=rspace-online"
- "traefik.http.routers.rspace-rcal.rule=Host(`rcal.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rcal.online`)" - "traefik.http.routers.rspace-rcal.rule=Host(`rcal.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rcal.online`)"
- "traefik.http.routers.rspace-rcal.entrypoints=web" - "traefik.http.routers.rspace-rcal.entrypoints=web"
- "traefik.http.routers.rspace-rcal.priority=120" - "traefik.http.routers.rspace-rcal.priority=120"
@ -252,6 +253,32 @@ services:
retries: 5 retries: 5
start_period: 10s start_period: 10s
# ── Open Notebook (NotebookLM-like RAG service) ──
open-notebook:
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
container_name: open-notebook
restart: always
env_file: ./open-notebook.env
volumes:
- open-notebook-data:/app/data
- open-notebook-db:/mydata
networks:
- traefik-public
- ai-internal
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
# Frontend UI
- "traefik.http.routers.rspace-notebook.rule=Host(`notebook.rspace.online`)"
- "traefik.http.routers.rspace-notebook.entrypoints=web"
- "traefik.http.routers.rspace-notebook.tls.certresolver=letsencrypt"
- "traefik.http.services.rspace-notebook.loadbalancer.server.port=8502"
# API endpoint (used by rNotes integration)
- "traefik.http.routers.rspace-notebook-api.rule=Host(`notebook-api.rspace.online`)"
- "traefik.http.routers.rspace-notebook-api.entrypoints=web"
- "traefik.http.routers.rspace-notebook-api.tls.certresolver=letsencrypt"
- "traefik.http.services.rspace-notebook-api.loadbalancer.server.port=5055"
volumes: volumes:
rspace-data: rspace-data:
rspace-books: rspace-books:
@ -262,6 +289,8 @@ volumes:
rspace-backups: rspace-backups:
rspace-pgdata: rspace-pgdata:
encryptid-pgdata: encryptid-pgdata:
open-notebook-data:
open-notebook-db:
networks: networks:
traefik-public: traefik-public:

View File

@ -22,7 +22,7 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" }, rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" },
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" }, rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" }, rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" },
rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" }, rtasks: { badge: "rTa", color: "#cbd5e1", name: "rTasks", icon: "📋" },
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" }, rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" }, rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" }, rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" },
@ -454,7 +454,7 @@ const WIDGET_API: Record<string, { path: string; transform: (data: any) => Widge
}; };
}, },
}, },
rwork: { rtasks: {
path: "/api/spaces", path: "/api/spaces",
transform: (data) => { transform: (data) => {
const spaces = Array.isArray(data) ? data : []; const spaces = Array.isArray(data) ? data : [];

View File

@ -7,6 +7,7 @@
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas"; import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
interface BookData { interface BookData {
id: string; id: string;
@ -31,6 +32,13 @@ export class FolkBookShelf extends HTMLElement {
private _searchTerm = ""; private _searchTerm = "";
private _selectedTag: string | null = null; private _selectedTag: string | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.search-input', title: "Search", message: "Search books by title, author, or description.", advanceOnClick: false },
{ target: '.tag', title: "Filter by Tag", message: "Click a tag to filter the library by category.", advanceOnClick: true },
{ target: '.upload-btn', title: "Add Book", message: "Upload a new book as a PDF to share with the community.", advanceOnClick: false },
{ target: '.book-card', title: "Read a Book", message: "Click any book to open it in the flipbook reader.", advanceOnClick: false },
];
static get observedAttributes() { static get observedAttributes() {
return ["space-slug"]; return ["space-slug"];
@ -52,6 +60,12 @@ export class FolkBookShelf extends HTMLElement {
connectedCallback() { connectedCallback() {
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadowRoot!,
FolkBookShelf.TOUR_STEPS,
"rbooks_tour_done",
() => this.shadowRoot!.host as HTMLElement,
);
this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug; this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug;
this.render(); this.render();
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") {
@ -59,6 +73,9 @@ export class FolkBookShelf extends HTMLElement {
} else { } else {
this.subscribeOffline(); this.subscribeOffline();
} }
if (!localStorage.getItem("rbooks_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
} }
disconnectedCallback() { disconnectedCallback() {
@ -426,6 +443,7 @@ export class FolkBookShelf extends HTMLElement {
<span class="rapp-nav__title">Library</span> <span class="rapp-nav__title">Library</span>
<div class="rapp-nav__actions"> <div class="rapp-nav__actions">
<button class="rapp-nav__btn upload-btn">+ Add Book</button> <button class="rapp-nav__btn upload-btn">+ Add Book</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px">Tour</button>
</div> </div>
</div> </div>
@ -446,7 +464,7 @@ export class FolkBookShelf extends HTMLElement {
</div>` </div>`
: `<div class="grid"> : `<div class="grid">
${books.map((b) => ` ${books.map((b) => `
<a class="book-card" href="/${this._spaceSlug}/rbooks/read/${b.slug}"> <a class="book-card" data-collab-id="book:${b.id}" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
<div class="book-cover" style="background:${b.cover_color}"> <div class="book-cover" style="background:${b.cover_color}">
<span class="book-cover-title">${this.escapeHtml(b.title)}</span> <span class="book-cover-title">${this.escapeHtml(b.title)}</span>
${b.featured ? '<span class="featured-badge">Featured</span>' : ""} ${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
@ -498,11 +516,18 @@ export class FolkBookShelf extends HTMLElement {
`; `;
this.bindEvents(); this.bindEvents();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private bindEvents() { private bindEvents() {
if (!this.shadowRoot) return; if (!this.shadowRoot) return;
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Search // Search
const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement; const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement;
searchInput?.addEventListener("input", () => { searchInput?.addEventListener("input", () => {

View File

@ -18,6 +18,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rbooks" class="rl-cta-primary" id="ml-primary">Browse Library</a> <a href="https://demo.rspace.online/rbooks" class="rl-cta-primary" id="ml-primary">Browse Library</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-book-shelf')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -104,6 +104,7 @@ function leafletZoomToSpatial(zoom: number): number {
// ── Offline-first imports ── // ── Offline-first imports ──
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas"; import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
// ── Component ── // ── Component ──
@ -155,19 +156,35 @@ class FolkCalendarView extends HTMLElement {
private mapMarkerLayer: any = null; private mapMarkerLayer: any = null;
private transitLineLayer: any = null; private transitLineLayer: any = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#prev', title: "Navigate", message: "Use the arrow buttons to move between time periods. Keyboard shortcuts: left/right arrows.", advanceOnClick: false },
{ target: '#calendar-pane', title: "Calendar View", message: "Click any day cell to add an event. Drag events to reschedule. Use keyboard 1-5 to switch views.", advanceOnClick: false },
{ target: '#toggle-lunar', title: "Lunar Phases", message: "Toggle lunar phase display to see moon cycles alongside your events. Press 'L' as a shortcut.", advanceOnClick: true },
{ target: '#map-fab, .map-panel', title: "Map View", message: "Events with locations appear on the map. Expand to see spatial context alongside your calendar.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkCalendarView.TOUR_STEPS,
"rcal_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
document.addEventListener("keydown", this.boundKeyHandler); document.addEventListener("keydown", this.boundKeyHandler);
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); }
this.subscribeOffline(); else { this.subscribeOffline(); this.loadMonth(); this.render(); }
this.loadMonth(); if (!localStorage.getItem("rcal_tour_done")) {
this.render(); setTimeout(() => this._tour.start(), 1200);
}
} }
disconnectedCallback() { disconnectedCallback() {
@ -784,6 +801,7 @@ class FolkCalendarView extends HTMLElement {
<button class="nav-btn" id="today">Today</button> <button class="nav-btn" id="today">Today</button>
<span class="nav-title">${this.getViewLabel()}</span> <span class="nav-title">${this.getViewLabel()}</span>
<button class="nav-btn" id="next">\u2192</button> <button class="nav-btn" id="next">\u2192</button>
<button class="nav-btn" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
</div> </div>
${this.renderSources()} ${this.renderSources()}
@ -806,6 +824,7 @@ class FolkCalendarView extends HTMLElement {
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
// Play transition if pending // Play transition if pending
if (this._pendingTransition && this._ghostHtml) { if (this._pendingTransition && this._ghostHtml) {
@ -1499,7 +1518,7 @@ class FolkCalendarView extends HTMLElement {
</div> </div>
${allDay.length > 0 ? `<div class="day-allday"> ${allDay.length > 0 ? `<div class="day-allday">
<div class="day-allday-label">All Day</div> <div class="day-allday-label">All Day</div>
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}"> ${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div> <div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div> <div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
</div>`).join("")} </div>`).join("")}
@ -1607,7 +1626,7 @@ class FolkCalendarView extends HTMLElement {
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : ""; const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
const es = this.getEventStyles(e); const es = this.getEventStyles(e);
const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : ""; const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : "";
return `<div class="dd-event" data-event-id="${e.id}" style="opacity:${es.opacity}"> return `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}" style="opacity:${es.opacity}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div> <div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div>
<div class="dd-info"> <div class="dd-info">
<div class="dd-title">${this.esc(e.title)}${likelihoodBadge}${srcTag}</div> <div class="dd-title">${this.esc(e.title)}${likelihoodBadge}${srcTag}</div>
@ -1705,12 +1724,16 @@ class FolkCalendarView extends HTMLElement {
} }
} }
startTour() { this._tour.start(); }
// ── Attach Listeners ── // ── Attach Listeners ──
private attachListeners() { private attachListeners() {
const $ = (id: string) => this.shadow.getElementById(id); const $ = (id: string) => this.shadow.getElementById(id);
const $$ = (sel: string) => this.shadow.querySelectorAll(sel); const $$ = (sel: string) => this.shadow.querySelectorAll(sel);
$("btn-tour")?.addEventListener("click", () => this.startTour());
// Nav // Nav
$("prev")?.addEventListener("click", () => this.navigate(-1)); $("prev")?.addEventListener("click", () => this.navigate(-1));
$("next")?.addEventListener("click", () => this.navigate(1)); $("next")?.addEventListener("click", () => this.navigate(1));

View File

@ -27,6 +27,11 @@ export function renderLanding(): string {
</a> </a>
<a href="#features" class="rl-cta-secondary">Learn More</a> <a href="#features" class="rl-cta-secondary">Learn More</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-calendar-view')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Principles (4-card grid) --> <!-- Principles (4-card grid) -->

View File

@ -10,6 +10,8 @@ import {
shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc, shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc,
} from "../schemas"; } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkCartShop extends HTMLElement { class FolkCartShop extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -26,10 +28,26 @@ class FolkCartShop extends HTMLElement {
private extensionInstalled = false; private extensionInstalled = false;
private bannerDismissed = false; private bannerDismissed = false;
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "orders">("carts");
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-view="carts"]', title: "Carts", message: "View and manage group shopping carts. Each cart collects items from multiple contributors.", advanceOnClick: true },
{ target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items.", advanceOnClick: true },
{ target: '[data-view="catalog"]', title: "Catalog", message: "Browse the cosmolocal catalog of available products and add them to your carts.", advanceOnClick: true },
{ target: '[data-view="orders"]', title: "Orders", message: "Track submitted orders and their status. Click to finish the tour!", advanceOnClick: true },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkCartShop.TOUR_STEPS,
"rcart_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
@ -46,13 +64,16 @@ class FolkCartShop extends HTMLElement {
if (this.space === "demo") { if (this.space === "demo") {
this.loadDemoData(); this.loadDemoData();
return; } else {
}
this.render(); this.render();
this.subscribeOffline(); this.subscribeOffline();
this.loadData(); this.loadData();
} }
// Auto-start tour on first visit
if (!localStorage.getItem("rcart_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
@ -342,11 +363,23 @@ class FolkCartShop extends HTMLElement {
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button> <button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button> <button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
</div> </div>
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button>
</div> </div>
${content} ${content}
`; `;
this.bindEvents(); this.bindEvents();
this._tour.renderOverlay();
}
startTour() { this._tour.start(); }
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view !== "cart-detail") this.selectedCartId = null;
this.render();
} }
private bindEvents() { private bindEvents() {
@ -354,12 +387,19 @@ class FolkCartShop extends HTMLElement {
this.shadow.querySelectorAll(".tab").forEach((el) => { this.shadow.querySelectorAll(".tab").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const v = (el as HTMLElement).dataset.view as any; const v = (el as HTMLElement).dataset.view as any;
if (v && v !== this.view) {
this._history.push(this.view);
this._history.push(v);
}
this.view = v; this.view = v;
if (v === 'carts') this.selectedCartId = null; if (v === 'carts') this.selectedCartId = null;
this.render(); this.render();
}); });
}); });
// Tour button
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Extension banner dismiss // Extension banner dismiss
this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => { this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => {
this.bannerDismissed = true; this.bannerDismissed = true;
@ -370,15 +410,15 @@ class FolkCartShop extends HTMLElement {
// Cart card clicks // Cart card clicks
this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => { this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
this._history.push(this.view);
this._history.push("cart-detail", { cartId: (el as HTMLElement).dataset.cartId });
this.loadCartDetail((el as HTMLElement).dataset.cartId!); this.loadCartDetail((el as HTMLElement).dataset.cartId!);
}); });
}); });
// Back button // Back button
this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => { this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => {
this.view = "carts"; this.goBack();
this.selectedCartId = null;
this.render();
}); });
// New cart form // New cart form
@ -527,9 +567,7 @@ class FolkCartShop extends HTMLElement {
`).join("") : `<div class="card-meta">No contributions yet.</div>`; `).join("") : `<div class="card-meta">No contributions yet.</div>`;
return ` return `
<div style="margin-bottom:1rem"> ${this._history.canGoBack ? '<div style="margin-bottom:1rem"><button data-action="back" class="btn btn-sm">\u2190 Back</button></div>' : ''}
<button data-action="back" class="btn btn-sm"> Back to Carts</button>
</div>
<div class="detail-layout"> <div class="detail-layout">
<div class="detail-left"> <div class="detail-left">
@ -582,7 +620,7 @@ class FolkCartShop extends HTMLElement {
return `<div class="grid"> return `<div class="grid">
${this.catalog.map((entry) => ` ${this.catalog.map((entry) => `
<div class="card"> <div class="card" data-collab-id="product:${entry.id || entry.title}">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3> <h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
<div class="card-meta"> <div class="card-meta">
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""} ${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
@ -607,7 +645,7 @@ class FolkCartShop extends HTMLElement {
return `<div class="grid"> return `<div class="grid">
${this.orders.map((order) => ` ${this.orders.map((order) => `
<div class="card"> <div class="card" data-collab-id="order:${order.id}">
<div class="order-card"> <div class="order-card">
<div class="order-info"> <div class="order-info">
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3> <h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>

View File

@ -16,6 +16,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rcart" class="rl-cta-primary" id="ml-primary">Start Shopping</a> <a href="https://demo.rspace.online/rcart" class="rl-cta-primary" id="ml-primary">Start Shopping</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-cart-shop')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -3,6 +3,8 @@
* from the current space and links to the canvas to create/interact with them. * from the current space and links to the canvas to create/interact with them.
*/ */
import { TourEngine } from "../../../shared/tour-engine";
class FolkChoicesDashboard extends HTMLElement { class FolkChoicesDashboard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private choices: any[] = []; private choices: any[] = [];
@ -19,20 +21,37 @@ class FolkChoicesDashboard extends HTMLElement {
private votedId: string | null = null; private votedId: string | null = null;
private simTimer: number | null = null; private simTimer: number | null = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-tab="spider"]', title: "Spider Charts", message: "Compare multiple criteria on a radar chart. Each participant's scores overlay in real time.", advanceOnClick: true },
{ target: '[data-tab="ranking"]', title: "Rankings", message: "Drag items to rank them. Rankings aggregate across all participants for a collective order.", advanceOnClick: true },
{ target: '[data-tab="voting"]', title: "Voting", message: "Cast your vote and watch results update live with animated bars and totals.", advanceOnClick: true },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this._tour = new TourEngine(
this.shadow,
FolkChoicesDashboard.TOUR_STEPS,
"rchoices_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
if (this.space === "demo") { if (this.space === "demo") {
this.loadDemoData(); this.loadDemoData();
return; } else {
}
this.render(); this.render();
this.loadChoices(); this.loadChoices();
} }
if (!localStorage.getItem("rchoices_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() { disconnectedCallback() {
if (this.simTimer !== null) { if (this.simTimer !== null) {
@ -124,7 +143,7 @@ class FolkChoicesDashboard extends HTMLElement {
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string { private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
return `<div class="grid"> return `<div class="grid">
${this.choices.map((ch) => ` ${this.choices.map((ch) => `
<a class="card" href="/${this.space}/rspace"> <a class="card" data-collab-id="choice:${ch.id}" href="/${this.space}/rspace">
<div class="card-icon">${icons[ch.type] || "☑"}</div> <div class="card-icon">${icons[ch.type] || "☑"}</div>
<div class="card-type">${labels[ch.type] || ch.type}</div> <div class="card-type">${labels[ch.type] || ch.type}</div>
<h3 class="card-title">${this.esc(ch.title)}</h3> <h3 class="card-title">${this.esc(ch.title)}</h3>
@ -247,6 +266,7 @@ class FolkChoicesDashboard extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">Choices</span> <span class="rapp-nav__title">Choices</span>
<span class="demo-badge">DEMO</span> <span class="demo-badge">DEMO</span>
<button class="demo-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem">Tour</button>
</div> </div>
<div class="demo-tabs"> <div class="demo-tabs">
@ -259,8 +279,11 @@ class FolkChoicesDashboard extends HTMLElement {
`; `;
this.bindDemoEvents(); this.bindDemoEvents();
this._tour.renderOverlay();
} }
startTour() { this._tour.start(); }
/* -- Spider Chart -- */ /* -- Spider Chart -- */
private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } { private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } {
@ -383,6 +406,7 @@ class FolkChoicesDashboard extends HTMLElement {
/* -- Demo event binding -- */ /* -- Demo event binding -- */
private bindDemoEvents() { private bindDemoEvents() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Tab switching // Tab switching
this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => { this.shadow.querySelectorAll<HTMLButtonElement>(".demo-tab").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rchoices" class="rl-cta-primary" id="ml-primary">Create a Choice Room</a> <a href="https://demo.rspace.online/rchoices" class="rl-cta-primary" id="ml-primary">Create a Choice Room</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-choices-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Three tools --> <!-- Three tools -->

View File

@ -29,7 +29,7 @@ class FolkAnalyticsView extends HTMLElement {
cookiesSet: 0, cookiesSet: 0,
scriptSize: "~2KB", scriptSize: "~2KB",
selfHosted: true, selfHosted: true,
apps: ["rSpace", "rBooks", "rCart", "rFlows", "rVote", "rWork", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"], apps: ["rSpace", "rBooks", "rCart", "rFlows", "rVote", "rTasks", "rCal", "rTrips", "rPubs", "rForum", "rInbox", "rMaps", "rTube", "rChoices", "rWallet", "rData", "rNotes"],
dashboardUrl: "https://analytics.rspace.online", dashboardUrl: "https://analytics.rspace.online",
}; };
this.render(); this.render();

View File

@ -19,7 +19,7 @@ const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6
const TRACKED_APPS = [ const TRACKED_APPS = [
"rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet", "rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles", "rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData", "rTrips", "rTube", "rTasks", "rNetwork", "rData",
]; ];
// ── API routes ── // ── API routes ──

View File

@ -7,6 +7,7 @@
import { filesSchema, type FilesDoc } from "../schemas"; import { filesSchema, type FilesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
class FolkFileBrowser extends HTMLElement { class FolkFileBrowser extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -16,10 +17,22 @@ class FolkFileBrowser extends HTMLElement {
private tab: "files" | "cards" = "files"; private tab: "files" | "cards" = "files";
private loading = false; private loading = false;
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.tab-btn[data-tab="files"]', title: "Files Tab", message: "Browse uploaded files — download, share, or delete them.", advanceOnClick: true },
{ target: '.tab-btn[data-tab="cards"]', title: "Memory Cards", message: "Create and manage memory cards to organise your knowledge.", advanceOnClick: true },
{ target: '#upload-form', title: "Upload Files", message: "Upload files to your space. They're stored locally and synced when online.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkFileBrowser.TOUR_STEPS,
"rfiles_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
@ -27,14 +40,16 @@ class FolkFileBrowser extends HTMLElement {
if (this.space === "demo") { if (this.space === "demo") {
this.loadDemoData(); this.loadDemoData();
return; } else {
}
this.render(); this.render();
this.subscribeOffline(); this.subscribeOffline();
this.loadFiles(); this.loadFiles();
this.loadCards(); this.loadCards();
} }
if (!localStorage.getItem("rfiles_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
@ -429,11 +444,14 @@ class FolkFileBrowser extends HTMLElement {
<div class="tabs"> <div class="tabs">
<div class="tab-btn" data-tab="files">📁 Files</div> <div class="tab-btn" data-tab="files">📁 Files</div>
<div class="tab-btn" data-tab="cards">🎴 Memory Cards</div> <div class="tab-btn" data-tab="cards">🎴 Memory Cards</div>
<button class="tab-btn" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
${filesActive ? this.renderFilesTab() : this.renderCardsTab()} ${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
`; `;
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => { this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards"; this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards";
@ -464,6 +482,12 @@ class FolkFileBrowser extends HTMLElement {
} }
}); });
}); });
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private renderFilesTab(): string { private renderFilesTab(): string {
@ -487,7 +511,7 @@ class FolkFileBrowser extends HTMLElement {
${this.files ${this.files
.map( .map(
(f) => ` (f) => `
<div class="file-card"> <div class="file-card" data-collab-id="file:${f.id}">
<div class="file-icon">${this.mimeIcon(f.mime_type)}</div> <div class="file-icon">${this.mimeIcon(f.mime_type)}</div>
<div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div> <div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div>
<div class="file-meta">${this.formatSize(f.file_size)} &middot; ${this.formatDate(f.created_at)}</div> <div class="file-meta">${this.formatSize(f.file_size)} &middot; ${this.formatDate(f.created_at)}</div>
@ -533,7 +557,7 @@ class FolkFileBrowser extends HTMLElement {
${this.cards ${this.cards
.map( .map(
(c) => ` (c) => `
<div class="memory-card"> <div class="memory-card" data-collab-id="card:${c.id}">
<div class="card-header"> <div class="card-header">
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span> <span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
<span class="card-type">${c.card_type}</span> <span class="card-type">${c.card_type}</span>

View File

@ -18,6 +18,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rfiles" class="rl-cta-primary" id="ml-primary">Start Sharing</a> <a href="https://demo.rspace.online/rfiles" class="rl-cta-primary" id="ml-primary">Start Sharing</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-file-browser')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -13,6 +13,7 @@
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
import { PORT_DEFS, deriveThresholds } from "../lib/types"; import { PORT_DEFS, deriveThresholds } from "../lib/types";
import { TourEngine } from "../../../shared/tour-engine";
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow"; import { mapFlowToNodes } from "../lib/map-flow";
@ -154,9 +155,8 @@ class FolkFlowsApp extends HTMLElement {
private flowManagerOpen = false; private flowManagerOpen = false;
private _lfcUnsub: (() => void) | null = null; private _lfcUnsub: (() => void) | null = null;
// Tour state // Tour engine
private tourActive = false; private _tour!: TourEngine;
private tourStep = 0;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true }, { target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true },
{ target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true }, { target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true },
@ -168,6 +168,12 @@ class FolkFlowsApp extends HTMLElement {
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkFlowsApp.TOUR_STEPS,
"rflows_tour_done",
() => this.shadow.getElementById("canvas-container"),
);
} }
connectedCallback() { connectedCallback() {
@ -1777,7 +1783,7 @@ class FolkFlowsApp extends HTMLElement {
}).join(""); }).join("");
} }
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})"> return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/> <rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
<rect class="faucet-pipe" x="0" y="${pipeY}" width="${w}" height="${pipeH}" rx="${pipeRx}" fill="url(#faucet-pipe-grad)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/> <rect class="faucet-pipe" x="0" y="${pipeY}" width="${w}" height="${pipeH}" rx="${pipeRx}" fill="url(#faucet-pipe-grad)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/>
<text x="${w / 2}" y="${pipeY + pipeH / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text> <text x="${w / 2}" y="${pipeY + pipeH / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
@ -1908,7 +1914,7 @@ class FolkFlowsApp extends HTMLElement {
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)"; const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b"; const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}"> return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs> <defs>
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath> <clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
</defs> </defs>
@ -1982,7 +1988,7 @@ class FolkFlowsApp extends HTMLElement {
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`; const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})"> return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/> <rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/>
<foreignObject x="0" y="0" width="${w}" height="${h}"> <foreignObject x="0" y="0" width="${w}" height="${h}">
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}"> <div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
@ -4973,105 +4979,7 @@ class FolkFlowsApp extends HTMLElement {
// ── Guided Tour ── // ── Guided Tour ──
startTour() { startTour() {
this.tourActive = true; this._tour.start();
this.tourStep = 0;
this.renderTourOverlay();
}
private advanceTour() {
this.tourStep++;
if (this.tourStep >= FolkFlowsApp.TOUR_STEPS.length) {
this.endTour();
} else {
this.renderTourOverlay();
}
}
private endTour() {
this.tourActive = false;
this.tourStep = 0;
localStorage.setItem("rflows_tour_done", "1");
const overlay = this.shadow.getElementById("flows-tour-overlay");
if (overlay) overlay.remove();
}
private renderTourOverlay() {
// Remove existing overlay
let overlay = this.shadow.getElementById("flows-tour-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "flows-tour-overlay";
overlay.className = "flows-tour-overlay";
const container = this.shadow.getElementById("canvas-container");
if (container) container.appendChild(overlay);
else this.shadow.appendChild(overlay);
}
const step = FolkFlowsApp.TOUR_STEPS[this.tourStep];
const targetEl = this.shadow.querySelector(step.target) as HTMLElement | null;
// Compute spotlight position
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
if (targetEl) {
const containerEl = this.shadow.getElementById("canvas-container") || this.shadow.host as HTMLElement;
const containerRect = containerEl.getBoundingClientRect();
const rect = targetEl.getBoundingClientRect();
spotX = rect.left - containerRect.left - 6;
spotY = rect.top - containerRect.top - 6;
spotW = rect.width + 12;
spotH = rect.height + 12;
}
const isLast = this.tourStep >= FolkFlowsApp.TOUR_STEPS.length - 1;
const stepNum = this.tourStep + 1;
const totalSteps = FolkFlowsApp.TOUR_STEPS.length;
// Position tooltip below target
const tooltipTop = spotY + spotH + 12;
const tooltipLeft = Math.max(8, spotX);
overlay.innerHTML = `
<div class="flows-tour-backdrop" style="clip-path: polygon(
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
)"></div>
<div class="flows-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
<div class="flows-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
<div class="flows-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
<div class="flows-tour-tooltip__title">${step.title}</div>
<div class="flows-tour-tooltip__msg">${step.message}</div>
<div class="flows-tour-tooltip__nav">
${this.tourStep > 0 ? '<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
${step.advanceOnClick
? `<span class="flows-tour-tooltip__hint">or click the button above</span>`
: `<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
}
<button class="flows-tour-tooltip__btn flows-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
</div>
</div>
`;
// Wire tour navigation
overlay.querySelectorAll("[data-tour]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.tour;
if (action === "next") this.advanceTour();
else if (action === "prev") { this.tourStep = Math.max(0, this.tourStep - 1); this.renderTourOverlay(); }
else if (action === "skip") this.endTour();
});
});
// For advanceOnClick steps, listen for the target button click
if (step.advanceOnClick && targetEl) {
const handler = () => {
targetEl.removeEventListener("click", handler);
// Delay slightly so the action completes first
setTimeout(() => this.advanceTour(), 300);
};
targetEl.addEventListener("click", handler);
}
} }
private esc(s: string): string { private esc(s: string): string {

View File

@ -7,6 +7,8 @@
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas"; import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkForumDashboard extends HTMLElement { class FolkForumDashboard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -18,19 +20,37 @@ class FolkForumDashboard extends HTMLElement {
private pollTimer: number | null = null; private pollTimer: number | null = null;
private space = ""; private space = "";
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail" | "create">("list");
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.instance-card', title: "Forum Instances", message: "View your deployed Discourse forums — click one for status and logs.", advanceOnClick: false },
{ target: '[data-action="show-create"]', title: "New Forum", message: "Deploy a new self-hosted Discourse forum in minutes.", advanceOnClick: true },
{ target: '.price-card', title: "Choose a Plan", message: "Select a hosting tier based on your community size and needs.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkForumDashboard.TOUR_STEPS,
"rforum_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || ""; this.space = this.getAttribute("space") || "";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline(); this.subscribeOffline();
this.render(); this.render();
this.loadInstances(); this.loadInstances();
} }
if (!localStorage.getItem("rforum_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private loadDemoData() { private loadDemoData() {
this.instances = [ this.instances = [
@ -318,6 +338,11 @@ class FolkForumDashboard extends HTMLElement {
`; `;
this.attachEvents(); this.attachEvents();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private renderList(): string { private renderList(): string {
@ -325,6 +350,7 @@ class FolkForumDashboard extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">Forum Instances</span> <span class="rapp-nav__title">Forum Instances</span>
<button class="rapp-nav__btn" data-action="show-create">+ New Forum</button> <button class="rapp-nav__btn" data-action="show-create">+ New Forum</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
${this.loading ? '<div class="loading">Loading...</div>' : ""} ${this.loading ? '<div class="loading">Loading...</div>' : ""}
@ -332,7 +358,7 @@ class FolkForumDashboard extends HTMLElement {
<div class="instance-list"> <div class="instance-list">
${this.instances.map((inst) => ` ${this.instances.map((inst) => `
<div class="instance-card" data-action="detail" data-id="${inst.id}"> <div class="instance-card" data-action="detail" data-id="${inst.id}" data-collab-id="forum:${inst.id}">
<div class="instance-header"> <div class="instance-header">
<span class="instance-name">${this.esc(inst.name)}</span> <span class="instance-name">${this.esc(inst.name)}</span>
${this.statusBadge(inst.status)} ${this.statusBadge(inst.status)}
@ -353,7 +379,7 @@ class FolkForumDashboard extends HTMLElement {
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<button class="rapp-nav__back" data-action="back"> Forums</button> ${this._history.canGoBack ? '<button class="rapp-nav__back" data-action="back">← Forums</button>' : ''}
<span class="rapp-nav__title">${this.esc(inst.name)}</span> <span class="rapp-nav__title">${this.esc(inst.name)}</span>
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""} ${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
</div> </div>
@ -398,7 +424,7 @@ class FolkForumDashboard extends HTMLElement {
private renderCreate(): string { private renderCreate(): string {
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<button class="rapp-nav__back" data-action="back"> Forums</button> ${this._history.canGoBack ? '<button class="rapp-nav__back" data-action="back">← Forums</button>' : ''}
<span class="rapp-nav__title">Deploy New Forum</span> <span class="rapp-nav__title">Deploy New Forum</span>
</div> </div>
@ -466,16 +492,17 @@ class FolkForumDashboard extends HTMLElement {
} }
private attachEvents() { private attachEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll("[data-action]").forEach((el) => { this.shadow.querySelectorAll("[data-action]").forEach((el) => {
const action = (el as HTMLElement).dataset.action!; const action = (el as HTMLElement).dataset.action!;
const id = (el as HTMLElement).dataset.id; const id = (el as HTMLElement).dataset.id;
el.addEventListener("click", () => { el.addEventListener("click", () => {
if (action === "show-create") { this.view = "create"; this.render(); } if (action === "show-create") { this._history.push(this.view); this._history.push("create"); this.view = "create"; this.render(); }
else if (action === "back") { else if (action === "back") {
if (this.pollTimer) clearInterval(this.pollTimer); this.goBack();
this.view = "list"; this.loadInstances();
} }
else if (action === "detail" && id) { this.loadInstanceDetail(id); } else if (action === "detail" && id) { this._history.push(this.view); this._history.push("detail", { id }); this.loadInstanceDetail(id); }
else if (action === "destroy" && id) { this.handleDestroy(id); } else if (action === "destroy" && id) { this.handleDestroy(id); }
}); });
}); });
@ -493,6 +520,15 @@ class FolkForumDashboard extends HTMLElement {
if (form) form.addEventListener("submit", (e) => this.handleCreate(e)); if (form) form.addEventListener("submit", (e) => this.handleCreate(e));
} }
private goBack() {
if (this.pollTimer) clearInterval(this.pollTimer);
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "list") this.loadInstances();
else this.render();
}
private formatStep(step: string): string { private formatStep(step: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
create_vps: "Create Server", create_vps: "Create Server",

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rforum" class="rl-cta-primary" id="ml-primary">Get Started</a> <a href="https://demo.rspace.online/rforum" class="rl-cta-primary" id="ml-primary">Get Started</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-forum-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- How It Works --> <!-- How It Works -->

View File

@ -20,6 +20,11 @@ export function renderLanding(): string {
</a> </a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-inbox-client')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- What rInbox Does --> <!-- What rInbox Does -->

View File

@ -12,6 +12,8 @@
import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync"; import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync";
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history"; import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
import { MapPushManager } from "./map-push"; import { MapPushManager } from "./map-push";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
// MapLibre loaded via CDN — use window access with type assertion // MapLibre loaded via CDN — use window access with type assertion
@ -88,6 +90,16 @@ class FolkMapViewer extends HTMLElement {
private pushManager: MapPushManager | null = null; private pushManager: MapPushManager | null = null;
private thumbnailTimer: ReturnType<typeof setTimeout> | null = null; private thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
private _themeObserver: MutationObserver | null = null; private _themeObserver: MutationObserver | null = null;
private _history = new ViewHistory<"lobby" | "map">("lobby");
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#create-room', title: "Create a Room", message: "Start a new map room to share locations with others in real time.", advanceOnClick: true },
{ target: '.room-card, .history-card', title: "Join a Room", message: "Click any room card to enter and see participants on the map.", advanceOnClick: false },
{ target: '#share-location', title: "Share Location", message: "Toggle location sharing so others in the room can see where you are.", advanceOnClick: false },
{ target: '#drop-waypoint', title: "Drop a Pin", message: "Drop a waypoint pin on the map to mark a point of interest for everyone.", advanceOnClick: false },
];
private isDarkTheme(): boolean { private isDarkTheme(): boolean {
const theme = document.documentElement.getAttribute("data-theme"); const theme = document.documentElement.getAttribute("data-theme");
@ -98,6 +110,12 @@ class FolkMapViewer extends HTMLElement {
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkMapViewer.TOUR_STEPS,
"rmaps_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
@ -105,17 +123,20 @@ class FolkMapViewer extends HTMLElement {
this.room = this.getAttribute("room") || ""; this.room = this.getAttribute("room") || "";
if (this.space === "demo") { if (this.space === "demo") {
this.loadDemoData(); this.loadDemoData();
return; } else {
}
this.loadUserProfile(); this.loadUserProfile();
this.pushManager = new MapPushManager(this.getApiBase()); this.pushManager = new MapPushManager(this.getApiBase());
if (this.room) { if (this.room) {
this.joinRoom(this.room); this.joinRoom(this.room);
return; } else {
}
this.checkSyncHealth(); this.checkSyncHealth();
this.render(); this.render();
} }
}
if (!localStorage.getItem("rmaps_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() { disconnectedCallback() {
this.leaveRoom(); this.leaveRoom();
@ -1447,15 +1468,18 @@ class FolkMapViewer extends HTMLElement {
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
} }
startTour() { this._tour.start(); }
private renderLobby(): string { private renderLobby(): string {
const history = loadRoomHistory(); const history = loadRoomHistory();
const historyCards = history.length > 0 ? ` const historyCards = history.length > 0 ? `
<div class="section-label">Recent Rooms</div> <div class="section-label">Recent Rooms</div>
<div class="room-history-grid"> <div class="room-history-grid">
${history.map((h) => ` ${history.map((h) => `
<div class="history-card" data-room="${this.esc(h.slug)}"> <div class="history-card" data-room="${this.esc(h.slug)}" data-collab-id="room:${this.esc(h.slug)}">
${h.thumbnail ${h.thumbnail
? `<img class="history-thumb" src="${h.thumbnail}" alt="">` ? `<img class="history-thumb" src="${h.thumbnail}" alt="">`
: `<div class="history-thumb-placeholder">&#127760;</div>` : `<div class="history-thumb-placeholder">&#127760;</div>`
@ -1476,12 +1500,13 @@ class FolkMapViewer extends HTMLElement {
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span> <span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
<span style="font-size:12px;color:var(--rs-text-muted);margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span> <span style="font-size:12px;color:var(--rs-text-muted);margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span>
<button class="rapp-nav__btn" id="create-room">+ New Room</button> <button class="rapp-nav__btn" id="create-room">+ New Room</button>
<button class="rapp-nav__btn" id="btn-tour" style="background:transparent;border:1px solid var(--rs-border,#334155);color:var(--rs-text-secondary,#94a3b8);font-weight:500">Tour</button>
</div> </div>
${this.rooms.length > 0 ? ` ${this.rooms.length > 0 ? `
<div class="section-label">Active Rooms</div> <div class="section-label">Active Rooms</div>
${this.rooms.map((r) => ` ${this.rooms.map((r) => `
<div class="room-card" data-room="${r}"> <div class="room-card" data-room="${r}" data-collab-id="room:${r}">
<span class="room-icon">&#128506;</span> <span class="room-icon">&#128506;</span>
<span class="room-name">${this.esc(r)}</span> <span class="room-name">${this.esc(r)}</span>
</div> </div>
@ -1501,7 +1526,7 @@ class FolkMapViewer extends HTMLElement {
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`; const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<button class="rapp-nav__back" data-back="lobby">&#8592; Rooms</button> ${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="lobby">&#8592; Rooms</button>' : ''}
<span class="rapp-nav__title">&#128506; ${this.esc(this.room)}</span> <span class="rapp-nav__title">&#128506; ${this.esc(this.room)}</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span> <span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
</div> </div>
@ -1532,20 +1557,21 @@ class FolkMapViewer extends HTMLElement {
} }
private attachListeners() { private attachListeners() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom()); this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
this.shadow.querySelectorAll("[data-room]").forEach((el) => { this.shadow.querySelectorAll("[data-room]").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const room = (el as HTMLElement).dataset.room!; const room = (el as HTMLElement).dataset.room!;
this._history.push("lobby");
this._history.push("map", { room });
this.joinRoom(room); this.joinRoom(room);
}); });
}); });
this.shadow.querySelectorAll("[data-back]").forEach((el) => { this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
this.leaveRoom(); this.goBack();
this.view = "lobby";
this.loadStats();
}); });
}); });
@ -1578,6 +1604,16 @@ class FolkMapViewer extends HTMLElement {
}); });
} }
private goBack() {
const prev = this._history.back();
if (!prev) return;
if (prev.view === "lobby" && this.view === "map") {
this.leaveRoom();
}
this.view = prev.view;
if (prev.view === "lobby") this.loadStats();
}
private esc(s: string): string { private esc(s: string): string {
const d = document.createElement("div"); const d = document.createElement("div");
d.textContent = s || ""; d.textContent = s || "";

View File

@ -16,6 +16,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rmaps" class="rl-cta-primary" id="ml-primary">Open Maps</a> <a href="https://demo.rspace.online/rmaps" class="rl-cta-primary" id="ml-primary">Open Maps</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-map-viewer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -83,7 +83,7 @@ export function renderLanding(): string {
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128196;</div> <div class="rl-icon-box">&#128196;</div>
<h3>Meeting Notes &amp; Docs</h3> <h3>Meeting Notes &amp; Docs</h3>
<p>Capture notes in rNotes, attach files in rFiles, link action items to rWork &mdash; all within the same space, all self-hosted.</p> <p>Capture notes in rNotes, attach files in rFiles, link action items to rTasks &mdash; all within the same space, all self-hosted.</p>
</div> </div>
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128197;</div> <div class="rl-icon-box">&#128197;</div>
@ -121,7 +121,7 @@ export function renderLanding(): string {
<div class="rl-icon-box">&#128268;</div> <div class="rl-icon-box">&#128268;</div>
<h3>Data Integrations</h3> <h3>Data Integrations</h3>
<span class="rl-badge">Coming Soon</span> <span class="rl-badge">Coming Soon</span>
<p>Auto-link recordings to documents, push summaries to rNotes, and sync action items to rWork task boards.</p> <p>Auto-link recordings to documents, push summaries to rNotes, and sync action items to rTasks task boards.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -53,6 +53,8 @@ const STAGE_LABELS: Record<string, string> = {
CLOSED_LOST: "Lost", CLOSED_LOST: "Lost",
}; };
import { TourEngine } from "../../../shared/tour-engine";
class FolkCrmView extends HTMLElement { class FolkCrmView extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
@ -67,15 +69,34 @@ class FolkCrmView extends HTMLElement {
private loading = true; private loading = true;
private error = ""; private error = "";
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-tab="pipeline"]', title: "Pipeline", message: "Track deals through stages — from incoming leads to closed-won. Drag cards to update their stage.", advanceOnClick: true },
{ target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true },
{ target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false },
{ target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkCrmView.TOUR_STEPS,
"rnetwork_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.render(); this.render();
this.loadData(); this.loadData();
// Auto-start tour on first visit
if (!localStorage.getItem("rnetwork_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
} }
private getApiBase(): string { private getApiBase(): string {
@ -165,7 +186,7 @@ class FolkCrmView extends HTMLElement {
</div> </div>
${total > 0 ? `<div class="pipeline-total">${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}</div>` : ""} ${total > 0 ? `<div class="pipeline-total">${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}</div>` : ""}
<div class="pipeline-cards"> <div class="pipeline-cards">
${opps.map(opp => `<div class="pipeline-card"> ${opps.map(opp => `<div class="pipeline-card" data-collab-id="opp:${opp.id}">
<div class="card-name">${this.esc(opp.name)}</div> <div class="card-name">${this.esc(opp.name)}</div>
<div class="card-amount">${this.formatAmount(opp.amount)}</div> <div class="card-amount">${this.formatAmount(opp.amount)}</div>
${opp.company?.name ? `<div class="card-company">${this.esc(opp.company.name)}</div>` : ""} ${opp.company?.name ? `<div class="card-company">${this.esc(opp.company.name)}</div>` : ""}
@ -220,7 +241,7 @@ class FolkCrmView extends HTMLElement {
<th>Phone</th> <th>Phone</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${filtered.map(p => `<tr> ${filtered.map(p => `<tr data-collab-id="contact:${p.id}">
<td class="cell-name">${this.esc(this.personName(p.name))}</td> <td class="cell-name">${this.esc(this.personName(p.name))}</td>
<td class="cell-email">${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"}</td> <td class="cell-email">${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"}</td>
<td>${this.esc(this.companyName(p.company))}</td> <td>${this.esc(this.companyName(p.company))}</td>
@ -273,7 +294,7 @@ class FolkCrmView extends HTMLElement {
<th>Country</th> <th>Country</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${filtered.map(c => `<tr> ${filtered.map(c => `<tr data-collab-id="company:${c.id}">
<td class="cell-name">${this.esc(c.name || "-")}</td> <td class="cell-name">${this.esc(c.name || "-")}</td>
<td class="cell-domain">${c.domainName?.primaryLinkUrl ? `<a href="${this.esc(c.domainName.primaryLinkUrl)}" target="_blank" rel="noopener">${this.esc(c.domainName.primaryLinkUrl)}</a>` : "-"}</td> <td class="cell-domain">${c.domainName?.primaryLinkUrl ? `<a href="${this.esc(c.domainName.primaryLinkUrl)}" target="_blank" rel="noopener">${this.esc(c.domainName.primaryLinkUrl)}</a>` : "-"}</td>
<td>${c.employees ?? "-"}</td> <td>${c.employees ?? "-"}</td>
@ -432,6 +453,7 @@ class FolkCrmView extends HTMLElement {
<div class="crm-header"> <div class="crm-header">
<span class="crm-title">CRM</span> <span class="crm-title">CRM</span>
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
</div> </div>
<div class="tabs"> <div class="tabs">
@ -448,9 +470,15 @@ class FolkCrmView extends HTMLElement {
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
} }
startTour() { this._tour.start(); }
private attachListeners() { private attachListeners() {
// Tour button
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Tab switching // Tab switching
this.shadow.querySelectorAll("[data-tab]").forEach(el => { this.shadow.querySelectorAll("[data-tab]").forEach(el => {
el.addEventListener("click", () => { el.addEventListener("click", () => {

View File

@ -14,10 +14,168 @@ export function renderLanding(): string {
</p> </p>
<div class="rl-cta-row"> <div class="rl-cta-row">
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Notebook</a> <a href="https://demo.rspace.online/rnotes" class="rl-cta-primary" id="ml-primary">Open Notebook</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
<a href="#extension-download" class="rl-cta-secondary">Get Extension</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-notes-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Live Transcription Demo -->
<section class="rl-section" id="transcription-demo">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Live Transcription Demo</h2>
<p class="rl-subtext" style="text-align:center">Try it right here &mdash; click the mic and start speaking.</p>
<div style="max-width:640px;margin:2rem auto;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:1rem;padding:1.5rem;position:relative">
<!-- Unsupported fallback (hidden by default, shown via JS) -->
<div id="transcription-unsupported" style="display:none;text-align:center;padding:2rem 1rem;color:#94a3b8">
<div style="font-size:2rem;margin-bottom:0.75rem">&#9888;&#65039;</div>
<p style="margin:0 0 0.5rem">Live transcription requires <strong>Chrome</strong> or <strong>Edge</strong> (Web Speech API).</p>
<p style="margin:0;font-size:0.85rem;color:#64748b">Try opening this page in a Chromium-based browser to test the demo.</p>
</div>
<!-- Demo UI (hidden if unsupported) -->
<div id="transcription-ui">
<!-- Controls -->
<div style="display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.25rem">
<button id="mic-btn" style="width:56px;height:56px;border-radius:50%;border:2px solid rgba(245,158,11,0.4);background:rgba(245,158,11,0.1);color:#f59e0b;font-size:1.5rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.2s" title="Start transcription">
&#127908;
</button>
<div style="text-align:left">
<div id="mic-status" style="font-size:0.9rem;color:#94a3b8">Click mic to start</div>
<div id="mic-timer" style="font-size:0.75rem;color:#64748b;font-variant-numeric:tabular-nums">00:00</div>
</div>
<div id="live-indicator" style="display:none;background:rgba(239,68,68,0.15);color:#ef4444;font-size:0.7rem;font-weight:600;padding:0.2rem 0.6rem;border-radius:9999px;text-transform:uppercase;letter-spacing:0.05em">
&#9679; Live
</div>
</div>
<!-- Transcript area -->
<div id="transcript-area" style="min-height:120px;max-height:240px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:0.5rem;padding:1rem;font-size:0.9rem;line-height:1.6;color:#e2e8f0">
<span style="color:#64748b;font-style:italic">Your transcript will appear here&hellip;</span>
</div>
</div>
<!-- Capability badges -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;justify-content:center;margin-top:1.25rem">
<span class="rl-badge" style="background:rgba(34,197,94,0.15);color:#22c55e">&#9679; Live streaming</span>
<span class="rl-badge" style="background:rgba(59,130,246,0.15);color:#3b82f6">&#127925; Audio file upload</span>
<span class="rl-badge" style="background:rgba(168,85,247,0.15);color:#a855f7">&#127909; Video transcription</span>
<span class="rl-badge" style="background:rgba(245,158,11,0.15);color:#f59e0b">&#128268; Offline (Parakeet.js)</span>
</div>
</div>
</div>
</section>
<!-- Transcription Demo Script -->
<script>
(function() {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
var ui = document.getElementById('transcription-ui');
var unsupported = document.getElementById('transcription-unsupported');
if (!SpeechRecognition) {
if (ui) ui.style.display = 'none';
if (unsupported) unsupported.style.display = 'block';
return;
}
var recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
var micBtn = document.getElementById('mic-btn');
var micStatus = document.getElementById('mic-status');
var micTimer = document.getElementById('mic-timer');
var liveIndicator = document.getElementById('live-indicator');
var transcriptArea = document.getElementById('transcript-area');
var isListening = false;
var timerInterval = null;
var seconds = 0;
var finalTranscript = '';
function formatTime(s) {
var m = Math.floor(s / 60);
var sec = s % 60;
return (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
}
function startTimer() {
seconds = 0;
micTimer.textContent = '00:00';
timerInterval = setInterval(function() {
seconds++;
micTimer.textContent = formatTime(seconds);
}, 1000);
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
micBtn.addEventListener('click', function() {
if (!isListening) {
finalTranscript = '';
transcriptArea.innerHTML = '';
recognition.start();
} else {
recognition.stop();
}
});
recognition.onstart = function() {
isListening = true;
micBtn.style.background = 'rgba(239,68,68,0.2)';
micBtn.style.borderColor = '#ef4444';
micBtn.style.color = '#ef4444';
micBtn.title = 'Stop transcription';
micStatus.textContent = 'Listening...';
micStatus.style.color = '#ef4444';
liveIndicator.style.display = 'block';
startTimer();
};
recognition.onend = function() {
isListening = false;
micBtn.style.background = 'rgba(245,158,11,0.1)';
micBtn.style.borderColor = 'rgba(245,158,11,0.4)';
micBtn.style.color = '#f59e0b';
micBtn.title = 'Start transcription';
micStatus.textContent = 'Click mic to start';
micStatus.style.color = '#94a3b8';
liveIndicator.style.display = 'none';
stopTimer();
};
recognition.onresult = function(event) {
var interim = '';
for (var i = event.resultIndex; i < event.results.length; i++) {
var transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript + ' ';
} else {
interim += transcript;
}
}
transcriptArea.innerHTML = finalTranscript +
(interim ? '<span style="color:#94a3b8">' + interim + '</span>' : '');
transcriptArea.scrollTop = transcriptArea.scrollHeight;
};
recognition.onerror = function(event) {
if (event.error === 'not-allowed') {
micStatus.textContent = 'Microphone access denied';
micStatus.style.color = '#ef4444';
}
};
})();
</script>
<!-- Features --> <!-- Features -->
<section class="rl-section"> <section class="rl-section">
<div class="rl-container"> <div class="rl-container">
@ -42,32 +200,75 @@ export function renderLanding(): string {
</div> </div>
</section> </section>
<!-- Chrome Extension -->
<section class="rl-section rl-section--alt" id="extension-download">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Chrome Extension</h2>
<p class="rl-subtext" style="text-align:center">Clip pages, record voice notes, and transcribe &mdash; right from the toolbar.</p>
<div class="rl-grid-2" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1.5rem;max-width:860px;margin:2rem auto">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128203;</div>
<h3>Web Clipper</h3>
<p>Save any page as a note with one click &mdash; article text, selection, or full HTML.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127908;</div>
<h3>Voice Recording</h3>
<p>Press <kbd style="background:rgba(255,255,255,0.1);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.8rem">Ctrl+Shift+V</kbd> to start recording and transcribing from any tab.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128275;</div>
<h3>Article Unlock</h3>
<p>Bypass soft paywalls by fetching archived versions &mdash; read the article, then save it to your notebook.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128268;</div>
<h3>Offline Transcription</h3>
<p>Parakeet.js runs entirely in-browser &mdash; your audio never leaves the device.</p>
</div>
</div>
<div style="text-align:center;margin-top:1.5rem">
<a href="/rnotes/extension/download" class="rl-cta-primary" style="display:inline-flex;align-items:center;gap:0.5rem">
&#11015; Download Extension
</a>
<p style="margin-top:0.75rem;font-size:0.8rem;color:#64748b">
Unzip, then load unpacked at <code style="font-size:0.75rem;color:#94a3b8">chrome://extensions</code>
</p>
</div>
</div>
</section>
<!-- How It Works --> <!-- How It Works -->
<section class="rl-section rl-section--alt"> <section class="rl-section">
<div class="rl-container"> <div class="rl-container">
<h2 class="rl-heading" style="text-align:center">How It Works</h2> <h2 class="rl-heading" style="text-align:center">How It Works</h2>
<div class="rl-grid-3"> <div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
<div class="rl-step"> <div class="rl-step">
<div class="rl-step__num">1</div> <div class="rl-step__num">1</div>
<h3>Create a Notebook</h3> <h3>Live Transcribe</h3>
<p>Name your notebook and start capturing. Organize by project, topic, or however you think.</p> <p>Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.</p>
</div> </div>
<div class="rl-step"> <div class="rl-step">
<div class="rl-step__num">2</div> <div class="rl-step__num">2</div>
<h3>Capture Notes, Voice, or Clips</h3> <h3>Audio &amp; Video</h3>
<p>Type rich text, record voice with live transcription, or drop in audio and video files to transcribe offline.</p> <p>Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.</p>
</div> </div>
<div class="rl-step"> <div class="rl-step">
<div class="rl-step__num">3</div> <div class="rl-step__num">3</div>
<h3>Search, Tag, and Share</h3> <h3>Notebooks &amp; Tags</h3>
<p>Find anything instantly with full-text search. Tag and filter your cards, then share notebooks with your team.</p> <p>Organize transcripts alongside notes, clips, code, and files. Tag, search, and filter across everything.</p>
</div>
<div class="rl-step">
<div class="rl-step__num">4</div>
<h3>Private &amp; Offline</h3>
<p>Parakeet.js runs in-browser &mdash; audio never leaves your device. Works offline once the model is cached.</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- Memory Cards --> <!-- Memory Cards -->
<section class="rl-section"> <section class="rl-section rl-section--alt">
<div class="rl-container"> <div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Memory Cards</h2> <h2 class="rl-heading" style="text-align:center">Memory Cards</h2>
<p class="rl-subtext" style="text-align:center"> <p class="rl-subtext" style="text-align:center">
@ -160,19 +361,23 @@ export function renderLanding(): string {
</section> </section>
<!-- Built on Open Source --> <!-- Built on Open Source -->
<section class="rl-section rl-section--alt"> <section class="rl-section">
<div class="rl-container"> <div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2> <h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rNotes.</p> <p class="rl-subtext" style="text-align:center">The libraries and tools that power rNotes.</p>
<div class="rl-grid-3"> <div class="rl-grid-4" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem">
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<h3>PostgreSQL</h3> <h3>Automerge</h3>
<p>Notebooks, notes, and tags stored in a battle-tested relational database with full-text search.</p> <p>Local-first CRDT for conflict-free real-time collaboration. Your notes sync across devices without a central server.</p>
</div> </div>
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<h3>Web Speech API</h3> <h3>Web Speech API</h3>
<p>Browser-native live transcription &mdash; speak and watch your words appear in real time.</p> <p>Browser-native live transcription &mdash; speak and watch your words appear in real time.</p>
</div> </div>
<div class="rl-card rl-card--center">
<h3>Parakeet.js</h3>
<p>NVIDIA&rsquo;s in-browser speech recognition. Transcribe audio and video files offline &mdash; nothing leaves your device.</p>
</div>
<div class="rl-card rl-card--center"> <div class="rl-card rl-card--center">
<h3>Hono</h3> <h3>Hono</h3>
<p>Ultra-fast, lightweight API framework powering the rNotes backend.</p> <p>Ultra-fast, lightweight API framework powering the rNotes backend.</p>
@ -182,7 +387,7 @@ export function renderLanding(): string {
</section> </section>
<!-- Your Data, Protected --> <!-- Your Data, Protected -->
<section class="rl-section"> <section class="rl-section rl-section--alt">
<div class="rl-container" style="text-align:center"> <div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Your Data, Protected</h2> <h2 class="rl-heading">Your Data, Protected</h2>
<p class="rl-subtext">How rNotes keeps your information safe.</p> <p class="rl-subtext">How rNotes keeps your information safe.</p>
@ -209,12 +414,13 @@ export function renderLanding(): string {
</section> </section>
<!-- CTA --> <!-- CTA -->
<section class="rl-section rl-section--alt"> <section class="rl-section">
<div class="rl-container" style="text-align:center"> <div class="rl-container" style="text-align:center">
<h2 class="rl-heading">(You)rNotes, your thoughts unbound.</h2> <h2 class="rl-heading">(You)rNotes, your thoughts unbound.</h2>
<p class="rl-subtext">Try the demo or create a space to get started.</p> <p class="rl-subtext">Try the demo or create a space to get started.</p>
<div class="rl-cta-row"> <div class="rl-cta-row">
<a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Notebook</a> <a href="https://demo.rspace.online/rnotes" class="rl-cta-primary">Open Notebook</a>
<a href="#transcription-demo" class="rl-cta-secondary">Transcribe</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
</div> </div>

View File

@ -8,6 +8,9 @@
* space space slug (default: "demo") * space space slug (default: "demo")
*/ */
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
interface Album { interface Album {
id: string; id: string;
albumName: string; albumName: string;
@ -44,20 +47,36 @@ class FolkPhotoGallery extends HTMLElement {
private loading = false; private loading = false;
private error = ""; private error = "";
private showingSampleData = false; private showingSampleData = false;
private _tour!: TourEngine;
private _history = new ViewHistory<"gallery" | "album" | "lightbox">("gallery");
private static readonly TOUR_STEPS = [
{ target: '.album-card', title: "Albums", message: "Browse shared photo albums — click one to see its photos.", advanceOnClick: false },
{ target: '.photo-cell', title: "Photo Grid", message: "Click any photo to open it in the lightbox viewer.", advanceOnClick: false },
{ target: '.rapp-nav__btn', title: "Open Immich", message: "Launch the full Immich interface for uploads and management.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkPhotoGallery.TOUR_STEPS,
"rphotos_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { if (this.space === "demo") {
this.loadDemoData(); this.loadDemoData();
return; } else {
}
this.loadGallery(); this.loadGallery();
} }
if (!localStorage.getItem("rphotos_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private loadDemoData() { private loadDemoData() {
this.albums = [ this.albums = [
@ -189,6 +208,18 @@ class FolkPhotoGallery extends HTMLElement {
this.render(); this.render();
} }
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "gallery") {
this.selectedAlbum = null;
this.albumAssets = [];
}
this.lightboxAsset = null;
this.render();
}
private thumbUrl(assetId: string): string { private thumbUrl(assetId: string): string {
const base = this.getApiBase(); const base = this.getApiBase();
return `${base}/api/assets/${assetId}/thumbnail`; return `${base}/api/assets/${assetId}/thumbnail`;
@ -353,6 +384,11 @@ class FolkPhotoGallery extends HTMLElement {
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private renderView(): string { private renderView(): string {
@ -370,6 +406,7 @@ class FolkPhotoGallery extends HTMLElement {
<a class="rapp-nav__btn" href="${this.getImmichUrl()}"> <a class="rapp-nav__btn" href="${this.getImmichUrl()}">
Open Immich Open Immich
</a> </a>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
</div> </div>
@ -391,7 +428,7 @@ class FolkPhotoGallery extends HTMLElement {
<div class="section-title">Shared Albums</div> <div class="section-title">Shared Albums</div>
<div class="albums-grid"> <div class="albums-grid">
${this.albums.map((a) => ` ${this.albums.map((a) => `
<div class="album-card" data-album-id="${a.id}"> <div class="album-card" data-album-id="${a.id}" data-collab-id="album:${a.id}">
<div class="album-thumb"> <div class="album-thumb">
${this.isDemo() ${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>` ? `<div class="demo-thumb" style="background:${this.getDemoAlbumColor(a.id)}">${this.esc(a.albumName)}</div>`
@ -413,7 +450,7 @@ class FolkPhotoGallery extends HTMLElement {
<div class="section-title">Recent Photos</div> <div class="section-title">Recent Photos</div>
<div class="photo-grid"> <div class="photo-grid">
${this.assets.map((a) => ` ${this.assets.map((a) => `
<div class="photo-cell" data-asset-id="${a.id}"> <div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
${this.isDemo() ${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>` ? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`} : `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
@ -428,7 +465,7 @@ class FolkPhotoGallery extends HTMLElement {
const album = this.selectedAlbum!; const album = this.selectedAlbum!;
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<button class="rapp-nav__back" data-back="gallery"> Photos</button> ${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="gallery">\u2190 Photos</button>' : ''}
<span class="rapp-nav__title">${this.esc(album.albumName)}</span> <span class="rapp-nav__title">${this.esc(album.albumName)}</span>
<div class="rapp-nav__actions"> <div class="rapp-nav__actions">
<a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}"> <a class="rapp-nav__btn rapp-nav__btn--secondary" href="${this.getImmichUrl()}">
@ -446,7 +483,7 @@ class FolkPhotoGallery extends HTMLElement {
` : ` ` : `
<div class="photo-grid"> <div class="photo-grid">
${this.albumAssets.map((a) => ` ${this.albumAssets.map((a) => `
<div class="photo-cell" data-asset-id="${a.id}"> <div class="photo-cell" data-asset-id="${a.id}" data-collab-id="photo:${a.id}">
${this.isDemo() ${this.isDemo()
? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>` ? `<div class="demo-thumb" style="background:${this.getDemoAssetMeta(a.id).color}">${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}</div>`
: `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`} : `<img src="${this.thumbUrl(a.id)}" alt="${this.esc(a.originalFileName)}" loading="lazy">`}
@ -483,12 +520,18 @@ class FolkPhotoGallery extends HTMLElement {
} }
private attachListeners() { private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Album cards // Album cards
this.shadow.querySelectorAll("[data-album-id]").forEach((el) => { this.shadow.querySelectorAll("[data-album-id]").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.albumId!; const id = (el as HTMLElement).dataset.albumId!;
const album = this.albums.find((a) => a.id === id); const album = this.albums.find((a) => a.id === id);
if (album) this.loadAlbum(album); if (album) {
this._history.push(this.view);
this._history.push("album", { albumId: id });
this.loadAlbum(album);
}
}); });
}); });
@ -498,24 +541,23 @@ class FolkPhotoGallery extends HTMLElement {
const id = (el as HTMLElement).dataset.assetId!; const id = (el as HTMLElement).dataset.assetId!;
const assets = this.view === "album" ? this.albumAssets : this.assets; const assets = this.view === "album" ? this.albumAssets : this.assets;
const asset = assets.find((a) => a.id === id); const asset = assets.find((a) => a.id === id);
if (asset) this.openLightbox(asset); if (asset) {
this._history.push(this.view);
this._history.push("lightbox", { assetId: id });
this.openLightbox(asset);
}
}); });
}); });
// Back button // Back button
this.shadow.querySelectorAll("[data-back]").forEach((el) => { this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => this.goBack());
this.selectedAlbum = null;
this.albumAssets = [];
this.view = "gallery";
this.render();
});
}); });
// Lightbox close // Lightbox close
this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.closeLightbox()); this.shadow.querySelector("[data-close-lightbox]")?.addEventListener("click", () => this.goBack());
this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => { this.shadow.querySelector("[data-lightbox]")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).matches("[data-lightbox]")) this.closeLightbox(); if ((e.target as HTMLElement).matches("[data-lightbox]")) this.goBack();
}); });
} }

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rphotos" class="rl-cta-primary" id="ml-primary">Browse Photos</a> <a href="https://demo.rspace.online/rphotos" class="rl-cta-primary" id="ml-primary">Browse Photos</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-photo-gallery')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -9,6 +9,7 @@
import { pubsDraftSchema, pubsDocId } from '../schemas'; import { pubsDraftSchema, pubsDocId } from '../schemas';
import type { PubsDoc } from '../schemas'; import type { PubsDoc } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document'; import type { DocumentId } from '../../../shared/local-first/document';
import { TourEngine } from '../../../shared/tour-engine';
interface BookFormat { interface BookFormat {
id: string; id: string;
@ -78,6 +79,13 @@ export class FolkPubsEditor extends HTMLElement {
private _isRemoteUpdate = false; private _isRemoteUpdate = false;
private _syncTimer: ReturnType<typeof setTimeout> | null = null; private _syncTimer: ReturnType<typeof setTimeout> | null = null;
private _syncConnected = false; private _syncConnected = false;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.content-area', title: "Editor", message: "Write or paste markdown content here. Drag-and-drop text files also works.", advanceOnClick: false },
{ target: '.format-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
{ target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF in the selected format.", advanceOnClick: false },
{ target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
];
set formats(val: BookFormat[]) { set formats(val: BookFormat[]) {
this._formats = val; this._formats = val;
@ -90,6 +98,12 @@ export class FolkPubsEditor extends HTMLElement {
async connectedCallback() { async connectedCallback() {
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadowRoot!,
FolkPubsEditor.TOUR_STEPS,
"rpubs_tour_done",
() => this.shadowRoot!.host as HTMLElement,
);
this.render(); this.render();
const space = this.getAttribute("space") || ""; const space = this.getAttribute("space") || "";
@ -101,6 +115,9 @@ export class FolkPubsEditor extends HTMLElement {
if (space === "demo" && !this._activeDocId) { if (space === "demo" && !this._activeDocId) {
this.loadDemoContent(); this.loadDemoContent();
} }
if (!localStorage.getItem("rpubs_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
} }
private async initRuntime() { private async initRuntime() {
@ -381,6 +398,7 @@ export class FolkPubsEditor extends HTMLElement {
<input type="file" accept=".md,.txt,.markdown" style="display:none" /> <input type="file" accept=".md,.txt,.markdown" style="display:none" />
Open File Open File
</label> </label>
<button class="btn-sample" id="btn-tour" style="font-size:0.78rem;padding:2px 8px;opacity:0.7">Tour</button>
</div> </div>
</div> </div>
<textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea> <textarea class="content-area" placeholder="Drop in your markdown or plain text here..."></textarea>
@ -440,6 +458,7 @@ export class FolkPubsEditor extends HTMLElement {
`; `;
this.bindEvents(); this.bindEvents();
this._tour.renderOverlay();
// Populate fields from current doc after render // Populate fields from current doc after render
if (this._runtime && this._activeDocId) { if (this._runtime && this._activeDocId) {
@ -448,9 +467,15 @@ export class FolkPubsEditor extends HTMLElement {
} }
} }
startTour() {
this._tour.start();
}
private bindEvents() { private bindEvents() {
if (!this.shadowRoot) return; if (!this.shadowRoot) return;
this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement; const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement; const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement;

View File

@ -22,6 +22,11 @@ export function renderLanding(): string {
</a> </a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-pubs-editor')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- How it works --> <!-- How it works -->

View File

@ -7,6 +7,8 @@
import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas"; import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
interface JobData { interface JobData {
id: string; id: string;
@ -82,11 +84,19 @@ class FolkScheduleApp extends HTMLElement {
private log: LogEntry[] = []; private log: LogEntry[] = [];
private reminders: ReminderData[] = []; private reminders: ReminderData[] = [];
private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs"; private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs";
private _history = new ViewHistory<"jobs" | "log" | "form" | "reminders" | "reminder-form">("jobs");
private editingJob: JobData | null = null; private editingJob: JobData | null = null;
private editingReminder: ReminderData | null = null; private editingReminder: ReminderData | null = null;
private loading = false; private loading = false;
private runningJobId: string | null = null; private runningJobId: string | null = null;
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-view="jobs"]', title: "Scheduled Jobs", message: "View and manage automated jobs that run on a cron schedule — email alerts, webhooks, and more.", advanceOnClick: true },
{ target: '[data-view="reminders"]', title: "Reminders", message: "Set personal reminders with optional email notifications and calendar sync.", advanceOnClick: true },
{ target: '[data-view="log"]', title: "Execution Log", message: "Review the history of all job runs with status, duration, and error details.", advanceOnClick: true },
{ target: '[data-action="create"]', title: "Create Job", message: "Create a new scheduled job with cron expressions and configurable actions.", advanceOnClick: false },
];
// Reminder form state // Reminder form state
private rFormTitle = ""; private rFormTitle = "";
@ -109,12 +119,21 @@ class FolkScheduleApp extends HTMLElement {
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkScheduleApp.TOUR_STEPS,
"rschedule_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.subscribeOffline(); this.subscribeOffline();
this.loadJobs(); this.loadJobs();
if (!localStorage.getItem("rschedule_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
} }
disconnectedCallback() { disconnectedCallback() {
@ -309,7 +328,9 @@ class FolkScheduleApp extends HTMLElement {
this.rFormAllDay = true; this.rFormAllDay = true;
this.rFormEmail = ""; this.rFormEmail = "";
this.rFormSyncCal = true; this.rFormSyncCal = true;
this._history.push(this.view);
this.view = "reminder-form"; this.view = "reminder-form";
this._history.push("reminder-form");
this.render(); this.render();
} }
@ -323,7 +344,9 @@ class FolkScheduleApp extends HTMLElement {
this.rFormAllDay = r.allDay; this.rFormAllDay = r.allDay;
this.rFormEmail = r.notifyEmail || ""; this.rFormEmail = r.notifyEmail || "";
this.rFormSyncCal = true; this.rFormSyncCal = true;
this._history.push(this.view);
this.view = "reminder-form"; this.view = "reminder-form";
this._history.push("reminder-form");
this.render(); this.render();
} }
@ -401,7 +424,9 @@ class FolkScheduleApp extends HTMLElement {
this.formActionType = "email"; this.formActionType = "email";
this.formEnabled = true; this.formEnabled = true;
this.formConfig = {}; this.formConfig = {};
this._history.push(this.view);
this.view = "form"; this.view = "form";
this._history.push("form");
this.render(); this.render();
} }
@ -419,10 +444,21 @@ class FolkScheduleApp extends HTMLElement {
this.formConfig[k] = String(v); this.formConfig[k] = String(v);
} }
} }
this._history.push(this.view);
this.view = "form"; this.view = "form";
this._history.push("form");
this.render(); this.render();
} }
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "reminders") this.loadReminders();
else if (prev.view === "log") this.loadLog();
else this.render();
}
private formatTime(ts: number | null): string { private formatTime(ts: number | null): string {
if (!ts) return "—"; if (!ts) return "—";
const d = new Date(ts); const d = new Date(ts);
@ -584,12 +620,18 @@ class FolkScheduleApp extends HTMLElement {
<button class="s-tab ${activeTab === "reminders" ? "active" : ""}" data-view="reminders">Reminders</button> <button class="s-tab ${activeTab === "reminders" ? "active" : ""}" data-view="reminders">Reminders</button>
<button class="s-tab ${activeTab === "log" ? "active" : ""}" data-view="log">Execution Log</button> <button class="s-tab ${activeTab === "log" ? "active" : ""}" data-view="log">Execution Log</button>
</div> </div>
<button class="s-btn s-btn-secondary s-btn-sm" id="btn-tour">Tour</button>
${headerAction} ${headerAction}
</div> </div>
${content} ${content}
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private renderJobList(): string { private renderJobList(): string {
@ -598,7 +640,7 @@ class FolkScheduleApp extends HTMLElement {
} }
const rows = this.jobs.map((j) => ` const rows = this.jobs.map((j) => `
<tr> <tr data-collab-id="job:${j.id}">
<td> <td>
<label class="s-toggle"> <label class="s-toggle">
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}"> <input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
@ -707,7 +749,7 @@ class FolkScheduleApp extends HTMLElement {
</div> </div>
<div style="display:flex;gap:8px;margin-top:24px"> <div style="display:flex;gap:8px;margin-top:24px">
<button class="s-btn s-btn-primary" data-action="submit">${isEdit ? "Update Job" : "Create Job"}</button> <button class="s-btn s-btn-primary" data-action="submit">${isEdit ? "Update Job" : "Create Job"}</button>
<button class="s-btn s-btn-secondary" data-action="cancel">Cancel</button> <button class="s-btn s-btn-secondary" data-action="cancel">\u2190 Back</button>
</div> </div>
</div> </div>
`; `;
@ -727,7 +769,7 @@ class FolkScheduleApp extends HTMLElement {
? '<span style="background:rgba(59,130,246,0.15);color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:12px">Sent</span>' ? '<span style="background:rgba(59,130,246,0.15);color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:12px">Sent</span>'
: '<span style="background:rgba(245,158,11,0.15);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">Pending</span>'; : '<span style="background:rgba(245,158,11,0.15);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">Pending</span>';
return `<tr> return `<tr data-collab-id="reminder:${r.id}">
<td> <td>
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<span style="width:8px;height:8px;border-radius:50%;background:${r.sourceColor || "#f59e0b"};flex-shrink:0"></span> <span style="width:8px;height:8px;border-radius:50%;background:${r.sourceColor || "#f59e0b"};flex-shrink:0"></span>
@ -797,19 +839,24 @@ class FolkScheduleApp extends HTMLElement {
</div> </div>
<div style="display:flex;gap:8px;margin-top:24px"> <div style="display:flex;gap:8px;margin-top:24px">
<button class="s-btn s-btn-primary" data-action="submit-reminder">${isEdit ? "Update Reminder" : "Create Reminder"}</button> <button class="s-btn s-btn-primary" data-action="submit-reminder">${isEdit ? "Update Reminder" : "Create Reminder"}</button>
<button class="s-btn s-btn-secondary" data-action="cancel-reminder">Cancel</button> <button class="s-btn s-btn-secondary" data-action="cancel-reminder">\u2190 Back</button>
</div> </div>
</div> </div>
`; `;
} }
private attachListeners() { private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Tab switching // Tab switching
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => { this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
this.view = btn.dataset.view as "jobs" | "log" | "reminders"; const newView = btn.dataset.view as "jobs" | "log" | "reminders";
if (this.view === "log") this.loadLog(); this._history.push(this.view);
else if (this.view === "reminders") this.loadReminders(); this.view = newView;
this._history.push(newView);
if (newView === "log") this.loadLog();
else if (newView === "reminders") this.loadReminders();
else this.render(); else this.render();
}); });
}); });
@ -844,10 +891,9 @@ class FolkScheduleApp extends HTMLElement {
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!)); btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
}); });
// Form: cancel // Form: cancel / back
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => { this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
this.view = "jobs"; this.goBack();
this.render();
}); });
// Form: submit // Form: submit
@ -906,10 +952,9 @@ class FolkScheduleApp extends HTMLElement {
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!)); btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!));
}); });
// Reminder form: cancel // Reminder form: cancel / back
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel-reminder']")?.addEventListener("click", () => { this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel-reminder']")?.addEventListener("click", () => {
this.view = "reminders"; this.goBack();
this.loadReminders();
}); });
// Reminder form: submit // Reminder form: submit

View File

@ -31,6 +31,11 @@ export function renderLanding(): string {
</a> </a>
<a href="#features" class="rl-cta-secondary">Learn More</a> <a href="#features" class="rl-cta-secondary">Learn More</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-schedule-app')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features (4-card grid) --> <!-- Features (4-card grid) -->

View File

@ -59,9 +59,9 @@ export interface Reminder {
completed: boolean; // dismissed by user? completed: boolean; // dismissed by user?
// Cross-module reference (null for free-form reminders) // Cross-module reference (null for free-form reminders)
sourceModule: string | null; // "rwork", "rnotes", etc. sourceModule: string | null; // "rtasks", "rnotes", etc.
sourceEntityId: string | null; sourceEntityId: string | null;
sourceLabel: string | null; // "rWork Task" sourceLabel: string | null; // "rTasks Task"
sourceColor: string | null; // "#f97316" sourceColor: string | null; // "#f97316"
// Optional recurrence // Optional recurrence
@ -172,7 +172,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
inputs: [], inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
configSchema: [ configSchema: [
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] }, { key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
{ key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' }, { key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' },
], ],
}, },
@ -315,7 +315,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
category: 'action', category: 'action',
label: 'Create Task', label: 'Create Task',
icon: '✅', icon: '✅',
description: 'Create a task in rWork', description: 'Create a task in rTasks',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }], outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
configSchema: [ configSchema: [
@ -347,7 +347,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
configSchema: [ configSchema: [
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork'] }, { key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork'] },
{ key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] }, { key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] },
{ key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' }, { key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' },
], ],

View File

@ -9,16 +9,32 @@ import { socialsSchema, socialsDocId } from '../schemas';
import type { SocialsDoc, Campaign, CampaignPost } from '../schemas'; import type { SocialsDoc, Campaign, CampaignPost } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document'; import type { DocumentId } from '../../../shared/local-first/document';
import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
import { TourEngine } from '../../../shared/tour-engine';
export class FolkCampaignManager extends HTMLElement { export class FolkCampaignManager extends HTMLElement {
private _space = 'demo'; private _space = 'demo';
private _campaigns: Campaign[] = []; private _campaigns: Campaign[] = [];
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.campaign-header', title: "Campaign Overview", message: "This is your campaign dashboard — see the title, description, platforms, and post count at a glance.", advanceOnClick: false },
{ target: '.phase:first-of-type', title: "View Posts by Phase", message: "Posts are organised into phases. Each phase has a timeline and its own set of scheduled posts.", advanceOnClick: false },
{ target: 'a[href*="thread-editor"]', title: "Open Thread Editor", message: "Jump to the thread editor to compose and preview tweet threads with live card preview.", advanceOnClick: true },
{ target: '#import-md-btn', title: "Import from Markdown", message: "Paste tweets separated by --- to bulk-import content into the campaign.", advanceOnClick: true },
];
static get observedAttributes() { return ['space']; } static get observedAttributes() { return ['space']; }
connectedCallback() { connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._tour = new TourEngine(
this.shadowRoot!,
FolkCampaignManager.TOUR_STEPS,
"rsocials_tour_done",
() => this.shadowRoot!.querySelector('.container') as HTMLElement,
);
this._space = this.getAttribute('space') || 'demo'; this._space = this.getAttribute('space') || 'demo';
// Start with demo campaign // Start with demo campaign
this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }]; this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }];
@ -26,6 +42,10 @@ export class FolkCampaignManager extends HTMLElement {
if (this._space !== 'demo') { if (this._space !== 'demo') {
this.subscribeOffline(); this.subscribeOffline();
} }
// Auto-start tour on first visit
if (!localStorage.getItem("rsocials_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
} }
disconnectedCallback() { disconnectedCallback() {
@ -141,6 +161,7 @@ export class FolkCampaignManager extends HTMLElement {
<div class="actions"> <div class="actions">
<a href="${this.basePath}thread-editor" class="btn btn--outline">Open Thread Editor</a> <a href="${this.basePath}thread-editor" class="btn btn--outline">Open Thread Editor</a>
<button class="btn btn--primary" id="import-md-btn">Import from Markdown</button> <button class="btn btn--primary" id="import-md-btn">Import from Markdown</button>
<button class="btn btn--outline" id="btn-tour" style="margin-left:auto">Tour</button>
</div> </div>
${phaseHTML} ${phaseHTML}
<div id="imported-posts"></div>`; <div id="imported-posts"></div>`;
@ -250,11 +271,17 @@ export class FolkCampaignManager extends HTMLElement {
`; `;
this.bindEvents(); this.bindEvents();
this._tour.renderOverlay();
} }
startTour() { this._tour.start(); }
private bindEvents() { private bindEvents() {
if (!this.shadowRoot) return; if (!this.shadowRoot) return;
// Tour button
this.shadowRoot.getElementById('btn-tour')?.addEventListener('click', () => this.startTour());
const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement; const modal = this.shadowRoot.getElementById('import-modal') as HTMLElement;
const openBtn = this.shadowRoot.getElementById('import-md-btn'); const openBtn = this.shadowRoot.getElementById('import-md-btn');
const closeBtn = this.shadowRoot.getElementById('import-modal-close'); const closeBtn = this.shadowRoot.getElementById('import-modal-close');

View File

@ -115,7 +115,7 @@ export class FolkThreadGallery extends HTMLElement {
const href = this._isDemoFallback const href = this._isDemoFallback
? `${this.basePath}thread-editor` ? `${this.basePath}thread-editor`
: `${this.basePath}thread-editor/${this.esc(t.id)}/edit`; : `${this.basePath}thread-editor/${this.esc(t.id)}/edit`;
return `<a href="${href}" class="card"> return `<a href="${href}" class="card" data-collab-id="thread:${this.esc(t.id)}">
${imageTag} ${imageTag}
<h3 class="card__title">${this.esc(t.title || 'Untitled Thread')}</h3> <h3 class="card__title">${this.esc(t.title || 'Untitled Thread')}</h3>
<p class="card__preview">${preview}</p> <p class="card__preview">${preview}</p>

View File

@ -19,6 +19,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rsocials" class="rl-cta-primary" id="ml-primary">Try Demo</a> <a href="https://demo.rspace.online/rsocials" class="rl-cta-primary" id="ml-primary">Try Demo</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-campaign-manager')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -150,7 +150,7 @@ export class FolkSplatViewer extends HTMLElement {
: `<span>${s.view_count} views</span>`; : `<span>${s.view_count} views</span>`;
return ` return `
<${tag} class="splat-card${statusClass}"${href}> <${tag} class="splat-card${statusClass}" data-collab-id="splat:${s.id}"${href}>
<div class="splat-card__preview"> <div class="splat-card__preview">
${overlay} ${overlay}
<span>${isReady ? "🔮" : "📸"}</span> <span>${isReady ? "🔮" : "📸"}</span>

View File

@ -147,6 +147,8 @@ function posterMockupSvg(): string {
// --- Component --- // --- Component ---
import { TourEngine } from "../../../shared/tour-engine";
class FolkSwagDesigner extends HTMLElement { class FolkSwagDesigner extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
@ -162,10 +164,23 @@ class FolkSwagDesigner extends HTMLElement {
private demoStep: 1 | 2 | 3 | 4 = 1; private demoStep: 1 | 2 | 3 | 4 = 1;
private progressStep = 0; private progressStep = 0;
private usedSampleDesign = false; private usedSampleDesign = false;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.product', title: "Choose Product", message: "Select a product type — tee, sticker, poster, or hoodie.", advanceOnClick: true },
{ target: '.steps-bar', title: "Design Flow", message: "Follow the 4-step flow: Product, Design, Generate, Pipeline.", advanceOnClick: false },
{ target: '.sample-btn', title: "Sample Design", message: "Try the demo with a pre-made sample design to see the full pipeline.", advanceOnClick: true },
{ target: '.generate-btn', title: "Generate", message: "Generate print-ready files and see provider matching + revenue splits.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkSwagDesigner.TOUR_STEPS,
"rswag_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
@ -177,10 +192,13 @@ class FolkSwagDesigner extends HTMLElement {
this.designTitle = "Cosmolocal Network Tee"; this.designTitle = "Cosmolocal Network Tee";
this.demoStep = 1; this.demoStep = 1;
this.render(); this.render();
return; } else {
}
this.render(); this.render();
} }
if (!localStorage.getItem("rswag_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private getApiBase(): string { private getApiBase(): string {
const path = window.location.pathname; const path = window.location.pathname;
@ -334,6 +352,11 @@ class FolkSwagDesigner extends HTMLElement {
} else { } else {
this.renderFull(); this.renderFull();
} }
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
// ---- Demo mode rendering (4-step flow) ---- // ---- Demo mode rendering (4-step flow) ----
@ -345,6 +368,8 @@ class FolkSwagDesigner extends HTMLElement {
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style>${this.getDemoStyles()}</style> <style>${this.getDemoStyles()}</style>
<div style="display:flex;justify-content:flex-end;margin-bottom:4px"><button id="btn-tour" style="background:transparent;border:1px solid rgba(255,255,255,0.15);color:var(--rs-text-secondary,#94a3b8);border-radius:6px;padding:2px 10px;font-size:0.78rem;cursor:pointer">Tour</button></div>
<!-- Step indicators --> <!-- Step indicators -->
<div class="steps-bar"> <div class="steps-bar">
${[ ${[
@ -362,7 +387,7 @@ class FolkSwagDesigner extends HTMLElement {
<section class="step-section ${this.demoStep >= 1 ? 'visible' : ''}"> <section class="step-section ${this.demoStep >= 1 ? 'visible' : ''}">
<div class="products"> <div class="products">
${DEMO_PRODUCTS.map(dp => ` ${DEMO_PRODUCTS.map(dp => `
<div class="product ${this.selectedProduct === dp.id ? 'active' : ''}" data-product="${dp.id}"> <div class="product ${this.selectedProduct === dp.id ? 'active' : ''}" data-product="${dp.id}" data-collab-id="product:${dp.id}">
<div class="product-icon">${this.productIcon(dp.id)}</div> <div class="product-icon">${this.productIcon(dp.id)}</div>
<div class="product-name">${dp.name}</div> <div class="product-name">${dp.name}</div>
<div class="product-specs">${dp.printArea}</div> <div class="product-specs">${dp.printArea}</div>
@ -541,6 +566,8 @@ class FolkSwagDesigner extends HTMLElement {
} }
private bindDemoEvents() { private bindDemoEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Product selection // Product selection
this.shadow.querySelectorAll(".product").forEach(el => { this.shadow.querySelectorAll(".product").forEach(el => {
el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee")); el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee"));

View File

@ -17,6 +17,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rswag" class="rl-cta-primary" id="ml-primary">Start Designing</a> <a href="https://demo.rspace.online/rswag" class="rl-cta-primary" id="ml-primary">Start Designing</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-swag-designer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -1,5 +1,5 @@
/** /**
* <folk-work-board> kanban board for workspace task management. * <folk-tasks-board> kanban board for workspace task management.
* *
* Views: workspace list board with draggable columns. * Views: workspace list board with draggable columns.
* Supports task creation, status changes, and priority labels. * Supports task creation, status changes, and priority labels.
@ -7,8 +7,10 @@
import { boardSchema, type BoardDoc } from "../schemas"; import { boardSchema, type BoardDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkWorkBoard extends HTMLElement { class FolkTasksBoard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
private view: "list" | "board" = "list"; private view: "list" | "board" = "list";
@ -24,19 +26,38 @@ class FolkWorkBoard extends HTMLElement {
private showCreateForm = false; private showCreateForm = false;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list");
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true },
{ target: '#create-task', title: "New Task", message: "Create a new task with title, priority, and description.", advanceOnClick: false },
{ target: '.board', title: "Kanban Board", message: "Drag tasks between columns — TODO, In Progress, Review, Done.", advanceOnClick: false },
{ target: '.badge.clickable', title: "Priority", message: "Click the priority badge on a task to cycle through levels.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkTasksBoard.TOUR_STEPS,
"rtasks_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline(); this.subscribeOffline();
this.loadWorkspaces(); this.loadWorkspaces();
this.render(); this.render();
} }
if (!localStorage.getItem("rtasks_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
@ -48,7 +69,7 @@ class FolkWorkBoard extends HTMLElement {
if (!runtime?.isInitialized) return; if (!runtime?.isInitialized) return;
try { try {
const docs = await runtime.subscribeModule('work', 'boards', boardSchema); const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema);
// Build workspace list from cached boards // Build workspace list from cached boards
if (docs.size > 0 && this.workspaces.length === 0) { if (docs.size > 0 && this.workspaces.length === 0) {
const boards: any[] = []; const boards: any[] = [];
@ -70,7 +91,7 @@ class FolkWorkBoard extends HTMLElement {
const runtime = (window as any).__rspaceOfflineRuntime; const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.workspaceSlug) return; if (!runtime || !this.workspaceSlug) return;
// Reload tasks for current board from runtime // Reload tasks for current board from runtime
const docId = runtime.makeDocId('work', 'boards', this.workspaceSlug); const docId = runtime.makeDocId('tasks', 'boards', this.workspaceSlug);
const doc = runtime.get(docId) as BoardDoc | undefined; const doc = runtime.get(docId) as BoardDoc | undefined;
if (doc?.tasks && Object.keys(doc.tasks).length > 0) { if (doc?.tasks && Object.keys(doc.tasks).length > 0) {
this.tasks = Object.values(doc.tasks).map(t => ({ this.tasks = Object.values(doc.tasks).map(t => ({
@ -105,7 +126,7 @@ class FolkWorkBoard extends HTMLElement {
private getApiBase(): string { private getApiBase(): string {
const path = window.location.pathname; const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rwork/); const match = path.match(/^(\/[^/]+)?\/rtasks/);
return match ? match[0] : ""; return match ? match[0] : "";
} }
@ -225,6 +246,14 @@ class FolkWorkBoard extends HTMLElement {
this.loadTasks(); this.loadTasks();
} }
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "list") this.loadWorkspaces();
else this.render();
}
private render() { private render() {
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style> <style>
@ -307,6 +336,11 @@ class FolkWorkBoard extends HTMLElement {
${this.view === "list" ? this.renderList() : this.renderBoard()} ${this.view === "list" ? this.renderList() : this.renderBoard()}
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private renderList(): string { private renderList(): string {
@ -314,6 +348,7 @@ class FolkWorkBoard extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">Workspaces</span> <span class="rapp-nav__title">Workspaces</span>
<button class="rapp-nav__btn" id="create-ws">+ New Workspace</button> <button class="rapp-nav__btn" id="create-ws">+ New Workspace</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
${this.workspaces.length > 0 ? `<div class="workspace-grid"> ${this.workspaces.length > 0 ? `<div class="workspace-grid">
${this.workspaces.map(ws => ` ${this.workspaces.map(ws => `
@ -351,7 +386,7 @@ class FolkWorkBoard extends HTMLElement {
private renderBoard(): string { private renderBoard(): string {
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button> ${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''}
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span> <span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
<button class="rapp-nav__btn" id="create-task">+ New Task</button> <button class="rapp-nav__btn" id="create-task">+ New Task</button>
</div> </div>
@ -381,7 +416,7 @@ class FolkWorkBoard extends HTMLElement {
return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : ""; return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
}; };
return ` return `
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}"> <div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}">
${isEditing ${isEditing
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">` ? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
: `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`} : `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`}
@ -398,6 +433,7 @@ class FolkWorkBoard extends HTMLElement {
} }
private attachListeners() { private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace()); this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
this.shadow.getElementById("create-task")?.addEventListener("click", () => { this.shadow.getElementById("create-task")?.addEventListener("click", () => {
this.showCreateForm = !this.showCreateForm; this.showCreateForm = !this.showCreateForm;
@ -461,10 +497,14 @@ class FolkWorkBoard extends HTMLElement {
}); });
this.shadow.querySelectorAll("[data-ws]").forEach(el => { this.shadow.querySelectorAll("[data-ws]").forEach(el => {
el.addEventListener("click", () => this.openBoard((el as HTMLElement).dataset.ws!)); el.addEventListener("click", () => {
this._history.push("list");
this._history.push("board", { ws: (el as HTMLElement).dataset.ws });
this.openBoard((el as HTMLElement).dataset.ws!);
});
}); });
this.shadow.querySelectorAll("[data-back]").forEach(el => { this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => { this.view = "list"; this.loadWorkspaces(); }); el.addEventListener("click", () => this.goBack());
}); });
this.shadow.querySelectorAll("[data-move]").forEach(el => { this.shadow.querySelectorAll("[data-move]").forEach(el => {
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {
@ -485,10 +525,10 @@ class FolkWorkBoard extends HTMLElement {
const task = this.tasks.find(t => t.id === this.dragTaskId); const task = this.tasks.find(t => t.id === this.dragTaskId);
dt.setData("text/plain", task?.title || this.dragTaskId); dt.setData("text/plain", task?.title || this.dragTaskId);
dt.setData("application/rspace-item", JSON.stringify({ dt.setData("application/rspace-item", JSON.stringify({
module: "rwork", module: "rtasks",
entityId: this.dragTaskId, entityId: this.dragTaskId,
title: task?.title || "", title: task?.title || "",
label: "rWork Task", label: "rTasks",
color: "#f97316", color: "#f97316",
})); }));
} }
@ -526,4 +566,4 @@ class FolkWorkBoard extends HTMLElement {
} }
} }
customElements.define("folk-work-board", FolkWorkBoard); customElements.define("folk-tasks-board", FolkTasksBoard);

View File

@ -1,5 +1,5 @@
/* Work module — dark theme */ /* Tasks module — dark theme */
folk-work-board { folk-tasks-board {
display: block; display: block;
min-height: 400px; min-height: 400px;
padding: 20px; padding: 20px;

View File

@ -1,14 +1,14 @@
-- rWork module schema -- rTasks module schema
CREATE SCHEMA IF NOT EXISTS rwork; CREATE SCHEMA IF NOT EXISTS rtasks;
CREATE TABLE IF NOT EXISTS rwork.users ( CREATE TABLE IF NOT EXISTS rtasks.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT UNIQUE NOT NULL, did TEXT UNIQUE NOT NULL,
username TEXT, username TEXT,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS rwork.spaces ( CREATE TABLE IF NOT EXISTS rtasks.spaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL, slug TEXT UNIQUE NOT NULL,
@ -21,40 +21,40 @@ CREATE TABLE IF NOT EXISTS rwork.spaces (
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS rwork.space_members ( CREATE TABLE IF NOT EXISTS rtasks.space_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES rwork.spaces(id) ON DELETE CASCADE, space_id UUID NOT NULL REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES rwork.users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES rtasks.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('ADMIN','MODERATOR','MEMBER','VIEWER')), role TEXT NOT NULL DEFAULT 'MEMBER' CHECK (role IN ('ADMIN','MODERATOR','MEMBER','VIEWER')),
joined_at TIMESTAMPTZ DEFAULT NOW(), joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(space_id, user_id) UNIQUE(space_id, user_id)
); );
CREATE TABLE IF NOT EXISTS rwork.tasks ( CREATE TABLE IF NOT EXISTS rtasks.tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES rwork.spaces(id) ON DELETE CASCADE, space_id UUID NOT NULL REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, description TEXT,
status TEXT NOT NULL DEFAULT 'TODO', status TEXT NOT NULL DEFAULT 'TODO',
priority TEXT DEFAULT 'MEDIUM' CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')), priority TEXT DEFAULT 'MEDIUM' CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')),
labels TEXT[] DEFAULT '{}', labels TEXT[] DEFAULT '{}',
assignee_id UUID REFERENCES rwork.users(id), assignee_id UUID REFERENCES rtasks.users(id),
created_by UUID REFERENCES rwork.users(id), created_by UUID REFERENCES rtasks.users(id),
sort_order INT DEFAULT 0, sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS rwork.activity_log ( CREATE TABLE IF NOT EXISTS rtasks.activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID REFERENCES rwork.spaces(id) ON DELETE CASCADE, space_id UUID REFERENCES rtasks.spaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES rwork.users(id), user_id UUID REFERENCES rtasks.users(id),
action TEXT NOT NULL, action TEXT NOT NULL,
metadata JSONB DEFAULT '{}', metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_rwork_tasks_space ON rwork.tasks(space_id); CREATE INDEX IF NOT EXISTS idx_rtasks_tasks_space ON rtasks.tasks(space_id);
CREATE INDEX IF NOT EXISTS idx_rwork_tasks_status ON rwork.tasks(space_id, status); CREATE INDEX IF NOT EXISTS idx_rtasks_tasks_status ON rtasks.tasks(space_id, status);
CREATE INDEX IF NOT EXISTS idx_rwork_activity_space ON rwork.activity_log(space_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_rtasks_activity_space ON rtasks.activity_log(space_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rwork_members_user ON rwork.space_members(user_id); CREATE INDEX IF NOT EXISTS idx_rtasks_members_user ON rtasks.space_members(user_id);

View File

@ -1,6 +1,6 @@
/** /**
* rWork landing page collective task management. * rTasks landing page collective task management.
* Ported from rwork-online Next.js page.tsx (shadcn/ui + Lucide). * Ported from rtasks-online Next.js page.tsx (shadcn/ui + Lucide).
*/ */
export function renderLanding(): string { export function renderLanding(): string {
return ` return `
@ -16,11 +16,16 @@ export function renderLanding(): string {
by markdown-native task management. by markdown-native task management.
</p> </p>
<div class="rl-cta-row"> <div class="rl-cta-row">
<a href="https://demo.rspace.online/rwork" class="rl-cta-primary" id="ml-primary"> <a href="https://demo.rspace.online/rtasks" class="rl-cta-primary" id="ml-primary">
Get Started &rarr; Get Started &rarr;
</a> </a>
<a href="https://demo.rspace.online/rwork" class="rl-cta-secondary">View Dashboard</a> <a href="https://demo.rspace.online/rtasks" class="rl-cta-secondary">View Dashboard</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-tasks-board')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- How It Works --> <!-- How It Works -->
@ -28,7 +33,7 @@ export function renderLanding(): string {
<div class="rl-container"> <div class="rl-container">
<div style="text-align:center;margin-bottom:1.5rem"> <div style="text-align:center;margin-bottom:1.5rem">
<span class="rl-badge">How It Works</span> <span class="rl-badge">How It Works</span>
<h2 class="rl-heading" style="margin-top:0.75rem">rWork in 30 Seconds</h2> <h2 class="rl-heading" style="margin-top:0.75rem">rTasks in 30 Seconds</h2>
<p class="rl-subtext"> <p class="rl-subtext">
<strong style="color:#3b82f6">Create a Space</strong> for your community, <strong style="color:#3b82f6">Create a Space</strong> for your community,
<strong style="color:#14b8a6">add tasks</strong> to your pipeline, and <strong style="color:#14b8a6">add tasks</strong> to your pipeline, and
@ -173,7 +178,7 @@ export function renderLanding(): string {
Invite members, configure your pipeline, and ship faster as a team. Invite members, configure your pipeline, and ship faster as a team.
</p> </p>
<div class="rl-cta-row" style="margin-top:1.5rem"> <div class="rl-cta-row" style="margin-top:1.5rem">
<a href="https://demo.rspace.online/rwork" class="rl-cta-primary"> <a href="https://demo.rspace.online/rtasks" class="rl-cta-primary">
Create a Space &rarr; Create a Space &rarr;
</a> </a>
</div> </div>

View File

@ -1,7 +1,7 @@
/** /**
* rWork Local-First Client * rTasks Local-First Client
* *
* Wraps the shared local-first stack into a work/kanban-specific API. * Wraps the shared local-first stack into a tasks/kanban-specific API.
*/ */
import * as Automerge from '@automerge/automerge'; import * as Automerge from '@automerge/automerge';
@ -13,7 +13,7 @@ import { DocCrypto } from '../../shared/local-first/crypto';
import { boardSchema, boardDocId } from './schemas'; import { boardSchema, boardDocId } from './schemas';
import type { BoardDoc, TaskItem, BoardMeta } from './schemas'; import type { BoardDoc, TaskItem, BoardMeta } from './schemas';
export class WorkLocalFirstClient { export class TasksLocalFirstClient {
#space: string; #space: string;
#documents: DocumentManager; #documents: DocumentManager;
#store: EncryptedDocStore; #store: EncryptedDocStore;
@ -37,7 +37,7 @@ export class WorkLocalFirstClient {
async init(): Promise<void> { async init(): Promise<void> {
if (this.#initialized) return; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('work', 'boards'); const cachedIds = await this.#store.listByModule('tasks', 'boards');
const cached = await this.#store.loadMany(cachedIds); const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) { for (const [docId, binary] of cached) {
this.#documents.open<BoardDoc>(docId, boardSchema, binary); this.#documents.open<BoardDoc>(docId, boardSchema, binary);
@ -45,7 +45,7 @@ export class WorkLocalFirstClient {
await this.#sync.preloadSyncStates(cachedIds); await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[WorkClient] Working offline'); } try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[TasksClient] Working offline'); }
this.#initialized = true; this.#initialized = true;
} }

View File

@ -1,5 +1,5 @@
/** /**
* Work module kanban workspace boards. * Tasks module kanban workspace boards.
* *
* Multi-tenant collaborative workspace with drag-and-drop kanban, * Multi-tenant collaborative workspace with drag-and-drop kanban,
* configurable statuses, and activity logging. * configurable statuses, and activity logging.
@ -51,7 +51,7 @@ function ensureDoc(space: string, boardId?: string): BoardDoc {
* Get all board doc IDs for a given space. * Get all board doc IDs for a given space.
*/ */
function getBoardDocIds(space: string): string[] { function getBoardDocIds(space: string): string[] {
return _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`)); return _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
} }
/** /**
@ -59,15 +59,15 @@ function getBoardDocIds(space: string): string[] {
*/ */
function seedDemoIfEmpty(space: string = 'rspace-dev') { function seedDemoIfEmpty(space: string = 'rspace-dev') {
if (!_syncServer) return; if (!_syncServer) return;
// Check if this space already has work boards // Check if this space already has tasks boards
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`)); const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`));
if (spaceWorkDocs.length > 0) return; if (spaceWorkDocs.length > 0) return;
const docId = boardDocId(space, space); const docId = boardDocId(space, space);
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'seed demo board', (d) => { const doc = Automerge.change(Automerge.init<BoardDoc>(), 'seed demo board', (d) => {
const now = Date.now(); const now = Date.now();
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: space, createdAt: now }; d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: space, createdAt: now };
d.board = { d.board = {
id: space, id: space,
name: 'rSpace Development', name: 'rSpace Development',
@ -109,14 +109,14 @@ function seedDemoIfEmpty(space: string = 'rspace-dev') {
}); });
_syncServer!.setDoc(docId, doc); _syncServer!.setDoc(docId, doc);
console.log(`[Work] Demo data seeded for "${space}": 1 board, 11 tasks`); console.log(`[Tasks] Demo data seeded for "${space}": 1 board, 11 tasks`);
} }
// ── API: Spaces (Boards) ── // ── API: Spaces (Boards) ──
// GET /api/spaces — list workspaces (boards) // GET /api/spaces — list workspaces (boards)
routes.get("/api/spaces", async (c) => { routes.get("/api/spaces", async (c) => {
const allIds = _syncServer!.getDocIds().filter((id) => id.includes(':work:boards:')); const allIds = _syncServer!.getDocIds().filter((id) => id.includes(':tasks:boards:'));
const rows = allIds.map((docId) => { const rows = allIds.map((docId) => {
const doc = _syncServer!.getDoc<BoardDoc>(docId); const doc = _syncServer!.getDoc<BoardDoc>(docId);
if (!doc) return null; if (!doc) return null;
@ -161,7 +161,7 @@ routes.post("/api/spaces", async (c) => {
const now = Date.now(); const now = Date.now();
const doc = Automerge.change(Automerge.init<BoardDoc>(), 'create board', (d) => { const doc = Automerge.change(Automerge.init<BoardDoc>(), 'create board', (d) => {
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: slug, createdAt: now }; d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: slug, createdAt: now };
d.board = { d.board = {
id: slug, id: slug,
name: name.trim(), name: name.trim(),
@ -314,7 +314,7 @@ routes.patch("/api/tasks/:id", async (c) => {
} }
// Find which board doc contains this task // Find which board doc contains this task
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:')); const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
let targetDocId: string | null = null; let targetDocId: string | null = null;
for (const docId of allBoardIds) { for (const docId of allBoardIds) {
const doc = _syncServer!.getDoc<BoardDoc>(docId); const doc = _syncServer!.getDoc<BoardDoc>(docId);
@ -362,7 +362,7 @@ routes.delete("/api/tasks/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
// Find which board doc contains this task // Find which board doc contains this task
const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:')); const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':tasks:boards:'));
let targetDocId: string | null = null; let targetDocId: string | null = null;
for (const docId of allBoardIds) { for (const docId of allBoardIds) {
const doc = _syncServer!.getDoc<BoardDoc>(docId); const doc = _syncServer!.getDoc<BoardDoc>(docId);
@ -395,26 +395,26 @@ routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({ return c.html(renderShell({
title: `${space}Work | rSpace`, title: `${space}Tasks | rSpace`,
moduleId: "rwork", moduleId: "rtasks",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-work-board space="${space}"></folk-work-board>`, body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
scripts: `<script type="module" src="/modules/rwork/folk-work-board.js"></script>`, scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwork/work.css">`, styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
})); }));
}); });
export const workModule: RSpaceModule = { export const tasksModule: RSpaceModule = {
id: "rwork", id: "rtasks",
name: "rWork", name: "rTasks",
icon: "📋", icon: "📋",
description: "Kanban workspace boards for collaborative task management", description: "Kanban workspace boards for collaborative task management",
scoping: { defaultScope: 'space', userConfigurable: false }, scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:work:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }], docSchemas: [{ pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }],
routes, routes,
standaloneDomain: "rwork.online", standaloneDomain: "rtasks.online",
landingPage: renderLanding, landingPage: renderLanding,
seedTemplate: seedDemoIfEmpty, seedTemplate: seedDemoIfEmpty,
async onInit(ctx) { async onInit(ctx) {
@ -426,7 +426,7 @@ export const workModule: RSpaceModule = {
const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug); const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug);
const doc = Automerge.init<BoardDoc>(); const doc = Automerge.init<BoardDoc>();
const initialized = Automerge.change(doc, 'Init board', (d) => { const initialized = Automerge.change(doc, 'Init board', (d) => {
d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() }; d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() };
d.board = { id: ctx.spaceSlug, name: ctx.spaceSlug, slug: ctx.spaceSlug, description: '', icon: null, ownerDid: ctx.ownerDID, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], labels: [], createdAt: Date.now(), updatedAt: Date.now() }; d.board = { id: ctx.spaceSlug, name: ctx.spaceSlug, slug: ctx.spaceSlug, description: '', icon: null, ownerDid: ctx.ownerDID, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], labels: [], createdAt: Date.now(), updatedAt: Date.now() };
d.tasks = {}; d.tasks = {};
}); });

View File

@ -1,8 +1,8 @@
/** /**
* rWork Automerge document schemas. * rTasks Automerge document schemas.
* *
* Granularity: one Automerge document per board/workspace. * Granularity: one Automerge document per board/workspace.
* DocId format: {space}:work:boards:{boardId} * DocId format: {space}:tasks:boards:{boardId}
*/ */
import type { DocSchema } from '../../shared/local-first/document'; import type { DocSchema } from '../../shared/local-first/document';
@ -52,12 +52,12 @@ export interface BoardDoc {
// ── Schema registration ── // ── Schema registration ──
export const boardSchema: DocSchema<BoardDoc> = { export const boardSchema: DocSchema<BoardDoc> = {
module: 'work', module: 'tasks',
collection: 'boards', collection: 'boards',
version: 1, version: 1,
init: (): BoardDoc => ({ init: (): BoardDoc => ({
meta: { meta: {
module: 'work', module: 'tasks',
collection: 'boards', collection: 'boards',
version: 1, version: 1,
spaceSlug: '', spaceSlug: '',
@ -82,7 +82,7 @@ export const boardSchema: DocSchema<BoardDoc> = {
// ── Helpers ── // ── Helpers ──
export function boardDocId(space: string, boardId: string) { export function boardDocId(space: string, boardId: string) {
return `${space}:work:boards:${boardId}` as const; return `${space}:tasks:boards:${boardId}` as const;
} }
export function createTaskItem( export function createTaskItem(

View File

@ -8,6 +8,8 @@
import { tripSchema, type TripDoc } from "../schemas"; import { tripSchema, type TripDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
class FolkTripsPlanner extends HTMLElement { class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -18,19 +20,37 @@ class FolkTripsPlanner extends HTMLElement {
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview"; private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = ""; private error = "";
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "detail">("list");
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#create-trip', title: "Plan a Trip", message: "Start planning a new trip — add destinations, itinerary, and budget.", advanceOnClick: false },
{ target: '.trip-card', title: "Trip Cards", message: "View trip details, status, budget progress, and collaborators.", advanceOnClick: true },
{ target: '.tab', title: "Detail Tabs", message: "Explore destinations, itinerary, bookings, expenses, and packing lists.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkTripsPlanner.TOUR_STEPS,
"rtrips_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline(); this.subscribeOffline();
this.loadTrips(); this.loadTrips();
this.render(); this.render();
} }
if (!localStorage.getItem("rtrips_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
disconnectedCallback() { disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub(); for (const unsub of this._offlineUnsubs) unsub();
@ -472,6 +492,11 @@ class FolkTripsPlanner extends HTMLElement {
${this.view === "list" ? this.renderList() : this.renderDetail()} ${this.view === "list" ? this.renderList() : this.renderDetail()}
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private renderList(): string { private renderList(): string {
@ -479,6 +504,7 @@ class FolkTripsPlanner extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">My Trips</span> <span class="rapp-nav__title">My Trips</span>
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button> <button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
${this.trips.length > 0 ? `<div class="trip-grid"> ${this.trips.length > 0 ? `<div class="trip-grid">
${this.trips.map(t => { ${this.trips.map(t => {
@ -488,7 +514,7 @@ class FolkTripsPlanner extends HTMLElement {
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0; const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6"; const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6";
return ` return `
<div class="trip-card" data-trip="${t.id}"> <div class="trip-card" data-trip="${t.id}" data-collab-id="trip:${t.id}">
<div class="trip-card-header"> <div class="trip-card-header">
<span class="trip-name">${this.esc(t.title)}</span> <span class="trip-name">${this.esc(t.title)}</span>
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span> <span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
@ -518,7 +544,7 @@ class FolkTripsPlanner extends HTMLElement {
const st = this.getStatusStyle(t.status || "PLANNING"); const st = this.getStatusStyle(t.status || "PLANNING");
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
<button class="rapp-nav__back" data-back="list">\u2190 Trips</button> ${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
<span class="rapp-nav__title">${this.esc(t.title)}</span> <span class="rapp-nav__title">${this.esc(t.title)}</span>
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span> <span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
</div> </div>
@ -573,7 +599,7 @@ class FolkTripsPlanner extends HTMLElement {
case "destinations": case "destinations":
return (t.destinations || []).length > 0 return (t.destinations || []).length > 0
? (t.destinations || []).map((d: any, i: number) => ` ? (t.destinations || []).map((d: any, i: number) => `
<div class="dest-card"> <div class="dest-card" data-collab-id="dest:${d.id || i}">
<span class="dest-pin">${i === 0 ? "\u{1F6EB}" : i === (t.destinations.length - 1) ? "\u{1F6EC}" : "\u{1F4CD}"}</span> <span class="dest-pin">${i === 0 ? "\u{1F6EB}" : i === (t.destinations.length - 1) ? "\u{1F6EC}" : "\u{1F4CD}"}</span>
<div class="dest-info"> <div class="dest-info">
<div class="dest-name">${this.esc(d.name)}</div> <div class="dest-name">${this.esc(d.name)}</div>
@ -650,20 +676,20 @@ class FolkTripsPlanner extends HTMLElement {
} }
private attachListeners() { private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip()); this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
this.shadow.querySelectorAll("[data-trip]").forEach(el => { this.shadow.querySelectorAll("[data-trip]").forEach(el => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
this._history.push("list");
this.view = "detail"; this.view = "detail";
this.tab = "overview"; this.tab = "overview";
this._history.push("detail", { tripId: (el as HTMLElement).dataset.trip });
this.loadTrip((el as HTMLElement).dataset.trip!); this.loadTrip((el as HTMLElement).dataset.trip!);
}); });
}); });
this.shadow.querySelectorAll("[data-back]").forEach(el => { this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => { el.addEventListener("click", () => this.goBack());
this.view = "list";
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
});
}); });
this.shadow.querySelectorAll("[data-tab]").forEach(el => { this.shadow.querySelectorAll("[data-tab]").forEach(el => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
@ -686,6 +712,17 @@ class FolkTripsPlanner extends HTMLElement {
}); });
} }
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (this.view === "list") {
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
} else {
this.render();
}
}
private esc(s: string): string { private esc(s: string): string {
const d = document.createElement("div"); const d = document.createElement("div");
d.textContent = s || ""; d.textContent = s || "";

View File

@ -18,6 +18,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rtrips" class="rl-cta-primary" id="ml-primary">Start Planning</a> <a href="https://demo.rspace.online/rtrips" class="rl-cta-primary" id="ml-primary">Start Planning</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-trips-planner')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- How It Works --> <!-- How It Works -->

View File

@ -5,6 +5,8 @@
* and provides HLS live stream viewing. * and provides HLS live stream viewing.
*/ */
import { TourEngine } from "../../../shared/tour-engine";
class FolkVideoPlayer extends HTMLElement { class FolkVideoPlayer extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = "demo"; private space = "demo";
@ -14,16 +16,31 @@ class FolkVideoPlayer extends HTMLElement {
private streamKey = ""; private streamKey = "";
private searchTerm = ""; private searchTerm = "";
private isDemo = false; private isDemo = false;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
{ target: '[data-input="search"]', title: "Search Videos", message: "Filter videos by name to quickly find what you need.", advanceOnClick: false },
{ target: '.player-area', title: "Video Player", message: "Select a video from the sidebar to start playback.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkVideoPlayer.TOUR_STEPS,
"rtube_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); }
this.loadVideos(); else { this.loadVideos(); }
if (!localStorage.getItem("rtube_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
} }
private loadDemoData() { private loadDemoData() {
@ -112,11 +129,17 @@ class FolkVideoPlayer extends HTMLElement {
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button> <button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
${!this.isDemo ? `<button class="tab ${this.mode === "live" ? "active" : ""}" data-mode="live">Live Stream</button>` : ""} ${!this.isDemo ? `<button class="tab ${this.mode === "live" ? "active" : ""}" data-mode="live">Live Stream</button>` : ""}
<span class="rapp-nav__title">${this.isDemo ? `${this.videos.length} recordings` : ""}</span> <span class="rapp-nav__title">${this.isDemo ? `${this.videos.length} recordings` : ""}</span>
<button class="tab" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
${this.mode === "library" ? this.renderLibrary() : this.renderLive()} ${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
</div> </div>
`; `;
this.bindEvents(); this.bindEvents();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private renderLibrary(): string { private renderLibrary(): string {
@ -129,7 +152,7 @@ class FolkVideoPlayer extends HTMLElement {
const videoList = filteredVideos.length === 0 const videoList = filteredVideos.length === 0
? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>` ? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>`
: filteredVideos.map((v) => ` : filteredVideos.map((v) => `
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}"> <div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}" data-collab-id="video:${v.name}">
<div class="video-name">${v.name}</div> <div class="video-name">${v.name}</div>
<div class="video-meta">${this.getExtension(v.name).toUpperCase()} &middot; ${this.formatSize(v.size)}${v.duration ? ` &middot; ${v.duration}` : ""}${v.date ? `<br>${v.date}` : ""}</div> <div class="video-meta">${this.getExtension(v.name).toUpperCase()} &middot; ${this.formatSize(v.size)}${v.duration ? ` &middot; ${v.duration}` : ""}${v.date ? `<br>${v.date}` : ""}</div>
</div> </div>
@ -202,6 +225,8 @@ class FolkVideoPlayer extends HTMLElement {
} }
private bindEvents() { private bindEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll(".tab").forEach((btn) => { this.shadow.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
this.mode = (btn as HTMLElement).dataset.mode as "library" | "live"; this.mode = (btn as HTMLElement).dataset.mode as "library" | "live";

View File

@ -21,6 +21,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rtube" class="rl-cta-secondary" id="ml-primary">Browse Videos</a> <a href="https://demo.rspace.online/rtube" class="rl-cta-secondary" id="ml-primary">Browse Videos</a>
<a href="https://demo.rspace.online/rtube" class="rl-cta-primary" style="background:#dc2626">Start Streaming</a> <a href="https://demo.rspace.online/rtube" class="rl-cta-primary" style="background:#dc2626">Start Streaming</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-video-player')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- How It Works --> <!-- How It Works -->

View File

@ -7,6 +7,8 @@
import { proposalSchema, type ProposalDoc } from "../schemas"; import { proposalSchema, type ProposalDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
interface VoteSpace { interface VoteSpace {
slug: string; slug: string;
@ -54,17 +56,35 @@ class FolkVoteDashboard extends HTMLElement {
private showTrendChart = true; private showTrendChart = true;
private scoreHistory: ScoreSnapshot[] = []; private scoreHistory: ScoreSnapshot[] = [];
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"spaces" | "proposals" | "proposal">("spaces");
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-space]', title: "Voting Spaces", message: "Each space has its own proposals and conviction parameters. Click a space to explore.", advanceOnClick: true },
{ target: '[data-toggle-create]', title: "Create Proposal", message: "Submit a new proposal for the community to vote on with conviction-weighted ranking.", advanceOnClick: true },
{ target: '[data-proposal]', title: "View Proposal", message: "Click a proposal to see its details, cast your vote, and watch conviction accumulate over time.", advanceOnClick: true },
{ target: '[data-toggle-trend]', title: "Trend Chart", message: "Toggle the conviction trend chart to see how proposal scores evolve as votes accumulate.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkVoteDashboard.TOUR_STEPS,
"rvote_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); }
this.subscribeOffline(); else { this.subscribeOffline(); this.loadSpaces(); }
this.loadSpaces(); if (!localStorage.getItem("rvote_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
} }
disconnectedCallback() { disconnectedCallback() {
@ -593,8 +613,11 @@ class FolkVoteDashboard extends HTMLElement {
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay();
} }
startTour() { this._tour.start(); }
private renderView(): string { private renderView(): string {
if (this.view === "proposal" && this.selectedProposal) { if (this.view === "proposal" && this.selectedProposal) {
return this.renderProposal(); return this.renderProposal();
@ -609,6 +632,7 @@ class FolkVoteDashboard extends HTMLElement {
return ` return `
<div class="header"> <div class="header">
<span class="header-title">Voting Spaces</span> <span class="header-title">Voting Spaces</span>
<button class="header-back" id="btn-tour">Tour</button>
</div> </div>
${this.spaces.length === 0 ? '<div class="empty"><div class="empty-icon">🗳</div><div class="empty-text">No voting spaces yet. Create one to get started.</div></div>' : ""} ${this.spaces.length === 0 ? '<div class="empty"><div class="empty-icon">🗳</div><div class="empty-text">No voting spaces yet. Create one to get started.</div></div>' : ""}
${this.spaces.map((s) => ` ${this.spaces.map((s) => `
@ -658,7 +682,7 @@ class FolkVoteDashboard extends HTMLElement {
return ` return `
<div class="header"> <div class="header">
${this.spaces.length > 1 ? `<button class="header-back" data-back="spaces">&larr;</button>` : ""} ${this._history.canGoBack && this.spaces.length > 1 ? '<button class="header-back" data-back="spaces">&larr;</button>' : ''}
<span class="header-title">${this.esc(s.name)}</span> <span class="header-title">${this.esc(s.name)}</span>
<span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span> <span class="header-sub">${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}</span>
<button class="btn-new" data-toggle-create>+ New Proposal</button> <button class="btn-new" data-toggle-create>+ New Proposal</button>
@ -710,7 +734,7 @@ class FolkVoteDashboard extends HTMLElement {
const downChevron = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`; const downChevron = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
return ` return `
<div class="proposal" data-pid="${p.id}"> <div class="proposal" data-pid="${p.id}" data-collab-id="proposal:${p.id}">
${p.status === "RANKING" ? ` ${p.status === "RANKING" ? `
<div class="vote-col"> <div class="vote-col">
<button class="vote-chevron up" data-vote-weight="1" data-vote-id="${p.id}" title="Upvote (+1 credit)">${upChevron}</button> <button class="vote-chevron up" data-vote-weight="1" data-vote-id="${p.id}" title="Upvote (+1 credit)">${upChevron}</button>
@ -892,7 +916,7 @@ class FolkVoteDashboard extends HTMLElement {
return ` return `
<div class="header"> <div class="header">
<button class="header-back" data-back="proposals">&larr; Proposals</button> ${this._history.canGoBack ? '<button class="header-back" data-back="proposals">&larr; Proposals</button>' : ''}
<span class="header-title">${this.esc(p.title)}</span> <span class="header-title">${this.esc(p.title)}</span>
<span class="badge" style="background:${this.getStatusColor(p.status)}18;color:${this.getStatusColor(p.status)}">${this.getStatusIcon(p.status)} ${p.status}</span> <span class="badge" style="background:${this.getStatusColor(p.status)}18;color:${this.getStatusColor(p.status)}">${this.getStatusIcon(p.status)} ${p.status}</span>
</div> </div>
@ -966,10 +990,13 @@ class FolkVoteDashboard extends HTMLElement {
} }
private attachListeners() { private attachListeners() {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
// Space cards // Space cards
this.shadow.querySelectorAll("[data-space]").forEach((el) => { this.shadow.querySelectorAll("[data-space]").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const slug = (el as HTMLElement).dataset.space!; const slug = (el as HTMLElement).dataset.space!;
this._history.push(this.view);
this._history.push("proposals", { space: slug });
this.selectedSpace = this.spaces.find((s) => s.slug === slug) || null; this.selectedSpace = this.spaces.find((s) => s.slug === slug) || null;
this.view = "proposals"; this.view = "proposals";
if (this.space === "demo") this.render(); if (this.space === "demo") this.render();
@ -982,6 +1009,8 @@ class FolkVoteDashboard extends HTMLElement {
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const id = (el as HTMLElement).dataset.proposal!; const id = (el as HTMLElement).dataset.proposal!;
this._history.push(this.view);
this._history.push("proposal", { proposalId: id });
this.view = "proposal"; this.view = "proposal";
if (this.space === "demo") { if (this.space === "demo") {
this.selectedProposal = this.proposals.find((p) => p.id === id) || null; this.selectedProposal = this.proposals.find((p) => p.id === id) || null;
@ -996,9 +1025,7 @@ class FolkVoteDashboard extends HTMLElement {
this.shadow.querySelectorAll("[data-back]").forEach((el) => { this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const target = (el as HTMLElement).dataset.back; this.goBack();
if (target === "spaces") { this.view = "spaces"; this.render(); }
else if (target === "proposals") { this.view = "proposals"; this.render(); }
}); });
}); });
@ -1051,6 +1078,13 @@ class FolkVoteDashboard extends HTMLElement {
}); });
} }
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
this.render();
}
private esc(s: string): string { private esc(s: string): string {
const d = document.createElement("div"); const d = document.createElement("div");
d.textContent = s || ""; d.textContent = s || "";

View File

@ -27,6 +27,11 @@ export function renderLanding(): string {
</a> </a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-vote-dashboard')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- ELI5 Section: rVote in 30 Seconds --> <!-- ELI5 Section: rVote in 30 Seconds -->

View File

@ -10,6 +10,7 @@ import { transformToTimelineData, transformToSankeyData, transformToMultichainDa
import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform"; import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform";
import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz";
import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data"; import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data";
import { TourEngine } from "../../../shared/tour-engine";
interface ChainInfo { interface ChainInfo {
chainId: string; chainId: string;
@ -108,24 +109,41 @@ class FolkWalletViewer extends HTMLElement {
multichain?: MultichainData; multichain?: MultichainData;
} = {}; } = {};
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#address-input', title: "Enter Address", message: "Paste any wallet or Safe multisig address to load balances across all supported chains.", advanceOnClick: false },
{ target: '#testnet-toggle', title: "Testnet Toggle", message: "Include testnet chains in the scan. Useful for checking Safe deployments on Sepolia or Goerli.", advanceOnClick: true },
{ target: '.chain-btn', title: "Chain Selector", message: "Click a chain to load balances for that specific network. Active chains are highlighted.", advanceOnClick: false },
{ target: '.view-tab', title: "Dashboard Views", message: "Switch between balance view, transfer timeline, and flow visualisations for deeper analysis.", advanceOnClick: false },
];
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkWalletViewer.TOUR_STEPS,
"rwallet_tour_done",
() => this.shadow.host as HTMLElement,
);
} }
connectedCallback() { connectedCallback() {
const space = this.getAttribute("space") || ""; const space = this.getAttribute("space") || "";
if (space === "demo") { if (space === "demo") {
this.loadDemoData(); this.loadDemoData();
return; } else {
}
// Check URL params for initial address
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
this.address = params.get("address") || ""; this.address = params.get("address") || "";
this.checkAuthState(); this.checkAuthState();
this.render(); this.render();
if (this.address) this.detectChains(); if (this.address) this.detectChains();
} }
if (!localStorage.getItem("rwallet_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private checkAuthState() { private checkAuthState() {
try { try {
@ -979,7 +997,7 @@ class FolkWalletViewer extends HTMLElement {
</div> </div>
<div class="wallet-list"> <div class="wallet-list">
${this.passKeyEOA ? ` ${this.passKeyEOA ? `
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}"> <div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}" data-collab-id="wallet:${this.esc(this.passKeyEOA)}">
<div class="wallet-item-info"> <div class="wallet-item-info">
<div class="wallet-item-label"> <div class="wallet-item-label">
<span class="wallet-badge encryptid">EncryptID</span> <span class="wallet-badge encryptid">EncryptID</span>
@ -990,7 +1008,7 @@ class FolkWalletViewer extends HTMLElement {
</div> </div>
` : ""} ` : ""}
${this.linkedWallets.map(w => ` ${this.linkedWallets.map(w => `
<div class="wallet-item" data-view-address="${this.esc(w.address)}"> <div class="wallet-item" data-view-address="${this.esc(w.address)}" data-collab-id="wallet:${this.esc(w.address)}">
<div class="wallet-item-info"> <div class="wallet-item-info">
<div class="wallet-item-label"> <div class="wallet-item-label">
<span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span> <span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span>
@ -1205,6 +1223,7 @@ class FolkWalletViewer extends HTMLElement {
<div class="toggle-track"><div class="toggle-thumb"></div></div> <div class="toggle-track"><div class="toggle-thumb"></div></div>
<span>Include testnets</span> <span>Include testnets</span>
</div> </div>
<button class="view-tab" id="btn-tour" style="margin-left:auto;font-size:0.78rem;padding:4px 10px">Tour</button>
${this.walletType ? ` ${this.walletType ? `
<div class="wallet-badge ${this.walletType}"> <div class="wallet-badge ${this.walletType}">
${this.walletType === "safe" ? "&#9939; Safe Multisig" : "&#128100; EOA Wallet"} ${this.walletType === "safe" ? "&#9939; Safe Multisig" : "&#128100; EOA Wallet"}
@ -1296,10 +1315,18 @@ class FolkWalletViewer extends HTMLElement {
}); });
}); });
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
// Draw visualization if active // Draw visualization if active
if (this.activeView !== "balances" && this.hasData()) { if (this.activeView !== "balances" && this.hasData()) {
requestAnimationFrame(() => this.drawActiveVisualization()); requestAnimationFrame(() => this.drawActiveVisualization());
} }
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
} }
private esc(s: string): string { private esc(s: string): string {

View File

@ -19,6 +19,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rwallet" class="rl-cta-primary" id="ml-primary">View Treasury</a> <a href="https://demo.rspace.online/rwallet" class="rl-cta-primary" id="ml-primary">View Treasury</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a> <a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div> </div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-wallet-viewer')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div> </div>
<!-- Features --> <!-- Features -->

View File

@ -56,7 +56,7 @@ import { walletModule } from "../modules/rwallet/mod";
import { voteModule } from "../modules/rvote/mod"; import { voteModule } from "../modules/rvote/mod";
import { notesModule } from "../modules/rnotes/mod"; import { notesModule } from "../modules/rnotes/mod";
import { mapsModule } from "../modules/rmaps/mod"; import { mapsModule } from "../modules/rmaps/mod";
import { workModule } from "../modules/rwork/mod"; import { tasksModule } from "../modules/rtasks/mod";
import { tripsModule } from "../modules/rtrips/mod"; import { tripsModule } from "../modules/rtrips/mod";
import { calModule } from "../modules/rcal/mod"; import { calModule } from "../modules/rcal/mod";
import { networkModule } from "../modules/rnetwork/mod"; import { networkModule } from "../modules/rnetwork/mod";
@ -99,7 +99,7 @@ registerModule(walletModule);
registerModule(voteModule); registerModule(voteModule);
registerModule(notesModule); registerModule(notesModule);
registerModule(mapsModule); registerModule(mapsModule);
registerModule(workModule); registerModule(tasksModule);
registerModule(tripsModule); registerModule(tripsModule);
registerModule(calModule); registerModule(calModule);
registerModule(networkModule); registerModule(networkModule);

View File

@ -329,7 +329,7 @@ export const notesMigration: ModuleMigration = {
// ============================================================================ // ============================================================================
/** /**
* Work: rwork.spaces + rwork.tasks * Tasks: rtasks.spaces + rtasks.tasks
* Granularity: 1 doc per space/board ({space}:work:boards:{spaceId}) * Granularity: 1 doc per space/board ({space}:work:boards:{spaceId})
*/ */
export const workMigration: ModuleMigration = { export const workMigration: ModuleMigration = {
@ -340,14 +340,14 @@ export const workMigration: ModuleMigration = {
try { try {
const { rows: spaces } = await pool.query( const { rows: spaces } = await pool.query(
`SELECT * FROM rwork.spaces WHERE slug = $1`, `SELECT * FROM rtasks.spaces WHERE slug = $1`,
[space] [space]
); );
for (const ws of spaces) { for (const ws of spaces) {
try { try {
const { rows: tasks } = await pool.query( const { rows: tasks } = await pool.query(
`SELECT * FROM rwork.tasks WHERE space_id = $1 ORDER BY sort_order, created_at`, `SELECT * FROM rtasks.tasks WHERE space_id = $1 ORDER BY sort_order, created_at`,
[ws.id] [ws.id]
); );

View File

@ -615,10 +615,10 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
ordersFulfilled: 127, ordersFulfilled: 127,
}, },
// ─── rWork: Task Board ───────────────────────────────────── // ─── rTasks: Task Board ─────────────────────────────────────
{ {
id: "demo-work-board", id: "demo-tasks-board",
type: "folk-work-board", type: "folk-tasks-board",
x: 750, y: 1350, width: 500, height: 280, rotation: 0, x: 750, y: 1350, width: 500, height: 280, rotation: 0,
boardTitle: "Trip Preparation Tasks", boardTitle: "Trip Preparation Tasks",
columns: [ columns: [

View File

@ -277,10 +277,10 @@ const TEMPLATE_SHAPES: Record<string, unknown>[] = [
ordersFulfilled: 0, ordersFulfilled: 0,
}, },
// ─── rWork: Task Board ────────────────────────────────────── // ─── rTasks: Task Board ──────────────────────────────────────
{ {
id: "tmpl-work-board", id: "tmpl-tasks-board",
type: "folk-work-board", type: "folk-tasks-board",
x: 750, y: 780, width: 500, height: 280, rotation: 0, x: 750, y: 780, width: 500, height: 280, rotation: 0,
boardTitle: "Getting Started Tasks", boardTitle: "Getting Started Tasks",
columns: [ columns: [

View File

@ -578,6 +578,17 @@ export function renderShell(opts: ShellOptions): string {
} }
}); });
tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail;
const oldIdx = layers.findIndex(l => l.id === layerId);
if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = layers.splice(oldIdx, 1);
layers.splice(newIndex, 0, moved);
layers.forEach((l, i) => l.order = i);
saveTabs();
tabBar.setLayers(layers);
});
// ── Dashboard navigate: user clicked a space/action on the dashboard ── // ── Dashboard navigate: user clicked a space/action on the dashboard ──
document.addEventListener('dashboard-navigate', (e) => { document.addEventListener('dashboard-navigate', (e) => {
const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail; const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail;
@ -782,9 +793,12 @@ export function renderShell(opts: ShellOptions): string {
}); });
tabBar.addEventListener('layer-reorder', (e) => { tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail; const { layerId, newIndex } = e.detail;
sync.updateLayer(layerId, { order: newIndex }); const all = sync.getLayers(); // already sorted by order
const all = sync.getLayers(); const oldIdx = all.findIndex(l => l.id === layerId);
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer(l.id, { order: i }); }); if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = all.splice(oldIdx, 1);
all.splice(newIndex, 0, moved);
all.forEach((l, i) => sync.updateLayer(l.id, { order: i }));
}); });
tabBar.addEventListener('flow-create', (e) => { sync.addFlow(e.detail.flow); }); tabBar.addEventListener('flow-create', (e) => { sync.addFlow(e.detail.flow); });
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow(e.detail.flowId); }); tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow(e.detail.flowId); });
@ -927,6 +941,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); }); tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); });
tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); }); tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); }); tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); if (oldIdx === -1 || oldIdx === newIndex) return; const [moved] = layers.splice(oldIdx, 1); layers.splice(newIndex, 0, moved); layers.forEach((l, i) => l.order = i); saveTabs(); tabBar.setLayers(layers); });
} }
</script> </script>
</body> </body>

View File

@ -53,7 +53,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
// Observing // Observing
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300 rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
// Work & Productivity // Work & Productivity
rwork: { badge: "r📋", color: "#cbd5e1" }, // slate-300 rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200 rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
// Identity & Infrastructure // Identity & Infrastructure
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300 rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
@ -88,7 +88,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rfiles: "Sharing", rfiles: "Sharing",
rbooks: "Sharing", rbooks: "Sharing",
rdata: "Observing", rdata: "Observing",
rwork: "Work & Productivity", rtasks: "Tasks & Productivity",
rschedule: "Work & Productivity", rschedule: "Work & Productivity",
rids: "Identity & Infrastructure", rids: "Identity & Infrastructure",
rstack: "Identity & Infrastructure", rstack: "Identity & Infrastructure",

View File

@ -0,0 +1,487 @@
/**
* <rstack-collab-overlay> drop-in multiplayer presence for all rApps.
*
* Features:
* - "N online" badge (top-right pill with colored dots)
* - Remote cursors (SVG arrows with username labels, viewport-relative)
* - Focus highlighting (colored outline rings on data-collab-id elements)
* - Auto-discovery via rspace-doc-subscribe events from runtime
* - Hides on canvas page (rSpace has its own CommunitySync)
*
* Attributes:
* module-id current module identifier
* space current space slug
* doc-id explicit doc ID (fallback if auto-discovery misses)
* mode "badge-only" for iframe/proxy modules (no cursors)
*/
import type { AwarenessMessage } from '../local-first/sync';
// ── Peer color palette (same as canvas PresenceManager) ──
const PEER_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1',
];
interface PeerState {
peerId: string;
username: string;
color: string;
cursor: { x: number; y: number } | null;
selection: string | null;
lastSeen: number;
}
export class RStackCollabOverlay extends HTMLElement {
#shadow: ShadowRoot;
#peers = new Map<string, PeerState>();
#docId: string | null = null;
#moduleId: string | null = null;
#localPeerId: string | null = null;
#localColor = PEER_COLORS[0];
#localUsername = 'Anonymous';
#unsubAwareness: (() => void) | null = null;
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
#lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | null = null;
#badgeOnly = false;
#hidden = false; // true on canvas page
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#moduleId = this.getAttribute('module-id');
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
// Hide on canvas page — it has its own CommunitySync + PresenceManager
if (this.#moduleId === 'rspace') {
this.#hidden = true;
return;
}
// Explicit doc-id attribute (fallback)
const explicitDocId = this.getAttribute('doc-id');
if (explicitDocId) this.#docId = explicitDocId;
// Listen for runtime doc subscriptions (auto-discovery)
window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
// Resolve local identity
this.#resolveIdentity();
// Render initial (empty badge)
this.#render();
// Try connecting to runtime
this.#tryConnect();
// GC stale peers every 5s
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
}
disconnectedCallback() {
if (this.#hidden) return;
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.();
this.#stopMouseTracking();
this.#stopFocusTracking();
if (this.#gcInterval) clearInterval(this.#gcInterval);
this.#gcInterval = null;
}
// ── Auto-discovery ──
#onDocSubscribe = (e: Event) => {
const { docId } = (e as CustomEvent).detail;
if (!this.#docId && docId) {
this.#docId = docId;
this.#connectToDoc();
}
};
// ── Runtime connection ──
#tryConnect() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
this.#onRuntimeReady(runtime);
} else {
// Retry until runtime is ready
const check = setInterval(() => {
const rt = (window as any).__rspaceOfflineRuntime;
if (rt?.isInitialized) {
clearInterval(check);
this.#onRuntimeReady(rt);
}
}, 500);
// Give up after 15s
setTimeout(() => clearInterval(check), 15000);
}
}
#onRuntimeReady(runtime: any) {
this.#localPeerId = runtime.peerId;
// Assign a deterministic color from peer ID
this.#localColor = this.#colorForPeer(this.#localPeerId!);
if (this.#docId) {
this.#connectToDoc();
}
}
#connectToDoc() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return;
// Unsubscribe from previous
this.#unsubAwareness?.();
// Listen for remote awareness
this.#unsubAwareness = runtime.onAwareness(this.#docId, (msg: AwarenessMessage) => {
if (msg.peer === this.#localPeerId) return; // ignore self
this.#handleRemoteAwareness(msg);
});
// Start broadcasting local presence
if (!this.#badgeOnly) {
this.#startMouseTracking();
this.#startFocusTracking();
}
// Send initial presence (announce we're here)
this.#broadcastPresence();
}
// ── Identity ──
#resolveIdentity() {
try {
const raw = localStorage.getItem('encryptid_session');
if (raw) {
const session = JSON.parse(raw);
if (session?.username) this.#localUsername = session.username;
else if (session?.displayName) this.#localUsername = session.displayName;
}
} catch { /* no session */ }
}
// ── Remote awareness handling ──
#handleRemoteAwareness(msg: AwarenessMessage) {
const existing = this.#peers.get(msg.peer);
const peer: PeerState = {
peerId: msg.peer,
username: msg.username || existing?.username || 'Anonymous',
color: msg.color || existing?.color || this.#colorForPeer(msg.peer),
cursor: msg.cursor ?? existing?.cursor ?? null,
selection: msg.selection ?? existing?.selection ?? null,
lastSeen: Date.now(),
};
this.#peers.set(msg.peer, peer);
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
// ── Local broadcasting ──
#broadcastPresence(cursor?: { x: number; y: number }, selection?: string) {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return;
runtime.sendAwareness(this.#docId, {
cursor,
selection,
username: this.#localUsername,
color: this.#localColor,
});
}
// ── Mouse tracking (15Hz throttle) ──
#mouseHandler = (e: MouseEvent) => {
this.#lastCursor = { x: e.clientX, y: e.clientY };
};
#startMouseTracking() {
document.addEventListener('mousemove', this.#mouseHandler, { passive: true });
// Broadcast at 15Hz
this.#mouseMoveTimer = setInterval(() => {
this.#broadcastPresence(this.#lastCursor, undefined);
}, 67); // ~15Hz
}
#stopMouseTracking() {
document.removeEventListener('mousemove', this.#mouseHandler);
if (this.#mouseMoveTimer) clearInterval(this.#mouseMoveTimer);
this.#mouseMoveTimer = null;
}
// ── Focus tracking on data-collab-id elements ──
#focusHandler = (e: FocusEvent) => {
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
if (target) {
const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId);
}
};
#blurHandler = () => {
this.#broadcastPresence(undefined, '');
};
#startFocusTracking() {
document.addEventListener('focusin', this.#focusHandler, { passive: true });
document.addEventListener('click', this.#clickHandler, { passive: true });
document.addEventListener('focusout', this.#blurHandler, { passive: true });
}
#stopFocusTracking() {
document.removeEventListener('focusin', this.#focusHandler);
document.removeEventListener('click', this.#clickHandler);
document.removeEventListener('focusout', this.#blurHandler);
}
// Also track clicks on data-collab-id (many elements aren't focusable)
#clickHandler = (e: MouseEvent) => {
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
if (target) {
const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId);
}
};
// ── GC stale peers ──
#gcPeers() {
const now = Date.now();
let changed = false;
for (const [id, peer] of this.#peers) {
if (now - peer.lastSeen > 15000) {
this.#peers.delete(id);
changed = true;
}
}
if (changed) {
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
}
// ── Color assignment ──
#colorForPeer(peerId: string): string {
let hash = 0;
for (let i = 0; i < peerId.length; i++) {
hash = ((hash << 5) - hash + peerId.charCodeAt(i)) | 0;
}
return PEER_COLORS[Math.abs(hash) % PEER_COLORS.length];
}
// ── Rendering ──
#render() {
this.#shadow.innerHTML = `
<style>${OVERLAY_CSS}</style>
<div class="collab-badge" id="badge"></div>
<div class="collab-cursors" id="cursors"></div>
`;
}
#renderBadge() {
const badge = this.#shadow.getElementById('badge');
if (!badge) return;
const count = this.#peers.size + 1; // +1 for self
if (count <= 1) {
badge.innerHTML = '';
badge.classList.remove('visible');
return;
}
const dots = Array.from(this.#peers.values())
.slice(0, 5) // show max 5 dots
.map(p => `<span class="dot" style="background:${p.color}"></span>`)
.join('');
badge.innerHTML = `
<span class="dot" style="background:${this.#localColor}"></span>
${dots}
<span class="count">${count} online</span>
`;
badge.classList.add('visible');
}
#renderCursors() {
const container = this.#shadow.getElementById('cursors');
if (!container) return;
const now = Date.now();
const fragments: string[] = [];
for (const peer of this.#peers.values()) {
if (!peer.cursor) continue;
const age = now - peer.lastSeen;
const opacity = age > 5000 ? 0.3 : 1;
fragments.push(`
<div class="cursor" style="left:${peer.cursor.x}px;top:${peer.cursor.y}px;opacity:${opacity}">
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
<path d="M1 1L6.5 18L8.5 11L15 9L1 1Z" fill="${peer.color}" stroke="white" stroke-width="1.5"/>
</svg>
<span class="cursor-label" style="background:${peer.color}">${this.#escHtml(peer.username)}</span>
</div>
`);
}
container.innerHTML = fragments.join('');
}
#renderFocusRings() {
// Remove all existing focus rings from the document
document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove());
for (const peer of this.#peers.values()) {
if (!peer.selection) continue;
const target = document.querySelector(`[data-collab-id="${CSS.escape(peer.selection)}"]`);
if (!target) continue;
const rect = target.getBoundingClientRect();
const ring = document.createElement('div');
ring.className = 'rstack-collab-focus-ring';
ring.style.cssText = `
position: fixed;
left: ${rect.left - 3}px;
top: ${rect.top - 3}px;
width: ${rect.width + 6}px;
height: ${rect.height + 6}px;
border: 2px solid ${peer.color};
border-radius: 6px;
pointer-events: none;
z-index: 9998;
box-shadow: 0 0 0 1px ${peer.color}33;
transition: all 0.15s ease;
`;
// Username label on the ring
const label = document.createElement('span');
label.textContent = peer.username;
label.style.cssText = `
position: absolute;
top: -18px;
left: 4px;
font-size: 10px;
color: white;
background: ${peer.color};
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
line-height: 14px;
`;
ring.appendChild(label);
document.body.appendChild(ring);
}
}
#escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
static define() {
if (!customElements.get('rstack-collab-overlay')) {
customElements.define('rstack-collab-overlay', RStackCollabOverlay);
}
}
}
// ── Styles (inside shadow DOM) ──
const OVERLAY_CSS = `
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
z-index: 9999;
pointer-events: none;
}
.collab-badge {
position: fixed;
top: 8px;
right: 80px;
display: none;
align-items: center;
gap: 4px;
padding: 4px 10px 4px 6px;
border-radius: 16px;
background: var(--rs-bg-secondary, rgba(30, 30, 30, 0.85));
backdrop-filter: blur(8px);
font-size: 11px;
color: var(--rs-text-secondary, #ccc);
pointer-events: auto;
cursor: default;
user-select: none;
z-index: 10000;
border: 1px solid var(--rs-border, rgba(255,255,255,0.08));
}
.collab-badge.visible {
display: inline-flex;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.count {
margin-left: 2px;
font-weight: 500;
}
.collab-cursors {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.cursor {
position: fixed;
pointer-events: none;
transition: left 0.1s linear, top 0.1s linear, opacity 0.3s ease;
z-index: 9999;
}
.cursor-label {
position: absolute;
left: 14px;
top: 14px;
font-size: 10px;
color: white;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
line-height: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
`;

View File

@ -51,7 +51,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
rfiles: { badge: "r📁", color: "#67e8f9" }, rfiles: { badge: "r📁", color: "#67e8f9" },
rbooks: { badge: "r📚", color: "#fda4af" }, rbooks: { badge: "r📚", color: "#fda4af" },
rdata: { badge: "r📊", color: "#d8b4fe" }, rdata: { badge: "r📊", color: "#d8b4fe" },
rwork: { badge: "r📋", color: "#cbd5e1" }, rtasks: { badge: "r📋", color: "#cbd5e1" },
rschedule: { badge: "r⏱", color: "#a5b4fc" }, rschedule: { badge: "r⏱", color: "#a5b4fc" },
rids: { badge: "r🪪", color: "#6ee7b7" }, rids: { badge: "r🪪", color: "#6ee7b7" },
rstack: { badge: "r✨", color: "" }, rstack: { badge: "r✨", color: "" },
@ -67,7 +67,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce", rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce",
rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing", rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
rdata: "Observing", rdata: "Observing",
rwork: "Work & Productivity", rtasks: "Tasks & Productivity",
rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure", rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure",
}; };
@ -1501,7 +1501,8 @@ const STYLES = `
/* ── Drag states ── */ /* ── Drag states ── */
.tab.dragging { opacity: 0.4; } .tab { cursor: grab; }
.tab.dragging { opacity: 0.4; cursor: grabbing; }
.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; } .tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; }
/* ── Add button ── */ /* ── Add button ── */

View File

@ -20,7 +20,7 @@ import {
DocumentManager, DocumentManager,
} from './document'; } from './document';
import { EncryptedDocStore } from './storage'; import { EncryptedDocStore } from './storage';
import { DocSyncManager } from './sync'; import { DocSyncManager, type AwarenessMessage } from './sync';
import { DocCrypto } from './crypto'; import { DocCrypto } from './crypto';
import { import {
getStorageInfo, getStorageInfo,
@ -72,6 +72,7 @@ export class RSpaceOfflineRuntime {
get isInitialized(): boolean { return this.#initialized; } get isInitialized(): boolean { return this.#initialized; }
get isOnline(): boolean { return this.#sync.isConnected; } get isOnline(): boolean { return this.#sync.isConnected; }
get status(): RuntimeStatus { return this.#status; } get status(): RuntimeStatus { return this.#status; }
get peerId(): string { return this.#sync.peerId; }
// ── Lifecycle ── // ── Lifecycle ──
@ -131,6 +132,11 @@ export class RSpaceOfflineRuntime {
// Subscribe for sync (sends subscribe + initial sync to server) // Subscribe for sync (sends subscribe + initial sync to server)
await this.#sync.subscribe([docId]); await this.#sync.subscribe([docId]);
// Notify overlay / other listeners about this subscription
window.dispatchEvent(new CustomEvent('rspace-doc-subscribe', {
detail: { docId, module: docId.split(':')[1] ?? '' },
}));
return doc; return doc;
} }
@ -178,6 +184,21 @@ export class RSpaceOfflineRuntime {
return () => { unsub1(); unsub2(); }; return () => { unsub1(); unsub2(); };
} }
/**
* Send awareness/presence data for a document (cursor, selection, etc.).
*/
sendAwareness(docId: DocumentId, data: Partial<AwarenessMessage>): void {
this.#sync.sendAwareness(docId, data);
}
/**
* Listen for awareness updates on a document from remote peers.
* Returns an unsubscribe function.
*/
onAwareness(docId: DocumentId, cb: (msg: AwarenessMessage) => void): () => void {
return this.#sync.onAwareness(docId, cb);
}
/** /**
* Configure module scope information from the page's module list. * Configure module scope information from the page's module list.
* Call once after init with the modules config for this space. * Call once after init with the modules config for this space.

277
shared/tour-engine.ts Normal file
View File

@ -0,0 +1,277 @@
/**
* Shared guided-tour engine for shadow-DOM rApp components.
*
* Designed to survive full innerHTML replacement call `renderOverlay()`
* at the end of each render cycle to re-inject the tour UI.
*/
export interface TourStep {
target: string; // CSS selector within shadow root
title: string;
message: string;
advanceOnClick?: boolean; // auto-advance when target is clicked
}
export class TourEngine {
private shadowRoot: ShadowRoot;
private steps: TourStep[];
private storageKey: string;
private getContainer: () => HTMLElement | null;
private _active = false;
private _step = 0;
private _clickHandler: (() => void) | null = null;
private _clickTarget: HTMLElement | null = null;
get isActive() { return this._active; }
constructor(
shadowRoot: ShadowRoot,
steps: TourStep[],
storageKey: string,
getContainer: () => HTMLElement | null,
) {
this.shadowRoot = shadowRoot;
this.steps = steps;
this.storageKey = storageKey;
this.getContainer = getContainer;
}
/** Start (or restart) the tour from step 0. */
start() {
this._active = true;
this._step = 0;
this.renderOverlay();
}
/** Advance to next step or finish if at the end. */
advance() {
this._detachClickHandler();
this._step++;
if (this._step >= this.steps.length) {
this.end();
} else {
this.renderOverlay();
}
}
/** End the tour and remove the overlay. */
end() {
this._detachClickHandler();
this._active = false;
this._step = 0;
localStorage.setItem(this.storageKey, "1");
this.shadowRoot.getElementById("rspace-tour-overlay")?.remove();
}
/**
* Re-inject / update the tour overlay.
* Call this at the end of every host render() so the overlay survives
* full innerHTML replacement. Safe to call when tour is inactive (no-op).
*/
renderOverlay() {
if (!this._active) return;
// Ensure styles exist
this._ensureStyles();
// Skip steps whose target doesn't exist (e.g. mic button when unsupported)
const step = this.steps[this._step];
const targetEl = this.shadowRoot.querySelector(step.target) as HTMLElement | null;
if (!targetEl && this._step < this.steps.length - 1) {
this._step++;
this.renderOverlay();
return;
}
// Get or create overlay element
let overlay = this.shadowRoot.getElementById("rspace-tour-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "rspace-tour-overlay";
overlay.className = "rspace-tour-overlay";
const container = this.getContainer();
if (container) container.appendChild(overlay);
else this.shadowRoot.appendChild(overlay);
}
// Compute spotlight position relative to container
let spotX = 0, spotY = 0, spotW = 120, spotH = 40;
if (targetEl) {
const container = this.getContainer() || this.shadowRoot.host as HTMLElement;
const containerRect = container.getBoundingClientRect();
const rect = targetEl.getBoundingClientRect();
spotX = rect.left - containerRect.left - 6;
spotY = rect.top - containerRect.top - 6;
spotW = rect.width + 12;
spotH = rect.height + 12;
}
const isLast = this._step >= this.steps.length - 1;
const stepNum = this._step + 1;
const totalSteps = this.steps.length;
// Position tooltip below target, clamped within container
const tooltipTop = spotY + spotH + 12;
const tooltipLeft = Math.max(8, spotX);
overlay.innerHTML = `
<div class="rspace-tour-backdrop" style="clip-path: polygon(
0% 0%, 0% 100%, ${spotX}px 100%, ${spotX}px ${spotY}px,
${spotX + spotW}px ${spotY}px, ${spotX + spotW}px ${spotY + spotH}px,
${spotX}px ${spotY + spotH}px, ${spotX}px 100%, 100% 100%, 100% 0%
)"></div>
<div class="rspace-tour-spotlight" style="left:${spotX}px;top:${spotY}px;width:${spotW}px;height:${spotH}px"></div>
<div class="rspace-tour-tooltip" style="top:${tooltipTop}px;left:${tooltipLeft}px">
<div class="rspace-tour-tooltip__step">${stepNum} / ${totalSteps}</div>
<div class="rspace-tour-tooltip__title">${step.title}</div>
<div class="rspace-tour-tooltip__msg">${step.message}</div>
<div class="rspace-tour-tooltip__nav">
${this._step > 0 ? '<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--prev" data-tour="prev">Back</button>' : ''}
${step.advanceOnClick
? `<span class="rspace-tour-tooltip__hint">or click the button above</span>`
: `<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--next" data-tour="next">${isLast ? 'Finish' : 'Next'}</button>`
}
<button class="rspace-tour-tooltip__btn rspace-tour-tooltip__btn--skip" data-tour="skip">Skip</button>
</div>
</div>
`;
// Wire tour navigation buttons
overlay.querySelectorAll("[data-tour]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.tour;
if (action === "next") this.advance();
else if (action === "prev") {
this._detachClickHandler();
this._step = Math.max(0, this._step - 1);
this.renderOverlay();
}
else if (action === "skip") this.end();
});
});
// For advanceOnClick steps, attach listener on the target
this._detachClickHandler();
if (step.advanceOnClick && targetEl) {
this._clickHandler = () => {
this._detachClickHandler();
setTimeout(() => this.advance(), 300);
};
this._clickTarget = targetEl;
targetEl.addEventListener("click", this._clickHandler);
}
}
/** Remove the click-to-advance listener from the previous target. */
private _detachClickHandler() {
if (this._clickHandler && this._clickTarget) {
this._clickTarget.removeEventListener("click", this._clickHandler);
}
this._clickHandler = null;
this._clickTarget = null;
}
/** Inject the shared tour CSS once into the shadow root. */
private _ensureStyles() {
if (this.shadowRoot.querySelector("[data-tour-styles]")) return;
const style = document.createElement("style");
style.setAttribute("data-tour-styles", "");
style.textContent = TOUR_CSS;
this.shadowRoot.appendChild(style);
}
}
const TOUR_CSS = `
.rspace-tour-overlay {
position: absolute; inset: 0; z-index: 10000;
pointer-events: none;
}
.rspace-tour-backdrop {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: auto;
transition: clip-path 0.3s ease;
}
.rspace-tour-spotlight {
position: absolute;
border: 2px solid var(--rs-primary, #06b6d4);
border-radius: 8px;
box-shadow: 0 0 0 4px rgba(6, 182, 212, 0.25);
pointer-events: none;
transition: all 0.3s ease;
}
.rspace-tour-tooltip {
position: absolute;
width: min(320px, calc(100% - 24px));
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-border, #334155);
border-radius: 12px;
padding: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
color: var(--rs-text-primary, #f1f5f9);
pointer-events: auto;
animation: rspace-tour-pop 0.25s ease-out;
}
@keyframes rspace-tour-pop {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.rspace-tour-tooltip__step {
font-size: 0.7rem;
color: var(--rs-text-muted, #64748b);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rspace-tour-tooltip__title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 6px;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.rspace-tour-tooltip__msg {
font-size: 0.85rem;
color: var(--rs-text-secondary, #94a3b8);
line-height: 1.5;
margin-bottom: 12px;
}
.rspace-tour-tooltip__nav {
display: flex;
align-items: center;
gap: 8px;
}
.rspace-tour-tooltip__btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: background 0.15s, transform 0.1s;
}
.rspace-tour-tooltip__btn:hover { transform: translateY(-1px); }
.rspace-tour-tooltip__btn--next {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
color: white;
}
.rspace-tour-tooltip__btn--prev {
background: var(--rs-btn-secondary-bg, #334155);
color: var(--rs-text-secondary, #94a3b8);
}
.rspace-tour-tooltip__btn--skip {
background: none;
color: var(--rs-text-muted, #64748b);
margin-left: auto;
}
.rspace-tour-tooltip__btn--skip:hover { color: var(--rs-text-primary, #f1f5f9); }
.rspace-tour-tooltip__hint {
font-size: 0.72rem;
color: var(--rs-text-muted, #64748b);
font-style: italic;
}
`;

58
shared/view-history.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* ViewHistory lightweight in-app navigation stack for rApps.
*
* Each rApp with hierarchical views instantiates one, calls push()
* on forward navigation, and back() from the back button. Replaces
* hardcoded data-back targets with a proper history stack.
*/
export interface ViewEntry<V extends string> {
view: V;
context?: Record<string, unknown>;
}
const MAX_DEPTH = 20;
export class ViewHistory<V extends string> {
private stack: ViewEntry<V>[] = [];
private root: V;
constructor(rootView: V) {
this.root = rootView;
}
/** Record a forward navigation. Skips if top of stack is same view+context. */
push(view: V, context?: Record<string, unknown>): void {
const top = this.stack[this.stack.length - 1];
if (top && top.view === view) return; // skip duplicate
this.stack.push({ view, context });
if (this.stack.length > MAX_DEPTH) this.stack.shift();
}
/** Pop and return the previous entry, or null if at root. */
back(): ViewEntry<V> | null {
if (this.stack.length <= 1) {
this.stack = [];
return { view: this.root };
}
this.stack.pop(); // remove current
return this.stack[this.stack.length - 1] ?? { view: this.root };
}
/** True when there's history to go back to. */
get canGoBack(): boolean {
return this.stack.length > 1;
}
/** Peek at the previous entry without popping. */
peekBack(): ViewEntry<V> | null {
if (this.stack.length <= 1) return { view: this.root };
return this.stack[this.stack.length - 2] ?? null;
}
/** Clear the stack and reset to a root view (e.g. on space switch). */
reset(rootView?: V): void {
if (rootView !== undefined) this.root = rootView;
this.stack = [];
}
}

View File

@ -170,7 +170,7 @@ const CONFIG = {
'https://demo.rsocials.online', 'https://demo.rsocials.online',
'https://socials.crypto-commons.org', 'https://socials.crypto-commons.org',
'https://socials.p2pfoundation.net', 'https://socials.p2pfoundation.net',
'https://rwork.online', 'https://rtasks.online',
'https://rforum.online', 'https://rforum.online',
'https://rchoices.online', 'https://rchoices.online',
'https://rswag.online', 'https://rswag.online',

View File

@ -472,31 +472,31 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rmaps/maps.css"), resolve(__dirname, "dist/modules/rmaps/maps.css"),
); );
// Build work module component // Build tasks module component
await build({ await build({
configFile: false, configFile: false,
root: resolve(__dirname, "modules/rwork/components"), root: resolve(__dirname, "modules/rtasks/components"),
build: { build: {
emptyOutDir: false, emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rwork"), outDir: resolve(__dirname, "dist/modules/rtasks"),
lib: { lib: {
entry: resolve(__dirname, "modules/rwork/components/folk-work-board.ts"), entry: resolve(__dirname, "modules/rtasks/components/folk-tasks-board.ts"),
formats: ["es"], formats: ["es"],
fileName: () => "folk-work-board.js", fileName: () => "folk-tasks-board.js",
}, },
rollupOptions: { rollupOptions: {
output: { output: {
entryFileNames: "folk-work-board.js", entryFileNames: "folk-tasks-board.js",
}, },
}, },
}, },
}); });
// Copy work CSS // Copy tasks CSS
mkdirSync(resolve(__dirname, "dist/modules/rwork"), { recursive: true }); mkdirSync(resolve(__dirname, "dist/modules/rtasks"), { recursive: true });
copyFileSync( copyFileSync(
resolve(__dirname, "modules/rwork/components/work.css"), resolve(__dirname, "modules/rtasks/components/tasks.css"),
resolve(__dirname, "dist/modules/rwork/work.css"), resolve(__dirname, "dist/modules/rtasks/tasks.css"),
); );
// Build trips module component // Build trips module component

View File

@ -2210,7 +2210,7 @@
<button id="embed-books" title="Embed rBooks">📚 rBooks</button> <button id="embed-books" title="Embed rBooks">📚 rBooks</button>
<button id="embed-forum" title="Embed rForum">💬 rForum</button> <button id="embed-forum" title="Embed rForum">💬 rForum</button>
<button id="embed-files" title="Embed rFiles">📁 rFiles</button> <button id="embed-files" title="Embed rFiles">📁 rFiles</button>
<button id="embed-work" title="Embed rWork">📋 rWork</button> <button id="embed-tasks" title="Embed rTasks">📋 rTasks</button>
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button> <button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
<button id="embed-cart" title="Embed rCart">🛒 rCart</button> <button id="embed-cart" title="Embed rCart">🛒 rCart</button>
<button id="embed-data" title="Embed rData">📊 rData</button> <button id="embed-data" title="Embed rData">📊 rData</button>
@ -2794,9 +2794,12 @@
}); });
tabBar.addEventListener('layer-reorder', (e) => { tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail; const { layerId, newIndex } = e.detail;
sync.updateLayer?.(layerId, { order: newIndex });
const all = sync.getLayers?.() || []; const all = sync.getLayers?.() || [];
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer?.(l.id, { order: i }); }); const oldIdx = all.findIndex(l => l.id === layerId);
if (oldIdx === -1 || oldIdx === newIndex) return;
const [moved] = all.splice(oldIdx, 1);
all.splice(newIndex, 0, moved);
all.forEach((l, i) => sync.updateLayer?.(l.id, { order: i }));
}); });
tabBar.addEventListener('flow-create', (e) => { sync.addFlow?.(e.detail.flow); }); tabBar.addEventListener('flow-create', (e) => { sync.addFlow?.(e.detail.flow); });
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); }); tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); });
@ -4403,7 +4406,7 @@
{ btnId: "embed-books", moduleId: "rbooks" }, { btnId: "embed-books", moduleId: "rbooks" },
{ btnId: "embed-pubs", moduleId: "rpubs" }, { btnId: "embed-pubs", moduleId: "rpubs" },
{ btnId: "embed-files", moduleId: "rfiles" }, { btnId: "embed-files", moduleId: "rfiles" },
{ btnId: "embed-work", moduleId: "rwork" }, { btnId: "embed-tasks", moduleId: "rtasks" },
{ btnId: "embed-forum", moduleId: "rforum" }, { btnId: "embed-forum", moduleId: "rforum" },
{ btnId: "embed-inbox", moduleId: "rinbox" }, { btnId: "embed-inbox", moduleId: "rinbox" },
{ btnId: "embed-tube", moduleId: "rtube" }, { btnId: "embed-tube", moduleId: "rtube" },

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"
import { RStackModuleSetup } from "../shared/components/rstack-module-setup"; import { RStackModuleSetup } from "../shared/components/rstack-module-setup";
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel"; import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay";
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
import { rspaceNavUrl } from "../shared/url-helpers"; import { rspaceNavUrl } from "../shared/url-helpers";
import { TabCache } from "../shared/tab-cache"; import { TabCache } from "../shared/tab-cache";
@ -39,6 +40,7 @@ RStackSpaceSettings.define();
RStackModuleSetup.define(); RStackModuleSetup.define();
RStackHistoryPanel.define(); RStackHistoryPanel.define();
RStackOfflineIndicator.define(); RStackOfflineIndicator.define();
RStackCollabOverlay.define();
RStackUserDashboard.define(); RStackUserDashboard.define();
// ── Offline Runtime ── // ── Offline Runtime ──