diff --git a/apps/todo/apps/web/package.json b/apps/todo/apps/web/package.json
index d92c576ed..83fc1952f 100644
--- a/apps/todo/apps/web/package.json
+++ b/apps/todo/apps/web/package.json
@@ -40,6 +40,7 @@
},
"dependencies": {
"@manacore/local-store": "workspace:*",
+ "@manacore/shared-links": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
diff --git a/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte b/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte
index 89f66cd78..9e34f379a 100644
--- a/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte
+++ b/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte
@@ -25,6 +25,7 @@
TagSelector,
} from './form';
import { ContactSelector, focusTrap } from '@manacore/shared-ui';
+ import { ManaLinkList } from '@manacore/shared-links/ui';
interface Props {
task: Task;
@@ -315,6 +316,12 @@
+
+
+
+
+
+
diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte
index 2acb29e21..64498d6fc 100644
--- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte
+++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte
@@ -31,6 +31,7 @@
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
+ import { linkLocalStore, linkMutations } from '@manacore/shared-links';
import { theme } from '$lib/stores/theme';
import TaskFilters from '$lib/components/TaskFilters.svelte';
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
@@ -317,18 +318,24 @@
async function handleLogout() {
await authStore.signOut();
tagMutations.stopSync();
+ linkMutations.stopSync();
goto('/login');
}
async function handleAuthReady() {
// Initialize local-first databases (opens IndexedDB, seeds guest data)
- await Promise.all([todoStore.initialize(), tagLocalStore.initialize()]);
+ await Promise.all([
+ todoStore.initialize(),
+ tagLocalStore.initialize(),
+ linkLocalStore.initialize(),
+ ]);
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
const getToken = () => authStore.getValidToken();
todoStore.startSync(getToken);
tagMutations.startSync(getToken);
+ linkMutations.startSync(getToken);
}
// Initialize split-panel from URL/localStorage
diff --git a/packages/shared-links/package.json b/packages/shared-links/package.json
new file mode 100644
index 000000000..0480b5bfe
--- /dev/null
+++ b/packages/shared-links/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@manacore/shared-links",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./ui": "./src/ui/index.ts"
+ },
+ "dependencies": {
+ "@manacore/local-store": "workspace:*",
+ "@manacore/shared-branding": "workspace:*"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ },
+ "devDependencies": {
+ "svelte": "^5.0.0",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/packages/shared-links/src/index.ts b/packages/shared-links/src/index.ts
new file mode 100644
index 000000000..7cd6b12de
--- /dev/null
+++ b/packages/shared-links/src/index.ts
@@ -0,0 +1,26 @@
+// Store
+export { linkLocalStore, linkCollection } from './store.js';
+
+// Types
+export type {
+ LocalManaLink,
+ CreateManaLinkInput,
+ LinkCachedData,
+ ManaRecordRef,
+ ManaLinkType,
+} from './types.js';
+export { LINK_TYPE_INVERSIONS } from './types.js';
+
+// Mutations
+export { linkMutations } from './mutations.svelte.js';
+
+// Queries
+export {
+ useLinksForRecord,
+ useLinksForApp,
+ useLinksOfType,
+ useLinkCount,
+} from './queries.svelte.js';
+
+// Resolvers
+export { buildCachedData, isCacheStale } from './resolvers.js';
diff --git a/packages/shared-links/src/mutations.svelte.ts b/packages/shared-links/src/mutations.svelte.ts
new file mode 100644
index 000000000..abbab58a1
--- /dev/null
+++ b/packages/shared-links/src/mutations.svelte.ts
@@ -0,0 +1,143 @@
+/**
+ * ManaLink — Mutation Service
+ *
+ * Handles creating, deleting, and updating cross-app links.
+ * Each link creates TWO records (forward + reverse) sharing a pairId
+ * for efficient bidirectional queries.
+ */
+
+import { linkLocalStore, linkCollection } from './store.js';
+import type { LocalManaLink, CreateManaLinkInput, LinkCachedData } from './types.js';
+import { LINK_TYPE_INVERSIONS } from './types.js';
+
+/** Invert a directional link type. Symmetric types return unchanged. */
+function invertLinkType(type: string): string {
+ return LINK_TYPE_INVERSIONS[type] ?? type;
+}
+
+let error = $state
(null);
+
+export const linkMutations = {
+ get error() {
+ return error;
+ },
+
+ // === Store Lifecycle ===
+
+ async initialize() {
+ await linkLocalStore.initialize();
+ },
+
+ startSync(getToken: () => Promise) {
+ linkLocalStore.startSync(getToken);
+ },
+
+ stopSync() {
+ linkLocalStore.stopSync();
+ },
+
+ // === Links ===
+
+ /**
+ * Create a bidirectional link. Inserts TWO records sharing a pairId:
+ * - Forward: source → target
+ * - Reverse: target → source (swapped)
+ */
+ async createLink(
+ input: CreateManaLinkInput
+ ): Promise<{ forward: LocalManaLink; reverse: LocalManaLink }> {
+ error = null;
+ try {
+ const pairId = crypto.randomUUID();
+ const linkType = input.linkType ?? 'related';
+
+ const forward: LocalManaLink = {
+ id: crypto.randomUUID(),
+ pairId,
+ direction: 'forward',
+ sourceApp: input.sourceApp,
+ sourceCollection: input.sourceCollection,
+ sourceId: input.sourceId,
+ targetApp: input.targetApp,
+ targetCollection: input.targetCollection,
+ targetId: input.targetId,
+ linkType,
+ cachedSource: input.cachedSource,
+ cachedTarget: input.cachedTarget,
+ };
+
+ const reverse: LocalManaLink = {
+ id: crypto.randomUUID(),
+ pairId,
+ direction: 'reverse',
+ sourceApp: input.targetApp,
+ sourceCollection: input.targetCollection,
+ sourceId: input.targetId,
+ targetApp: input.sourceApp,
+ targetCollection: input.sourceCollection,
+ targetId: input.sourceId,
+ linkType: invertLinkType(linkType),
+ cachedSource: input.cachedTarget,
+ cachedTarget: input.cachedSource,
+ };
+
+ const insertedForward = await linkCollection.insert(forward);
+ const insertedReverse = await linkCollection.insert(reverse);
+
+ return { forward: insertedForward, reverse: insertedReverse };
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to create link';
+ throw e;
+ }
+ },
+
+ /**
+ * Delete a link pair by pairId. Soft-deletes both forward and reverse records.
+ */
+ async deleteLinkPair(pairId: string): Promise {
+ error = null;
+ try {
+ const links = await linkCollection.getAll({
+ pairId,
+ } as Partial);
+ for (const link of links) {
+ await linkCollection.delete(link.id);
+ }
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to delete link pair';
+ throw e;
+ }
+ },
+
+ /**
+ * Delete a link by its record ID (and its pair partner).
+ */
+ async deleteLink(linkId: string): Promise {
+ error = null;
+ try {
+ const link = await linkCollection.get(linkId);
+ if (link) {
+ await this.deleteLinkPair(link.pairId);
+ }
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to delete link';
+ throw e;
+ }
+ },
+
+ /**
+ * Update cached display data on a link record.
+ */
+ async updateCache(
+ linkId: string,
+ cached: Partial<{ cachedSource: LinkCachedData; cachedTarget: LinkCachedData }>
+ ): Promise {
+ error = null;
+ try {
+ await linkCollection.update(linkId, cached as Partial);
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to update link cache';
+ throw e;
+ }
+ },
+};
diff --git a/packages/shared-links/src/queries.svelte.ts b/packages/shared-links/src/queries.svelte.ts
new file mode 100644
index 000000000..9ad45e350
--- /dev/null
+++ b/packages/shared-links/src/queries.svelte.ts
@@ -0,0 +1,64 @@
+/**
+ * ManaLink — Reactive Live Queries
+ *
+ * Svelte 5 reactive queries for cross-app links.
+ * Auto-update when IndexedDB changes (local writes, sync, other tabs).
+ */
+
+import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
+import { linkCollection } from './store.js';
+import type { LocalManaLink, ManaRecordRef } from './types.js';
+
+/**
+ * Get all links where this record is the source.
+ * Because we store reverse records, this returns links in BOTH directions.
+ */
+export function useLinksForRecord(ref: ManaRecordRef) {
+ return useLiveQueryWithDefault(
+ () =>
+ linkCollection.getAll({
+ sourceApp: ref.app,
+ sourceId: ref.id,
+ } as Partial),
+ [] as LocalManaLink[]
+ );
+}
+
+/**
+ * Get all links for an entire app.
+ */
+export function useLinksForApp(appId: string) {
+ return useLiveQueryWithDefault(
+ () =>
+ linkCollection.getAll({
+ sourceApp: appId,
+ } as Partial),
+ [] as LocalManaLink[]
+ );
+}
+
+/**
+ * Get links of a specific type for a record.
+ */
+export function useLinksOfType(ref: ManaRecordRef, linkType: string) {
+ return useLiveQueryWithDefault(async () => {
+ const all = await linkCollection.getAll({
+ sourceApp: ref.app,
+ sourceId: ref.id,
+ } as Partial);
+ return all.filter((l) => l.linkType === linkType);
+ }, [] as LocalManaLink[]);
+}
+
+/**
+ * Count links for a record.
+ */
+export function useLinkCount(ref: ManaRecordRef) {
+ return useLiveQueryWithDefault(async () => {
+ const all = await linkCollection.getAll({
+ sourceApp: ref.app,
+ sourceId: ref.id,
+ } as Partial);
+ return all.length;
+ }, 0);
+}
diff --git a/packages/shared-links/src/resolvers.ts b/packages/shared-links/src/resolvers.ts
new file mode 100644
index 000000000..f5ecffaa0
--- /dev/null
+++ b/packages/shared-links/src/resolvers.ts
@@ -0,0 +1,34 @@
+/**
+ * ManaLink — Display Data Resolvers
+ *
+ * Helpers to build cached display data from app metadata.
+ */
+
+import { getManaApp } from '@manacore/shared-branding';
+import type { AppIconId } from '@manacore/shared-branding';
+import type { LinkCachedData } from './types.js';
+
+/**
+ * Build LinkCachedData from app branding + a title.
+ * Called by the app creating the link to populate offline-friendly display data.
+ */
+export function buildCachedData(appId: string, title: string, subtitle?: string): LinkCachedData {
+ const app = getManaApp(appId as AppIconId);
+ return {
+ title,
+ subtitle,
+ icon: app?.icon,
+ color: app?.color,
+ appName: app?.name,
+ fetchedAt: new Date().toISOString(),
+ };
+}
+
+/**
+ * Check if cached data is stale (older than threshold).
+ * Default: 24 hours.
+ */
+export function isCacheStale(cached: LinkCachedData, maxAgeMs = 24 * 60 * 60 * 1000): boolean {
+ const age = Date.now() - new Date(cached.fetchedAt).getTime();
+ return age > maxAgeMs;
+}
diff --git a/packages/shared-links/src/store.ts b/packages/shared-links/src/store.ts
new file mode 100644
index 000000000..e06d2cf6d
--- /dev/null
+++ b/packages/shared-links/src/store.ts
@@ -0,0 +1,40 @@
+/**
+ * ManaLink — Local-First Store
+ *
+ * Creates a shared IndexedDB database ('manacore-links') that all apps
+ * can read from. Links are synced to the server via mana-sync.
+ */
+
+import { createLocalStore } from '@manacore/local-store';
+import type { LocalManaLink } from './types.js';
+
+const SYNC_SERVER_URL =
+ (typeof window !== 'undefined' &&
+ (window as unknown as { __PUBLIC_SYNC_SERVER_URL__?: string }).__PUBLIC_SYNC_SERVER_URL__) ||
+ (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_SYNC_SERVER_URL) ||
+ 'http://localhost:3050';
+
+export const linkLocalStore = createLocalStore({
+ appId: 'links',
+ collections: [
+ {
+ name: 'links',
+ indexes: [
+ 'pairId',
+ 'direction',
+ 'sourceApp',
+ 'sourceId',
+ 'targetApp',
+ 'targetId',
+ 'linkType',
+ '[sourceApp+sourceId]',
+ '[sourceApp+sourceCollection+sourceId]',
+ ],
+ },
+ ],
+ sync: {
+ serverUrl: SYNC_SERVER_URL,
+ },
+});
+
+export const linkCollection = linkLocalStore.collection('links');
diff --git a/packages/shared-links/src/types.ts b/packages/shared-links/src/types.ts
new file mode 100644
index 000000000..f97c012bd
--- /dev/null
+++ b/packages/shared-links/src/types.ts
@@ -0,0 +1,92 @@
+/**
+ * ManaLink — Cross-App Linking Types
+ *
+ * Defines the data structures for bidirectional links between records
+ * across different apps in the Mana ecosystem.
+ */
+
+import type { BaseRecord } from '@manacore/local-store';
+
+/** Cached display data for a linked record (offline-friendly). */
+export interface LinkCachedData {
+ /** Display title of the linked record. */
+ title: string;
+ /** Optional subtitle (e.g. date, status). */
+ subtitle?: string;
+ /** App icon (data URL from shared-branding). */
+ icon?: string;
+ /** App color hex (from shared-branding). */
+ color?: string;
+ /** Human-readable app name. */
+ appName?: string;
+ /** ISO timestamp when this cache was built. */
+ fetchedAt: string;
+}
+
+/** A single link record stored in IndexedDB (manacore-links). */
+export interface LocalManaLink extends BaseRecord {
+ /** UUID shared by the forward and reverse link records. */
+ pairId: string;
+ /** Direction marker for this half of the pair. */
+ direction: 'forward' | 'reverse';
+ /** App ID of the source record (e.g. 'todo'). */
+ sourceApp: string;
+ /** Collection name in the source app (e.g. 'tasks'). */
+ sourceCollection: string;
+ /** Record ID in the source app. */
+ sourceId: string;
+ /** App ID of the target record (e.g. 'calendar'). */
+ targetApp: string;
+ /** Collection name in the target app (e.g. 'events'). */
+ targetCollection: string;
+ /** Record ID in the target app. */
+ targetId: string;
+ /** Semantic link type. */
+ linkType: string;
+ /** Cached display data for the source record. */
+ cachedSource?: LinkCachedData;
+ /** Cached display data for the target record. */
+ cachedTarget?: LinkCachedData;
+ /** User ID (for sync scoping). */
+ userId?: string;
+}
+
+/** Input for creating a link (user-facing). */
+export interface CreateManaLinkInput {
+ sourceApp: string;
+ sourceCollection: string;
+ sourceId: string;
+ targetApp: string;
+ targetCollection: string;
+ targetId: string;
+ /** Link type. Default: 'related'. */
+ linkType?: string;
+ cachedSource?: LinkCachedData;
+ cachedTarget?: LinkCachedData;
+}
+
+/** Identifies a record across apps. */
+export interface ManaRecordRef {
+ app: string;
+ collection: string;
+ id: string;
+}
+
+/** Built-in link types. */
+export type ManaLinkType =
+ | 'related'
+ | 'blocks'
+ | 'blocked-by'
+ | 'time-block'
+ | 'attachment'
+ | 'reference'
+ | 'parent'
+ | 'child';
+
+/** Map of directional link type inversions. */
+export const LINK_TYPE_INVERSIONS: Record = {
+ blocks: 'blocked-by',
+ 'blocked-by': 'blocks',
+ parent: 'child',
+ child: 'parent',
+};
diff --git a/packages/shared-links/src/ui/ManaLinkBadge.svelte b/packages/shared-links/src/ui/ManaLinkBadge.svelte
new file mode 100644
index 000000000..3661de05a
--- /dev/null
+++ b/packages/shared-links/src/ui/ManaLinkBadge.svelte
@@ -0,0 +1,119 @@
+
+
+
+
+ {#if onRemove}
+
+ {/if}
+
+
+
diff --git a/packages/shared-links/src/ui/ManaLinkList.svelte b/packages/shared-links/src/ui/ManaLinkList.svelte
new file mode 100644
index 000000000..8af157e16
--- /dev/null
+++ b/packages/shared-links/src/ui/ManaLinkList.svelte
@@ -0,0 +1,36 @@
+
+
+{#if (links.value ?? []).length > 0}
+
+ {#each links.value ?? [] as link (link.id)}
+
+ {/each}
+
+{/if}
+
+
diff --git a/packages/shared-links/src/ui/index.ts b/packages/shared-links/src/ui/index.ts
new file mode 100644
index 000000000..c0e359298
--- /dev/null
+++ b/packages/shared-links/src/ui/index.ts
@@ -0,0 +1,2 @@
+export { default as ManaLinkBadge } from './ManaLinkBadge.svelte';
+export { default as ManaLinkList } from './ManaLinkList.svelte';
diff --git a/packages/shared-links/tsconfig.json b/packages/shared-links/tsconfig.json
new file mode 100644
index 000000000..199ad556d
--- /dev/null
+++ b/packages/shared-links/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "types": ["svelte"]
+ },
+ "include": ["src/**/*"]
+}