diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 000000000..35d6dd192 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,102 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + actions: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build-demo: + runs-on: ubuntu-latest + # Only rebuild Wasm when app code or build files change; site-only edits skip this job + if: | + github.event_name == 'workflow_dispatch' || + contains(toJSON(github.event.head_commit.modified), 'kmp/') || + contains(toJSON(github.event.head_commit.added), 'kmp/') || + contains(toJSON(github.event.head_commit.modified), 'gradle') || + contains(toJSON(github.event.head_commit.modified), 'settings.gradle') + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + - uses: gradle/actions/setup-gradle@v4 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Build wasmJs demo + run: ./gradlew :kmp:wasmJsBrowserDistribution --no-daemon --build-cache -PenableJs=true + - name: Assert expected output exists + run: test -d kmp/build/dist/wasmJs/productionExecutable/ + - name: Assert no single file exceeds 90 MB + run: | + find kmp/build/dist/wasmJs/productionExecutable -type f -size +90M \ + -exec echo "FILE TOO LARGE: {}" \; | grep . && exit 1 || true + - uses: actions/upload-artifact@v4 + with: + name: demo-dist + path: kmp/build/dist/wasmJs/productionExecutable/ + retention-days: 7 + + build-site: + runs-on: ubuntu-latest + needs: build-demo + if: always() && (needs.build-demo.result == 'success' || needs.build-demo.result == 'skipped') + steps: + - uses: actions/checkout@v4 + # Try current run first (when build-demo ran); fall back to last successful run + # so site-only edits keep the previously-built demo live. + - id: download-demo + name: Download demo artifact (current run) + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: demo-dist + path: site/public/demo/ + - id: download-demo-fallback + name: Download demo artifact (last successful run) + if: steps.download-demo.outcome == 'failure' + continue-on-error: true + run: | + LAST_RUN=$(gh run list --workflow pages.yml --status success --limit 1 \ + --json databaseId --jq '.[0].databaseId // empty') + if [ -n "$LAST_RUN" ]; then + gh run download "$LAST_RUN" --name demo-dist --dir site/public/demo/ + fi + env: + GH_TOKEN: ${{ github.token }} + - uses: actions/configure-pages@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: site/package-lock.json + - run: npm ci + working-directory: site + - run: npm run build + working-directory: site + env: + DEMO_AVAILABLE: ${{ steps.download-demo.outcome == 'success' }} + - uses: actions/upload-pages-artifact@v3 + with: + path: site/dist/ + + deploy: + runs-on: ubuntu-latest + needs: build-site + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 4fad5d584..f8e0f7a3a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# SteleKit +

+ SteleKit +

-**Your knowledge, carved in stone.** - - +

Your knowledge, carved in stone.

