diff --git a/.gitignore b/.gitignore index 2d0a196..6fd00ef 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Thumbs.db .env .env.local .env.*.local +open-notebook.env # Bun bun.lockb diff --git a/ONTOLOGY.md b/ONTOLOGY.md index 7ba9665..3b0c827 100644 --- a/ONTOLOGY.md +++ b/ONTOLOGY.md @@ -264,7 +264,7 @@ redirects to the unified server with subdomain-based space routing. | **rCal** | rcal.online | Spatio-temporal calendar with map + lunar overlay | | **rMaps** | rmaps.online | Geographic mapping & location hierarchy | | **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 | ### 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 non-linear knowledge work: a funding proposal (rFunds) sits next to 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 Every interaction works offline. Changes queue locally and merge diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml index 758d366..5c89f27 100644 --- a/docker-compose.standalone.yml +++ b/docker-compose.standalone.yml @@ -197,16 +197,16 @@ services: traefik.http.routers.rmaps-sa.entrypoints: web traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000" - # ── rWork ── - rwork-standalone: + # ── rTasks ── + rtasks-standalone: <<: *standalone-base - container_name: rwork-standalone - command: ["bun", "run", "modules/rwork/standalone.ts"] + container_name: rtasks-standalone + command: ["bun", "run", "modules/rtasks/standalone.ts"] labels: <<: *traefik-enabled - traefik.http.routers.rwork-sa.rule: Host(`rwork.online`) - traefik.http.routers.rwork-sa.entrypoints: web - traefik.http.services.rwork-sa.loadbalancer.server.port: "3000" + traefik.http.routers.rtasks-sa.rule: Host(`rtasks.online`) + traefik.http.routers.rtasks-sa.entrypoints: web + traefik.http.services.rtasks-sa.loadbalancer.server.port: "3000" # ── rTrips ── rtrips-standalone: diff --git a/docker-compose.yml b/docker-compose.yml index 03835b0..5822de0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,7 @@ services: - INFISICAL_AI_PROJECT_SLUG=claude-ops - INFISICAL_AI_SECRET_PATH=/ai - LISTMONK_URL=https://newsletter.cosmolocal.world + - NOTEBOOK_API_URL=http://open-notebook:5055 depends_on: rspace-db: condition: service_healthy @@ -88,10 +89,10 @@ services: - "traefik.http.routers.rspace-rvote.entrypoints=web" - "traefik.http.routers.rspace-rvote.priority=120" - "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-rwork.entrypoints=web" - - "traefik.http.routers.rspace-rwork.priority=120" - - "traefik.http.routers.rspace-rwork.service=rspace-online" + - "traefik.http.routers.rspace-rtasks.rule=Host(`rtasks.online`) || HostRegexp(`{sub:[a-z0-9-]+}.rtasks.online`)" + - "traefik.http.routers.rspace-rtasks.entrypoints=web" + - "traefik.http.routers.rspace-rtasks.priority=120" + - "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.entrypoints=web" - "traefik.http.routers.rspace-rcal.priority=120" @@ -252,6 +253,32 @@ services: retries: 5 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: rspace-data: rspace-books: @@ -262,6 +289,8 @@ volumes: rspace-backups: rspace-pgdata: encryptid-pgdata: + open-notebook-data: + open-notebook-db: networks: traefik-public: diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index e68f124..981efb2 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -22,7 +22,7 @@ const MODULE_META: Record Widge }; }, }, - rwork: { + rtasks: { path: "/api/spaces", transform: (data) => { const spaces = Array.isArray(data) ? data : []; diff --git a/modules/rbooks/components/folk-book-shelf.ts b/modules/rbooks/components/folk-book-shelf.ts index 38bd59c..b46918f 100644 --- a/modules/rbooks/components/folk-book-shelf.ts +++ b/modules/rbooks/components/folk-book-shelf.ts @@ -7,6 +7,7 @@ import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; +import { TourEngine } from "../../../shared/tour-engine"; interface BookData { id: string; @@ -31,6 +32,13 @@ export class FolkBookShelf extends HTMLElement { private _searchTerm = ""; private _selectedTag: string | 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() { return ["space-slug"]; @@ -52,6 +60,12 @@ export class FolkBookShelf extends HTMLElement { connectedCallback() { 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.render(); if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { @@ -59,6 +73,9 @@ export class FolkBookShelf extends HTMLElement { } else { this.subscribeOffline(); } + if (!localStorage.getItem("rbooks_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } disconnectedCallback() { @@ -426,6 +443,7 @@ export class FolkBookShelf extends HTMLElement { Library
+
@@ -446,7 +464,7 @@ export class FolkBookShelf extends HTMLElement { ` : `
${books.map((b) => ` - + +

+ + Start Guided Tour → + +

diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index fbdd2c8..dcdc630 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -104,6 +104,7 @@ function leafletZoomToSpatial(zoom: number): number { // ── Offline-first imports ── import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; +import { TourEngine } from "../../../shared/tour-engine"; // ── Component ── @@ -155,19 +156,35 @@ class FolkCalendarView extends HTMLElement { private mapMarkerLayer: 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkCalendarView.TOUR_STEPS, + "rcal_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); document.addEventListener("keydown", this.boundKeyHandler); - if (this.space === "demo") { this.loadDemoData(); return; } - this.subscribeOffline(); - this.loadMonth(); - this.render(); + if (this.space === "demo") { this.loadDemoData(); } + else { this.subscribeOffline(); this.loadMonth(); this.render(); } + if (!localStorage.getItem("rcal_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } disconnectedCallback() { @@ -784,6 +801,7 @@ class FolkCalendarView extends HTMLElement { ${this.getViewLabel()} + ${this.renderSources()} @@ -806,6 +824,7 @@ class FolkCalendarView extends HTMLElement { `; this.attachListeners(); + this._tour.renderOverlay(); // Play transition if pending if (this._pendingTransition && this._ghostHtml) { @@ -1499,7 +1518,7 @@ class FolkCalendarView extends HTMLElement { ${allDay.length > 0 ? `
All Day
- ${allDay.map(e => `
+ ${allDay.map(e => `
${this.esc(e.title)}
`).join("")} @@ -1607,7 +1626,7 @@ class FolkCalendarView extends HTMLElement { const srcTag = e.source_name ? `${this.esc(e.source_name)}` : ""; const es = this.getEventStyles(e); const likelihoodBadge = es.isTentative ? `${es.likelihoodLabel}` : ""; - return `
+ return `
${this.esc(e.title)}${likelihoodBadge}${srcTag}
@@ -1705,12 +1724,16 @@ class FolkCalendarView extends HTMLElement { } } + startTour() { this._tour.start(); } + // ── Attach Listeners ── private attachListeners() { const $ = (id: string) => this.shadow.getElementById(id); const $$ = (sel: string) => this.shadow.querySelectorAll(sel); + $("btn-tour")?.addEventListener("click", () => this.startTour()); + // Nav $("prev")?.addEventListener("click", () => this.navigate(-1)); $("next")?.addEventListener("click", () => this.navigate(1)); diff --git a/modules/rcal/landing.ts b/modules/rcal/landing.ts index de793e2..055ad54 100644 --- a/modules/rcal/landing.ts +++ b/modules/rcal/landing.ts @@ -27,6 +27,11 @@ export function renderLanding(): string { Learn More
+

+ + Start Guided Tour → + +

diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index ac7deb2..415901e 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -10,6 +10,8 @@ import { shoppingCartSchema, shoppingCartDocId, type ShoppingCartDoc, } from "../schemas"; 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 { private shadow: ShadowRoot; @@ -26,10 +28,26 @@ class FolkCartShop extends HTMLElement { private extensionInstalled = false; private bannerDismissed = false; 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkCartShop.TOUR_STEPS, + "rcart_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { @@ -46,12 +64,15 @@ class FolkCartShop extends HTMLElement { if (this.space === "demo") { this.loadDemoData(); - return; + } else { + this.render(); + this.subscribeOffline(); + this.loadData(); + } + // Auto-start tour on first visit + if (!localStorage.getItem("rcart_tour_done")) { + setTimeout(() => this._tour.start(), 1200); } - - this.render(); - this.subscribeOffline(); - this.loadData(); } disconnectedCallback() { @@ -342,11 +363,23 @@ class FolkCartShop extends HTMLElement {
+
${content} `; 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() { @@ -354,12 +387,19 @@ class FolkCartShop extends HTMLElement { this.shadow.querySelectorAll(".tab").forEach((el) => { el.addEventListener("click", () => { 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; if (v === 'carts') this.selectedCartId = null; this.render(); }); }); + // Tour button + this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); + // Extension banner dismiss this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => { this.bannerDismissed = true; @@ -370,15 +410,15 @@ class FolkCartShop extends HTMLElement { // Cart card clicks this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => { 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!); }); }); // Back button this.shadow.querySelector("[data-action='back']")?.addEventListener("click", () => { - this.view = "carts"; - this.selectedCartId = null; - this.render(); + this.goBack(); }); // New cart form @@ -527,9 +567,7 @@ class FolkCartShop extends HTMLElement { `).join("") : `
No contributions yet.
`; return ` -
- -
+ ${this._history.canGoBack ? '
' : ''}
@@ -582,7 +620,7 @@ class FolkCartShop extends HTMLElement { return `
${this.catalog.map((entry) => ` -
+

${this.esc(entry.title || "Untitled")}

${entry.product_type ? `${this.esc(entry.product_type)}` : ""} @@ -607,7 +645,7 @@ class FolkCartShop extends HTMLElement { return `
${this.orders.map((order) => ` -
+

${this.esc(order.artifact_title || "Order")}

diff --git a/modules/rcart/landing.ts b/modules/rcart/landing.ts index 1da28c8..2f44c5b 100644 --- a/modules/rcart/landing.ts +++ b/modules/rcart/landing.ts @@ -16,6 +16,11 @@ export function renderLanding(): string { Start Shopping Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index 852e454..8c7587d 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -3,6 +3,8 @@ * from the current space and links to the canvas to create/interact with them. */ +import { TourEngine } from "../../../shared/tour-engine"; + class FolkChoicesDashboard extends HTMLElement { private shadow: ShadowRoot; private choices: any[] = []; @@ -19,19 +21,36 @@ class FolkChoicesDashboard extends HTMLElement { private votedId: string | 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); this.space = this.getAttribute("space") || "demo"; + this._tour = new TourEngine( + this.shadow, + FolkChoicesDashboard.TOUR_STEPS, + "rchoices_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { if (this.space === "demo") { this.loadDemoData(); - return; + } else { + this.render(); + this.loadChoices(); + } + if (!localStorage.getItem("rchoices_tour_done")) { + setTimeout(() => this._tour.start(), 1200); } - this.render(); - this.loadChoices(); } disconnectedCallback() { @@ -124,7 +143,7 @@ class FolkChoicesDashboard extends HTMLElement { private renderGrid(icons: Record, labels: Record): string { return ` diff --git a/modules/rdata/components/folk-analytics-view.ts b/modules/rdata/components/folk-analytics-view.ts index 7892c59..c4b8d07 100644 --- a/modules/rdata/components/folk-analytics-view.ts +++ b/modules/rdata/components/folk-analytics-view.ts @@ -29,7 +29,7 @@ class FolkAnalyticsView extends HTMLElement { cookiesSet: 0, scriptSize: "~2KB", 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", }; this.render(); diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index 41aade6..61a9e4e 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -19,7 +19,7 @@ const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6 const TRACKED_APPS = [ "rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet", "rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles", - "rTrips", "rTube", "rWork", "rNetwork", "rData", + "rTrips", "rTube", "rTasks", "rNetwork", "rData", ]; // ── API routes ── diff --git a/modules/rfiles/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts index 87889b4..d4a6a8b 100644 --- a/modules/rfiles/components/folk-file-browser.ts +++ b/modules/rfiles/components/folk-file-browser.ts @@ -7,6 +7,7 @@ import { filesSchema, type FilesDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; +import { TourEngine } from "../../../shared/tour-engine"; class FolkFileBrowser extends HTMLElement { private shadow: ShadowRoot; @@ -16,10 +17,22 @@ class FolkFileBrowser extends HTMLElement { private tab: "files" | "cards" = "files"; private loading = false; 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkFileBrowser.TOUR_STEPS, + "rfiles_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { @@ -27,13 +40,15 @@ class FolkFileBrowser extends HTMLElement { if (this.space === "demo") { this.loadDemoData(); - return; + } else { + this.render(); + this.subscribeOffline(); + this.loadFiles(); + this.loadCards(); + } + if (!localStorage.getItem("rfiles_tour_done")) { + setTimeout(() => this._tour.start(), 1200); } - - this.render(); - this.subscribeOffline(); - this.loadFiles(); - this.loadCards(); } disconnectedCallback() { @@ -429,11 +444,14 @@ class FolkFileBrowser extends HTMLElement {
📁 Files
🎴 Memory Cards
+
${filesActive ? this.renderFilesTab() : this.renderCardsTab()} `; + this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + this.shadow.querySelectorAll(".tab-btn").forEach((btn) => { btn.addEventListener("click", () => { 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 { @@ -487,7 +511,7 @@ class FolkFileBrowser extends HTMLElement { ${this.files .map( (f) => ` -
+
${this.mimeIcon(f.mime_type)}
${this.esc(f.title || f.original_filename)}
${this.formatSize(f.file_size)} · ${this.formatDate(f.created_at)}
@@ -533,7 +557,7 @@ class FolkFileBrowser extends HTMLElement { ${this.cards .map( (c) => ` -
+
${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)} ${c.card_type} diff --git a/modules/rfiles/landing.ts b/modules/rfiles/landing.ts index 570fa5a..e1e3bb8 100644 --- a/modules/rfiles/landing.ts +++ b/modules/rfiles/landing.ts @@ -18,6 +18,11 @@ export function renderLanding(): string { Start Sharing Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index ddbb233..e68da74 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -13,6 +13,7 @@ import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } 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 { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { mapFlowToNodes } from "../lib/map-flow"; @@ -154,9 +155,8 @@ class FolkFlowsApp extends HTMLElement { private flowManagerOpen = false; private _lfcUnsub: (() => void) | null = null; - // Tour state - private tourActive = false; - private tourStep = 0; + // Tour engine + private _tour!: TourEngine; 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-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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkFlowsApp.TOUR_STEPS, + "rflows_tour_done", + () => this.shadow.getElementById("canvas-container"), + ); } connectedCallback() { @@ -1777,7 +1783,7 @@ class FolkFlowsApp extends HTMLElement { }).join(""); } - return ` + return ` ${this.esc(d.label)} @@ -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 statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b"; - return ` + return ` @@ -1982,7 +1988,7 @@ class FolkFlowsApp extends HTMLElement { const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`; - return ` + return `
@@ -4973,105 +4979,7 @@ class FolkFlowsApp extends HTMLElement { // ── Guided Tour ── startTour() { - this.tourActive = true; - 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 = ` -
-
-
-
${stepNum} / ${totalSteps}
-
${step.title}
-
${step.message}
-
- ${this.tourStep > 0 ? '' : ''} - ${step.advanceOnClick - ? `or click the button above` - : `` - } - -
-
- `; - - // 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); - } + this._tour.start(); } private esc(s: string): string { diff --git a/modules/rforum/components/folk-forum-dashboard.ts b/modules/rforum/components/folk-forum-dashboard.ts index f40455f..6f0fe1b 100644 --- a/modules/rforum/components/folk-forum-dashboard.ts +++ b/modules/rforum/components/folk-forum-dashboard.ts @@ -7,6 +7,8 @@ import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas"; 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 { private shadow: ShadowRoot; @@ -18,18 +20,36 @@ class FolkForumDashboard extends HTMLElement { private pollTimer: number | null = null; private space = ""; 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkForumDashboard.TOUR_STEPS, + "rforum_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { this.space = this.getAttribute("space") || ""; - if (this.space === "demo") { this.loadDemoData(); return; } - this.subscribeOffline(); - this.render(); - this.loadInstances(); + if (this.space === "demo") { this.loadDemoData(); } + else { + this.subscribeOffline(); + this.render(); + this.loadInstances(); + } + if (!localStorage.getItem("rforum_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } private loadDemoData() { @@ -318,6 +338,11 @@ class FolkForumDashboard extends HTMLElement { `; this.attachEvents(); + this._tour.renderOverlay(); + } + + startTour() { + this._tour.start(); } private renderList(): string { @@ -325,6 +350,7 @@ class FolkForumDashboard extends HTMLElement {
Forum Instances +
${this.loading ? '
Loading...
' : ""} @@ -332,7 +358,7 @@ class FolkForumDashboard extends HTMLElement {
${this.instances.map((inst) => ` -
+
${this.esc(inst.name)} ${this.statusBadge(inst.status)} @@ -353,7 +379,7 @@ class FolkForumDashboard extends HTMLElement { return `
- + ${this._history.canGoBack ? '' : ''} ${this.esc(inst.name)} ${inst.status !== "destroyed" ? `` : ""}
@@ -398,7 +424,7 @@ class FolkForumDashboard extends HTMLElement { private renderCreate(): string { return `
- + ${this._history.canGoBack ? '' : ''} Deploy New Forum
@@ -466,16 +492,17 @@ class FolkForumDashboard extends HTMLElement { } private attachEvents() { + this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + this.shadow.querySelectorAll("[data-action]").forEach((el) => { const action = (el as HTMLElement).dataset.action!; const id = (el as HTMLElement).dataset.id; 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") { - if (this.pollTimer) clearInterval(this.pollTimer); - this.view = "list"; this.loadInstances(); + this.goBack(); } - 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); } }); }); @@ -493,6 +520,15 @@ class FolkForumDashboard extends HTMLElement { 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 { const labels: Record = { create_vps: "Create Server", diff --git a/modules/rforum/landing.ts b/modules/rforum/landing.ts index 9f40dc9..4392eba 100644 --- a/modules/rforum/landing.ts +++ b/modules/rforum/landing.ts @@ -17,6 +17,11 @@ export function renderLanding(): string { Get Started Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rinbox/landing.ts b/modules/rinbox/landing.ts index e4c86c1..ca9d780 100644 --- a/modules/rinbox/landing.ts +++ b/modules/rinbox/landing.ts @@ -20,6 +20,11 @@ export function renderLanding(): string { Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 8112591..f801079 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -12,6 +12,8 @@ import { RoomSync, type RoomState, type ParticipantState, type LocationState } from "./map-sync"; import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history"; 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 @@ -88,6 +90,16 @@ class FolkMapViewer extends HTMLElement { private pushManager: MapPushManager | null = null; private thumbnailTimer: ReturnType | 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 { const theme = document.documentElement.getAttribute("data-theme"); @@ -98,6 +110,12 @@ class FolkMapViewer extends HTMLElement { constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkMapViewer.TOUR_STEPS, + "rmaps_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { @@ -105,16 +123,19 @@ class FolkMapViewer extends HTMLElement { this.room = this.getAttribute("room") || ""; if (this.space === "demo") { this.loadDemoData(); - return; + } else { + this.loadUserProfile(); + this.pushManager = new MapPushManager(this.getApiBase()); + if (this.room) { + this.joinRoom(this.room); + } else { + this.checkSyncHealth(); + this.render(); + } } - this.loadUserProfile(); - this.pushManager = new MapPushManager(this.getApiBase()); - if (this.room) { - this.joinRoom(this.room); - return; + if (!localStorage.getItem("rmaps_tour_done")) { + setTimeout(() => this._tour.start(), 1200); } - this.checkSyncHealth(); - this.render(); } disconnectedCallback() { @@ -1447,15 +1468,18 @@ class FolkMapViewer extends HTMLElement { `; this.attachListeners(); + this._tour.renderOverlay(); } + startTour() { this._tour.start(); } + private renderLobby(): string { const history = loadRoomHistory(); const historyCards = history.length > 0 ? `
${history.map((h) => ` -
+
${h.thumbnail ? `` : `
🌐
` @@ -1476,12 +1500,13 @@ class FolkMapViewer extends HTMLElement { ${this.syncStatus === "connected" ? "Sync online" : "Sync offline"} +
${this.rooms.length > 0 ? ` ${this.rooms.map((r) => ` -
+
🗺 ${this.esc(r)}
@@ -1501,7 +1526,7 @@ class FolkMapViewer extends HTMLElement { const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`; return `
- + ${this._history.canGoBack ? '' : ''} 🗺 ${this.esc(this.room)}
@@ -1532,20 +1557,21 @@ class FolkMapViewer extends HTMLElement { } private attachListeners() { + this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom()); this.shadow.querySelectorAll("[data-room]").forEach((el) => { el.addEventListener("click", () => { const room = (el as HTMLElement).dataset.room!; + this._history.push("lobby"); + this._history.push("map", { room }); this.joinRoom(room); }); }); this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", () => { - this.leaveRoom(); - this.view = "lobby"; - this.loadStats(); + this.goBack(); }); }); @@ -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 { const d = document.createElement("div"); d.textContent = s || ""; diff --git a/modules/rmaps/landing.ts b/modules/rmaps/landing.ts index 847ec58..5ff311d 100644 --- a/modules/rmaps/landing.ts +++ b/modules/rmaps/landing.ts @@ -16,6 +16,11 @@ export function renderLanding(): string { Open Maps Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rmeets/landing.ts b/modules/rmeets/landing.ts index bcced68..78e13b6 100644 --- a/modules/rmeets/landing.ts +++ b/modules/rmeets/landing.ts @@ -83,7 +83,7 @@ export function renderLanding(): string {
📄

Meeting Notes & Docs

-

Capture notes in rNotes, attach files in rFiles, link action items to rWork — all within the same space, all self-hosted.

+

Capture notes in rNotes, attach files in rFiles, link action items to rTasks — all within the same space, all self-hosted.

📅
@@ -121,7 +121,7 @@ export function renderLanding(): string {
🔌

Data Integrations

Coming Soon -

Auto-link recordings to documents, push summaries to rNotes, and sync action items to rWork task boards.

+

Auto-link recordings to documents, push summaries to rNotes, and sync action items to rTasks task boards.

diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index 216035b..709ed81 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -53,6 +53,8 @@ const STAGE_LABELS: Record = { CLOSED_LOST: "Lost", }; +import { TourEngine } from "../../../shared/tour-engine"; + class FolkCrmView extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -67,15 +69,34 @@ class FolkCrmView extends HTMLElement { private loading = true; 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkCrmView.TOUR_STEPS, + "rnetwork_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.render(); this.loadData(); + // Auto-start tour on first visit + if (!localStorage.getItem("rnetwork_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } private getApiBase(): string { @@ -165,7 +186,7 @@ class FolkCrmView extends HTMLElement {
${total > 0 ? `
${this.formatAmount({ amountMicros: total * 1_000_000, currencyCode: "USD" })}
` : ""}
- ${opps.map(opp => `
+ ${opps.map(opp => `
${this.esc(opp.name)}
${this.formatAmount(opp.amount)}
${opp.company?.name ? `
${this.esc(opp.company.name)}
` : ""} @@ -220,7 +241,7 @@ class FolkCrmView extends HTMLElement { Phone - ${filtered.map(p => ` + ${filtered.map(p => ` ${this.esc(this.personName(p.name))} ${p.email?.primaryEmail ? this.esc(p.email.primaryEmail) : "-"} ${this.esc(this.companyName(p.company))} @@ -273,7 +294,7 @@ class FolkCrmView extends HTMLElement { Country - ${filtered.map(c => ` + ${filtered.map(c => ` ${this.esc(c.name || "-")} ${c.domainName?.primaryLinkUrl ? `${this.esc(c.domainName.primaryLinkUrl)}` : "-"} ${c.employees ?? "-"} @@ -432,6 +453,7 @@ class FolkCrmView extends HTMLElement {
CRM +
@@ -448,9 +470,15 @@ class FolkCrmView extends HTMLElement { `; this.attachListeners(); + this._tour.renderOverlay(); } + startTour() { this._tour.start(); } + private attachListeners() { + // Tour button + this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); + // Tab switching this.shadow.querySelectorAll("[data-tab]").forEach(el => { el.addEventListener("click", () => { diff --git a/modules/rnotes/landing.ts b/modules/rnotes/landing.ts index aba6aa2..362fdc1 100644 --- a/modules/rnotes/landing.ts +++ b/modules/rnotes/landing.ts @@ -14,10 +14,168 @@ export function renderLanding(): string {

+

+ + Start Guided Tour → + +

+ +
+
+

Live Transcription Demo

+

Try it right here — click the mic and start speaking.

+ +
+ + + + +
+ +
+ +
+
Click mic to start
+
00:00
+
+ +
+ + +
+ Your transcript will appear here… +
+
+ + +
+ ● Live streaming + 🎵 Audio file upload + 🎥 Video transcription + 🔌 Offline (Parakeet.js) +
+
+
+
+ + + +
@@ -42,32 +200,75 @@ export function renderLanding(): string {
+ +
+
+

Chrome Extension

+

Clip pages, record voice notes, and transcribe — right from the toolbar.

+
+
+
📋
+

Web Clipper

+

Save any page as a note with one click — article text, selection, or full HTML.

+
+
+
🎤
+

Voice Recording

+

Press Ctrl+Shift+V to start recording and transcribing from any tab.

+
+
+
🔓
+

Article Unlock

+

Bypass soft paywalls by fetching archived versions — read the article, then save it to your notebook.

+
+
+
🔌
+

Offline Transcription

+

Parakeet.js runs entirely in-browser — your audio never leaves the device.

+
+
+
+ + ⬇ Download Extension + +

+ Unzip, then load unpacked at chrome://extensions +

+
+
+
+ -
+

How It Works

-
+
1
-

Create a Notebook

-

Name your notebook and start capturing. Organize by project, topic, or however you think.

+

Live Transcribe

+

Speak and watch words appear in real time via the Web Speech API. No uploads, no waiting.

2
-

Capture Notes, Voice, or Clips

-

Type rich text, record voice with live transcription, or drop in audio and video files to transcribe offline.

+

Audio & Video

+

Drop files and get full transcripts via Parakeet, running entirely in-browser. Supports MP3, WAV, MP4, and more.

3
-

Search, Tag, and Share

-

Find anything instantly with full-text search. Tag and filter your cards, then share notebooks with your team.

+

Notebooks & Tags

+

Organize transcripts alongside notes, clips, code, and files. Tag, search, and filter across everything.

+
+
+
4
+

Private & Offline

+

Parakeet.js runs in-browser — audio never leaves your device. Works offline once the model is cached.

-
+

Memory Cards

@@ -160,19 +361,23 @@ export function renderLanding(): string {

-
+

Built on Open Source

The libraries and tools that power rNotes.

-
+
-

PostgreSQL

-

Notebooks, notes, and tags stored in a battle-tested relational database with full-text search.

+

Automerge

+

Local-first CRDT for conflict-free real-time collaboration. Your notes sync across devices without a central server.

Web Speech API

Browser-native live transcription — speak and watch your words appear in real time.

+
+

Parakeet.js

+

NVIDIA’s in-browser speech recognition. Transcribe audio and video files offline — nothing leaves your device.

+

Hono

Ultra-fast, lightweight API framework powering the rNotes backend.

@@ -182,7 +387,7 @@ export function renderLanding(): string {
-
+

Your Data, Protected

How rNotes keeps your information safe.

@@ -209,12 +414,13 @@ export function renderLanding(): string {
-
+

(You)rNotes, your thoughts unbound.

Try the demo or create a space to get started.

diff --git a/modules/rphotos/components/folk-photo-gallery.ts b/modules/rphotos/components/folk-photo-gallery.ts index 16d43f9..c8a212f 100644 --- a/modules/rphotos/components/folk-photo-gallery.ts +++ b/modules/rphotos/components/folk-photo-gallery.ts @@ -8,6 +8,9 @@ * space — space slug (default: "demo") */ +import { TourEngine } from "../../../shared/tour-engine"; +import { ViewHistory } from "../../../shared/view-history.js"; + interface Album { id: string; albumName: string; @@ -44,19 +47,35 @@ class FolkPhotoGallery extends HTMLElement { private loading = false; private error = ""; 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkPhotoGallery.TOUR_STEPS, + "rphotos_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); - return; + } else { + this.loadGallery(); + } + if (!localStorage.getItem("rphotos_tour_done")) { + setTimeout(() => this._tour.start(), 1200); } - this.loadGallery(); } private loadDemoData() { @@ -189,6 +208,18 @@ class FolkPhotoGallery extends HTMLElement { 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 { const base = this.getApiBase(); return `${base}/api/assets/${assetId}/thumbnail`; @@ -353,6 +384,11 @@ class FolkPhotoGallery extends HTMLElement { `; this.attachListeners(); + this._tour.renderOverlay(); + } + + startTour() { + this._tour.start(); } private renderView(): string { @@ -370,6 +406,7 @@ class FolkPhotoGallery extends HTMLElement { Open Immich +
@@ -391,7 +428,7 @@ class FolkPhotoGallery extends HTMLElement {
Shared Albums
${this.albums.map((a) => ` -
+
${this.isDemo() ? `
${this.esc(a.albumName)}
` @@ -413,7 +450,7 @@ class FolkPhotoGallery extends HTMLElement {
Recent Photos
${this.assets.map((a) => ` -
+
${this.isDemo() ? `
${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}
` : `${this.esc(a.originalFileName)}`} @@ -428,7 +465,7 @@ class FolkPhotoGallery extends HTMLElement { const album = this.selectedAlbum!; return `
- + ${this._history.canGoBack ? '' : ''} ${this.esc(album.albumName)}
@@ -446,7 +483,7 @@ class FolkPhotoGallery extends HTMLElement { ` : `
${this.albumAssets.map((a) => ` -
+
${this.isDemo() ? `
${this.esc(a.originalFileName.replace(/\.[^.]+$/, "").replace(/-/g, " "))}
` : `${this.esc(a.originalFileName)}`} @@ -483,12 +520,18 @@ class FolkPhotoGallery extends HTMLElement { } private attachListeners() { + this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + // Album cards this.shadow.querySelectorAll("[data-album-id]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.albumId!; 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 assets = this.view === "album" ? this.albumAssets : this.assets; 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 this.shadow.querySelectorAll("[data-back]").forEach((el) => { - el.addEventListener("click", () => { - this.selectedAlbum = null; - this.albumAssets = []; - this.view = "gallery"; - this.render(); - }); + el.addEventListener("click", () => this.goBack()); }); // 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) => { - if ((e.target as HTMLElement).matches("[data-lightbox]")) this.closeLightbox(); + if ((e.target as HTMLElement).matches("[data-lightbox]")) this.goBack(); }); } diff --git a/modules/rphotos/landing.ts b/modules/rphotos/landing.ts index b1de3e0..36fd1ad 100644 --- a/modules/rphotos/landing.ts +++ b/modules/rphotos/landing.ts @@ -17,6 +17,11 @@ export function renderLanding(): string {
Browse Photos Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts index 90fe731..07ebcd0 100644 --- a/modules/rpubs/components/folk-pubs-editor.ts +++ b/modules/rpubs/components/folk-pubs-editor.ts @@ -9,6 +9,7 @@ import { pubsDraftSchema, pubsDocId } from '../schemas'; import type { PubsDoc } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; +import { TourEngine } from '../../../shared/tour-engine'; interface BookFormat { id: string; @@ -78,6 +79,13 @@ export class FolkPubsEditor extends HTMLElement { private _isRemoteUpdate = false; private _syncTimer: ReturnType | null = null; 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[]) { this._formats = val; @@ -90,6 +98,12 @@ export class FolkPubsEditor extends HTMLElement { async connectedCallback() { this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadowRoot!, + FolkPubsEditor.TOUR_STEPS, + "rpubs_tour_done", + () => this.shadowRoot!.host as HTMLElement, + ); this.render(); const space = this.getAttribute("space") || ""; @@ -101,6 +115,9 @@ export class FolkPubsEditor extends HTMLElement { if (space === "demo" && !this._activeDocId) { this.loadDemoContent(); } + if (!localStorage.getItem("rpubs_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } private async initRuntime() { @@ -381,6 +398,7 @@ export class FolkPubsEditor extends HTMLElement { Open File +
@@ -440,6 +458,7 @@ export class FolkPubsEditor extends HTMLElement { `; this.bindEvents(); + this._tour.renderOverlay(); // Populate fields from current doc after render if (this._runtime && this._activeDocId) { @@ -448,9 +467,15 @@ export class FolkPubsEditor extends HTMLElement { } } + startTour() { + this._tour.start(); + } + private bindEvents() { if (!this.shadowRoot) return; + this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement; const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement; diff --git a/modules/rpubs/landing.ts b/modules/rpubs/landing.ts index 9b8be75..5ffc58a 100644 --- a/modules/rpubs/landing.ts +++ b/modules/rpubs/landing.ts @@ -22,6 +22,11 @@ export function renderLanding(): string { Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rschedule/components/folk-schedule-app.ts b/modules/rschedule/components/folk-schedule-app.ts index 37a842d..a0321b7 100644 --- a/modules/rschedule/components/folk-schedule-app.ts +++ b/modules/rschedule/components/folk-schedule-app.ts @@ -7,6 +7,8 @@ import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; +import { TourEngine } from "../../../shared/tour-engine"; +import { ViewHistory } from "../../../shared/view-history.js"; interface JobData { id: string; @@ -82,11 +84,19 @@ class FolkScheduleApp extends HTMLElement { private log: LogEntry[] = []; private reminders: ReminderData[] = []; 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 editingReminder: ReminderData | null = null; private loading = false; private runningJobId: string | 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 private rFormTitle = ""; @@ -109,12 +119,21 @@ class FolkScheduleApp extends HTMLElement { constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkScheduleApp.TOUR_STEPS, + "rschedule_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.subscribeOffline(); this.loadJobs(); + if (!localStorage.getItem("rschedule_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } disconnectedCallback() { @@ -309,7 +328,9 @@ class FolkScheduleApp extends HTMLElement { this.rFormAllDay = true; this.rFormEmail = ""; this.rFormSyncCal = true; + this._history.push(this.view); this.view = "reminder-form"; + this._history.push("reminder-form"); this.render(); } @@ -323,7 +344,9 @@ class FolkScheduleApp extends HTMLElement { this.rFormAllDay = r.allDay; this.rFormEmail = r.notifyEmail || ""; this.rFormSyncCal = true; + this._history.push(this.view); this.view = "reminder-form"; + this._history.push("reminder-form"); this.render(); } @@ -401,7 +424,9 @@ class FolkScheduleApp extends HTMLElement { this.formActionType = "email"; this.formEnabled = true; this.formConfig = {}; + this._history.push(this.view); this.view = "form"; + this._history.push("form"); this.render(); } @@ -419,10 +444,21 @@ class FolkScheduleApp extends HTMLElement { this.formConfig[k] = String(v); } } + this._history.push(this.view); this.view = "form"; + this._history.push("form"); 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 { if (!ts) return "—"; const d = new Date(ts); @@ -584,12 +620,18 @@ class FolkScheduleApp extends HTMLElement {
+ ${headerAction}
${content} `; this.attachListeners(); + this._tour.renderOverlay(); + } + + startTour() { + this._tour.start(); } private renderJobList(): string { @@ -598,7 +640,7 @@ class FolkScheduleApp extends HTMLElement { } const rows = this.jobs.map((j) => ` - +
- +
`; @@ -727,7 +769,7 @@ class FolkScheduleApp extends HTMLElement { ? 'Sent' : 'Pending'; - return ` + return `
@@ -797,19 +839,24 @@ class FolkScheduleApp extends HTMLElement {
- +
`; } private attachListeners() { + this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + // Tab switching this.shadow.querySelectorAll("[data-view]").forEach((btn) => { btn.addEventListener("click", () => { - this.view = btn.dataset.view as "jobs" | "log" | "reminders"; - if (this.view === "log") this.loadLog(); - else if (this.view === "reminders") this.loadReminders(); + const newView = btn.dataset.view as "jobs" | "log" | "reminders"; + this._history.push(this.view); + this.view = newView; + this._history.push(newView); + if (newView === "log") this.loadLog(); + else if (newView === "reminders") this.loadReminders(); else this.render(); }); }); @@ -844,10 +891,9 @@ class FolkScheduleApp extends HTMLElement { btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!)); }); - // Form: cancel + // Form: cancel / back this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => { - this.view = "jobs"; - this.render(); + this.goBack(); }); // Form: submit @@ -906,10 +952,9 @@ class FolkScheduleApp extends HTMLElement { btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!)); }); - // Reminder form: cancel + // Reminder form: cancel / back this.shadow.querySelector("[data-action='cancel-reminder']")?.addEventListener("click", () => { - this.view = "reminders"; - this.loadReminders(); + this.goBack(); }); // Reminder form: submit diff --git a/modules/rschedule/landing.ts b/modules/rschedule/landing.ts index 52b6058..fdea940 100644 --- a/modules/rschedule/landing.ts +++ b/modules/rschedule/landing.ts @@ -31,6 +31,11 @@ export function renderLanding(): string { Learn More
+

+ + Start Guided Tour → + +

diff --git a/modules/rschedule/schemas.ts b/modules/rschedule/schemas.ts index 649a2f2..119f63b 100644 --- a/modules/rschedule/schemas.ts +++ b/modules/rschedule/schemas.ts @@ -59,9 +59,9 @@ export interface Reminder { completed: boolean; // dismissed by user? // Cross-module reference (null for free-form reminders) - sourceModule: string | null; // "rwork", "rnotes", etc. + sourceModule: string | null; // "rtasks", "rnotes", etc. sourceEntityId: string | null; - sourceLabel: string | null; // "rWork Task" + sourceLabel: string | null; // "rTasks Task" sourceColor: string | null; // "#f97316" // Optional recurrence @@ -172,7 +172,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [ inputs: [], outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], 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' }, ], }, @@ -315,7 +315,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [ category: 'action', label: 'Create Task', icon: '✅', - description: 'Create a task in rWork', + description: 'Create a task in rTasks', inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }], configSchema: [ @@ -347,7 +347,7 @@ export const NODE_CATALOG: AutomationNodeDef[] = [ inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], 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: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' }, ], diff --git a/modules/rsocials/components/folk-campaign-manager.ts b/modules/rsocials/components/folk-campaign-manager.ts index b531dac..356c006 100644 --- a/modules/rsocials/components/folk-campaign-manager.ts +++ b/modules/rsocials/components/folk-campaign-manager.ts @@ -9,16 +9,32 @@ import { socialsSchema, socialsDocId } from '../schemas'; import type { SocialsDoc, Campaign, CampaignPost } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; import { MYCOFI_CAMPAIGN, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data'; +import { TourEngine } from '../../../shared/tour-engine'; export class FolkCampaignManager extends HTMLElement { private _space = 'demo'; private _campaigns: Campaign[] = []; 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']; } connectedCallback() { 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'; // Start with demo campaign this._campaigns = [{ ...MYCOFI_CAMPAIGN, createdAt: Date.now(), updatedAt: Date.now() }]; @@ -26,6 +42,10 @@ export class FolkCampaignManager extends HTMLElement { if (this._space !== 'demo') { this.subscribeOffline(); } + // Auto-start tour on first visit + if (!localStorage.getItem("rsocials_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } disconnectedCallback() { @@ -141,6 +161,7 @@ export class FolkCampaignManager extends HTMLElement {
Open Thread Editor +
${phaseHTML}
`; @@ -250,11 +271,17 @@ export class FolkCampaignManager extends HTMLElement { `; this.bindEvents(); + this._tour.renderOverlay(); } + startTour() { this._tour.start(); } + private bindEvents() { 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 openBtn = this.shadowRoot.getElementById('import-md-btn'); const closeBtn = this.shadowRoot.getElementById('import-modal-close'); diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts index 9491b3d..ac18100 100644 --- a/modules/rsocials/components/folk-thread-gallery.ts +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -115,7 +115,7 @@ export class FolkThreadGallery extends HTMLElement { const href = this._isDemoFallback ? `${this.basePath}thread-editor` : `${this.basePath}thread-editor/${this.esc(t.id)}/edit`; - return ` + return ` ${imageTag}

${this.esc(t.title || 'Untitled Thread')}

${preview}

diff --git a/modules/rsocials/landing.ts b/modules/rsocials/landing.ts index 37e70f0..292cc83 100644 --- a/modules/rsocials/landing.ts +++ b/modules/rsocials/landing.ts @@ -19,6 +19,11 @@ export function renderLanding(): string {
Try Demo Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts index af5bcf5..81929c7 100644 --- a/modules/rsplat/components/folk-splat-viewer.ts +++ b/modules/rsplat/components/folk-splat-viewer.ts @@ -150,7 +150,7 @@ export class FolkSplatViewer extends HTMLElement { : `${s.view_count} views`; return ` - <${tag} class="splat-card${statusClass}"${href}> + <${tag} class="splat-card${statusClass}" data-collab-id="splat:${s.id}"${href}>
${overlay} ${isReady ? "🔮" : "📸"} diff --git a/modules/rswag/components/folk-swag-designer.ts b/modules/rswag/components/folk-swag-designer.ts index 5a2067f..141d873 100644 --- a/modules/rswag/components/folk-swag-designer.ts +++ b/modules/rswag/components/folk-swag-designer.ts @@ -147,6 +147,8 @@ function posterMockupSvg(): string { // --- Component --- +import { TourEngine } from "../../../shared/tour-engine"; + class FolkSwagDesigner extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -162,10 +164,23 @@ class FolkSwagDesigner extends HTMLElement { private demoStep: 1 | 2 | 3 | 4 = 1; private progressStep = 0; 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkSwagDesigner.TOUR_STEPS, + "rswag_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { @@ -177,9 +192,12 @@ class FolkSwagDesigner extends HTMLElement { this.designTitle = "Cosmolocal Network Tee"; this.demoStep = 1; this.render(); - return; + } else { + this.render(); + } + if (!localStorage.getItem("rswag_tour_done")) { + setTimeout(() => this._tour.start(), 1200); } - this.render(); } private getApiBase(): string { @@ -334,6 +352,11 @@ class FolkSwagDesigner extends HTMLElement { } else { this.renderFull(); } + this._tour.renderOverlay(); + } + + startTour() { + this._tour.start(); } // ---- Demo mode rendering (4-step flow) ---- @@ -345,6 +368,8 @@ class FolkSwagDesigner extends HTMLElement { this.shadow.innerHTML = ` +
+
${[ @@ -362,7 +387,7 @@ class FolkSwagDesigner extends HTMLElement {
${DEMO_PRODUCTS.map(dp => ` -
+
${this.productIcon(dp.id)}
${dp.name}
${dp.printArea}
@@ -541,6 +566,8 @@ class FolkSwagDesigner extends HTMLElement { } private bindDemoEvents() { + this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + // Product selection this.shadow.querySelectorAll(".product").forEach(el => { el.addEventListener("click", () => this.demoSelectProduct((el as HTMLElement).dataset.product || "tee")); diff --git a/modules/rswag/landing.ts b/modules/rswag/landing.ts index c284aae..a1e5c57 100644 --- a/modules/rswag/landing.ts +++ b/modules/rswag/landing.ts @@ -17,6 +17,11 @@ export function renderLanding(): string { Start Designing Create a Space
+

+ + Start Guided Tour → + +

diff --git a/modules/rwork/components/folk-work-board.ts b/modules/rtasks/components/folk-tasks-board.ts similarity index 89% rename from modules/rwork/components/folk-work-board.ts rename to modules/rtasks/components/folk-tasks-board.ts index 936084e..31e04dd 100644 --- a/modules/rwork/components/folk-work-board.ts +++ b/modules/rtasks/components/folk-tasks-board.ts @@ -1,5 +1,5 @@ /** - * — kanban board for workspace task management. + * — kanban board for workspace task management. * * Views: workspace list → board with draggable columns. * Supports task creation, status changes, and priority labels. @@ -7,8 +7,10 @@ import { boardSchema, type BoardDoc } from "../schemas"; 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 space = ""; private view: "list" | "board" = "list"; @@ -24,18 +26,37 @@ class FolkWorkBoard extends HTMLElement { private showCreateForm = false; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; 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() { super(); this.shadow = this.attachShadow({ mode: "open" }); + this._tour = new TourEngine( + this.shadow, + FolkTasksBoard.TOUR_STEPS, + "rtasks_tour_done", + () => this.shadow.host as HTMLElement, + ); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; - if (this.space === "demo") { this.loadDemoData(); return; } - this.subscribeOffline(); - this.loadWorkspaces(); - this.render(); + if (this.space === "demo") { this.loadDemoData(); } + else { + this.subscribeOffline(); + this.loadWorkspaces(); + this.render(); + } + if (!localStorage.getItem("rtasks_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } } disconnectedCallback() { @@ -48,7 +69,7 @@ class FolkWorkBoard extends HTMLElement { if (!runtime?.isInitialized) return; try { - const docs = await runtime.subscribeModule('work', 'boards', boardSchema); + const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema); // Build workspace list from cached boards if (docs.size > 0 && this.workspaces.length === 0) { const boards: any[] = []; @@ -70,7 +91,7 @@ class FolkWorkBoard extends HTMLElement { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime || !this.workspaceSlug) return; // 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; if (doc?.tasks && Object.keys(doc.tasks).length > 0) { this.tasks = Object.values(doc.tasks).map(t => ({ @@ -105,7 +126,7 @@ class FolkWorkBoard extends HTMLElement { private getApiBase(): string { const path = window.location.pathname; - const match = path.match(/^(\/[^/]+)?\/rwork/); + const match = path.match(/^(\/[^/]+)?\/rtasks/); return match ? match[0] : ""; } @@ -225,6 +246,14 @@ class FolkWorkBoard extends HTMLElement { 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() { this.shadow.innerHTML = ` +
+
+ `; + } + + #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 => ``) + .join(''); + + badge.innerHTML = ` + + ${dots} + ${count} online + `; + 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(` +
+ + + + ${this.#escHtml(peer.username)} +
+ `); + } + + 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, '&').replace(//g, '>').replace(/"/g, '"'); + } + + 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; + } +`; diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 8b60f77..5ff05ea 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -51,7 +51,7 @@ const MODULE_BADGES: Record = { rfiles: { badge: "r📁", color: "#67e8f9" }, rbooks: { badge: "r📚", color: "#fda4af" }, rdata: { badge: "r📊", color: "#d8b4fe" }, - rwork: { badge: "r📋", color: "#cbd5e1" }, + rtasks: { badge: "r📋", color: "#cbd5e1" }, rschedule: { badge: "r⏱", color: "#a5b4fc" }, rids: { badge: "r🪪", color: "#6ee7b7" }, rstack: { badge: "r✨", color: "" }, @@ -67,7 +67,7 @@ const MODULE_CATEGORIES: Record = { rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce", rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing", rdata: "Observing", - rwork: "Work & Productivity", + rtasks: "Tasks & Productivity", rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure", }; @@ -1501,7 +1501,8 @@ const STYLES = ` /* ── 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; } /* ── Add button ── */ diff --git a/shared/local-first/runtime.ts b/shared/local-first/runtime.ts index fdf34e0..6ed6908 100644 --- a/shared/local-first/runtime.ts +++ b/shared/local-first/runtime.ts @@ -20,7 +20,7 @@ import { DocumentManager, } from './document'; import { EncryptedDocStore } from './storage'; -import { DocSyncManager } from './sync'; +import { DocSyncManager, type AwarenessMessage } from './sync'; import { DocCrypto } from './crypto'; import { getStorageInfo, @@ -72,6 +72,7 @@ export class RSpaceOfflineRuntime { get isInitialized(): boolean { return this.#initialized; } get isOnline(): boolean { return this.#sync.isConnected; } get status(): RuntimeStatus { return this.#status; } + get peerId(): string { return this.#sync.peerId; } // ── Lifecycle ── @@ -131,6 +132,11 @@ export class RSpaceOfflineRuntime { // Subscribe for sync (sends subscribe + initial sync to server) 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; } @@ -178,6 +184,21 @@ export class RSpaceOfflineRuntime { return () => { unsub1(); unsub2(); }; } + /** + * Send awareness/presence data for a document (cursor, selection, etc.). + */ + sendAwareness(docId: DocumentId, data: Partial): 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. * Call once after init with the modules config for this space. diff --git a/shared/tour-engine.ts b/shared/tour-engine.ts new file mode 100644 index 0000000..637c41c --- /dev/null +++ b/shared/tour-engine.ts @@ -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 = ` +
+
+
+
${stepNum} / ${totalSteps}
+
${step.title}
+
${step.message}
+
+ ${this._step > 0 ? '' : ''} + ${step.advanceOnClick + ? `or click the button above` + : `` + } + +
+
+ `; + + // 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; +} +`; diff --git a/shared/view-history.ts b/shared/view-history.ts new file mode 100644 index 0000000..72eb32e --- /dev/null +++ b/shared/view-history.ts @@ -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 { + view: V; + context?: Record; +} + +const MAX_DEPTH = 20; + +export class ViewHistory { + private stack: ViewEntry[] = []; + 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): 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 | 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 | 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 = []; + } +} diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 4d709e7..7a3775b 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -170,7 +170,7 @@ const CONFIG = { 'https://demo.rsocials.online', 'https://socials.crypto-commons.org', 'https://socials.p2pfoundation.net', - 'https://rwork.online', + 'https://rtasks.online', 'https://rforum.online', 'https://rchoices.online', 'https://rswag.online', diff --git a/vite.config.ts b/vite.config.ts index 185f036..b676caf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -472,31 +472,31 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rmaps/maps.css"), ); - // Build work module component + // Build tasks module component await build({ configFile: false, - root: resolve(__dirname, "modules/rwork/components"), + root: resolve(__dirname, "modules/rtasks/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/rwork"), + outDir: resolve(__dirname, "dist/modules/rtasks"), lib: { - entry: resolve(__dirname, "modules/rwork/components/folk-work-board.ts"), + entry: resolve(__dirname, "modules/rtasks/components/folk-tasks-board.ts"), formats: ["es"], - fileName: () => "folk-work-board.js", + fileName: () => "folk-tasks-board.js", }, rollupOptions: { output: { - entryFileNames: "folk-work-board.js", + entryFileNames: "folk-tasks-board.js", }, }, }, }); - // Copy work CSS - mkdirSync(resolve(__dirname, "dist/modules/rwork"), { recursive: true }); + // Copy tasks CSS + mkdirSync(resolve(__dirname, "dist/modules/rtasks"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/rwork/components/work.css"), - resolve(__dirname, "dist/modules/rwork/work.css"), + resolve(__dirname, "modules/rtasks/components/tasks.css"), + resolve(__dirname, "dist/modules/rtasks/tasks.css"), ); // Build trips module component diff --git a/website/canvas.html b/website/canvas.html index b3d221d..98dde36 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2210,7 +2210,7 @@ - + @@ -2794,9 +2794,12 @@ }); tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; - sync.updateLayer?.(layerId, { order: newIndex }); 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-remove', (e) => { sync.removeFlow?.(e.detail.flowId); }); @@ -4403,7 +4406,7 @@ { btnId: "embed-books", moduleId: "rbooks" }, { btnId: "embed-pubs", moduleId: "rpubs" }, { btnId: "embed-files", moduleId: "rfiles" }, - { btnId: "embed-work", moduleId: "rwork" }, + { btnId: "embed-tasks", moduleId: "rtasks" }, { btnId: "embed-forum", moduleId: "rforum" }, { btnId: "embed-inbox", moduleId: "rinbox" }, { btnId: "embed-tube", moduleId: "rtube" }, diff --git a/website/public/landing.html b/website/public/landing.html index d1c4fbe..eb1e81c 100644 --- a/website/public/landing.html +++ b/website/public/landing.html @@ -10,4 +10,4 @@ └───────────────────────────────────────────────┘ | v - Your device (keys & data stay here)

Your data, connected across tools

A budget created in rFlows can reference a vote from rVote. A map pin in rMaps can link to files in rFiles and notes in rNotes. Because all r-Ecosystem tools share the same Automerge CRDT sync layer, data is interoperable without import/export steps or API integrations. Changes propagate in real-time across every tool and every collaborator — conflict-free.

No vendor lock-in, no data silos

Every piece of community data is stored as a local-first CRDT document that your community owns. There's no central server gating access and no proprietary format trapping your data. Export everything. Fork your community. Move between hosts. The r-Ecosystem is designed so that the community — not the platform — controls the data.

Shared Digital Spaces for Real World Impact

rSpaces aren't just for chatting online—they're tools for making your real neighborhood better.

The rSpace Ecosystem

rMaps

rMaps.online

Collaborative spatial mapping. Plot ideas, resources, and relationships on shared canvases.

rCart

rCart.online

Community commerce. Coordinate group purchases and share resources locally.

rCal

rCal.online

Shared calendars for communities. Coordinate events, meetings, and availability together.

rFlows

rFlows.online

Threshold-based flow funding. Design continuous funding flows with overflow routing and outcome tracking.

rFiles

rFiles.online

Shared file workspaces. Organize documents, media, and assets together in real time.

rWallet

rWallet.online

Community treasury management. Track shared resources, contributions, and funding flows.

rNotes

rNotes.online

Collaborative note-taking. Capture and organize ideas together in shared notebooks.

rVote

rVote.online

Democratic backlog prioritization. Quadratic proposal ranking lets communities surface the best ideas.

rTrips

rTrips.online

Plan adventures together. Coordinate group travel, shared itineraries, and trip logistics.

rTube

rTube.online

Community video sharing. Host and curate video content for your community.

rChats

rChats.online

Encrypted group messaging. Real-time channels for your community, end-to-end encrypted.

rForum

rForum.online

Community discussion forums. Threaded conversations for deeper, asynchronous dialogue.

rSwag

rSwag.online

Community merchandise on demand. Design custom stickers, shirts, and more with AI-powered tools.

rNetwork

rNetwork.online

Visualize community connections. Map relationships and networks across your ecosystem.

rInbox

rInbox.online

Community email and notifications. Unified inbox for all your rSpace communications.

rWork

rWork.online

Collaborative task management. Kanban boards and project pipelines for community projects.

Your space. Your community. Your rules.

Join the Movement

Part of a growing ecosystem building alternatives to surveillance capitalism

Ready to Reclaim Your Digital Space?

Join communities building the future of the web - one rSpace at a time

Stay Connected with rSpace

Subscribe for updates on real-time collaboration, spatial computing, and building connected futures.

No spam, unsubscribe anytime. We respect your privacy.

\ No newline at end of file + Your device (keys & data stay here)

Your data, connected across tools

A budget created in rFlows can reference a vote from rVote. A map pin in rMaps can link to files in rFiles and notes in rNotes. Because all r-Ecosystem tools share the same Automerge CRDT sync layer, data is interoperable without import/export steps or API integrations. Changes propagate in real-time across every tool and every collaborator — conflict-free.

No vendor lock-in, no data silos

Every piece of community data is stored as a local-first CRDT document that your community owns. There's no central server gating access and no proprietary format trapping your data. Export everything. Fork your community. Move between hosts. The r-Ecosystem is designed so that the community — not the platform — controls the data.

Shared Digital Spaces for Real World Impact

rSpaces aren't just for chatting online—they're tools for making your real neighborhood better.

The rSpace Ecosystem

rMaps

rMaps.online

Collaborative spatial mapping. Plot ideas, resources, and relationships on shared canvases.

rCart

rCart.online

Community commerce. Coordinate group purchases and share resources locally.

rCal

rCal.online

Shared calendars for communities. Coordinate events, meetings, and availability together.

rFlows

rFlows.online

Threshold-based flow funding. Design continuous funding flows with overflow routing and outcome tracking.

rFiles

rFiles.online

Shared file workspaces. Organize documents, media, and assets together in real time.

rWallet

rWallet.online

Community treasury management. Track shared resources, contributions, and funding flows.

rNotes

rNotes.online

Collaborative note-taking. Capture and organize ideas together in shared notebooks.

rVote

rVote.online

Democratic backlog prioritization. Quadratic proposal ranking lets communities surface the best ideas.

rTrips

rTrips.online

Plan adventures together. Coordinate group travel, shared itineraries, and trip logistics.

rTube

rTube.online

Community video sharing. Host and curate video content for your community.

rChats

rChats.online

Encrypted group messaging. Real-time channels for your community, end-to-end encrypted.

rForum

rForum.online

Community discussion forums. Threaded conversations for deeper, asynchronous dialogue.

rSwag

rSwag.online

Community merchandise on demand. Design custom stickers, shirts, and more with AI-powered tools.

rNetwork

rNetwork.online

Visualize community connections. Map relationships and networks across your ecosystem.

rInbox

rInbox.online

Community email and notifications. Unified inbox for all your rSpace communications.

rTasks

rTasks.online

Collaborative task management. Kanban boards and project pipelines for community projects.

Your space. Your community. Your rules.

Join the Movement

Part of a growing ecosystem building alternatives to surveillance capitalism

Ready to Reclaim Your Digital Space?

Join communities building the future of the web - one rSpace at a time

Stay Connected with rSpace

Subscribe for updates on real-time collaboration, spatial computing, and building connected futures.

No spam, unsubscribe anytime. We respect your privacy.

\ No newline at end of file diff --git a/website/shell.ts b/website/shell.ts index c7f58c4..3fe60e8 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -17,6 +17,7 @@ import { RStackSpaceSettings } from "../shared/components/rstack-space-settings" import { RStackModuleSetup } from "../shared/components/rstack-module-setup"; import { RStackHistoryPanel } from "../shared/components/rstack-history-panel"; 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 { rspaceNavUrl } from "../shared/url-helpers"; import { TabCache } from "../shared/tab-cache"; @@ -39,6 +40,7 @@ RStackSpaceSettings.define(); RStackModuleSetup.define(); RStackHistoryPanel.define(); RStackOfflineIndicator.define(); +RStackCollabOverlay.define(); RStackUserDashboard.define(); // ── Offline Runtime ──