A local-first outliner that keeps your notes as plain markdown on your disk — forever — and runs natively on every platform you use. @@ -33,6 +33,41 @@ SteleKit exists because of Logseq's ideas, not in spite of them. Your existing L --- +## Why I built this + +I've been a Logseq user for years. The outliner-first, block-based approach shaped by Zettelkasten — as laid out in *How to Take Smart Notes* and *How to Read a Book* — is central to how I think about notes. Obsidian is excellent software, but it treats notes as flat documents. That missing outliner layer is not a minor UX preference for me; it changes how ideas connect. + +The problem was Logseq's Android app. Over time it got slower, and the team's attention moved toward hosted sync and collaborative editing — reasonable bets for a business, wrong bets for my workflow. I work offline. I don't want a server. I just want a fast, reliable editor on every device I own. + +At the same time, I'd watched Kotlin Multiplatform mature into the clearest answer to "write once, run natively everywhere." The developer tooling gap between KMP and ClojureScript is not subtle. Type safety, incremental compilation, first-class IDE support, native rendering via Compose — none of that is available in the Electron/CLJS stack. I wanted a foundation I could maintain and build on without fighting the toolchain. + +SteleKit is the editor I wanted to exist. It reads your existing Logseq markdown without migration, runs natively on the platforms I care about (Desktop and Android, primarily), and stores the graph in persistent SQLite so opening a large graph is instant rather than a cold-scan. The sync story is a git repo with an auto-committing cron job — not glamorous, but it works everywhere and costs nothing. + +--- + +## SteleKit is not for you if… + +- **You're happy with Logseq or Obsidian.** Both are great projects with active teams, large plugin ecosystems, and paying users funding their development. If they're working for you, please keep using them and support the developers. +- **You rely on Logseq's hosted sync or multiplayer editing.** SteleKit has no sync service and no plans for one. You manage your own files. +- **You're primarily on Windows or iOS.** Desktop (JVM) and Android are the focus. Windows works but gets less attention. iOS is planned but not there yet. +- **You need a mature plugin ecosystem today.** The plugin scaffolding exists but it's early-stage. +- **You don't want to run software built by one person in their spare time.** That's a completely fair reason. + +--- + +## Acknowledgements + +SteleKit stands on the shoulders of several projects and their communities: + +- **[Logseq](https://logseq.com)** — the original inspiration. The block-based, bidirectional-linking, local-first model is Logseq's vision. SteleKit exists because that vision is right. +- **[Obsidian](https://obsidian.md)** — for proving that a local-first, file-based knowledge tool can be polished, fast, and sustainable as a business. +- **[Roam Research](https://roamresearch.com)** — for popularising the outliner-as-knowledge-graph idea and proving people would pay for it. +- **[Athens Research](https://github.com/athensresearch/athens)** — an open-source Roam alternative that explored similar technical territory before shutting down. Its existence showed the problem was worth solving. +- **[JetBrains](https://www.jetbrains.com)** — for Kotlin, Compose Multiplatform, and SQLDelight (via Cash App). The KMP ecosystem made this project feasible. +- **Niklas Luhmann** — for the Zettelkasten method, and Sönke Ahrens (*How to Take Smart Notes*) for explaining why it works. + +--- + ## Quick Start ```bash diff --git a/docs/tasks/TODO.md b/docs/tasks/TODO.md index cf7e81d2e..ab43d7f00 100644 --- a/docs/tasks/TODO.md +++ b/docs/tasks/TODO.md @@ -1,5 +1,7 @@ # Tasks +- [ ] SteleKit Site — Astro + Starlight GitHub Pages site with CI/CD pipeline, landing page, user docs, developer docs, and demo page; depends on browser-wasm-demo.md for demo embed (docs/tasks/stelekit-site.md) +- [ ] Browser Wasm Demo — migrate `js(IR)` to `wasmJs` + `CanvasBasedWindow`, rendering the real Compose UI in-browser via Skia canvas; switch SQLDelight to IN_MEMORY backend for demo (docs/tasks/browser-wasm-demo.md) - [ ] Recent Pages — per-graph visit history in left sidebar showing last 10 visited pages, persists across sessions (docs/tasks/recent-pages.md) - [ ] Copy / Cut / Paste Blocks — Ctrl+C/X/V block-tree clipboard with undo, external Markdown paste, and Ctrl+Shift+V block reference paste (docs/tasks/copy-cut-paste-blocks.md) - [ ] All Pages View — sortable table of every page with backlink counts, name filter, journal/page toggle, and multi-select bulk delete (docs/tasks/all-pages-view.md) diff --git a/docs/tasks/browser-wasm-demo.md b/docs/tasks/browser-wasm-demo.md new file mode 100644 index 000000000..f15eb3286 --- /dev/null +++ b/docs/tasks/browser-wasm-demo.md @@ -0,0 +1,331 @@ +# Browser Wasm Demo + +**Status**: Planned +**Depends on**: nothing — self-contained migration + +--- + +## Problem + +The current `js(IR)` build produces a status page with raw DOM `innerHTML` writes — not the actual Compose app. To demonstrate SteleKit in the browser, the build must switch to the `wasmJs` Gradle target with `CanvasBasedWindow`, which renders the real Compose UI via Skia on a ``. + +## Architecture: Two Layers Required + +Even with File System Access API handling the markdown files, **SQLite is still needed** as the query index. SteleKit's architecture separates concerns clearly: + +- **File layer** (`PlatformFileSystem`) — reads/writes `.md` files; source of truth +- **Index layer** (SQLDelight + SQLite) — built from those files; enables fast backlink/search queries + +For the browser, these map to: + +| Layer | Desktop | Browser | +|-------|---------|---------| +| File I/O | `java.nio.file` | **File System Access API** (`FileSystemDirectoryHandle`) | +| SQLite | `sqlite-driver` (JVM) | **`@sqlite.org/sqlite-wasm`** (in-memory or OPFS) | + +Both layers must be implemented for a real web target. The demo launch uses IN_MEMORY for SQLite and a seeded graph (no file picker), then the full implementation adds both layers. + +## Phases + +**Phase A — Demo launch**: `CanvasBasedWindow` renders the actual Compose UI; SQLite is `IN_MEMORY`; graph is seeded with demo content. Ships first to prove the canvas works. + +**Phase B — Full web target**: File System Access API for file I/O + `@sqlite.org/sqlite-wasm` for the SQLite index. User picks their Logseq graph folder; the app reads/indexes it exactly like the desktop app. SQLite index is rebuilt from the markdown files on each load (fast enough for typical graph sizes); OPFS caching is an optimization for large graphs. + +## Current State → Phase A Target + +| Item | Current | Phase A Target | +|------|---------|----------------| +| Gradle target | `js(IR)` | `wasmJs` | +| Compose entry | Raw DOM writes in `Main.kt` | `CanvasBasedWindow { StelekitApp() }` | +| SQLite | `WebWorkerDriver` + sql.js | `IN_MEMORY` (demo only) | +| File I/O | stub | stub (demo graph seeded in code) | +| DB dep | `web-worker-driver` + sql.js npm | removed | +| Compose dep | `html-core` (DOM renderer) | removed | +| Output path | `kmp/build/distributions/` | `kmp/build/dist/wasmJs/productionExecutable/` | +| SharedArrayBuffer | not needed | `coi-serviceworker.min.js` required | + +--- + +## Story 1: Switch Gradle target from `js(IR)` to `wasmJs` + +### Task 1.1 — Update `kmp/build.gradle.kts` + +Replace the `js(IR)` target with `wasmJs`: + +```kotlin +// BEFORE +if (project.findProperty("enableJs") == "true") { + js(IR) { + browser() + binaries.executable() + } +} + +// AFTER +if (project.findProperty("enableJs") == "true") { + wasmJs { + browser() + binaries.executable() + } +} +``` + +Replace the `jsMain` source set block with `wasmJsMain`. Remove all `js(IR)`-only dependencies: + +```kotlin +// BEFORE +val jsMain by getting { + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.10.2") + implementation("org.jetbrains.compose.html:html-core:1.7.3") + implementation("app.cash.sqldelight:web-worker-driver:2.3.2") + implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.3.2")) + implementation(npm("sql.js", "1.10.3")) + implementation(devNpm("copy-webpack-plugin", "9.1.0")) + } +} + +// AFTER +val wasmJsMain by getting { + dependencies { + implementation(npm("@sqlite.org/sqlite-wasm", "3.46.1-build2")) + } +} +``` + +Removed: `html-core`, `web-worker-driver`, `@cashapp/sqldelight-sqljs-worker`, `sql.js`, `copy-webpack-plugin`. +Added: `@sqlite.org/sqlite-wasm` (needed for Phase B; harmless to add now). + +### Task 1.2 — Rename `jsMain` source directory to `wasmJsMain` + +``` +kmp/src/jsMain/ → kmp/src/wasmJsMain/ +``` + +Rename the directory. Package declarations are unchanged. + +### Task 1.3 — Delete `kmp/webpack.config.d/sqljs-config.js` + +This file copies `sql-wasm.wasm` from sql.js. Not needed for `wasmJs`. + +--- + +## Story 2: Update platform-specific implementations + +### Task 2.1 — `DriverFactory.wasmJs.kt` — Phase A: IN_MEMORY stub; Phase B: `@sqlite.org/sqlite-wasm` + +**Phase A stub** (unblocks canvas rendering): + +```kotlin +// kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.wasmJs.kt +package dev.stapler.stelekit.db + +import app.cash.sqldelight.db.SqlDriver + +actual class DriverFactory actual constructor() { + actual fun init(context: Any) {} + actual fun createDriver(jdbcUrl: String): SqlDriver { + // Phase B: replace with @sqlite.org/sqlite-wasm driver + throw UnsupportedOperationException("Use RepositoryBackend.IN_MEMORY for browser demo") + } +} + +actual val defaultDatabaseUrl: String + get() = "jdbc:sqlite:stelekit" +``` + +**Phase B implementation** (full web target): Wire `@sqlite.org/sqlite-wasm` via a dedicated Web Worker. The worker runs SQLite synchronously on the OPFS thread; the main thread communicates via `SharedArrayBuffer` message passing. This requires: + +1. A `sqlite-worker.js` Web Worker script that initialises `@sqlite.org/sqlite-wasm` with the synchronous OPFS VFS +2. A Kotlin `JsInterop` wrapper that calls the worker's `exec`/`query` methods from `wasmJs` +3. A custom `SqlDriver` implementation wrapping the interop — executes SQL, maps results to `SqlCursor` + +This is a non-trivial integration. The SQLite index is always rebuilt from the markdown files on load (same as the desktop app's cold start), so the OPFS database can be treated as a cache that is safe to discard. Start without OPFS persistence (in-memory `@sqlite.org/sqlite-wasm`), add OPFS once correctness is confirmed. + +### Task 2.2 — `PlatformFileSystem.wasmJs.kt` — Phase A: stub; Phase B: `FileSystemDirectoryHandle` + +**Phase A stub** (demo graph is seeded in code; no real file access needed): + +```kotlin +// Key stubs: +// listFiles(path) → emptyList() +// readFile(path) → null +// writeFile(path, content) → no-op +// exists(path) → false +``` + +**Phase B implementation** (full web target): Back `PlatformFileSystem` with the File System Access API. + +The browser shows a native folder picker (`window.showDirectoryPicker()`); the returned `FileSystemDirectoryHandle` is stored for the session. All `PlatformFileSystem` calls translate to `FileSystemDirectoryHandle.getFileHandle()` / `FileSystemFileHandle.getFile()` / `createWritable()`. + +Key considerations: +- The file picker must be triggered by a user gesture (button click) — cannot be called on app startup +- Handle permission persistence: the browser revokes directory access when the tab closes; on reload the user must re-grant (or the app can request persistent permission via `queryPermission`) +- `FileSystemDirectoryHandle` access is async; `PlatformFileSystem` is currently synchronous on JVM. The `wasmJs` implementation will need to bridge this via coroutines (`suspending` calls or a blocking wrapper on the Wasm thread) + +The UI flow: +1. App loads → shows "Open your graph folder" button +2. User clicks → browser folder picker appears +3. User selects their Logseq graph directory +4. `GraphManager.addGraph(handle)` indexes the files into SQLite +5. App navigates to journal view + +### Task 2.3 — `PlatformSettings.wasmJs.kt` — `localStorage` + +Store settings as JSON in `localStorage`. Reads/writes are synchronous; no async bridging needed. + +### Task 2.4 — Stub `GitManager.wasmJs.kt` + +Return `false` / no-op for all git operations. Git is desktop-only. + +### Task 2.5 — Verify remaining platform files compile under `wasmJs` + +Expected to require no changes: +- `PlatformDispatcher.wasmJs.kt` +- `PlatformBackHandler.wasmJs.kt` +- `PlatformBottomBar.wasmJs.kt` +- `PlatformClipboardProvider.wasmJs.kt` +- `DynamicColorSupport.wasmJs.kt` +- `DynamicColorScheme.wasmJs.kt` +- `ModifierExtensions.wasmJs.kt` +- `Time.wasmJs.kt` + +Run `./gradlew :kmp:compileKotlinWasmJs -PenableJs=true` after each rename to catch issues early. + +--- + +## Story 3: Add Compose canvas entry point + +### Task 3.1 — Replace `Main.kt` with `CanvasBasedWindow` entry + +```kotlin +// kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt +package dev.stapler.stelekit.browser + +import androidx.compose.ui.window.CanvasBasedWindow +import dev.stapler.stelekit.ui.StelekitApp +import dev.stapler.stelekit.db.GraphManager +import dev.stapler.stelekit.repository.RepositoryBackend + +fun main() { + CanvasBasedWindow(canvasElementId = "ComposeTarget") { + val graphManager = GraphManager(backend = RepositoryBackend.IN_MEMORY) + StelekitApp(graphManager = graphManager) + } +} +``` + +Check `ui/App.kt` for the exact `StelekitApp` constructor signature and adjust the call site. + +### Task 3.2 — Update `index.html` for canvas target + +```html + + + + + + SteleKit — Try in Browser + + + + + +
+

Loading SteleKit…

+

Requires Chrome 119+, Firefox 120+, or Safari 18.2+

+
+ + + + + + +``` + +The exact JS filenames (`skiko.js`, `kmp.js`) depend on the Gradle module name — verify against actual output in `build/dist/wasmJs/productionExecutable/` after first build. + +### Task 3.3 — Add `coi-serviceworker.min.js` to resources + +Download from https://github.com/gzuidhof/coi-serviceworker and place at: +`kmp/src/wasmJsMain/resources/coi-serviceworker.min.js` + +This service worker intercepts fetch events and injects COOP/COEP headers, enabling `SharedArrayBuffer` on GitHub Pages (which cannot set HTTP headers directly). It causes a single page reload on first visit — expected behavior. + +--- + +## Story 4: Seed a minimal demo graph + +Until the full File System Access API integration ships, the demo loads with an empty graph, which is confusing. + +### Task 4.1 — Add a `DemoGraphSeeder` in `wasmJsMain` + +Pre-populate the `IN_MEMORY` `RepositorySet` before `StelekitApp` mounts: + +- Today's journal entry ("Welcome to SteleKit") +- A "Getting Started" page with nested blocks demonstrating the outliner +- A "Backlinks" page that `[[links]]` to Getting Started, demonstrating bidirectional linking +- A "Block Editing" cheat-sheet page + +**Deferred**: Do this after Story 3 confirms the canvas renders. + +--- + +## Build and Test Commands + +```bash +# Build wasmJs production bundle +./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true + +# Development run (hot reload at localhost:8080) +./gradlew :kmp:wasmJsBrowserDevelopmentRun -PenableJs=true + +# Verify output location +ls kmp/build/dist/wasmJs/productionExecutable/ +``` + +--- + +## Success Criteria + +### Phase A +- `./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true` completes without errors +- Output exists at `kmp/build/dist/wasmJs/productionExecutable/` +- Opening in Chrome shows the SteleKit Compose UI on a canvas (not a status page) +- No `SharedArrayBuffer` errors in the browser console +- JVM and Android builds are unaffected (`./gradlew jvmTest` still passes) + +### Phase B +- User can click "Open graph folder", pick their Logseq directory, and navigate their pages +- Backlinks and search work (SQLite index built from the markdown files) +- Edits to blocks write back to the `.md` files via `FileSystemDirectoryHandle` +- No data loss: closing and reopening the same folder shows the same content + +--- + +## Known Risks + +| Risk | Mitigation | +|------|-----------| +| `CanvasBasedWindow` API differs between CMP 1.7.3 and 1.9.x | Check `Kotlin/kotlin-wasm-compose-template` for current API; upgrade CMP if needed | +| `StelekitApp` requires JVM-specific lifecycle APIs at startup | Create a `wasmJsMain`-specific app root that avoids `ViewModel` until the wasmJs lifecycle story is clear | +| `material-icons-extended` bloats bundle by 10–20 MB | Exclude from `wasmJsMain` dependencies; use a curated icon subset | +| `commonMain` code references `java.io.*` or other JVM-only APIs | Compile errors surface these; each needs a `wasmJsMain` `expect/actual` override | +| `FileSystemDirectoryHandle` async API vs synchronous `PlatformFileSystem` contract | Bridge via coroutines; the `wasmJsMain` impl suspends where JVM impl blocks | +| `@sqlite.org/sqlite-wasm` JS interop from `wasmJs` is uncharted territory | Prototype the interop boundary in isolation before wiring to SQLDelight | +| File System Access API not available in Firefox (behind flag) or older Safari | Show a clear "requires Chrome or Edge" message for the folder picker; demo graph mode works everywhere | +| `coi-serviceworker` one-time reload surprises users | Add a comment in the loading screen: "Loading… (may refresh once)" | diff --git a/docs/tasks/stelekit-site.md b/docs/tasks/stelekit-site.md new file mode 100644 index 000000000..807caf6ac --- /dev/null +++ b/docs/tasks/stelekit-site.md @@ -0,0 +1,806 @@ +# SteleKit Site + +**Status**: Planned +**Depends on**: `docs/tasks/browser-wasm-demo.md` (wasmJs migration must be complete before Story 4 — demo page — can be fully implemented; all other stories are independent) + +--- + +## Problem + +SteleKit has no public web presence. Developers who discover the repository see a bare directory listing. Potential users cannot try the app without cloning and building it locally. The project competes with Logseq for mindshare but is invisible on the web. + +This plan covers the GitHub Pages site, CI/CD pipeline, landing page, documentation, and demo page integration — everything except the wasmJs migration itself (planned in `docs/tasks/browser-wasm-demo.md`). + +--- + +## Architecture Summary + +``` +stelekit/ + kmp/ ← Kotlin source (unchanged) + site/ ← NEW: Astro + Starlight + src/ + pages/ + index.astro ← Landing page + demo.astro ← Demo wrapper (iframe) + content/ + docs/ + user/ ← User guide (5–6 pages) + developer/ ← Developer docs (3–4 pages) + public/ + .nojekyll ← Committed static file; never generated in CI + astro.config.mjs ← base: '/stelekit/' + package.json + .github/workflows/ + pages.yml ← NEW: two-job deploy workflow +``` + +**CI pipeline (pages.yml)**: +``` +push to main + └── build-demo (Gradle wasmJsBrowserDistribution, ~15–20 min) + └── build-site (npm ci + astro build, ~2 min) + └── deploy (actions/deploy-pages) +``` + +Site-only changes (`site/**`) skip `build-demo` via `paths` filters and reuse the last successful demo artifact. + +**Key decisions** (see ADRs in `project_plans/stelekit-documentation-and-branding/decisions/`): +- ADR-001: Astro + Starlight over Docusaurus/VitePress/MkDocs +- ADR-002: Monorepo two-job CI over separate site repo +- ADR-003: GitHub Pages + `.nojekyll` + `coi-serviceworker` + +--- + +## Dependency Visualization + +``` +Story 1: Site Scaffolding (Astro + Starlight) + | + +── Story 2: CI/CD Pipeline (pages.yml) + | | + | +── Story 3: Landing Page Content + | | + | +── Story 5: User Documentation + | | + | +── Story 6: Developer Documentation + | + +── Story 4: Demo Page + | + depends on: browser-wasm-demo.md (Phase A complete) + degrades gracefully if artifact not yet available +``` + +Stories 1–2 are the critical path. Stories 3, 5, 6 can proceed in parallel once Story 1 is complete. Story 4 can be scaffolded in Story 1 and filled in after `browser-wasm-demo.md` ships. + +--- + +## Story 1: Site Scaffolding + +**Goal**: A runnable Astro + Starlight project in `site/` with correct base path configuration, `.nojekyll`, and a placeholder home page. + +### Task 1.1 — Bootstrap Astro + Starlight in `site/` + +**Objective**: Create the `site/` directory with a working Astro + Starlight install. + +**Context boundary**: Files created or modified are entirely within `site/`. No Kotlin or Gradle files are touched. + +**Prerequisites**: Node 20+, npm. + +**Implementation approach**: + +```bash +cd /path/to/stelekit +npm create astro@latest site -- --template starlight --no-install +cd site && npm install +``` + +The Starlight template creates: +``` +site/ + src/ + content/ + docs/ + index.mdx ← replace with our landing page stub + astro.config.mjs + package.json + tsconfig.json +``` + +Starlight's default layout uses `src/content/docs/` for all documentation pages. The marketing landing page (`index.astro`) lives in `src/pages/` and overrides the default docs index route. + +**Validation strategy**: +- `npm run dev` in `site/` serves the project at `localhost:4321` +- Landing page loads at `/` during dev (will be `/stelekit/` in production) +- No build errors in `npm run build` + +**INVEST check**: Self-contained; no Kotlin knowledge required; deliverable is a committed `site/` directory. + +--- + +### Task 1.2 — Configure `astro.config.mjs` for GitHub Pages subpath + +**Objective**: Set `base: '/stelekit/'` and `site: 'https://tstapler.github.io'` so all asset references resolve correctly on GitHub Pages. + +**Context boundary**: `site/astro.config.mjs` only. + +**Prerequisites**: Task 1.1 complete. + +**Implementation approach**: + +```js +// site/astro.config.mjs +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; + +export default defineConfig({ + site: 'https://tstapler.github.io', + base: '/stelekit', + integrations: [ + starlight({ + title: 'SteleKit', + description: 'A local-first outliner for Desktop and Android. Reads your Logseq markdown.', + social: { + github: 'https://github.com/tstapler/stelekit', + }, + sidebar: [ + { + label: 'User Guide', + items: [ + { label: 'Getting Started', slug: 'user/getting-started' }, + { label: 'Outliner', slug: 'user/outliner' }, + { label: 'Journals', slug: 'user/journals' }, + { label: 'Backlinks', slug: 'user/backlinks' }, + { label: 'Search', slug: 'user/search' }, + ], + }, + { + label: 'Developer', + items: [ + { label: 'Architecture', slug: 'developer/architecture' }, + { label: 'Build', slug: 'developer/build' }, + { label: 'Contributing', slug: 'developer/contributing' }, + { label: 'Module Structure', slug: 'developer/module-structure' }, + ], + }, + ], + }), + ], +}); +``` + +Note: Astro `base` must not have a trailing slash in the config; Astro adds the slash when generating URLs. + +**Validation strategy**: +- `npm run build` in `site/` produces `dist/` with all paths prefixed `/stelekit/` +- `grep -r "href=\"/" site/dist/ | grep -v "/stelekit/"` returns no results (no unpatched absolute paths) +- Serve locally with `npx serve -p 3000 site/dist` and verify `http://localhost:3000/stelekit/` loads + +**INVEST check**: Single file change; independently reviewable. + +--- + +### Task 1.3 — Add `site/public/.nojekyll` + +**Objective**: Commit `.nojekyll` as a static file so Jekyll is never run on the GitHub Pages deployment. + +**Context boundary**: Create `site/public/.nojekyll` as an empty file. + +**Prerequisites**: `site/` directory exists (Task 1.1). + +**Implementation approach**: Create the file: + +```bash +touch site/public/.nojekyll +``` + +Commit it. The file has no content. Astro copies all `public/` files verbatim to `dist/`, so `.nojekyll` will always be present at the root of every deployed artifact. + +**Why committed, not generated**: If this file were created in a CI step (`touch site/public/.nojekyll`), it could be omitted in a manual deploy or a tooling change. Committing it makes it structurally impossible to forget. + +**Validation strategy**: After `npm run build`, confirm `ls site/dist/.nojekyll` exists. + +**INVEST check**: Trivial; wrong approach (CI-generated) has a documented failure mode that makes committed approach clearly superior. + +--- + +### Task 1.4 — Add placeholder `src/pages/index.astro` + +**Objective**: Replace the default Starlight index page with a custom `.astro` landing page stub so the marketing page route is established. + +**Context boundary**: `site/src/pages/index.astro`. + +**Prerequisites**: Tasks 1.1–1.2 complete. + +**Implementation approach**: + +```astro +--- +// site/src/pages/index.astro +import BaseHead from '../components/BaseHead.astro'; +--- + + + + + + +

SteleKit

+

Your knowledge, carved in stone.

+

Try in Browser | Documentation

+ + +``` + +This is a stub. Full landing page content is in Story 3. + +**Validation strategy**: `npm run build` succeeds; `site/dist/index.html` exists and contains "SteleKit". + +**INVEST check**: Isolated; does not touch documentation content. + +--- + +## Story 2: CI/CD Pipeline + +**Goal**: A working `pages.yml` workflow that builds the demo (Gradle) and site (Astro) and deploys to GitHub Pages on push to `main`. + +### Task 2.1 — Create `.github/workflows/pages.yml` + +**Objective**: Implement the two-job CI pipeline with path-filtered triggers, Gradle caching, Node caching, and `actions/deploy-pages`. + +**Context boundary**: `.github/workflows/pages.yml` (new file). + +**Prerequisites**: Story 1 complete; wasmJs Gradle target exists (from `browser-wasm-demo.md`). If the wasmJs target is not yet available, use a placeholder `build-demo` job that creates an empty `site/public/demo/` directory so the site pipeline can be validated independently. + +**Implementation approach**: + +```yaml +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build-demo: + runs-on: ubuntu-latest + # Only rebuild Wasm when app code changes; site-only edits skip this job + if: | + github.event_name == 'workflow_dispatch' || + contains(toJSON(github.event.head_commit.modified), 'kmp/') || + contains(toJSON(github.event.head_commit.added), 'kmp/') + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + - uses: gradle/actions/setup-gradle@v4 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Build wasmJs demo + run: ./gradlew :kmp:wasmJsBrowserDistribution --no-daemon --build-cache -PenableJs=true + - name: Assert expected output exists + run: test -d kmp/build/dist/wasmJs/productionExecutable/ + - name: Assert no single file exceeds 90 MB + run: | + find kmp/build/dist/wasmJs/productionExecutable -type f -size +90M \ + -exec echo "FILE TOO LARGE: {}" \; | grep . && exit 1 || true + - uses: actions/upload-artifact@v4 + with: + name: demo-dist + path: kmp/build/dist/wasmJs/productionExecutable/ + retention-days: 7 + + build-site: + runs-on: ubuntu-latest + needs: build-demo + if: always() && (needs.build-demo.result == 'success' || needs.build-demo.result == 'skipped') + steps: + - uses: actions/checkout@v4 + - name: Download demo artifact (if available) + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: demo-dist + path: site/public/demo/ + - uses: actions/configure-pages@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: site/package-lock.json + - run: npm ci + working-directory: site + - run: npm run build + working-directory: site + env: + # Expose demo availability to Astro build for conditional rendering + DEMO_AVAILABLE: ${{ steps.download-demo.outcome == 'success' }} + - uses: actions/upload-pages-artifact@v3 + with: + path: site/dist/ + + deploy: + runs-on: ubuntu-latest + needs: build-site + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 +``` + +Key decisions in this implementation: +- `build-demo` only runs when `kmp/` files changed or on manual dispatch; this prevents the 15–20 min Gradle build from running on every docs edit. +- `build-site` uses `if: always() && ...needs.build-demo.result == 'skipped'` so site-only changes still deploy. +- `continue-on-error: true` on the artifact download lets the site build proceed even when no demo artifact was produced (before `browser-wasm-demo.md` ships); the demo page degrades gracefully. +- `DEMO_AVAILABLE` environment variable allows the Astro build to render a fallback screenshot on the demo page when no artifact is present. +- `concurrency: group: pages` prevents concurrent Pages deploys. + +**Validation strategy**: +- Push a change to `kmp/` — verify `build-demo` runs and uploads artifact. +- Push a change to `site/src/` only — verify `build-demo` is skipped but `build-site` + `deploy` run. +- Verify deployment URL is `https://tstapler.github.io/stelekit/`. +- Verify `_astro/` assets are served (`.nojekyll` working). + +**INVEST check**: Can be implemented before demo is ready; path filter strategy is testable independently of Gradle. + +--- + +### Task 2.2 — Enable GitHub Pages in repository settings + +**Objective**: Configure the `stelekit` GitHub repository to use GitHub Actions as the Pages source. + +**Context boundary**: Repository settings (manual step; not automated). + +**Prerequisites**: Task 2.1 committed and pushed. + +**Implementation approach** (manual, one-time): +1. Navigate to `https://github.com/tstapler/stelekit/settings/pages`. +2. Under "Source", select **GitHub Actions**. +3. Do not configure a custom domain (use `tstapler.github.io/stelekit/` for now). +4. Create the `github-pages` deployment environment if it does not exist (`Settings > Environments > New environment`). + +**Validation strategy**: After the first successful `pages.yml` run, `https://tstapler.github.io/stelekit/` responds with `200 OK` and serves the landing page. + +**INVEST check**: One-time operation; no code changes. + +--- + +## Story 3: Landing Page Content + +**Goal**: A complete, publication-ready landing page at `site/src/pages/index.astro` that addresses all three audiences within two clicks. + +### Task 3.1 — Implement landing page sections + +**Objective**: Build the full landing page: hero, platform matrix, feature cards, Logseq comparison table, quick start, dual audience links. + +**Context boundary**: `site/src/pages/index.astro` and any extracted component files in `site/src/components/landing/`. + +**Prerequisites**: Task 1.2 (base path), Task 1.4 (placeholder page exists). + +**Implementation approach**: + +The landing page is a single `.astro` page. Sections in order: + +**Section 1 — Hero** + +``` +Your knowledge, carved in stone. + +A local-first outliner for Desktop and Android. +Reads your existing Logseq markdown. No install required to try. + +[Try in Browser] [Download] +``` + +- "Try in Browser" links to `/stelekit/demo/` +- "Download" links to the GitHub Releases page + +**Section 2 — Platform matrix** + +| Platform | Status | +|---|---| +| Desktop (macOS, Linux, Windows) | Working | +| Android | Working | +| Web (Browser Demo) | Demo available | +| iOS | Planned | + +**Section 3 — Feature cards** (5 cards with icon + headline + 1-sentence description): +1. Outliner — Hierarchical block editing with keyboard-driven nesting +2. Journals — Daily notes auto-created at app startup +3. Bidirectional links — `[[page]]` links indexed in real time; backlinks panel always visible +4. Search — Full-text search across all pages and blocks +5. Local-first — Your files stay on your machine; plain Markdown, always + +**Section 4 — Logseq comparison table** + +| | SteleKit | Logseq | +|---|---|---| +| File format | Markdown (Logseq-compatible) | Markdown | +| Browser demo | Yes — no install | Server-required | +| Desktop | Yes | Yes | +| Android | Yes | Yes | +| iOS | Planned | Yes | +| Open source | Yes (Elastic 2.0) | Yes (AGPL) | +| KMP architecture | Yes | No | + +**Section 5 — Quick start** + +```bash +git clone https://github.com/tstapler/stelekit +./gradlew run +``` + +**Section 6 — Audience links** (row of three cards) +- "User Guide" → `/stelekit/docs/user/getting-started/` +- "Developer Docs" → `/stelekit/docs/developer/architecture/` +- "Contribute" → `/stelekit/docs/developer/contributing/` + +**Voice rules** (from branding-readme.md): +- No adverbs ("seamlessly", "effortlessly", "simply") +- No marketing filler ("powerful", "robust", "cutting-edge") +- Sentences under 15 words +- Active voice + +**Validation strategy**: +- Page loads at `http://localhost:3000/stelekit/` in local serve +- All six sections visible on desktop and mobile viewport +- "Try in Browser" and "Download" links resolve correctly +- No broken links in the audience row + +**INVEST check**: Content-only; no Wasm or CI dependency; can be written and reviewed as a standalone change. + +--- + +## Story 4: Demo Page + +**Goal**: A demo page at `/demo/` that embeds the Compose Wasm canvas, degrades gracefully when the artifact is not available, and guides users on what to try. + +**Depends on**: `browser-wasm-demo.md` Phase A complete (wasmJs + `CanvasBasedWindow` + demo graph seeded). Can be scaffolded before this dependency ships. + +### Task 4.1 — Scaffold demo page with graceful fallback + +**Objective**: Create `site/src/pages/demo.astro` that renders the demo canvas when the artifact is present and shows a screenshot gallery when it is not. + +**Context boundary**: `site/src/pages/demo.astro`. + +**Prerequisites**: Story 1 (site scaffolding), Task 2.1 (`DEMO_AVAILABLE` env var plumbing). + +**Implementation approach**: + +```astro +--- +// site/src/pages/demo.astro +const demoAvailable = import.meta.env.DEMO_AVAILABLE === 'true'; +--- + + + + + SteleKit — Try in Browser + + + +
+

SteleKit in Your Browser

+
+

What to try

+
    +
  • Open the journal entry and edit a block — click to focus, Enter to add below, Tab to indent
  • +
  • Type [[Getting Started]] to create a wiki link
  • +
  • Navigate to Getting Started and look for the Backlinks panel
  • +
  • Press Cmd/Ctrl+K to open search
  • +
+

The demo runs entirely in your browser. No data leaves your device.

+

Requires Chrome 119+, Firefox 120+, or Safari 18.2+.

+
+ {demoAvailable ? ( +