Compare commits
302 Commits
v0.1.0
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e503cb179 | ||
|
|
d83f04c6ff | ||
|
|
9ffa6930c5 | ||
|
|
88b20b83dc | ||
|
|
548e5694ac | ||
|
|
0cf8f55a8d | ||
|
|
72b88cef43 | ||
|
|
8db9f61d5c | ||
|
|
fb2067ec79 | ||
|
|
b60bdb685d | ||
|
|
83009d8fb9 | ||
|
|
068d434344 | ||
|
|
6b2a1b0659 | ||
|
|
bf5f5521af | ||
|
|
f4961d0bc3 | ||
|
|
058de96cbc | ||
|
|
d29f919182 | ||
|
|
42e37e035c | ||
|
|
0ad0fa9b6b | ||
|
|
c37c03d6a9 | ||
|
|
fcc264d7dc | ||
|
|
382ba0a6d4 | ||
|
|
4719810b10 | ||
|
|
20b4ba7a3b | ||
|
|
de00ab6794 | ||
|
|
5d8f514bea | ||
|
|
1fd3ea02ae | ||
|
|
8da1484ebf | ||
|
|
90ebc2e939 | ||
|
|
c14927df8f | ||
|
|
a6d60695a2 | ||
|
|
7ddc0c14bf | ||
|
|
2a00b8aac7 | ||
|
|
b7d2304282 | ||
|
|
50b5fa9c2e | ||
|
|
963e7cf111 | ||
|
|
c9a5d20c12 | ||
|
|
586e1fcde5 | ||
|
|
75d67623ce | ||
|
|
b9d94abae4 | ||
|
|
18b2aaedf6 | ||
|
|
1706300494 | ||
|
|
628ab76f09 | ||
|
|
d3896ddf01 | ||
|
|
7289a60db3 | ||
|
|
0cff626e48 | ||
|
|
5a4c986fe3 | ||
|
|
540b1400e2 | ||
|
|
826cda852a | ||
|
|
19583ea1f5 | ||
|
|
63eb4ff980 | ||
|
|
bbb29857b8 | ||
|
|
900ce7f66f | ||
|
|
eb55c5ec02 | ||
|
|
6f6e7d7cfe | ||
|
|
edcb306c7f | ||
|
|
97be1d1ca3 | ||
|
|
ea29a81d79 | ||
|
|
5b1fda2279 | ||
|
|
1cd596061a | ||
|
|
efb233b18f | ||
|
|
fd34df8db0 | ||
|
|
244deeb727 | ||
|
|
70f94d3a51 | ||
|
|
bb20ef17f6 | ||
|
|
15a1ba6829 | ||
|
|
e2c6669fd6 | ||
|
|
d4098d3308 | ||
|
|
1c3d677851 | ||
|
|
3da0b0e652 | ||
|
|
bc2f97d1d4 | ||
|
|
2fb6a30dff | ||
|
|
0206fb3720 | ||
|
|
51d1b28460 | ||
|
|
0183062467 | ||
|
|
1c244a85eb | ||
|
|
900755055b | ||
|
|
3af9a09d29 | ||
|
|
348b2654eb | ||
|
|
013cf0ffe1 | ||
|
|
418ea8a7a8 | ||
|
|
1e3c296787 | ||
|
|
32b4a90ae5 | ||
|
|
25260c7d58 | ||
|
|
ff3e7af65a | ||
|
|
75878c8b64 | ||
|
|
8f0c6e04fe | ||
|
|
adf45a51b5 | ||
|
|
96bc4e935a | ||
|
|
eab457b388 | ||
|
|
89da970455 | ||
|
|
6a57e83c99 | ||
|
|
d41e4ea34a | ||
|
|
c371e26dcb | ||
|
|
c78018b92a | ||
|
|
5e84ebc28a | ||
|
|
5775cb0c96 | ||
|
|
7ced48d001 | ||
|
|
533c9da4d0 | ||
|
|
e1afaaa5b6 | ||
|
|
914bcfdafd | ||
|
|
cfb047cb22 | ||
|
|
55ff9e6f5d | ||
|
|
d3ea632673 | ||
|
|
6d3d752fd5 | ||
|
|
26181b57b6 | ||
|
|
7efcaa11c4 | ||
|
|
197aad0e97 | ||
|
|
db47ffe454 | ||
|
|
008f067bf7 | ||
|
|
55a2509658 | ||
|
|
790f2ea85c | ||
|
|
ed5e6d6820 | ||
|
|
feb97caa87 | ||
|
|
5bcd344d5f | ||
|
|
816e3ca8bd | ||
|
|
ef148be94e | ||
|
|
ceee8b04ea | ||
|
|
ba7d2f1acc | ||
|
|
7e3c8630a4 | ||
|
|
d5b621e174 | ||
|
|
24c4a5a823 | ||
|
|
ea61ded4af | ||
|
|
3a623d48bb | ||
|
|
3fcb06396a | ||
|
|
17eeac75cd | ||
|
|
2dad764ea7 | ||
|
|
c3fa296b99 | ||
|
|
6370b66e05 | ||
|
|
1ec27dd264 | ||
|
|
10e6d7b366 | ||
|
|
0c87e51037 | ||
|
|
7df1961172 | ||
|
|
ec74f86d53 | ||
|
|
d86dfbfcb6 | ||
|
|
e77208ec34 | ||
|
|
818707603e | ||
|
|
fa3f9adccc | ||
|
|
bf6312000e | ||
|
|
832181b9a9 | ||
|
|
bc011692cd | ||
|
|
0604a13cdd | ||
|
|
eb77607401 | ||
|
|
e305c29e6e | ||
|
|
5b1804e5e1 | ||
|
|
2c5cfe2923 | ||
|
|
35144aedfb | ||
|
|
4d7252c78f | ||
|
|
eb9a3d889f | ||
|
|
6d2988b722 | ||
|
|
a33a000c57 | ||
|
|
f0b456bb8c | ||
|
|
b489fa8e3e | ||
|
|
1a92b4694c | ||
|
|
6b9ff9a4a2 | ||
|
|
97d2182c0e | ||
|
|
80baa74175 | ||
|
|
f71e6d07e7 | ||
|
|
b8a0702e3c | ||
|
|
c48f337c4d | ||
|
|
b32d46f482 | ||
|
|
fd100e1c3b | ||
|
|
249db95c9e | ||
|
|
3c27b63908 | ||
|
|
a246de4483 | ||
|
|
2793024904 | ||
|
|
f0ef9cacec | ||
|
|
d2538e121f | ||
|
|
531666b031 | ||
|
|
e239d71a48 | ||
|
|
e2f1fe239e | ||
|
|
8495f0e2e6 | ||
|
|
19bc3c67e3 | ||
|
|
628d97d909 | ||
|
|
aa6b31317c | ||
|
|
b24ba8a4c6 | ||
|
|
b3f47f4507 | ||
|
|
6fcd0552d0 | ||
|
|
8747a84453 | ||
|
|
2eb3080050 | ||
|
|
99b64c0fac | ||
|
|
b2a68a9a67 | ||
|
|
f5bbcb81a4 | ||
|
|
db5e53f77e | ||
|
|
90e67adc41 | ||
|
|
185feb5a2c | ||
|
|
f04dfdbd37 | ||
|
|
3c27403f86 | ||
|
|
276b3c1a37 | ||
|
|
cadb3500f1 | ||
|
|
ee8f0d6bcd | ||
|
|
41d43501a0 | ||
|
|
32b8e2ee57 | ||
|
|
b1110ead87 | ||
|
|
b75b917538 | ||
|
|
24bb64841e | ||
|
|
df2452dcaf | ||
|
|
89d0e39ad2 | ||
|
|
e3977a6042 | ||
|
|
4cdc534c01 | ||
|
|
1a93acdce7 | ||
|
|
9c8f7907b8 | ||
|
|
8d9862ffef | ||
|
|
a5e4efcf86 | ||
|
|
e4bab72952 | ||
|
|
f08b807a0c | ||
|
|
b521736102 | ||
|
|
4d13628d0b | ||
|
|
2b6000f5d4 | ||
|
|
894cb45779 | ||
|
|
2a1b17f8c6 | ||
|
|
47ca3cb13c | ||
|
|
5fb059d2e9 | ||
|
|
bd7feb94e7 | ||
|
|
de7b624902 | ||
|
|
7e1057482e | ||
|
|
7aa4bf7efe | ||
|
|
6e99bf7d03 | ||
|
|
669d473a64 | ||
|
|
ba1404b808 | ||
|
|
98314c5106 | ||
|
|
bac5a911c1 | ||
|
|
67155db9ab | ||
|
|
b42a4d3608 | ||
|
|
e29be460c3 | ||
|
|
bcfbc8e00e | ||
|
|
95bcf3b25a | ||
|
|
3fff794d9b | ||
|
|
de6023547f | ||
|
|
0effc563dc | ||
|
|
37ac70f720 | ||
|
|
222f697873 | ||
|
|
681554af58 | ||
|
|
87bce9d4cc | ||
|
|
248774ed13 | ||
|
|
6363e84402 | ||
|
|
8106eb86a8 | ||
|
|
7feae25d45 | ||
|
|
cc08055e0a | ||
|
|
c789b49bef | ||
|
|
bccc0ceed4 | ||
|
|
b924ec9b95 | ||
|
|
c56a8c0e7f | ||
|
|
c5bcbecf1f | ||
|
|
5ef4f2d642 | ||
|
|
f52ce5c618 | ||
|
|
d83fef67f0 | ||
|
|
8a91d25395 | ||
|
|
77f76a33a4 | ||
|
|
d8aa615ea3 | ||
|
|
82dafe9334 | ||
|
|
aff56af6aa | ||
|
|
4e2fa0d374 | ||
|
|
465a80d3e1 | ||
|
|
ce12e3cd6f | ||
|
|
e67eb5e85e | ||
|
|
c11184b24a | ||
|
|
ee42731ecc | ||
|
|
3c57b0ff1a | ||
|
|
bd0576c593 | ||
|
|
57f0378bab | ||
|
|
1ff342ae60 | ||
|
|
da55318979 | ||
|
|
384cffec48 | ||
|
|
c0ce5064d8 | ||
|
|
e9c869075a | ||
|
|
f881c13b62 | ||
|
|
92c7b003d9 | ||
|
|
cafdc84260 | ||
|
|
d87c80ece4 | ||
|
|
a67e0bd221 | ||
|
|
ed8a51aa8f | ||
|
|
fb763f5338 | ||
|
|
a18a19166b | ||
|
|
1efdbfb978 | ||
|
|
233497cb5c | ||
|
|
6ff3a1929a | ||
|
|
42b463e07a | ||
|
|
a6b92d5248 | ||
|
|
8c574882e5 | ||
|
|
bb0a5c5c98 | ||
|
|
cd1246d43c | ||
|
|
92c8dbd1bf | ||
|
|
4672fcb5c0 | ||
|
|
d962cd9eae | ||
|
|
88a8035c45 | ||
|
|
035bd7f630 | ||
|
|
9ce890576b | ||
|
|
f569750666 | ||
|
|
9cfa8a7ca2 | ||
|
|
918b204d3d | ||
|
|
177e593e90 | ||
|
|
959e4975e3 | ||
|
|
a2f1476b98 | ||
|
|
9d5c90f5c0 | ||
|
|
e91a7ae7f6 | ||
|
|
47e38ba6b0 | ||
|
|
cbb88b34b8 | ||
|
|
52b1693f43 | ||
|
|
3d784ce740 | ||
|
|
480d124a0e | ||
|
|
b15a4d143c |
1
(HOME)/.tauri/laputa-v2.key
Normal file
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg==
|
||||
1
(HOME)/.tauri/laputa-v2.key.pub
Normal file
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK
|
||||
36
.claude-done
@@ -1,36 +0,0 @@
|
||||
task: refactor-editor-notelist-sidebar
|
||||
ui-change: no
|
||||
|
||||
## Summary
|
||||
|
||||
Refactored three CodeScene hotspot files to improve code health scores:
|
||||
|
||||
| File | Before | After | Approach |
|
||||
|------|--------|-------|----------|
|
||||
| Editor.tsx | 7.82 (cc=35) | 10.0 | Decomposed into EditorContent, EditorRightPanel, SingleEditorView, editorSchema, useDiffMode, useEditorFocus, suggestionEnrichment |
|
||||
| NoteList.tsx | 9.04 (cc=13) | 9.38 | Extracted createNoteStatusResolver, toggleSetMember standalone functions |
|
||||
| Sidebar.tsx | 9.09 (cc=10) | 10.0 | Extracted applyCustomization standalone function |
|
||||
|
||||
## New files
|
||||
|
||||
- `src/components/EditorContent.tsx` — breadcrumb bar + editor/diff view switching
|
||||
- `src/components/EditorRightPanel.tsx` — AI chat / inspector panel toggle
|
||||
- `src/components/SingleEditorView.tsx` — BlockNote editor with suggestion menus
|
||||
- `src/components/editorSchema.tsx` — WikiLink inline content spec + BlockNote schema
|
||||
- `src/hooks/useDiffMode.ts` — diff mode state management
|
||||
- `src/hooks/useEditorFocus.ts` — editor focus on custom event
|
||||
- `src/utils/suggestionEnrichment.ts` — shared suggestion item enrichment pipeline
|
||||
|
||||
## New tests (20 tests)
|
||||
|
||||
- `src/hooks/useDiffMode.test.ts` — 10 tests
|
||||
- `src/hooks/useEditorFocus.test.ts` — 3 tests
|
||||
- `src/utils/suggestionEnrichment.test.ts` — 7 tests
|
||||
|
||||
## Validation
|
||||
|
||||
- 976 tests pass (956 existing + 20 new)
|
||||
- 80% frontend coverage (≥70% required)
|
||||
- 270 Rust tests pass
|
||||
- CodeScene quality gates: passed
|
||||
- Zero behavior change — pure refactor
|
||||
1
.claude-pid
Normal file
@@ -0,0 +1 @@
|
||||
91305
|
||||
9
.codescenerc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"exclude": [
|
||||
"tools/",
|
||||
"scripts/",
|
||||
"src-tauri/gen/",
|
||||
"coverage/",
|
||||
"dist/"
|
||||
]
|
||||
}
|
||||
5
.github/workflows/ci.yml
vendored
@@ -60,6 +60,9 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Bundle MCP server resources (required by Tauri build)
|
||||
run: node scripts/bundle-mcp-server.mjs
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
@@ -72,8 +75,10 @@ jobs:
|
||||
run: |
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--fail-under-lines 85
|
||||
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
|
||||
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
|
||||
|
||||
# ── 3. Code Health (CodeScene — Hotspot Code Health gate) ────────────
|
||||
# The webhook integration handles per-PR delta analysis (posts review
|
||||
|
||||
39
.github/workflows/release.yml
vendored
@@ -55,6 +55,11 @@ jobs:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun (required for bundle-qmd.sh)
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -80,10 +85,34 @@ jobs:
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (with signing)
|
||||
- name: Import Apple Developer certificate into keychain
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Decode and import the certificate so codesign can use it in beforeBuildCommand
|
||||
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
|
||||
KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
@@ -147,7 +176,7 @@ jobs:
|
||||
REPO="refactoringhq/laputa-app"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
|
||||
cat > latest.json << EOF
|
||||
{
|
||||
@@ -157,7 +186,7 @@ jobs:
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +225,8 @@ jobs:
|
||||
run: |
|
||||
mkdir -p _site
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
# Copy latest.json to GitHub Pages for auto-updater endpoint
|
||||
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
4
.gitignore
vendored
@@ -41,3 +41,7 @@ final_selection.py
|
||||
# Claude Code task signals
|
||||
.claude-done
|
||||
.claude-blocked
|
||||
src-tauri/target
|
||||
|
||||
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
|
||||
src-tauri/resources/
|
||||
|
||||
@@ -47,26 +47,26 @@ fi
|
||||
|
||||
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [0/4] TypeScript + Vite build..."
|
||||
echo "📦 [0/5] TypeScript + Vite build..."
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm build
|
||||
echo " ✅ Build OK"
|
||||
|
||||
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
echo ""
|
||||
echo "📊 [1/4] Frontend tests + coverage (≥70%)..."
|
||||
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
|
||||
pnpm test:coverage --silent
|
||||
echo " ✅ Frontend coverage OK"
|
||||
|
||||
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
echo "🔧 [2/4] Clippy + rustfmt..."
|
||||
echo "🔧 [2/5] Clippy + rustfmt..."
|
||||
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
echo " ✅ Rust lint OK"
|
||||
else
|
||||
echo "⏭️ [2/4] Rust lint — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
@@ -75,23 +75,38 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
LLVM_COV_FLAGS="--no-clean"
|
||||
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
|
||||
LLVM_COV_FLAGS=""
|
||||
echo "🦀 [3/4] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
else
|
||||
echo "🦀 [3/4] Rust coverage (≥85%, incremental)..."
|
||||
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
|
||||
fi
|
||||
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
# shellcheck disable=SC2086
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
$LLVM_COV_FLAGS \
|
||||
--fail-under-lines 85
|
||||
--ignore-filename-regex "search\.rs|lib\.rs" \
|
||||
--fail-under-lines 85 \
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 4. CodeScene code health gate (≥9.2) ────────────────────────────────
|
||||
# ── 4. Playwright smoke tests (if any exist) ──────────────────────────
|
||||
echo ""
|
||||
echo "🏥 [4/4] CodeScene code health gate (≥9.2)..."
|
||||
SMOKE_FILES=$(find tests/smoke -name '*.spec.ts' 2>/dev/null | head -1)
|
||||
if [ -n "$SMOKE_FILES" ]; then
|
||||
echo "🎭 [4/5] Playwright smoke tests..."
|
||||
pnpm playwright:smoke
|
||||
echo " ✅ Smoke tests OK"
|
||||
else
|
||||
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate (≥9.2) ────────────────────────────────
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health gate (≥9.2)..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
|
||||
177
CLAUDE.md
@@ -15,7 +15,40 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
|
||||
|
||||
**CI is a safety net, not a discovery tool.** If CI catches something you didn't catch locally, that's a process failure. All these tools are available locally — use them while you code, not just at the end.
|
||||
|
||||
## ⛔ BEFORE FIRING laputa-task-done — QA on real vault
|
||||
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA (mandatory)
|
||||
|
||||
### Phase 1: Playwright browser QA (headless, you do this yourself)
|
||||
|
||||
Test every acceptance criterion using Playwright against the dev server **before** marking done. This catches 80% of bugs before Brian sees them.
|
||||
|
||||
```bash
|
||||
# 1. Start the dev server (use your worktree port)
|
||||
pnpm dev --port <N> &
|
||||
DEV_PID=$!
|
||||
sleep 3 # wait for vite to be ready
|
||||
|
||||
# 2. Run Playwright smoke test for this task
|
||||
BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
# 3. Or run all smoke tests
|
||||
BASE_URL="http://localhost:<N>" pnpm playwright:smoke
|
||||
|
||||
kill $DEV_PID
|
||||
```
|
||||
|
||||
**What to test in Playwright:**
|
||||
- Every command palette entry from the spec → open `Cmd+K`, type the command name, verify it appears and executes
|
||||
- Every keyboard shortcut → send keydown events, verify UI state changes
|
||||
- Every UI element described in the spec → verify it renders, is focusable, responds to Tab
|
||||
- Edge cases: empty state, long text, rapid keypresses
|
||||
|
||||
**Playwright is non-negotiable even if tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
|
||||
|
||||
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. Test those via `pnpm tauri dev` in Phase 2 if the task touches them.
|
||||
|
||||
### Phase 2: Native Tauri QA (Brian does this after you push)
|
||||
|
||||
Brian installs the release build and runs keyboard-only QA on the native app. You don't do Phase 2 — but Phase 1 must pass before you fire the done signal, or Brian's QA will fail and the task goes back to To Rework.
|
||||
|
||||
1. Acquire lockfile: `echo $$ > /tmp/laputa-qa.lock && trap "rm -f /tmp/laputa-qa.lock" EXIT`
|
||||
2. Kill other instances: `pkill -x laputa 2>/dev/null || true; sleep 1`
|
||||
@@ -25,6 +58,19 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
|
||||
6. If task touches file save: verify `git -C ~/Laputa diff` shows changes
|
||||
7. If QA fails → fix and re-run. Do NOT fire the signal until it passes.
|
||||
|
||||
**⚠️ QA ≠ tests. QA means using the app as a user.**
|
||||
- "Tests pass" is NOT QA. Tests verify code, QA verifies the user experience.
|
||||
- The QA comment must describe what you did as a user: "Opened app → Cmd+K → typed 'Trash' → pressed Enter → note disappeared from list → restarted app → note still not visible"
|
||||
- Every QA comment must include: the exact keyboard/command palette steps used, what was visible before and after, and any edge case tested.
|
||||
- If you cannot test a feature using keyboard only (osascript shortcuts + command palette), the feature is not keyboard-first → QA fails.
|
||||
|
||||
**⚠️ Test in a clean environment when the feature depends on state.**
|
||||
If a feature involves indexing, fresh installs, first-time setup, or anything that only runs once:
|
||||
- **Do not test in the existing dev vault** — it already has the state you're trying to test.
|
||||
- **Create a new empty vault** for the test: Cmd+K → "New Vault" (or equivalent), pick a temp folder like `/tmp/test-vault-<slug>`, then test the full first-time flow from scratch.
|
||||
- This applies to: search indexing, vault init, getting-started setup, any "on first open" logic.
|
||||
- If you can't reproduce the fresh-install scenario locally, the feature is untestable → do not fire done.
|
||||
|
||||
Fire done signal only after QA passes:
|
||||
```bash
|
||||
rm -f /tmp/laputa-qa.lock
|
||||
@@ -69,14 +115,70 @@ Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with
|
||||
|
||||
- **Never develop on `main`** — always on `task/<slug>` branch
|
||||
- **Commit every 20–30 min** — atomic commits, one concern per commit (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`)
|
||||
- **Update docs/** when changing architecture, abstractions, or significant design
|
||||
- **Test as you go** — write tests alongside code, not after
|
||||
- **Update docs/** when changing architecture, abstractions, or significant design (mandatory — see rule below)
|
||||
|
||||
## Testing
|
||||
## ⛔ DOCS — Keep docs/ in sync with code (mandatory)
|
||||
|
||||
After any significant feature change, update the relevant `docs/` files **in the same commit**:
|
||||
|
||||
- **`docs/ARCHITECTURE.md`** — stack, system overview, component structure, Tauri commands, data flow, backend modules
|
||||
- **`docs/ABSTRACTIONS.md`** — domain models, VaultEntry fields, entity types, key abstractions, integration patterns
|
||||
- **`docs/GETTING-STARTED.md`** — directory structure, key files, common tasks, test commands, onboarding
|
||||
|
||||
**What counts as "significant":**
|
||||
- Adding a new Tauri command or backend module
|
||||
- Adding a new major component, hook, or feature (not a bugfix)
|
||||
- Changing the data model (VaultEntry fields, new types, new config files)
|
||||
- Adding a new integration (API, service, transport)
|
||||
- Changing the architecture (new panels, new state management, new build steps)
|
||||
|
||||
**How to update:**
|
||||
1. Read the relevant doc section before making changes
|
||||
2. After your code changes, update the doc to reflect the new state
|
||||
3. Commit doc changes together with the code — not in a separate follow-up commit
|
||||
|
||||
If unsure whether a change is "significant", err on the side of updating. Stale docs are worse than slightly verbose docs.
|
||||
|
||||
## TDD — Red/Green/Refactor (mandatory)
|
||||
|
||||
**Always use test-driven development.** No production code without a failing test first.
|
||||
|
||||
The loop:
|
||||
1. **Red** — write a failing test that describes the behavior you want. Run it, confirm it fails for the right reason.
|
||||
2. **Green** — write the minimum code to make the test pass. No more, no less.
|
||||
3. **Refactor** — clean up the code (extract, rename, simplify) while keeping tests green.
|
||||
4. **Commit** — one red/green/refactor cycle = one atomic commit.
|
||||
5. Repeat.
|
||||
|
||||
**Why this matters:**
|
||||
- Forces you to think about behavior before implementation
|
||||
- Produces only code that's actually needed (no speculative abstractions)
|
||||
- Tests written first are always behavioral and structure-insensitive by construction
|
||||
- Tiny cycles = fast feedback, smaller diffs, easier to review
|
||||
|
||||
**For bug fixes:**
|
||||
1. Write a failing test that reproduces the bug (this is the regression test)
|
||||
2. Fix the bug until the test passes
|
||||
3. Commit both together: `fix: [bug] — regression test added`
|
||||
|
||||
**For Rust:**
|
||||
```bash
|
||||
cargo watch -x test # run tests on every save
|
||||
```
|
||||
|
||||
**For frontend:**
|
||||
```bash
|
||||
pnpm test --watch # run tests on every save
|
||||
```
|
||||
|
||||
**When to deviate:** Pure UI layout/styling work with no logic is the only exception. Everything else — hooks, utilities, Rust commands, state management — must be TDD.
|
||||
|
||||
## Testing (quality bar)
|
||||
|
||||
- Unit tests must cover real business logic, not "component renders"
|
||||
- Every bug fixed manually → add a regression test
|
||||
- Every new feature → new tests for new behavior paths
|
||||
- Tests test **behavior** (what the code does), not **structure** (how it does it)
|
||||
- Every bug fixed → regression test that would have caught it
|
||||
- Every new feature → TDD from the start (see above)
|
||||
- `pnpm test:coverage` and `cargo llvm-cov` must pass before committing
|
||||
|
||||
## Design File (every UI task)
|
||||
@@ -99,6 +201,33 @@ node -e "const f=JSON.parse(require('fs').readFileSync('ui-design.pen','utf8'));
|
||||
echo '{"children":[],"variables":{}}' > design/<slug>.pen
|
||||
```
|
||||
|
||||
## Vault File Retrocompatibility (mandatory for every feature that adds vault files)
|
||||
|
||||
Laputa vaults are long-lived. New app versions must work on existing vaults that were created before a feature existed.
|
||||
|
||||
**Rule: never assume a vault file exists. Always auto-create if missing.**
|
||||
|
||||
Every feature that depends on a vault file or folder must:
|
||||
1. **Auto-bootstrap on vault open** — check if the required file/folder exists; if not, create it with defaults. This must be silent and non-blocking.
|
||||
2. **Be idempotent** — creating defaults must be safe to run multiple times (never overwrite user data).
|
||||
3. **Expose a repair command** — add a `Cmd+K` command like "Restore Default Themes" or "Repair Vault Config" that explicitly re-creates missing files. Users can run this if something is broken.
|
||||
|
||||
**General "Repair Vault" command** — when adding a new vault file dependency, register it with the central repair system so that `Cmd+K → "Repair Vault"` fixes everything in one shot.
|
||||
|
||||
**Pattern:**
|
||||
```
|
||||
on vault open:
|
||||
if file X does not exist → create X with defaults ← silent auto-repair
|
||||
if file X exists but is malformed → log warning, use defaults (don't crash)
|
||||
|
||||
on "Repair Vault" command:
|
||||
for each known vault file/folder:
|
||||
if missing → create with defaults
|
||||
if present → leave untouched (idempotent)
|
||||
```
|
||||
|
||||
This principle applies to: themes, config files, type files, any `.laputa/` subfolder, or any file Laputa expects to find in a vault.
|
||||
|
||||
## macOS / Tauri Gotchas
|
||||
|
||||
- `Option+N` on macOS → special chars (`¡`, `™`), not `key:'N'`. Use `e.code` or `Cmd+N`.
|
||||
@@ -114,6 +243,39 @@ bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/click.sh 400 300 # logical coords
|
||||
```
|
||||
|
||||
## Menu Bar Discoverability (mandatory for every new command)
|
||||
|
||||
The command palette is powerful but not discoverable — users must already know a command exists to find it. The macOS menu bar is where users discover what an app can do.
|
||||
|
||||
**Rule: every significant command palette entry must also appear in the menu bar.**
|
||||
|
||||
When adding a new command to the palette:
|
||||
1. **Identify the right menu bar group** — File, Edit, View, Note, Vault, or create a new group if needed
|
||||
2. **Add a menu item** with the same label as the palette command
|
||||
3. **Show the keyboard shortcut** next to the menu item (if one exists)
|
||||
4. **If no direct shortcut exists**, still add the menu item — it's discoverable and triggers the same action
|
||||
|
||||
The menu bar should be organized around what Laputa does:
|
||||
- **File** — new note, open vault, switch vault, close
|
||||
- **Edit** — undo, redo, find, note actions (rename, trash, duplicate)
|
||||
- **View** — view modes, zoom, sidebar, panels
|
||||
- **Note** — note-specific actions (move to trash, archive, properties)
|
||||
- **Vault** — vault management (themes, config, repair, sync)
|
||||
- **Window / Help** — standard macOS items
|
||||
|
||||
**This is a QA requirement:** before marking any task done, verify that every new command palette entry has a corresponding menu bar item.
|
||||
|
||||
## Keyboard-First Principle (mandatory for every new feature)
|
||||
|
||||
Every feature must be reachable via keyboard. This is both a UX requirement and a QA requirement — Brian tests the native app using keyboard only (osascript key events, no mouse).
|
||||
|
||||
**Before marking any task done:**
|
||||
- Can the feature be triggered/used without touching the mouse?
|
||||
- If it requires clicking a button, add a command palette entry or keyboard shortcut
|
||||
- Document the shortcut in the command palette or menu bar
|
||||
|
||||
**If you add UI that is only reachable by mouse**, you must also add a keyboard path (command palette entry, shortcut, or Tab-navigable focus). No exceptions.
|
||||
|
||||
## Push Workflow (IMPORTANT — changed Feb 27, 2026)
|
||||
|
||||
**Push directly to main** — no PRs, no branches, no CI queue.
|
||||
@@ -125,6 +287,9 @@ The pre-push hook runs all checks locally before the push goes through. This rep
|
||||
git push origin main # pre-push hook runs automatically
|
||||
```
|
||||
|
||||
### ⛔ NEVER open a Pull Request
|
||||
PRs on separate branches diverge from main with every merge, requiring continuous rebases and creating unnecessary conflicts. Always push directly to main. If the push fails (disk full, test failure, etc.) — fix the problem, then push again. There is no scenario where opening a PR is the right fallback.
|
||||
|
||||
### ⛔ NEVER use --no-verify
|
||||
```bash
|
||||
# FORBIDDEN — will be caught and rejected:
|
||||
|
||||
53
VISION.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Laputa — Vision
|
||||
|
||||
Laputa is a personal knowledge base where humans and AI agents collaborate as equals.
|
||||
|
||||
---
|
||||
|
||||
## Core principles
|
||||
|
||||
### 1. The vault is the source of truth
|
||||
Everything lives in the vault as plain text files. Notes, relations, configuration, instructions — all Markdown with YAML frontmatter. No proprietary database, no hidden state. If you can open a terminal, you can read your vault. If you can write Markdown, you can modify it.
|
||||
|
||||
### 2. Vault-native configuration
|
||||
Laputa configures itself through files inside the vault — the same files you write and read every day. There is no separate "settings app" or admin panel. If you want to change a theme, you edit a note. If you want to give instructions to an AI agent, you write a note. If you want to define a template, you create a note.
|
||||
|
||||
This applies to:
|
||||
- **`_themes/`** — themes as notes with a YAML block in the body. Edit `_themes/dark.md`, see the colors change in real time.
|
||||
- **`AGENTS.md`** — instructions for AI agents. Write what you want them to know about your vault in plain language. They read it before acting.
|
||||
- **`_templates/`** — note templates per type. Create `_templates/event.md` and every new event starts from that structure.
|
||||
- **`_procedures/`** — recurring tasks as notes with a `schedule` frontmatter field.
|
||||
|
||||
The principle: **if it can be expressed in frontmatter + Markdown, it doesn't need a UI**.
|
||||
|
||||
### 3. Structure through types, not folders
|
||||
Notes have a `type` field. Types determine folders, icons, and colors — but the structure is defined by the data, not the filesystem hierarchy. You can query "all events in February" without knowing anything about folder layout.
|
||||
|
||||
Relations between notes are expressed as frontmatter arrays: `people: [Marco, Sara]`. A wikilink `[[Marco]]` in the body navigates to the person note. The graph emerges from the data, not from a separate graph database.
|
||||
|
||||
### 4. The file is the interface
|
||||
You can use Laputa's UI, or you can open a terminal. Or a text editor. Or Claude Code. They all operate on the same files. There is no difference between "the app" and "the vault" — the vault is the app.
|
||||
|
||||
This is why Laputa has an MCP server: external agents get the same tools the in-app AI panel uses. The interface is a convenience, not a requirement.
|
||||
|
||||
### 5. Humans and AI as collaborators
|
||||
Pulse — the activity feed — shows the history of the vault without distinguishing between human commits and agent commits. That's intentional. Laputa is designed to be a space where you and your AI agents work together, each contributing to the same knowledge base.
|
||||
|
||||
The AI doesn't have a separate workspace. It works in yours.
|
||||
|
||||
---
|
||||
|
||||
## What Laputa is not
|
||||
|
||||
- Not a todo app (though you can use it as one)
|
||||
- Not a note-taking app that syncs to the cloud (the vault is yours, sync however you want — git, iCloud, rsync)
|
||||
- Not a replacement for a terminal (power users will use both)
|
||||
- Not trying to abstract away git (git is a feature, not an implementation detail)
|
||||
|
||||
---
|
||||
|
||||
## The long game
|
||||
|
||||
A vault that grows with you for years. Events, people, projects, thoughts — all interconnected, all version-controlled, all accessible to any tool that can read a file.
|
||||
|
||||
Ten years from now, your vault should still be readable. Plain text is forever.
|
||||
3
demo-vault-v2/.laputa/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"theme": null
|
||||
}
|
||||
33
demo-vault-v2/_themes/dark.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Dark",
|
||||
"description": "Dark variant with deep navy tones",
|
||||
"colors": {
|
||||
"background": "#0f0f1a",
|
||||
"foreground": "#e0e0e0",
|
||||
"card": "#16162a",
|
||||
"popover": "#1e1e3a",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#2a2a4a",
|
||||
"secondary-foreground": "#e0e0e0",
|
||||
"muted": "#1e1e3a",
|
||||
"muted-foreground": "#888888",
|
||||
"accent": "#2a2a4a",
|
||||
"accent-foreground": "#e0e0e0",
|
||||
"destructive": "#f44336",
|
||||
"border": "#2a2a4a",
|
||||
"input": "#2a2a4a",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#1a1a2e",
|
||||
"sidebar-foreground": "#e0e0e0",
|
||||
"sidebar-border": "#2a2a4a",
|
||||
"sidebar-accent": "#2a2a4a"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/default.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Default",
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"colors": {
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#37352F",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"destructive": "#E03E3E",
|
||||
"border": "#E9E9E7",
|
||||
"input": "#E9E9E7",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-foreground": "#37352F",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-accent": "#EBEBEA"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/minimal.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Minimal",
|
||||
"description": "High contrast, minimal chrome",
|
||||
"colors": {
|
||||
"background": "#FAFAFA",
|
||||
"foreground": "#111111",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#000000",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#F0F0F0",
|
||||
"secondary-foreground": "#111111",
|
||||
"muted": "#F5F5F5",
|
||||
"muted-foreground": "#666666",
|
||||
"accent": "#F0F0F0",
|
||||
"accent-foreground": "#111111",
|
||||
"destructive": "#CC0000",
|
||||
"border": "#E0E0E0",
|
||||
"input": "#E0E0E0",
|
||||
"ring": "#000000",
|
||||
"sidebar-background": "#F5F5F5",
|
||||
"sidebar-foreground": "#111111",
|
||||
"sidebar-border": "#E0E0E0",
|
||||
"sidebar-accent": "#E8E8E8"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'SF Mono', 'Menlo', monospace",
|
||||
"font-size-base": "13px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "220px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-2.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-3.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-4.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
5
demo-vault-v2/config/ui.config.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: config
|
||||
zoom: 1.3
|
||||
view_mode: all
|
||||
---
|
||||
@@ -4,6 +4,8 @@ Is A: Note
|
||||
Author: "Clayton Christensen"
|
||||
Topics: ["[[topic-saas-business]]"]
|
||||
URL: "https://example.com/innovators-dilemma"
|
||||
trashed: true
|
||||
trashed_at: 2026-03-04
|
||||
---
|
||||
# The Innovator's Dilemma
|
||||
*Clayton Christensen*
|
||||
|
||||
5
demo-vault-v2/note/wikilinks-qa-test.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Wikilinks QA Test
|
||||
|
||||
See [[ProjectX]] and [[Team Goals|Goals]] for details on the project timeline.
|
||||
|
||||
This is a test note for QA purposes.
|
||||
54
demo-vault-v2/theme/dark.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Dark variant with deep navy tones
|
||||
background: "#0f0f1a"
|
||||
foreground: "#e0e0e0"
|
||||
card: "#16162a"
|
||||
popover: "#1e1e3a"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#2a2a4a"
|
||||
secondary-foreground: "#e0e0e0"
|
||||
muted: "#1e1e3a"
|
||||
muted-foreground: "#888888"
|
||||
accent: "#2a2a4a"
|
||||
accent-foreground: "#e0e0e0"
|
||||
destructive: "#f44336"
|
||||
border: "#2a2a4a"
|
||||
input: "#2a2a4a"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#1a1a2e"
|
||||
sidebar-foreground: "#e0e0e0"
|
||||
sidebar-border: "#2a2a4a"
|
||||
sidebar-accent: "#2a2a4a"
|
||||
text-primary: "#e0e0e0"
|
||||
text-secondary: "#888888"
|
||||
text-muted: "#666666"
|
||||
text-heading: "#e0e0e0"
|
||||
bg-primary: "#0f0f1a"
|
||||
bg-sidebar: "#1a1a2e"
|
||||
bg-hover: "#2a2a4a"
|
||||
bg-hover-subtle: "#1e1e3a"
|
||||
bg-selected: "#155DFF22"
|
||||
border-primary: "#2a2a4a"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#f44336"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF33"
|
||||
accent-green-light: "#00B38B33"
|
||||
accent-purple-light: "#A932FF33"
|
||||
accent-red-light: "#f4433633"
|
||||
accent-yellow-light: "#F0B10033"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720
|
||||
---
|
||||
|
||||
# Dark Theme
|
||||
|
||||
A dark theme with deep navy tones for comfortable night-time reading.
|
||||
54
demo-vault-v2/theme/default.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#EBEBEA"
|
||||
secondary-foreground: "#37352F"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#787774"
|
||||
accent: "#EBEBEA"
|
||||
accent-foreground: "#37352F"
|
||||
destructive: "#E03E3E"
|
||||
border: "#E9E9E7"
|
||||
input: "#E9E9E7"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
sidebar-foreground: "#37352F"
|
||||
sidebar-border: "#E9E9E7"
|
||||
sidebar-accent: "#EBEBEA"
|
||||
text-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#E03E3E"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF14"
|
||||
accent-green-light: "#00B38B14"
|
||||
accent-purple-light: "#A932FF14"
|
||||
accent-red-light: "#E03E3E14"
|
||||
accent-yellow-light: "#F0B10014"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720
|
||||
---
|
||||
|
||||
# Default Theme
|
||||
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.
|
||||
54
demo-vault-v2/theme/minimal.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: High contrast, minimal chrome
|
||||
background: "#FAFAFA"
|
||||
foreground: "#111111"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#000000"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#F0F0F0"
|
||||
secondary-foreground: "#111111"
|
||||
muted: "#F5F5F5"
|
||||
muted-foreground: "#666666"
|
||||
accent: "#F0F0F0"
|
||||
accent-foreground: "#111111"
|
||||
destructive: "#CC0000"
|
||||
border: "#E0E0E0"
|
||||
input: "#E0E0E0"
|
||||
ring: "#000000"
|
||||
sidebar: "#F5F5F5"
|
||||
sidebar-foreground: "#111111"
|
||||
sidebar-border: "#E0E0E0"
|
||||
sidebar-accent: "#E8E8E8"
|
||||
text-primary: "#111111"
|
||||
text-secondary: "#666666"
|
||||
text-muted: "#999999"
|
||||
text-heading: "#111111"
|
||||
bg-primary: "#FAFAFA"
|
||||
bg-sidebar: "#F5F5F5"
|
||||
bg-hover: "#EBEBEB"
|
||||
bg-hover-subtle: "#F5F5F5"
|
||||
bg-selected: "#00000014"
|
||||
border-primary: "#E0E0E0"
|
||||
accent-blue: "#000000"
|
||||
accent-green: "#006600"
|
||||
accent-orange: "#996600"
|
||||
accent-red: "#CC0000"
|
||||
accent-purple: "#660099"
|
||||
accent-yellow: "#996600"
|
||||
accent-blue-light: "#00000014"
|
||||
accent-green-light: "#00660014"
|
||||
accent-purple-light: "#66009914"
|
||||
accent-red-light: "#CC000014"
|
||||
accent-yellow-light: "#99660014"
|
||||
font-family: "'SF Mono', 'Menlo', monospace"
|
||||
font-size-base: 13px
|
||||
editor-font-size: 15
|
||||
editor-line-height: 1.6
|
||||
editor-max-width: 680
|
||||
---
|
||||
|
||||
# Minimal Theme
|
||||
|
||||
High contrast, minimal chrome. Monospace typography throughout.
|
||||
340
design/ai-agent-panel-ui.pen
Normal file
@@ -0,0 +1,340 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_closed",
|
||||
"name": "AI Agent Panel — Closed (trigger button in toolbar)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_closed_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_closed_toolbar",
|
||||
"name": "Right Toolbar",
|
||||
"layout": "vertical",
|
||||
"width": 36,
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"padding": [8, 4],
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_trigger_btn",
|
||||
"name": "AI Trigger Button",
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
"cornerRadius": 6,
|
||||
"fill": "$--muted",
|
||||
"layout": "horizontal",
|
||||
"padding": [6, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_trigger_icon",
|
||||
"content": "✦",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_open_idle",
|
||||
"name": "AI Agent Panel — Open, Idle (empty state)",
|
||||
"x": 1320,
|
||||
"y": 0,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_open_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_sidebar",
|
||||
"name": "AI Panel Sidebar",
|
||||
"layout": "vertical",
|
||||
"width": 320,
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_header",
|
||||
"name": "Panel Header",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_header_label",
|
||||
"content": "AI",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "$--muted-foreground",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_close_icon",
|
||||
"content": "✕",
|
||||
"fontSize": 12,
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_empty_state",
|
||||
"name": "Empty State",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"padding": [24, 24],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_empty_hint",
|
||||
"content": "Ask the AI agent to work on your vault…",
|
||||
"fontSize": 13,
|
||||
"fill": "$--muted-foreground",
|
||||
"textAlign": "center",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_input_row",
|
||||
"name": "Input Row",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_input_field",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": 6,
|
||||
"fill": "$--muted"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_send_btn",
|
||||
"name": "Send Button",
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
"cornerRadius": 6,
|
||||
"fill": "$--primary",
|
||||
"layout": "horizontal",
|
||||
"padding": [6, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_send_icon",
|
||||
"content": "→",
|
||||
"fontSize": 12,
|
||||
"fill": "$--primary-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_active",
|
||||
"name": "AI Agent Panel — Active (streaming, action cards visible)",
|
||||
"x": 2640,
|
||||
"y": 0,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_active_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_active_panel",
|
||||
"name": "AI Panel — With Messages",
|
||||
"layout": "vertical",
|
||||
"width": 320,
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_active_header",
|
||||
"name": "Panel Header",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_active_label",
|
||||
"content": "AI",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "$--muted-foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_message_thread",
|
||||
"name": "Message Thread",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"padding": [12, 12],
|
||||
"gap": 16,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_user_msg",
|
||||
"name": "User Message",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "You",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "600",
|
||||
"fill": "$--muted-foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Crea una nota evento per la riunione con Marco domani",
|
||||
"fontSize": 13,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_agent_msg",
|
||||
"name": "Agent Message + Actions",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_action_done",
|
||||
"name": "Action Card — Done",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [0, 8],
|
||||
"gap": 6,
|
||||
"cornerRadius": 4,
|
||||
"fill": "$--muted",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "✓",
|
||||
"fontSize": 12,
|
||||
"fill": "$--primary"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Loaded vault context",
|
||||
"fontSize": 12,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_action_progress",
|
||||
"name": "Action Card — In Progress",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [0, 8],
|
||||
"gap": 6,
|
||||
"cornerRadius": 4,
|
||||
"fill": "$--muted",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "◌",
|
||||
"fontSize": 12,
|
||||
"fill": "$--primary"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Creating: 2026-03-01-meeting-marco.md",
|
||||
"fontSize": 12,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.",
|
||||
"fontSize": 13,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
design/ai-agent-wiring.pen
Normal file
1
design/archived-note-indicator.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
96
design/build-number-status-bar.pen
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "build_number_status_bar",
|
||||
"name": "Build Number — Status Bar",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 36,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_vault",
|
||||
"content": "vault-name",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_sep1",
|
||||
"content": "|",
|
||||
"fill": "$--border",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_build",
|
||||
"content": "b223",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_note",
|
||||
"content": "← dynamic build number from package.json, replacing hardcoded v0.4.2",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "build_number_fallback",
|
||||
"name": "Build Number — Fallback (no build info)",
|
||||
"x": 0,
|
||||
"y": 60,
|
||||
"width": 400,
|
||||
"height": 36,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fallback_build",
|
||||
"content": "b?",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fallback_note",
|
||||
"content": "← shows b? when build number unavailable",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
387
design/command-palette-type-aware.pen
Normal file
@@ -0,0 +1,387 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_ev",
|
||||
"name": "Command Palette — type \"new ev\" autocomplete",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 620,
|
||||
"height": "fit_content(500)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 24,
|
||||
"padding": [32, 32],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_title",
|
||||
"content": "Command Palette — \"new ev\" autocomplete",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_dialog",
|
||||
"name": "Palette Dialog",
|
||||
"width": 520,
|
||||
"height": "fit_content(440)",
|
||||
"fill": "$--popover",
|
||||
"layout": "vertical",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"stroke": "$--border",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_input_row",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["bottom"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_query",
|
||||
"content": "new ev",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_results",
|
||||
"name": "Results",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"padding": [4, 0],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_group_label",
|
||||
"content": "NOTE",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1,
|
||||
"padding": [8, 16, 4, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_row1",
|
||||
"name": "New Event (selected)",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"fill": "$--accent",
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_row1_label",
|
||||
"content": "New Event",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_row2",
|
||||
"name": "New Event Log",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_row2_label",
|
||||
"content": "New Event Log",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_row3",
|
||||
"name": "New Experiment",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_row3_label",
|
||||
"content": "New Experiment",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_footer",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"gap": 16,
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["top"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_hint1",
|
||||
"content": "↑↓ navigate",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_hint2",
|
||||
"content": "↵ select",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_hint3",
|
||||
"content": "esc close",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_pe",
|
||||
"name": "Command Palette — type \"list pe\" autocomplete",
|
||||
"x": 720,
|
||||
"y": 0,
|
||||
"width": 620,
|
||||
"height": "fit_content(500)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 24,
|
||||
"padding": [32, 32],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_title",
|
||||
"content": "Command Palette — \"list pe\" autocomplete",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_dialog",
|
||||
"name": "Palette Dialog",
|
||||
"width": 520,
|
||||
"height": "fit_content(440)",
|
||||
"fill": "$--popover",
|
||||
"layout": "vertical",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"stroke": "$--border",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_input_row",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["bottom"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_query",
|
||||
"content": "list pe",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_results",
|
||||
"name": "Results",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"padding": [4, 0],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_group_label",
|
||||
"content": "NAVIGATION",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1,
|
||||
"padding": [8, 16, 4, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_row1",
|
||||
"name": "List People (selected)",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"fill": "$--accent",
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_row1_label",
|
||||
"content": "List People",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_row2",
|
||||
"name": "List Procedures",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_row2_label",
|
||||
"content": "List Procedures",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_row3",
|
||||
"name": "List Projects",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_row3_label",
|
||||
"content": "List Projects",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_footer",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"gap": 16,
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["top"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_hint1",
|
||||
"content": "↑↓ navigate",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_hint2",
|
||||
"content": "↵ select",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_hint3",
|
||||
"content": "esc close",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
78
design/drag-drop-images.pen
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_drop_idle",
|
||||
"name": "Drag & Drop Images — Idle (no drag)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 900,
|
||||
"height": 600,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "drag_idle_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_drop_active",
|
||||
"name": "Drag & Drop Images — Drag Over (overlay shown)",
|
||||
"x": 940,
|
||||
"y": 0,
|
||||
"width": 900,
|
||||
"height": 600,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_active_container",
|
||||
"name": "Editor with Drag Overlay",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "drag_active_editor_bg",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_overlay",
|
||||
"name": "Drop Overlay",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"layout": "vertical",
|
||||
"fill": "rgba(0,0,0,0.4)",
|
||||
"cornerRadius": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "drag_overlay_label",
|
||||
"content": "Drop image to insert",
|
||||
"fontSize": 18,
|
||||
"fontWeight": "600",
|
||||
"fill": "#ffffff",
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
56
design/fix-property-dropdown-narrow.pen
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fpdn_before",
|
||||
"name": "Property Dropdown Narrow — Before (clipped)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": 200,
|
||||
"fill": "#1a1a1a",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 12,
|
||||
"theme": "dark",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fpdn_before_label",
|
||||
"content": "Bug: StatusDropdown clipped by overflow:hidden container at 200-280px panel width",
|
||||
"fill": "#ff4444",
|
||||
"fontFamily": "SF Pro Text",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "regular",
|
||||
"letterSpacing": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fpdn_after",
|
||||
"name": "Property Dropdown Narrow — After (portal, no clip)",
|
||||
"x": 320,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": 200,
|
||||
"fill": "#1a1a1a",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 12,
|
||||
"theme": "dark",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fpdn_after_label",
|
||||
"content": "Fix: StatusDropdown + DisplayModeSelector use createPortal with fixed positioning — renders above overflow:hidden, visible at any panel width",
|
||||
"fill": "#44bb44",
|
||||
"fontFamily": "SF Pro Text",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "regular",
|
||||
"letterSpacing": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
381
design/getting-started-vault.pen
Normal file
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_screen",
|
||||
"name": "Getting Started — Welcome Screen",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_card",
|
||||
"name": "Welcome Card",
|
||||
"width": 520,
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 24,
|
||||
"padding": [48, 48, 48, 48],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_icon_wrap",
|
||||
"name": "Icon",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"fill": "#EBF4FF",
|
||||
"cornerRadius": [16, 16, 16, 16],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_icon_text",
|
||||
"content": "✦",
|
||||
"fontSize": 28,
|
||||
"fill": "#2383E2",
|
||||
"fontFamily": "Inter"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_text_group",
|
||||
"name": "Text Group",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_title",
|
||||
"content": "Welcome to Laputa",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700",
|
||||
"letterSpacing": -0.5,
|
||||
"textAlign": "center"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_subtitle",
|
||||
"content": "Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"lineHeight": 1.6,
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "welcome_divider",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_actions",
|
||||
"name": "Actions",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_create_vault",
|
||||
"name": "Create Getting Started Vault Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#2383E2",
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_create_icon",
|
||||
"content": "+",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_create_label",
|
||||
"content": "Create Getting Started vault",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_open_folder",
|
||||
"name": "Open Existing Folder Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#FFFFFF",
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_open_icon",
|
||||
"content": "📂",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_open_label",
|
||||
"content": "Open an existing folder",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_hint",
|
||||
"content": "The Getting Started vault will be created in ~/Documents/Laputa",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_missing_screen",
|
||||
"name": "Getting Started — Vault Missing Error",
|
||||
"x": 1540,
|
||||
"y": 0,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_card",
|
||||
"name": "Missing Vault Card",
|
||||
"width": 520,
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 24,
|
||||
"padding": [48, 48, 48, 48],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_icon_wrap",
|
||||
"name": "Warning Icon",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"fill": "#FFF3E0",
|
||||
"cornerRadius": [16, 16, 16, 16],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_icon_text",
|
||||
"content": "⚠",
|
||||
"fontSize": 28,
|
||||
"fill": "#E8890C",
|
||||
"fontFamily": "Inter"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_text_group",
|
||||
"name": "Text Group",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_title",
|
||||
"content": "Vault not found",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700",
|
||||
"letterSpacing": -0.5,
|
||||
"textAlign": "center"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_subtitle",
|
||||
"content": "The vault folder could not be found on disk.\nIt may have been moved or deleted.",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"lineHeight": 1.6,
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_path_badge",
|
||||
"name": "Path Badge",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#F7F6F3",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"padding": [8, 12, 8, 12],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_path_text",
|
||||
"content": "~/Laputa",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "SF Mono, monospace",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "missing_divider",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_actions",
|
||||
"name": "Actions",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_recreate_vault",
|
||||
"name": "Create Getting Started Vault Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#2383E2",
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_recreate_icon",
|
||||
"content": "+",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_recreate_label",
|
||||
"content": "Create Getting Started vault",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_choose_folder",
|
||||
"name": "Choose Different Folder Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#FFFFFF",
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_choose_icon",
|
||||
"content": "📂",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_choose_label",
|
||||
"content": "Choose a different folder",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
258
design/github-oauth-fix.pen
Normal file
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "GitHub OAuth Fix — Modal with inline device flow",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix01-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix01-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix01-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix01-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-body",
|
||||
"name": "DeviceFlowLogin",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-login-btn",
|
||||
"name": "LoginButton",
|
||||
"width": 200,
|
||||
"height": 36,
|
||||
"fill": "$--foreground",
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix01-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
|
||||
{ "type": "text", "id": "gofix01-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02",
|
||||
"x": 660,
|
||||
"y": 0,
|
||||
"name": "GitHub OAuth Fix — Device code waiting state",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix02-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix02-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-body",
|
||||
"name": "DeviceCodeCard",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-card",
|
||||
"name": "CodeCard",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [16, 24],
|
||||
"cornerRadius": 8,
|
||||
"alignItems": "center",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-hint", "content": "Enter this code on GitHub:", "fontSize": 12, "fill": "$--muted-foreground" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-code-row",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-code", "content": "ABCD-1234", "fontSize": 24, "fontWeight": 700, "letterSpacing": 4, "fontFamily": "monospace", "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "gofix02-copy", "content": "[copy]", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "gofix02-url", "content": "https://github.com/login/device", "fontSize": 12, "fill": "$--muted-foreground", "textDecoration": "underline" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-spinner-row",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-waiting", "content": "Waiting for authorization...", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-cancel-btn",
|
||||
"name": "CancelButton",
|
||||
"width": 80,
|
||||
"height": 32,
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-cancel-label", "content": "Cancel", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03",
|
||||
"x": 1320,
|
||||
"y": 0,
|
||||
"name": "GitHub OAuth Fix — Expired/Error state with retry",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix03-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix03-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-body",
|
||||
"name": "ErrorState",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-login-btn",
|
||||
"name": "LoginButton",
|
||||
"width": 200,
|
||||
"height": 36,
|
||||
"fill": "$--foreground",
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
|
||||
{ "type": "text", "id": "gofix03-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-error-row",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-error-msg", "content": "Authorization expired. Please try again.", "fontSize": 12, "fill": "$--destructive" },
|
||||
{ "type": "text", "id": "gofix03-retry-label", "content": "Retry", "fontSize": 12, "fill": "$--destructive" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
1
design/gs-git-init.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"type":"frame","id":"gs_git_init_after","name":"Getting Started Vault — Git Initialized","x":0,"y":0,"width":600,"height":"fit_content(200)","fill":"#FFFFFF","layout":"vertical","gap":16,"padding":[32,32],"children":[{"type":"text","id":"gs_title","content":"After: Getting Started vault created","fontFamily":"Inter","fontSize":20,"fontWeight":"700","fill":"#111111"},{"type":"text","id":"gs_desc","content":"When a new Getting Started vault is created, it is automatically initialized as a git repo with an initial commit containing all sample files.","fontFamily":"Inter","fontSize":14,"lineHeight":1.5,"fill":"#555555"},{"type":"frame","id":"gs_terminal","name":"Git Log Output","width":"fill_container","height":"fit_content(80)","fill":"#1E1E1E","cornerRadius":[8,8,8,8],"padding":[16,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"gs_prompt","content":"$ git log --oneline","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#AAAAAA"},{"type":"text","id":"gs_log","content":"a1b2c3d Initial vault setup","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#66FF66"}]},{"type":"frame","id":"gs_details","name":"Details","width":"fill_container","layout":"vertical","gap":8,"children":[{"type":"text","id":"gs_detail1","content":"git init + git add . + git commit","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail2","content":"Fallback author: Laputa <vault@laputa.app> (if no global git config)","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail3","content":"No UI changes — backend only (git.rs + getting_started.rs)","fontFamily":"Inter","fontSize":13,"fill":"#333333"}]}]}],"variables":{}}
|
||||
17
design/gs-wikilinks-fix.pen
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"name": "Getting Started Wikilinks — Fixed (styled with type color)",
|
||||
"type": "FRAME",
|
||||
"width": 800,
|
||||
"height": 400,
|
||||
"children": [
|
||||
{
|
||||
"name": "Description",
|
||||
"type": "TEXT",
|
||||
"characters": "Wikilinks in Getting Started vault now resolve correctly:\n- path/to/note targets match entry paths ending in /path/to/note.md\n- Display text shows entry title (not raw path)\n- Color reflects the linked note's type (e.g. blue for Note type)\n- Broken links show muted color as expected\n\nBefore: [[note/welcome-to-laputa]] showed raw path, unstyled\nAfter: [[note/welcome-to-laputa]] shows 'Welcome to Laputa' in Note blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
156
design/keyboard-first-nav.pen
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_notelist_nav",
|
||||
"name": "Keyboard-first — Note list keyboard navigation",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": "fit_content(400)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"padding": [8, 0],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_notelist_label",
|
||||
"content": "Note list — ↑↓ highlight, Enter opens",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500",
|
||||
"padding": [0, 12, 8, 12]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_note_item_selected",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"fill": "$--accent",
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_note_title_active",
|
||||
"content": "My selected note (keyboard highlight)",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_note_item_normal",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"fill": "transparent",
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_note_title_normal",
|
||||
"content": "Another note",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_menu_bar",
|
||||
"name": "Keyboard-first — Menu bar shortcuts added",
|
||||
"x": 320,
|
||||
"y": 0,
|
||||
"width": 400,
|
||||
"height": "fit_content(200)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [16, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_menu_title",
|
||||
"content": "New menu bar shortcuts",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_s1",
|
||||
"content": "Archive Note — ⌘E\nTrash Note — ⌘⌫\nFind in Vault — ⌘⇧F\nGo Back — ⌘[\nGo Forward — ⌘]",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"lineHeight": 1.8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_inspector_tab",
|
||||
"name": "Keyboard-first — Inspector Tab+Enter navigation",
|
||||
"x": 760,
|
||||
"y": 0,
|
||||
"width": 300,
|
||||
"height": "fit_content(200)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [16, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_inspector_title",
|
||||
"content": "Inspector — Tab focuses rows, Enter starts editing",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_prop_focused",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_prop_key",
|
||||
"content": "Status",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_prop_val",
|
||||
"content": "In Progress (focused — ring visible)",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
design/mcp-autodetect-status-bar.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "nnc_pending_save",
|
||||
"name": "New Note Creation — Pending Save State",
|
||||
"name": "New Note Creation — Unsaved State (Cmd+N)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 800,
|
||||
@@ -36,7 +36,7 @@
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "nnc_tab_active",
|
||||
"name": "Tab — New Note (unsaved)",
|
||||
"name": "Tab — Unsaved Note (blue dot + italic)",
|
||||
"layout": "horizontal",
|
||||
"width": 180,
|
||||
"height": 40,
|
||||
@@ -53,16 +53,17 @@
|
||||
"width": 8,
|
||||
"height": 8,
|
||||
"cornerRadius": 4,
|
||||
"fill": "#22c55e"
|
||||
"fill": "#3b82f6"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "nnc_tab_label",
|
||||
"content": "Untitled",
|
||||
"content": "Untitled Note",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "400"
|
||||
"fontWeight": "400",
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
design/note-list-title-position.pen
Normal file
1
design/note-templates.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/notes-type-icon-search.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/raw-editor-mode.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
165
design/relationship-x-cosmetic.pen
Normal file
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_default",
|
||||
"name": "Relationship Pill — Default (X hidden)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": "fit_content(40)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_default_label",
|
||||
"content": "Relationship Pill — Default State",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_pill_default",
|
||||
"layout": "horizontal",
|
||||
"gap": 4,
|
||||
"padding": [
|
||||
4,
|
||||
8
|
||||
],
|
||||
"fill": "$--accent",
|
||||
"borderRadius": 6,
|
||||
"width": "fit_content",
|
||||
"height": "fit_content",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_pill_label",
|
||||
"content": "Note Title",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "rel_x_type_icon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"fill": "$--muted-foreground",
|
||||
"opacity": 0.5,
|
||||
"borderRadius": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_note_default",
|
||||
"content": "X button not visible in default state",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_hover",
|
||||
"name": "Relationship Pill — Hover (X visible)",
|
||||
"x": 320,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": "fit_content(40)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_hover_label",
|
||||
"content": "Relationship Pill — Hover State",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_pill_hover",
|
||||
"layout": "horizontal",
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
4,
|
||||
8
|
||||
],
|
||||
"fill": "$--accent",
|
||||
"borderRadius": 6,
|
||||
"width": "fit_content",
|
||||
"height": "fit_content",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_pill_label_hover",
|
||||
"content": "Note Title",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_trailing",
|
||||
"layout": "horizontal",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_icon",
|
||||
"content": "×",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "400"
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "rel_x_type_icon_hover",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"fill": "$--muted-foreground",
|
||||
"opacity": 0.5,
|
||||
"borderRadius": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_note_hover",
|
||||
"content": "X appears inline as flex child, before type icon. Ring border on hover.",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
design/search-bundle-qmd.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"id":"status-bar-indexing","type":"frame","name":"StatusBar - Indexing Progress States","layout":"vertical","x":0,"y":0,"width":1400,"height":400,"gap":24,"padding":24,"fill":"#F7F6F3","children":[{"id":"title","type":"text","content":"StatusBar Indexing Progress - search-bundle-qmd","fontSize":20,"fontWeight":"600","fill":"#37352F"},{"id":"desc","type":"text","content":"The indexing badge appears in the StatusBar left section, after the pending changes badge. It shows the current indexing phase with a spinner icon (Loader2) during active phases and a Search icon when complete.","fontSize":14,"fill":"#9B9A97","width":900},{"id":"states","type":"frame","name":"States","layout":"vertical","gap":16,"width":1350,"children":[{"id":"state-idle","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-idle","type":"text","content":"idle:","fontSize":12,"fontWeight":"600","fill":"#9B9A97","width":100},{"id":"val-idle","type":"text","content":"(hidden - no badge shown)","fontSize":12,"fill":"#9B9A97"}]},{"id":"state-installing","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-install","type":"text","content":"installing:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-install","type":"text","content":"| [spinner] Installing search\u2026","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-scanning","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-scan","type":"text","content":"scanning:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-scan","type":"text","content":"| [spinner] Indexing\u2026 342/1,057","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-embedding","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-embed","type":"text","content":"embedding:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-embed","type":"text","content":"| [spinner] Embedding\u2026 50/200","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-complete","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-done","type":"text","content":"complete:","fontSize":12,"fontWeight":"600","fill":"#22c55e","width":100},{"id":"val-done","type":"text","content":"| [search icon] Index ready (auto-dismisses after 5s)","fontSize":12,"fill":"#22c55e"}]},{"id":"state-error","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-err","type":"text","content":"error:","fontSize":12,"fontWeight":"600","fill":"#f97316","width":100},{"id":"val-err","type":"text","content":"| [search icon] Index error (orange accent)","fontSize":12,"fill":"#f97316"}]}]},{"id":"flow","type":"frame","name":"Flow","layout":"vertical","gap":8,"width":1350,"children":[{"id":"flow-title","type":"text","content":"Auto-indexing Flow","fontSize":16,"fontWeight":"600","fill":"#37352F"},{"id":"flow-desc","type":"text","content":"1. Vault opens \u2192 useIndexing checks index status via get_index_status\n2. If qmd missing or collection missing or pending embeds \u2192 triggers start_indexing\n3. Rust emits indexing-progress events (installing \u2192 scanning \u2192 embedding \u2192 complete)\n4. StatusBar shows IndexingBadge with spinner + phase label + progress counts\n5. On file save \u2192 trigger_incremental_index runs qmd update in background\n6. Complete phase auto-dismisses after 5 seconds","fontSize":13,"fill":"#37352F","width":900}]}]}],"variables":{}}
|
||||
665
design/search-results-subtitle.pen
Normal file
@@ -0,0 +1,665 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_main",
|
||||
"name": "Search Results — subtitle with metadata",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 540,
|
||||
"fill": "$--card",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "$--border"
|
||||
},
|
||||
"shadow": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 8,
|
||||
"blur": 32,
|
||||
"spread": -4,
|
||||
"fill": "#00000020"
|
||||
}
|
||||
],
|
||||
"layout": "vertical",
|
||||
"clip": true,
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_inputRow",
|
||||
"name": "searchInputRow",
|
||||
"width": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 8,
|
||||
"padding": [14, 16],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "srs_searchIcon",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"iconFontName": "magnifying-glass",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--primary"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_query",
|
||||
"fill": "$--foreground",
|
||||
"content": "time management",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_resultsHeader",
|
||||
"name": "resultsHeader",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 16],
|
||||
"alignItems": "center",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_count",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "3 results \u00b7 45ms",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_result1",
|
||||
"name": "searchResult1 (selected)",
|
||||
"width": "fill_container",
|
||||
"fill": "$--accent-blue-light",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r1_titleRow",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Time Management Strategies",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r1_badge",
|
||||
"name": "typeBadge",
|
||||
"fill": "$--accent-green-light",
|
||||
"cornerRadius": 4,
|
||||
"padding": [2, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_badgeTxt",
|
||||
"fill": "$--accent-green",
|
||||
"content": "Evergreen",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r1_meta",
|
||||
"name": "metadataRow",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_date",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "2d ago",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_sep1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_words",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "1,247 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_sep2",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_links",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "5 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_result2",
|
||||
"name": "searchResult2",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r2_titleRow",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Weekly Review Process",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r2_badge",
|
||||
"name": "typeBadge",
|
||||
"fill": "$--accent-purple-light",
|
||||
"cornerRadius": 4,
|
||||
"padding": [2, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_badgeTxt",
|
||||
"fill": "$--accent-purple",
|
||||
"content": "Procedure",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r2_meta",
|
||||
"name": "metadataRow",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_date",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "5h ago",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_sep1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_created",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Created Jan 15",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_sep2",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_words",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "856 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_sep3",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_links",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "12 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_result3",
|
||||
"name": "searchResult3",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16, 14, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r3_titleRow",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Q1 Planning",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r3_meta",
|
||||
"name": "metadataRow",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_date",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Jan 3",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_sep1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_words",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "2,103 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_footer",
|
||||
"name": "searchFooter",
|
||||
"width": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"top": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 16,
|
||||
"padding": [8, 16],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_hint1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u2191\u2193 Navigate",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_hint2",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u21b5 Open",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_hint3",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Esc Close",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_states",
|
||||
"name": "Search Results — subtitle states",
|
||||
"x": 600,
|
||||
"y": 0,
|
||||
"width": 540,
|
||||
"layout": "vertical",
|
||||
"gap": 24,
|
||||
"padding": [24, 24],
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "$--border"
|
||||
},
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_states_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Metadata Subtitle — States",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state1",
|
||||
"name": "State: all metadata present",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s1_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "All metadata present (modified \u2260 created)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s1_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Time Management Strategies",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s1_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "2d ago \u00b7 Created Jan 15 \u00b7 1,247 words \u00b7 5 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state2",
|
||||
"name": "State: no created date (same as modified)",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s2_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "Modified = Created (skip 'Created' field)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s2_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Quick Note",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s2_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "just now \u00b7 342 words \u00b7 2 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state3",
|
||||
"name": "State: no links (zero outgoingLinks)",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s3_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "No links (omitted from metadata)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s3_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Random Thought",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s3_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "3h ago \u00b7 128 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state4",
|
||||
"name": "State: empty note (zero words)",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s4_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "Empty note (word count shows 'Empty')",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s4_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Untitled Draft",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s4_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "5d ago \u00b7 Empty",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state5",
|
||||
"name": "State: no date at all",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s5_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "No date available (graceful degradation)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s5_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Legacy Import",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s5_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "89 words \u00b7 1 link",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
1
design/sort-picker-properties.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/sync-conflict-resolution.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/tab-responsive-width.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/tags-property-type.pen
Normal file
1
design/themes-editable.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/theming-system.pen
Normal file
1
design/trashed-note-editor.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"id":"trashed-banner-frame","type":"frame","name":"Trashed Note Banner","x":0,"y":0,"width":720,"height":340,"fill":"#FFFFFF","cornerRadius":[8,8,8,8],"children":[{"id":"breadcrumb","type":"frame","name":"Breadcrumb Bar","width":720,"height":45,"fill":"#FFFFFF","layout":"horizontal","mainAxisAlignment":"space-between","crossAxisAlignment":"center","padding":16,"children":[{"id":"breadcrumb-left","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","children":[{"id":"type-label","type":"text","content":"Note","fontSize":12,"textColor":"#6B7280"},{"id":"sep","type":"text","content":"›","fontSize":12,"textColor":"#6B7280"},{"id":"title","type":"text","content":"My Trashed Note","fontSize":12,"fontWeight":"600","textColor":"#1F2937"},{"id":"dot","type":"text","content":"·","fontSize":12,"textColor":"#6B7280"},{"id":"words","type":"text","content":"1,234 words","fontSize":12,"textColor":"#6B7280"}]},{"id":"breadcrumb-right","type":"frame","layout":"horizontal","gap":12,"crossAxisAlignment":"center","children":[{"id":"restore-icon","type":"text","content":"↺","fontSize":14,"textColor":"#6B7280"},{"id":"inspector-icon","type":"text","content":"⚙","fontSize":14,"textColor":"#6B7280"}]}]},{"id":"banner","type":"frame","name":"Trashed Note Banner","width":720,"height":32,"fill":"#FEF2F2","layout":"horizontal","crossAxisAlignment":"center","gap":8,"padding":16,"children":[{"id":"trash-icon","type":"text","content":"🗑","fontSize":12,"textColor":"#DC2626"},{"id":"banner-text","type":"text","content":"This note is in the Trash","fontSize":12,"textColor":"#6B7280","width":400},{"id":"restore-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"restore-icon-2","type":"text","content":"↺","fontSize":11,"textColor":"#2563EB"},{"id":"restore-label","type":"text","content":"Restore","fontSize":11,"textColor":"#2563EB"}]},{"id":"delete-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"delete-icon","type":"text","content":"🗑","fontSize":11,"textColor":"#DC2626"},{"id":"delete-label","type":"text","content":"Delete permanently","fontSize":11,"textColor":"#DC2626"}]}]},{"id":"editor-area","type":"frame","name":"Editor (read-only, muted)","width":720,"height":260,"fill":"#FAFAFA","padding":32,"children":[{"id":"h1","type":"text","content":"My Trashed Note","fontSize":24,"fontWeight":"700","textColor":"#9CA3AF"},{"id":"p1","type":"text","content":"This is the content of a trashed note. The editor is non-editable.","fontSize":14,"textColor":"#9CA3AF","y":40},{"id":"p2","type":"text","content":"Navigation and copy still work, but typing and editing are disabled.","fontSize":14,"textColor":"#9CA3AF","y":64}]}]}],"variables":{}}
|
||||
1
design/vault-agents-md.pen
Normal file
281
design/vault-management-remove.pen
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_menu_remove",
|
||||
"name": "Vault Menu — Remove from List",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 380,
|
||||
"height": 320,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24, 24, 24],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vault_menu_title",
|
||||
"content": "Vault Menu — Remove Action",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_menu_dropdown",
|
||||
"name": "Vault Dropdown",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"layout": "vertical",
|
||||
"padding": [4, 4, 4, 4],
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_active",
|
||||
"name": "Active Vault Item",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"fill": "#EBEBEA",
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_active_left",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{ "type": "text", "id": "vault_check", "content": "✓", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "vault_label_active", "content": "My Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "remove_btn_active",
|
||||
"name": "Remove Button",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"cornerRadius": [3, 3, 3, 3],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "x_icon_active", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_other",
|
||||
"name": "Other Vault Item",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_other_left",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{ "type": "text", "id": "vault_spacer", "content": " ", "fill": "transparent", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "vault_label_other", "content": "Work Vault", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "remove_btn_other",
|
||||
"name": "Remove Button",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"cornerRadius": [3, 3, 3, 3],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "x_icon_other", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_separator",
|
||||
"name": "Separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_open_folder",
|
||||
"name": "Open Local Folder",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{ "type": "text", "id": "folder_icon", "content": "📁", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "open_folder_label", "content": "Open local folder", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vault_menu_note",
|
||||
"content": "× button removes vault from list without deleting files.\nAvailable when 2+ vaults in list.",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmd_k_vault_commands",
|
||||
"name": "Cmd+K — Vault Commands",
|
||||
"x": 420,
|
||||
"y": 0,
|
||||
"width": 520,
|
||||
"height": 320,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24, 24, 24],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cmdk_title",
|
||||
"content": "Command Palette — Vault Commands",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_palette",
|
||||
"name": "Command Palette",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"layout": "vertical",
|
||||
"padding": [0, 0, 0, 0],
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_search",
|
||||
"name": "Search Input",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 16, 0, 16],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmdk_search_text", "content": "vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_group_header",
|
||||
"name": "Settings Group",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "group_label", "content": "Settings", "fill": "#B4B4B4", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_open_vault",
|
||||
"name": "Open Vault Command",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_open_vault", "content": "Open Vault…", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_remove_vault",
|
||||
"name": "Remove Vault Command (highlighted)",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"fill": "#E8F4FE",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_remove_vault", "content": "Remove Vault from List", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_restore_gs",
|
||||
"name": "Restore Getting Started Command",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_restore_gs", "content": "Restore Getting Started Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cmdk_note",
|
||||
"content": "• 'Remove Vault from List' removes active vault (disabled if only 1 vault)\n• 'Restore Getting Started Vault' shown when GS vault is hidden\n• Both accessible via Cmd+K search with 'vault' keyword",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
202
design/zoom-shortcuts.pen
Normal file
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_normal",
|
||||
"name": "Zoom Shortcuts — StatusBar (100% / default)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 800,
|
||||
"height": 32,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"padding": [0, 12],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_normal_desc",
|
||||
"name": "description",
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_normal_desc_text",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "StatusBar at 100% zoom — zoom indicator is HIDDEN. Cmd+= zooms in, Cmd+- zooms out, Cmd+0 resets to 100%.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_zoomed",
|
||||
"name": "Zoom Shortcuts — StatusBar (150% / zoomed in)",
|
||||
"x": 0,
|
||||
"y": 60,
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 800,
|
||||
"height": 32,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"padding": [0, 12],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_statusbar_zoomed",
|
||||
"name": "StatusBar — 150% (zoom indicator visible)",
|
||||
"layout": "horizontal",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"padding": [0, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_wordcount_z",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "1,247 words",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_zoom_badge",
|
||||
"name": "Zoom % Badge",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"padding": [2, 6],
|
||||
"alignItems": "center",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_zoom_label",
|
||||
"fill": "$--foreground",
|
||||
"content": "150%",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_palette",
|
||||
"name": "Zoom Shortcuts — Command Palette entries",
|
||||
"x": 0,
|
||||
"y": 120,
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 540,
|
||||
"height": 120,
|
||||
"fill": "$--popover",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_row1",
|
||||
"name": "Command — Zoom In (⌘=)",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"fill": "$--accent",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row1_label",
|
||||
"fill": "$--accent-foreground",
|
||||
"content": "Zoom In",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row1_shortcut",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Command+=",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_row2",
|
||||
"name": "Command — Zoom Out (⌘-)",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row2_label",
|
||||
"fill": "$--foreground",
|
||||
"content": "Zoom Out",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row2_shortcut",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Command+-",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_row3",
|
||||
"name": "Command — Reset Zoom (⌘0)",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row3_label",
|
||||
"fill": "$--foreground",
|
||||
"content": "Reset Zoom",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row3_shortcut",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Command+0",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -8,24 +8,32 @@ All data lives in markdown files with YAML frontmatter. There is no database —
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined identically in Rust (`src-tauri/src/vault.rs`) and TypeScript (`src/types.ts`):
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`):
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
interface VaultEntry {
|
||||
path: string // Absolute file path: /Users/luca/Laputa/project/my-project.md
|
||||
filename: string // Just the filename: my-project.md
|
||||
title: string // Extracted from first # heading, or filename as fallback
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc.
|
||||
aliases: string[] // Alternative names for wikilink resolution
|
||||
belongsTo: string[] // Parent relationships (wikilinks)
|
||||
relatedTo: string[] // Related entity links (wikilinks)
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
owner: string | null // Person responsible
|
||||
cadence: string | null // Update frequency: Weekly, Monthly, etc.
|
||||
path: string // Absolute file path
|
||||
filename: string // Just the filename
|
||||
title: string // From first # heading, or filename fallback
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc.
|
||||
aliases: string[] // Alternative names for wikilink resolution
|
||||
belongsTo: string[] // Parent relationships (wikilinks)
|
||||
relatedTo: string[] // Related entity links (wikilinks)
|
||||
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
|
||||
outgoingLinks: string[] // All [[wikilinks]] found in note body
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
owner: string | null // Person responsible
|
||||
cadence: string | null // Update frequency: Weekly, Monthly, etc.
|
||||
modifiedAt: number | null // Unix timestamp (seconds)
|
||||
createdAt: number | null // Unix timestamp (seconds)
|
||||
fileSize: number
|
||||
wordCount: number | null // Body word count (excludes frontmatter)
|
||||
snippet: string | null // First 200 chars of body
|
||||
archived: boolean // Archived flag
|
||||
trashed: boolean // Trashed flag
|
||||
trashedAt: number | null // When trashed (for auto-purge)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -47,26 +55,41 @@ Entity type is inferred from the folder structure. The vault is organized by typ
|
||||
├── quarter/ → "Quarter"
|
||||
├── journal/ → "Journal"
|
||||
├── essay/ → "Essay"
|
||||
└── evergreen/ → "Evergreen"
|
||||
├── evergreen/ → "Evergreen"
|
||||
├── theme/ → "Theme" ← vault-based themes
|
||||
└── config/ → "Config" ← meta-configuration files (agents.md, etc.)
|
||||
```
|
||||
|
||||
Mapping logic lives in `vault.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
||||
Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
||||
|
||||
### Types as Files
|
||||
|
||||
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
||||
|
||||
- Have `Is A: Type` in their frontmatter
|
||||
- Describe what the type means, its expected properties, and how it relates to other types
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any other note
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
|
||||
- Serve as the "definition" for their type category
|
||||
|
||||
**Type document properties** (read by Rust and used in the UI):
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `icon` | string | Phosphor icon name (kebab-case, e.g., "cooking-pot") |
|
||||
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
|
||||
| `order` | number | Sidebar display order (lower = higher priority) |
|
||||
| `sidebar_label` | string | Custom label overriding auto-pluralization |
|
||||
| `template` | string | Markdown template for new notes of this type |
|
||||
| `sort` | string | Default sort: "modified:desc", "title:asc", "property:Priority:asc" |
|
||||
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
|
||||
| `visible` | bool | Whether type appears in sidebar (default: true) |
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header (e.g., "Projects") pins the type document at the top of the NoteList if it exists, with instances listed below
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
|
||||
- The Type field in the Inspector properties panel is rendered as a clickable chip that navigates to the type document
|
||||
- The Type field in the Inspector is rendered as a clickable chip that navigates to the type document
|
||||
|
||||
### Frontmatter Format
|
||||
|
||||
@@ -88,24 +111,48 @@ aliases:
|
||||
---
|
||||
```
|
||||
|
||||
Supported value types (defined in `src-tauri/src/frontmatter.rs` as `FrontmatterValue`):
|
||||
Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `FrontmatterValue`):
|
||||
- **String**: `status: Active`
|
||||
- **Number**: `priority: 5`
|
||||
- **Bool**: `archived: true`
|
||||
- **List**: Multi-line ` - item` or inline `[item1, item2]`
|
||||
- **Null**: `owner:` (empty value)
|
||||
|
||||
### Custom Relationships
|
||||
|
||||
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Topics:
|
||||
- "[[topic/writing]]"
|
||||
- "[[topic/productivity]]"
|
||||
Key People:
|
||||
- "[[person/matteo-cellini]]"
|
||||
---
|
||||
```
|
||||
|
||||
Becomes: `relationships["Topics"] = ["[[topic/writing]]", "[[topic/productivity]]"]`
|
||||
|
||||
This enables arbitrary, extensible relationship types without code changes.
|
||||
|
||||
### Outgoing Links
|
||||
|
||||
All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs.
|
||||
|
||||
### Title Extraction
|
||||
|
||||
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. This logic lives in `vault.rs:extract_title()`.
|
||||
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. Logic in `vault/parsing.rs:extract_title()`.
|
||||
|
||||
### Sidebar Selection
|
||||
|
||||
Navigation state is modeled as a discriminated union:
|
||||
|
||||
```typescript
|
||||
type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' }
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
|
||||
| { kind: 'entity'; entry: VaultEntry } // specific entity selected
|
||||
| { kind: 'topic'; entry: VaultEntry } // topic selected
|
||||
@@ -115,49 +162,60 @@ type SidebarSelection =
|
||||
|
||||
### Vault Scanning (Rust)
|
||||
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault.rs`:
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Uses `walkdir` to recursively traverse the directory (follows symlinks)
|
||||
2. Uses `walkdir` to recursively traverse (follows symlinks)
|
||||
3. Filters to `.md` files only
|
||||
4. For each file, calls `parse_md_file()`:
|
||||
- Reads file content with `fs::read_to_string()`
|
||||
- Reads content with `fs::read_to_string()`
|
||||
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
||||
- Extracts title from first `#` heading
|
||||
- Infers entity type from parent folder name
|
||||
- Parses dates (`created_at`, `created_time`) as ISO 8601 to Unix timestamps
|
||||
- Collects file metadata (size, modification time)
|
||||
5. Sorts results by `modified_at` descending (newest first)
|
||||
- Infers entity type from parent folder name (or explicit `Is A`/`type` frontmatter)
|
||||
- Parses dates as ISO 8601 to Unix timestamps
|
||||
- Extracts relationships, outgoing links, custom properties, word count, snippet
|
||||
5. Sorts by `modified_at` descending
|
||||
6. Skips unparseable files with a warning log
|
||||
|
||||
### Vault Caching
|
||||
|
||||
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
|
||||
|
||||
1. Reads `.laputa-cache.json` if it exists
|
||||
2. Compares cache version, vault path, and git HEAD commit hash
|
||||
3. If cache is valid and same commit → only re-parse uncommitted changed files
|
||||
4. If different commit → use `git diff` to find changed files → selective re-parse
|
||||
5. If no cache → full scan
|
||||
6. Writes updated cache after every scan
|
||||
|
||||
### Frontmatter Manipulation (Rust)
|
||||
|
||||
`frontmatter::update_frontmatter_content()` in `src-tauri/src/frontmatter.rs` performs line-by-line YAML editing:
|
||||
`frontmatter/ops.rs:update_frontmatter_content()` performs line-by-line YAML editing:
|
||||
|
||||
1. Finds the frontmatter block between `---` delimiters
|
||||
2. Iterates through lines looking for the target key (handles quoted keys like `"Is A"`)
|
||||
2. Iterates through lines looking for the target key
|
||||
3. If found: replaces the value (consuming multi-line list items if present)
|
||||
4. If not found: appends the new key-value at the end of the frontmatter
|
||||
5. If no frontmatter exists: creates a new `---` block with the key-value
|
||||
4. If not found: appends the new key-value at the end
|
||||
5. If no frontmatter exists: creates a new `---` block
|
||||
|
||||
The `with_frontmatter()` helper wraps this in a read-transform-write cycle on the actual file.
|
||||
|
||||
### Content Loading
|
||||
|
||||
- **Tauri mode**: Content is loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
|
||||
- **Browser mode**: All content is loaded at startup from `MOCK_CONTENT` in `mock-tauri.ts`
|
||||
- **Tauri mode**: Content loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
|
||||
- **Browser mode**: All content loaded at startup from mock data
|
||||
- Content for backlink detection (`allContent`) is stored in memory as `Record<string, string>`
|
||||
|
||||
## Git Integration
|
||||
|
||||
Git operations live in `src-tauri/src/git.rs`. All operations shell out to the `git` CLI (not libgit2).
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
|
||||
|
||||
### Data Types
|
||||
|
||||
```typescript
|
||||
interface GitCommit {
|
||||
hash: string // Full SHA-1
|
||||
shortHash: string // First 7 chars
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
author: string
|
||||
date: number // Unix timestamp
|
||||
@@ -168,32 +226,54 @@ interface ModifiedFile {
|
||||
relativePath: string // Relative to vault root
|
||||
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
|
||||
}
|
||||
|
||||
interface PulseCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
date: number
|
||||
githubUrl: string | null
|
||||
files: PulseFile[]
|
||||
added: number
|
||||
modified: number
|
||||
deleted: number
|
||||
}
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation | Git command | Notes |
|
||||
|-----------|------------|-------|
|
||||
| File history | `git log --format=%H\|%h\|%an\|%aI\|%s -n 20 -- <file>` | Last 20 commits for a file |
|
||||
| Modified files | `git status --porcelain` | Filtered to `.md` files only |
|
||||
| File diff | `git diff -- <file>`, fallback to `--cached`, then synthetic diff for untracked | Unified diff format |
|
||||
| Commit | `git add -A && git commit -m "<message>"` | Stages all changes |
|
||||
| Push | `git push` | Pushes to upstream of current branch |
|
||||
| Module | Operation | Notes |
|
||||
|--------|-----------|-------|
|
||||
| `history.rs` | File history | `git log` — last 20 commits per file |
|
||||
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
|
||||
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
|
||||
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
|
||||
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
|
||||
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
|
||||
| `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes |
|
||||
|
||||
### Auto-Sync
|
||||
|
||||
`useAutoSync` hook handles automatic git sync:
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
- **Modified file badges**: Loaded at startup, shown in sidebar and breadcrumb bar
|
||||
- **Diff view**: Loaded on-demand when user clicks the diff toggle in the breadcrumb bar
|
||||
- **Git history**: Loaded when active tab changes, shown in Inspector panel
|
||||
- **Commit dialog**: Triggered from sidebar, runs commit + push
|
||||
- **Modified file badges**: Orange dots in sidebar and tab bar
|
||||
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
|
||||
- **Git history**: Shown in Inspector panel for active note
|
||||
- **Commit dialog**: Triggered from sidebar or Cmd+K
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
|
||||
## BlockNote Customization
|
||||
|
||||
The editor uses [BlockNote](https://www.blocknotejs.org/) (not CodeMirror 6) for rich text editing.
|
||||
The editor uses [BlockNote](https://www.blocknotejs.org/) for rich text editing, with CodeMirror 6 available as a raw editing alternative.
|
||||
|
||||
### Custom Wikilink Inline Content
|
||||
|
||||
Defined in `src/components/Editor.tsx`:
|
||||
Defined in `src/components/editorSchema.tsx`:
|
||||
|
||||
```typescript
|
||||
const WikiLink = createReactInlineContentSpec(
|
||||
@@ -202,69 +282,196 @@ const WikiLink = createReactInlineContentSpec(
|
||||
propSchema: { target: { default: "" } },
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => (
|
||||
<span className="wikilink" data-target={props.inlineContent.props.target}>
|
||||
{props.inlineContent.props.target}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
{ render: (props) => <span className="wikilink">...</span> }
|
||||
)
|
||||
|
||||
const schema = BlockNoteSchema.create({
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
wikilink: WikiLink,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
Since BlockNote doesn't natively understand `[[wikilinks]]`, content goes through a preprocessing pipeline in `src/utils/wikilinks.ts`:
|
||||
|
||||
```
|
||||
Raw markdown
|
||||
→ splitFrontmatter() → [yaml, body]
|
||||
→ preProcessWikilinks(body) → replaces [[target]] with Unicode placeholder tokens
|
||||
→ editor.tryParseMarkdownToBlocks() → BlockNote block tree
|
||||
→ injectWikilinks(blocks) → walks tree, replaces placeholder text with wikilink inline content nodes
|
||||
→ injectWikilinks(blocks) → walks tree, replaces placeholders with wikilink inline content nodes
|
||||
→ editor.replaceBlocks()
|
||||
```
|
||||
|
||||
Placeholder tokens use `\u2039` (single left-pointing angle quotation mark) and `\u203A` (single right-pointing) to avoid colliding with markdown syntax.
|
||||
Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```
|
||||
BlockNote blocks
|
||||
→ editor.blocksToMarkdownLossy()
|
||||
→ postProcessWikilinks() → restore [[target]] syntax from wikilink nodes
|
||||
→ prepend frontmatter yaml
|
||||
→ invoke('save_note_content', { path, content })
|
||||
```
|
||||
|
||||
### Wikilink Navigation
|
||||
|
||||
Two navigation mechanisms:
|
||||
|
||||
1. **Click handler**: A DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements and calls `onNavigateWikilink(target)`.
|
||||
1. **Click handler**: DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements → `onNavigateWikilink(target)`.
|
||||
2. **Suggestion menu**: Typing `[[` triggers `SuggestionMenuController` with filtered vault entries.
|
||||
|
||||
2. **Suggestion menu**: Typing `[[` triggers BlockNote's `SuggestionMenuController`, which shows a filtered list of all vault entries. Selecting one inserts a wikilink inline content node.
|
||||
Wikilink resolution (`useNoteActions`) uses fuzzy matching: exact title → alias → path stem → filename stem → slug-to-words.
|
||||
|
||||
Wikilink resolution in `useNoteActions.handleNavigateWikilink()` uses fuzzy matching:
|
||||
- Exact title match
|
||||
- Alias match
|
||||
- Path stem match (e.g., `person/matteo-cellini`)
|
||||
- Filename stem match
|
||||
- Slug-to-words match (e.g., `matteo-cellini` → `matteo cellini`)
|
||||
### Raw Editor Mode
|
||||
|
||||
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
|
||||
|
||||
## Theme System
|
||||
|
||||
See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
||||
|
||||
In brief: `src/theme.json` defines editor typography and styling as nested JSON. The `useEditorTheme` hook flattens it into CSS custom properties that are applied as inline styles on the BlockNote container.
|
||||
### Overview
|
||||
|
||||
Two-layer theming:
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
|
||||
|
||||
### Vault-Based Themes
|
||||
|
||||
Themes are markdown notes in `theme/` with `Is A: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
accent-blue: "#155DFF"
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
---
|
||||
```
|
||||
|
||||
### ThemeManager
|
||||
|
||||
`useThemeManager` hook manages the theme lifecycle:
|
||||
|
||||
```typescript
|
||||
interface ThemeManager {
|
||||
themes: ThemeFile[]
|
||||
activeThemeId: string | null
|
||||
activeTheme: ThemeFile | null
|
||||
isDark: boolean
|
||||
switchTheme(themeId: string): Promise<void>
|
||||
createTheme(name?: string): Promise<string>
|
||||
reloadThemes(): Promise<void>
|
||||
updateThemeProperty(key: string, value: string): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- Detects dark backgrounds via luminance calculation → sets `color-scheme` and `data-theme-mode`
|
||||
- Live preview: re-applies when active theme note is saved
|
||||
- Three built-in themes: Default (light), Dark (deep navy), Minimal (high contrast)
|
||||
- Legacy JSON themes (`_themes/*.json`) supported for backward compatibility
|
||||
|
||||
### Theme Property Editor
|
||||
|
||||
`ThemePropertyEditor` component provides an interactive UI for editing theme properties. Uses `themeSchema.ts` to determine input types (color picker, number slider, text field) based on property names and values.
|
||||
|
||||
## Inspector Abstraction
|
||||
|
||||
The Inspector panel (`src/components/Inspector.tsx`) is composed of four sub-panels:
|
||||
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs with two distinct sections:
|
||||
- **Editable properties** (top): frontmatter fields the user can modify — shown with interactive hover styling (`hover:bg-muted`), cursor pointer, and click-to-edit. Includes Type badge, Status pill, boolean toggles, array tag pills, and text fields.
|
||||
- **Info section** (bottom, separated by border): read-only derived metadata — Modified, Created, Words, File Size. Uses muted text color (`--text-muted`) with no hover states or click interaction. These fields are computed from file metadata and content, not from frontmatter.
|
||||
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section since they are either internal or already displayed elsewhere (e.g., `is_a` is shown via the TypeRow badge).
|
||||
2. **Relationships**: Shows `belongs_to` and `related_to` wikilinks as clickable chips.
|
||||
3. **Backlinks**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
4. **Git History**: Shows the last few commits from `gitHistory` state.
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
Frontmatter parsing on the TypeScript side is handled by `src/utils/frontmatter.ts:parseFrontmatter()`, a lightweight YAML parser that handles strings, booleans, inline arrays, and multi-line lists.
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
|
||||
|
||||
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
|
||||
4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps.
|
||||
|
||||
## Search & Indexing
|
||||
|
||||
### Search Modes
|
||||
|
||||
```typescript
|
||||
type SearchMode = 'keyword' | 'semantic' | 'hybrid'
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
path: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: SearchResult[]
|
||||
elapsedMs: number
|
||||
}
|
||||
```
|
||||
|
||||
### Search Integration
|
||||
|
||||
`SearchPanel` component provides the search UI:
|
||||
- Mode selector (keyword/semantic/hybrid)
|
||||
- Real-time results as user types
|
||||
- Click result to open note in editor
|
||||
- Shows relevance score and snippet
|
||||
|
||||
### Indexing
|
||||
|
||||
Managed by `useIndexing` hook:
|
||||
- Checks index status on vault load
|
||||
- Two-phase indexing: scanning (parse files) → embedding (generate vectors)
|
||||
- Progress streamed via Tauri events
|
||||
- Incremental updates after git sync
|
||||
- Metadata persisted in `.laputa-index.json`
|
||||
|
||||
## Vault Management
|
||||
|
||||
### Vault Switching
|
||||
|
||||
`useVaultSwitcher` hook manages multiple vaults:
|
||||
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
|
||||
- Switching closes all tabs and resets sidebar
|
||||
- Supports adding, removing, hiding/restoring vaults
|
||||
- Default vault: Getting Started demo vault
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault settings stored in `config/ui.config.md`:
|
||||
- Editable as a normal note (YAML frontmatter)
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### Getting Started / Onboarding
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- User can create Getting Started vault or open existing folder
|
||||
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
|
||||
|
||||
### GitHub Integration
|
||||
|
||||
Device Authorization Flow for GitHub-backed vaults:
|
||||
- `GitHubDeviceFlow` component handles OAuth
|
||||
- `GitHubVaultModal` for cloning existing repos or creating new ones
|
||||
- Token persisted in app settings for future git operations
|
||||
- `SettingsPanel` shows connection status with disconnect option
|
||||
|
||||
## Settings
|
||||
|
||||
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
|
||||
|
||||
```typescript
|
||||
interface Settings {
|
||||
anthropic_key: string | null
|
||||
openai_key: string | null
|
||||
google_key: string | null
|
||||
github_token: string | null
|
||||
github_username: string | null
|
||||
auto_pull_interval_minutes: number | null
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
|
||||
@@ -9,46 +9,58 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
|
||||
| Desktop shell | Tauri v2 | 2.10.0 |
|
||||
| Frontend | React + TypeScript | React 19, TS 5.9 |
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
| Icons | Phosphor Icons + Lucide | - |
|
||||
| Build | Vite | 7.3.1 |
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI | Anthropic Claude API (Haiku 3.5 default) | - |
|
||||
| AI (in-app chat) | Anthropic Claude API (Haiku 3.5 default) | - |
|
||||
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
|
||||
| Search | qmd (keyword + semantic + hybrid) | - |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
| Tests | Vitest (unit), Playwright (E2E), cargo test (Rust) | - |
|
||||
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
|
||||
| Package manager | pnpm | - |
|
||||
|
||||
## System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 Window │
|
||||
│ │
|
||||
│ ┌─────────────────── React Frontend ───────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ App.tsx (orchestrator) │ │
|
||||
│ │ ├── Sidebar (navigation + filters) │ │
|
||||
│ │ ├── NoteList (filtered note list) │ │
|
||||
│ │ ├── Editor (BlockNote + tabs + diff) │ │
|
||||
│ │ │ ├── Inspector (metadata + relationships) │ │
|
||||
│ │ │ └── AIChatPanel (AI assistant + context) │ │
|
||||
│ │ ├── StatusBar (footer info) │ │
|
||||
│ │ └── Modals (QuickOpen, CreateNote, CommitDialog) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────┬──────────┬──────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ Tauri IPC│ Vite Proxy / WS │
|
||||
│ ┌──────────────▼────┐ ┌──▼───────────────────────────┐ │
|
||||
│ │ Rust Backend │ │ External Services │ │
|
||||
│ │ lib.rs → 10 cmds │ │ Anthropic API (Claude) │ │
|
||||
│ │ vault/ │ │ MCP Server (ws://9710) │ │
|
||||
│ │ frontmatter.rs │ │ │ │
|
||||
│ │ git.rs │ └──────────────────────────────┘ │
|
||||
│ │ ai_chat.rs │ │
|
||||
│ └───────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 Window │
|
||||
│ │
|
||||
│ ┌──────────────────── React Frontend ────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ App.tsx (orchestrator) │ │
|
||||
│ │ ├── WelcomeScreen (onboarding / vault-missing) │ │
|
||||
│ │ ├── Sidebar (navigation + filters + types) │ │
|
||||
│ │ ├── NoteList / PulseView (filtered list / activity) │ │
|
||||
│ │ ├── Editor (BlockNote + tabs + diff + raw) │ │
|
||||
│ │ │ ├── Inspector (metadata + relationships) │ │
|
||||
│ │ │ ├── AIChatPanel (API-based chat) │ │
|
||||
│ │ │ └── AiPanel (Claude CLI agent + tools) │ │
|
||||
│ │ ├── SearchPanel (keyword/semantic/hybrid search) │ │
|
||||
│ │ ├── SettingsPanel (API keys, GitHub, zoom, theme) │ │
|
||||
│ │ ├── StatusBar (vault picker + sync + version) │ │
|
||||
│ │ ├── CommandPalette (Cmd+K fuzzy command launcher) │ │
|
||||
│ │ └── Modals (CreateNote, CreateType, Commit, GitHub) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────┬──────────┬──────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ Tauri IPC│ Vite Proxy / WS │
|
||||
│ ┌──────────────▼────┐ ┌──▼────────────────────────────────┐ │
|
||||
│ │ Rust Backend │ │ External Services │ │
|
||||
│ │ lib.rs → 61 cmds │ │ Anthropic API (Claude chat) │ │
|
||||
│ │ vault/ │ │ Claude CLI (agent subprocess) │ │
|
||||
│ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │
|
||||
│ │ git/ │ │ qmd (search/indexing engine) │ │
|
||||
│ │ github/ │ │ GitHub API (OAuth, repos, clone) │ │
|
||||
│ │ theme/ │ │ │ │
|
||||
│ │ search.rs │ └───────────────────────────────────┘ │
|
||||
│ │ indexing.rs │ │
|
||||
│ │ claude_cli.rs │ │
|
||||
│ └───────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Four-Panel Layout
|
||||
@@ -57,71 +69,90 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
|
||||
┌────────┬─────────────┬─────────────────────────┬────────────┐
|
||||
│Sidebar │ Note List │ Editor │ Inspector │
|
||||
│(250px) │ (300px) │ (flex-1) │ (280px) │
|
||||
│ │ │ │ OR │
|
||||
│ All │ [Search] │ [Tab Bar] │ AI Chat │
|
||||
│ Favs │ [Type Pill] │ [Breadcrumb Bar] │ │
|
||||
│ │ OR │ │ OR │
|
||||
│ All │ Pulse View │ [Tab Bar] │ AI Chat │
|
||||
│ Favs │ │ [Breadcrumb Bar] │ OR │
|
||||
│ Changes│ [Search] │ │ AI Agent │
|
||||
│ Pulse │ [Sort/Filt] │ # My Note │ │
|
||||
│ │ │ │ Context │
|
||||
│Projects│ Note 1 │ # My Note │ Messages │
|
||||
│Experim.│ Note 2 │ │ Actions │
|
||||
│Respons.│ Note 3 │ Content here... │ Input │
|
||||
│Procedu.│ ... │ │ │
|
||||
│People │ │ │ │
|
||||
│Projects│ Note 1 │ Content here... │ Messages │
|
||||
│Experim.│ Note 2 │ (BlockNote or Raw) │ Actions │
|
||||
│Respons.│ Note 3 │ │ Input │
|
||||
│People │ ... │ │ │
|
||||
│Events │ │ │ │
|
||||
│Topics │ │ │ │
|
||||
├────────┴─────────────┴─────────────────────────┴────────────┤
|
||||
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ 3 pending notes│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ Vault: ~/Laputa │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.)
|
||||
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, relationship groups, and orange dot indicators for uncommitted modified notes.
|
||||
- **Editor** (flex, fills remaining space): Tab bar (with orange modified dots on dirty tabs), breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. Decomposed into focused subcomponents: `Editor` (orchestrator), `EditorContent` (breadcrumb + editor/diff views), `EditorRightPanel` (inspector/AI toggle), `SingleEditorView` (BlockNote + suggestions), with hooks `useDiffMode` and `useEditorFocus`.
|
||||
- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them.
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Tab bar with modified dots, breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`.
|
||||
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them.
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
## AI Chat System
|
||||
## AI System
|
||||
|
||||
### Architecture
|
||||
Laputa has two AI interfaces with distinct architectures:
|
||||
|
||||
The AI chat feature has three layers:
|
||||
### AI Chat (AIChatPanel)
|
||||
|
||||
Simple chat mode — no tool execution, streaming text responses.
|
||||
|
||||
1. **Frontend** (`AIChatPanel` + `useAIChat` hook) — UI and state management
|
||||
2. **API Proxy** (Vite middleware in dev, Rust `ai_chat` command in Tauri) — routes to Anthropic
|
||||
3. **MCP Server** (`mcp-server/`) — vault operation tools for AI assistants
|
||||
3. **Context picker** — selected notes sent as system context with token estimation
|
||||
|
||||
### Data Flow
|
||||
### AI Agent (AiPanel)
|
||||
|
||||
Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useAiAgent` hook) — streaming UI with reasoning blocks, tool action cards, and response display
|
||||
2. **Backend** (`claude_cli.rs`) — spawns `claude` binary with `--output-format stream-json`, parses NDJSON events
|
||||
3. **MCP Integration** — passes vault MCP config via `--mcp-config` flag so the agent can search, read, and modify vault notes
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
```
|
||||
User types message in AIChatPanel
|
||||
→ useAIChat.sendMessage(text)
|
||||
→ buildSystemPrompt(contextNotes, allContent, model)
|
||||
→ Assembles selected notes as system context
|
||||
→ Estimates tokens, truncates if needed
|
||||
→ streamChat(messages, systemPrompt, model, callbacks)
|
||||
→ POST /api/ai/chat (Vite proxy → Anthropic API)
|
||||
→ SSE stream parsed, chunks dispatched to onChunk callback
|
||||
→ UI updates in real-time as tokens arrive
|
||||
→ On completion: message added to conversation history
|
||||
User sends message in AiPanel
|
||||
→ useAiAgent.sendMessage(text, references)
|
||||
→ buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
→ invoke('stream_claude_agent', { message, systemPrompt, vaultPath })
|
||||
→ Rust spawns: claude -p <msg> --output-format stream-json --mcp-config <json>
|
||||
→ NDJSON lines parsed into ClaudeStreamEvent variants:
|
||||
Init, TextDelta, ThinkingDelta, ToolStart, ToolDone, Result, Error, Done
|
||||
→ Events emitted via Tauri: app_handle.emit("claude-agent-stream", &event)
|
||||
→ Frontend listener routes events:
|
||||
onText → accumulate response (revealed on Done)
|
||||
onThinking → show reasoning block (collapsed on first text)
|
||||
onToolStart → add AiActionCard with spinner
|
||||
onToolDone → update card with output
|
||||
onDone → reveal full response, detect file operations
|
||||
```
|
||||
|
||||
### Context Picker
|
||||
#### File Operation Detection
|
||||
|
||||
The context picker controls which notes are sent to the AI as context:
|
||||
When the agent writes or edits vault files, `useAiAgent` detects this from tool inputs (Write/Edit tool JSON) and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
|
||||
|
||||
- **Current note** is auto-added when the panel opens
|
||||
- **Add button** opens a search dropdown to select additional notes
|
||||
- **Token estimation** shows approximate context size (~4 chars/token)
|
||||
- **Truncation** kicks in when context exceeds 60% of model limit (108k tokens)
|
||||
- Context pills show selected notes with remove buttons
|
||||
### Context Building
|
||||
|
||||
### API Key Management
|
||||
Both AI modes use context from the active note and linked entries. The agent panel (`ai-context.ts`) builds a structured JSON snapshot:
|
||||
|
||||
- Stored in `localStorage` under key `laputa:anthropic-api-key`
|
||||
- Configurable via the key icon in the AI Chat header
|
||||
- When no key is set, falls back to mock responses for testing
|
||||
```json
|
||||
{
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "content" },
|
||||
"linkedNotes": [{ "path", "title", "content" }],
|
||||
"openTabs": [{ "title", "snippet" }],
|
||||
"vaultMetadata": { "noteTypes", "stats", "filter" },
|
||||
"references": [{ "title", "path", "type" }]
|
||||
}
|
||||
```
|
||||
|
||||
### Models
|
||||
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
|
||||
|
||||
### Models (Chat mode)
|
||||
|
||||
| Model | ID | Use case |
|
||||
|-------|----|----------|
|
||||
@@ -129,141 +160,432 @@ The context picker controls which notes are sent to the AI as context:
|
||||
| Sonnet 4 | `claude-sonnet-4-20250514` | Balanced |
|
||||
| Opus 4 | `claude-opus-4-20250514` | Most capable |
|
||||
|
||||
### MCP Server
|
||||
### API Key Management
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools:
|
||||
- Stored in app settings (`~/.config/com.laputa.app/settings.json`) under `anthropic_key`
|
||||
- Configurable via Settings panel (also supports `openai_key`, `google_key`)
|
||||
- Claude CLI (agent mode) uses its own authentication — no API key needed
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `open_note` | Open and read a note by path |
|
||||
| `read_note` | Read note content (alias) |
|
||||
| `create_note` | Create new note with frontmatter |
|
||||
| `search_notes` | Search by title or content |
|
||||
| `append_to_note` | Append text to a note |
|
||||
## MCP Server
|
||||
|
||||
**Transports:**
|
||||
- **stdio** — standard MCP transport (`node mcp-server/index.js`)
|
||||
- **WebSocket** — live bridge for app integration (`node mcp-server/ws-bridge.js`, port 9710)
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
|
||||
|
||||
### Tool Surface (14 tools)
|
||||
|
||||
| Tool | Params | Description |
|
||||
|------|--------|-------------|
|
||||
| `open_note` | `path` | Open and read a note by relative path |
|
||||
| `read_note` | `path` | Read note content (alias for `open_note`) |
|
||||
| `create_note` | `path, title, [is_a]` | Create new note with title and optional type frontmatter |
|
||||
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
|
||||
| `append_to_note` | `path, text` | Append text to end of existing note |
|
||||
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
|
||||
| `delete_note` | `path` | Delete a note file from the vault |
|
||||
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
|
||||
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
|
||||
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
|
||||
| `ui_open_tab` | `path` | Open a note in a new UI tab |
|
||||
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
|
||||
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
|
||||
|
||||
### Transports
|
||||
|
||||
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
|
||||
- **WebSocket** — live bridge for Laputa app integration:
|
||||
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
|
||||
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
|
||||
|
||||
### Auto-Registration
|
||||
|
||||
On app startup, Laputa automatically registers itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
|
||||
Registration is non-destructive (additive, preserves other servers) and uses `upsert` semantics. The `useMcpStatus` hook tracks registration state (`checking | installed | not_installed | no_claude_cli`).
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ MCP Server (Node.js) │
|
||||
│ │
|
||||
│ index.js ─── stdio transport ──→ Claude Code │
|
||||
│ │ Cursor │
|
||||
│ ├── vault.js (9 vault operations) │
|
||||
│ │ ├── findMarkdownFiles ├── deleteNote │
|
||||
│ │ ├── readNote ├── linkNotes │
|
||||
│ │ ├── createNote ├── listNotes │
|
||||
│ │ ├── searchNotes ├── vaultContext │
|
||||
│ │ ├── appendToNote │
|
||||
│ │ └── editNoteFrontmatter │
|
||||
│ │ │
|
||||
│ └── ws-bridge.js │
|
||||
│ ├── port 9710: tool bridge ←→ AI clients │
|
||||
│ └── port 9711: UI bridge ←→ Frontend │
|
||||
│ │
|
||||
│ Spawned by Tauri (mcp.rs) on app startup │
|
||||
│ Auto-registered in ~/.claude/mcp.json │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### WebSocket Bridge
|
||||
|
||||
The WebSocket bridge (`useMcpBridge` hook) enables real-time vault operations from the frontend:
|
||||
The WebSocket bridge enables real-time vault operations from both the frontend and external AI clients:
|
||||
|
||||
```
|
||||
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
|
||||
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions (useAiActivity)
|
||||
```
|
||||
|
||||
Protocol: JSON-RPC-like with `{id, tool, args}` requests and `{id, result}` responses.
|
||||
**Tool bridge protocol** (port 9710):
|
||||
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
|
||||
- Response: `{ "id": "req-1", "result": { ... } }`
|
||||
|
||||
### Rust Backend (Tauri)
|
||||
**UI bridge protocol** (port 9711):
|
||||
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
|
||||
- `useAiActivity` hook receives these and applies them (highlight with 800ms feedback, open note, set filter, etc.)
|
||||
|
||||
The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streaming alternative:
|
||||
- Uses `reqwest` to call the Anthropic Messages API directly
|
||||
- API key from `ANTHROPIC_API_KEY` environment variable
|
||||
- Returns full response (not streamed)
|
||||
- Used in production Tauri builds where Vite proxy is unavailable
|
||||
### Rust MCP Module
|
||||
|
||||
### Files
|
||||
`src-tauri/src/mcp.rs` manages the MCP server lifecycle:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
|
||||
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
|
||||
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
|
||||
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
|
||||
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
|
||||
| `mcp-server/index.js` | MCP server entry (stdio transport) |
|
||||
| `mcp-server/vault.js` | Vault file operations |
|
||||
| `mcp-server/ws-bridge.js` | WebSocket bridge server |
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
|
||||
|
||||
## Search & Indexing
|
||||
|
||||
### Search Engine
|
||||
|
||||
Search uses the external `qmd` binary (semantic search engine) with three modes:
|
||||
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| `keyword` | `qmd search` | Term matching (default) |
|
||||
| `semantic` | `qmd vsearch` | Vector similarity search |
|
||||
| `hybrid` | `qmd query` | Combined keyword + semantic |
|
||||
|
||||
### Indexing Flow
|
||||
|
||||
```
|
||||
Vault opened
|
||||
→ check_index_status() → parse qmd status output
|
||||
→ if stale or missing:
|
||||
→ start_indexing() (two phases):
|
||||
Phase 1 (Scanning): qmd update — scan all .md files
|
||||
Phase 2 (Embedding): qmd embed — generate vector embeddings
|
||||
→ Progress streamed via Tauri "indexing-progress" event
|
||||
→ Metadata saved to .laputa-index.json (last_indexed_commit, timestamp)
|
||||
→ run_incremental_update() for subsequent changes
|
||||
```
|
||||
|
||||
Embedding failure is non-fatal — keyword search still works.
|
||||
|
||||
### qmd Binary Resolution
|
||||
|
||||
1. Bundled macOS app resource: `<app>/Contents/Resources/qmd/qmd`
|
||||
2. Dev mode: `CARGO_MANIFEST_DIR/resources/qmd/qmd`
|
||||
3. System locations: `~/.bun/bin/qmd`, `/usr/local/bin/qmd`, `/opt/homebrew/bin/qmd`
|
||||
4. PATH lookup via `which qmd`
|
||||
5. Auto-install via `bun install -g qmd` if missing
|
||||
|
||||
## Vault Cache System
|
||||
|
||||
The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates.
|
||||
|
||||
### Cache File
|
||||
|
||||
`.laputa-cache.json` at vault root. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan).
|
||||
|
||||
### Three Cache Strategies
|
||||
|
||||
1. **Same Commit (Cache Hit)**: Git HEAD matches cached hash → only re-parse uncommitted changed files via `git status --porcelain`
|
||||
2. **Different Commit (Incremental Update)**: Uses `git diff <old>..<new> --name-only` to find changed files + uncommitted changes → selective re-parse
|
||||
3. **No Cache / Corrupt Cache (Full Scan)**: Recursive `walkdir` of all `.md` files → full parse
|
||||
|
||||
Cache auto-excludes itself from git via `.git/info/exclude`.
|
||||
|
||||
## Theme System
|
||||
|
||||
See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
||||
|
||||
### Two-Layer Architecture
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
|
||||
|
||||
### Vault-Based Themes
|
||||
|
||||
Themes are markdown notes in the `theme/` folder with `Is A: Theme` frontmatter. Each frontmatter property becomes a CSS variable. Managed by `useThemeManager` hook and the `src-tauri/src/theme/` Rust module (create, seed, defaults).
|
||||
|
||||
- **Vault settings**: `.laputa/settings.json` stores the active theme reference
|
||||
- **Legacy support**: `_themes/*.json` files still supported for backward compatibility
|
||||
- **Built-in themes**: Default (light), Dark, Minimal — auto-seeded on vault open
|
||||
- **Live preview**: Re-applies when the active theme note is saved
|
||||
|
||||
## Vault Management
|
||||
|
||||
### Vault List
|
||||
|
||||
Persisted at `~/.config/com.laputa.app/vaults.json`:
|
||||
```json
|
||||
{
|
||||
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
|
||||
"active_vault": "/path/to/vault",
|
||||
"hidden_defaults": []
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets sidebar.
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `tag_colors`, `status_colors`: Custom color overrides
|
||||
- `property_display_modes`: Property display preferences
|
||||
|
||||
### Getting Started Vault
|
||||
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, shows `WelcomeScreen` with two options:
|
||||
- **Create Getting Started vault** → calls `create_getting_started_vault()` Tauri command
|
||||
- **Open an existing folder** → system file picker
|
||||
|
||||
### GitHub OAuth Integration
|
||||
|
||||
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
|
||||
|
||||
**Flow:**
|
||||
1. User clicks "Login with GitHub" in Settings panel
|
||||
2. `github_device_flow_start()` returns a user code + verification URL
|
||||
3. User authorizes at `github.com/login/device`
|
||||
4. App polls `github_device_flow_poll()` until authorized
|
||||
5. Token stored in `~/.config/com.laputa.app/settings.json`
|
||||
|
||||
**Vault operations:**
|
||||
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
|
||||
- `clone_repo()`: Clones with token-injected HTTPS URL
|
||||
- Token persists for future git push/pull operations
|
||||
|
||||
## Pulse View
|
||||
|
||||
`PulseView` is a git activity feed that replaces the NoteList when the Pulse filter is selected.
|
||||
|
||||
- Groups commits by day ("Today", "Yesterday", or full date)
|
||||
- Shows commit message, short hash, timestamp, and changed files
|
||||
- Files have status icons (added/modified/deleted) and are clickable to open in editor
|
||||
- Links to GitHub commits when `githubUrl` is available
|
||||
- Infinite scroll pagination (20 commits per page) via Intersection Observer
|
||||
|
||||
Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Startup Sequence
|
||||
|
||||
```
|
||||
1. App mounts
|
||||
2. useVaultLoader fires:
|
||||
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
|
||||
→ VaultEntry[] stored in state
|
||||
b. Load all content (mock mode) or on-demand (Tauri mode)
|
||||
c. invoke('get_modified_files') → ModifiedFile[] stored in state
|
||||
3. User clicks note in NoteList
|
||||
4. useNoteActions.handleSelectNote:
|
||||
a. invoke('get_note_content') → raw markdown string
|
||||
1. Tauri setup:
|
||||
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, migrate AGENTS.md, seed config files, register MCP
|
||||
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
|
||||
2. App mounts
|
||||
3. useOnboarding checks vault exists → WelcomeScreen if not
|
||||
4. useVaultLoader fires:
|
||||
a. invoke('list_vault', { path }) → scan_vault_cached() → VaultEntry[]
|
||||
b. Load modified files via invoke('get_modified_files')
|
||||
c. useMcpStatus → register MCP if needed
|
||||
d. useThemeManager → load and apply active theme
|
||||
e. useIndexing → check index status, trigger incremental update if needed
|
||||
5. User clicks note in NoteList
|
||||
6. useNoteActions.handleSelectNote:
|
||||
a. invoke('get_note_content') → raw markdown
|
||||
b. Add tab { entry, content } to tabs state
|
||||
c. Set activeTabPath
|
||||
5. Editor renders BlockNoteTab:
|
||||
7. Editor renders BlockNoteTab:
|
||||
a. splitFrontmatter(content) → [yaml, body]
|
||||
b. preProcessWikilinks(body) → replaces [[target]] with tokens
|
||||
c. editor.tryParseMarkdownToBlocks(preprocessed)
|
||||
d. injectWikilinks(blocks) → replaces tokens with wikilink nodes
|
||||
e. editor.replaceBlocks()
|
||||
6. Inspector renders frontmatter parsed from content
|
||||
8. Inspector renders frontmatter parsed from content
|
||||
```
|
||||
|
||||
### Frontmatter Edit Flow
|
||||
### Auto-Save Flow
|
||||
|
||||
```
|
||||
User edits property in Inspector
|
||||
→ handleUpdateFrontmatter(path, key, value)
|
||||
→ Tauri: invoke('update_frontmatter') → Rust reads file, modifies YAML, writes back
|
||||
→ Mock: updateMockFrontmatter() → client-side YAML manipulation
|
||||
→ Update tab content in state
|
||||
→ Update allContent for backlink recalculation
|
||||
→ Toast: "Property updated"
|
||||
Editor content changes
|
||||
→ useEditorSave detects change (debounced)
|
||||
→ serialize BlockNote blocks → markdown
|
||||
→ postProcessWikilinks → restore [[target]] syntax
|
||||
→ invoke('save_note_content', { path, content })
|
||||
→ Update tab status indicator
|
||||
```
|
||||
|
||||
### Git Flow
|
||||
### Git Sync Flow
|
||||
|
||||
```
|
||||
User clicks Commit button → CommitDialog opens
|
||||
→ handleCommitPush(message)
|
||||
→ invoke('git_commit') → git add -A && git commit -m "..."
|
||||
→ invoke('git_push') → git push
|
||||
useAutoSync (configurable interval, default from settings):
|
||||
→ invoke('git_pull') → GitPullResult
|
||||
→ if conflicts → ConflictResolverModal
|
||||
→ if fast-forward → reload vault
|
||||
→ invoke('git_push') → GitPushResult
|
||||
|
||||
Manual commit:
|
||||
→ CommitDialog → invoke('git_commit', { message })
|
||||
→ invoke('git_push')
|
||||
→ Reload modified files
|
||||
→ Toast: "Committed and pushed"
|
||||
```
|
||||
|
||||
## Vault Module Structure
|
||||
|
||||
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| File | Purpose | CodeScene Health |
|
||||
|------|---------|-----------------|
|
||||
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship extraction | 10.0 |
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` | 9.68 |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | 9.68 |
|
||||
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days | 9.38 |
|
||||
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault | 9.68 |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | 10.0 |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction |
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
|
||||
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | Frontmatter migration utilities |
|
||||
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
Public API (re-exported from `mod.rs`): `scan_vault_cached`, `save_image`, `rename_note`, `RenameResult`, `purge_trash`, `get_note_content`, `parse_md_file`, `VaultEntry`.
|
||||
## Rust Backend Modules
|
||||
|
||||
## Tauri IPC Commands
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, trash, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
| `theme/` | Theme management (`mod.rs`, `create.rs`, `defaults.rs`, `seed.rs`) |
|
||||
| `search.rs` | qmd search integration (keyword/semantic/hybrid) |
|
||||
| `indexing.rs` | qmd indexing with progress streaming |
|
||||
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
|
||||
| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
| `commands.rs` | All 61 Tauri command handlers |
|
||||
| `settings.rs` | App settings persistence |
|
||||
| `vault_config.rs` | Per-vault UI config |
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::generate_handler![]`.
|
||||
## Tauri IPC Commands (61 total)
|
||||
|
||||
| Command | Params | Returns | Backend function |
|
||||
|---------|--------|---------|-----------------|
|
||||
| `list_vault` | `path` | `Vec<VaultEntry>` | `vault::scan_vault()` |
|
||||
| `get_note_content` | `path` | `String` | `vault::get_note_content()` |
|
||||
| `update_frontmatter` | `path, key, value` | `String` (updated content) | `frontmatter::with_frontmatter()` |
|
||||
| `delete_frontmatter_property` | `path, key` | `String` (updated content) | `frontmatter::with_frontmatter()` |
|
||||
| `get_file_history` | `vault_path, path` | `Vec<GitCommit>` | `git::get_file_history()` |
|
||||
| `get_modified_files` | `vault_path` | `Vec<ModifiedFile>` | `git::get_modified_files()` |
|
||||
| `get_file_diff` | `vault_path, path` | `String` (unified diff) | `git::get_file_diff()` |
|
||||
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
|
||||
| `git_push` | `vault_path` | `String` | `git::git_push()` |
|
||||
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
|
||||
### Vault Operations
|
||||
|
||||
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Move note to trash |
|
||||
| `rename_note` | Rename note + update cross-vault wikilinks |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_trash_notes` | Trash multiple notes |
|
||||
| `purge_trash` | Delete notes trashed >30 days ago |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_getting_started_vault` | Bootstrap demo vault |
|
||||
|
||||
### Frontmatter
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `update_frontmatter` | Update a frontmatter property |
|
||||
| `delete_frontmatter_property` | Remove a frontmatter property |
|
||||
|
||||
### Git
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `git_commit` | Stage all + commit |
|
||||
| `git_pull` | Pull from remote |
|
||||
| `git_push` | Push to remote |
|
||||
| `git_resolve_conflict` | Resolve a merge conflict |
|
||||
| `git_commit_conflict_resolution` | Commit conflict resolution |
|
||||
| `get_file_history` | Last N commits for a file |
|
||||
| `get_modified_files` | `git status` filtered to .md |
|
||||
| `get_file_diff` | Unified diff for a file |
|
||||
| `get_file_diff_at_commit` | Diff at a specific commit |
|
||||
| `get_conflict_files` | List conflicted files |
|
||||
| `get_conflict_mode` | Get conflict resolution mode |
|
||||
| `get_vault_pulse` | Git activity feed (paginated) |
|
||||
| `get_last_commit_info` | Latest commit metadata |
|
||||
|
||||
### GitHub
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `github_device_flow_start` | Begin OAuth device flow |
|
||||
| `github_device_flow_poll` | Poll for authorization |
|
||||
| `github_get_user` | Get authenticated user info |
|
||||
| `github_list_repos` | List user's repos |
|
||||
| `github_create_repo` | Create new repo |
|
||||
| `clone_repo` | Clone repo with token auth |
|
||||
|
||||
### Search & Indexing
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search_vault` | Search via qmd (keyword/semantic/hybrid) |
|
||||
| `get_index_status` | Check qmd index state |
|
||||
| `start_indexing` | Full index with progress streaming |
|
||||
| `trigger_incremental_index` | Incremental index update |
|
||||
|
||||
### Theme
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list_themes` | List all themes (legacy JSON) |
|
||||
| `get_theme` | Read a theme file |
|
||||
| `get_vault_settings` | Read `.laputa/settings.json` |
|
||||
| `save_vault_settings` | Write vault settings |
|
||||
| `set_active_theme` | Set active theme ID |
|
||||
| `create_theme` | Create JSON theme from template |
|
||||
| `create_vault_theme` | Create markdown theme note |
|
||||
| `ensure_vault_themes` | Seed default themes if missing |
|
||||
| `restore_default_themes` | Restore all default themes |
|
||||
| `repair_vault` | Restore default themes + missing config files |
|
||||
|
||||
### AI & MCP
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `ai_chat` | Direct Anthropic API call (non-streaming) |
|
||||
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
|
||||
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
|
||||
| `check_mcp_status` | Check MCP registration state |
|
||||
|
||||
### Settings & Config
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get_settings` | Load app settings |
|
||||
| `save_settings` | Save app settings |
|
||||
| `load_vault_list` | Load vault list |
|
||||
| `save_vault_list` | Save vault list |
|
||||
| `get_vault_config` | Load per-vault UI config |
|
||||
| `save_vault_config` | Save per-vault UI config |
|
||||
| `get_default_vault_path` | Get default vault path |
|
||||
| `get_build_number` | Get app build number |
|
||||
| `save_image` | Save base64 image to vault |
|
||||
| `copy_image_to_vault` | Copy image file to vault |
|
||||
| `update_menu_state` | Update native menu checkmarks |
|
||||
|
||||
## Mock Layer
|
||||
|
||||
When running outside Tauri (browser at `localhost:5201`), `src/mock-tauri.ts` provides a transparent mock layer:
|
||||
When running outside Tauri (browser at `localhost:5173`), `src/mock-tauri.ts` provides a transparent mock layer:
|
||||
|
||||
```typescript
|
||||
// In hooks, the pattern is always:
|
||||
if (isTauri()) {
|
||||
result = await invoke<T>('command_name', { args })
|
||||
} else {
|
||||
@@ -271,14 +593,7 @@ if (isTauri()) {
|
||||
}
|
||||
```
|
||||
|
||||
The mock layer includes:
|
||||
- **15 sample entries** across all entity types (Project, Responsibility, Procedure, Experiment, Note, Person, Event, Topic, Essay)
|
||||
- **Full markdown content** with realistic frontmatter for each entry
|
||||
- **Mock git history, modified files, and diff output**
|
||||
- **Mock AI chat responses** with context-aware answers (summarize, expand, grammar)
|
||||
- `addMockEntry()` and `updateMockContent()` for runtime updates
|
||||
|
||||
This means the entire UI can be developed and tested in Chrome without the Rust backend.
|
||||
The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits.
|
||||
|
||||
## State Management
|
||||
|
||||
@@ -286,11 +601,18 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
|
||||
| State owner | State | Purpose |
|
||||
|-------------|-------|---------|
|
||||
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, `showAIChat` | UI state |
|
||||
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
|
||||
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations |
|
||||
| `useAIChat` | `messages`, `isStreaming`, `streamingContent` | AI conversation state |
|
||||
| `useMcpBridge` | `connected`, tool methods | MCP WebSocket connection |
|
||||
| `useTabManagement` | Tab ordering, pinning, swapping | Tab lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state |
|
||||
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
|
||||
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useIndexing` | Index status, progress | Search indexing |
|
||||
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
|
||||
@@ -298,10 +620,14 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Cmd+P | Open Quick Open palette |
|
||||
| Cmd+N | Open Create Note dialog |
|
||||
| Cmd+S | Show "Saved" toast |
|
||||
| Cmd+K | Open command palette |
|
||||
| Cmd+P | Open quick open palette |
|
||||
| Cmd+N | Create new note |
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+W | Close active tab |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
## Auto-Release & In-App Updates
|
||||
@@ -323,47 +649,22 @@ push to main
|
||||
→ generate latest.json (per-arch + universal platform entries)
|
||||
→ publish GitHub Release with all assets + auto-generated notes
|
||||
→ pages job:
|
||||
→ fetch all releases via gh api
|
||||
→ build static HTML release history page
|
||||
→ deploy to gh-pages via peaceiris/actions-gh-pages
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). The `0.` prefix keeps it SemVer-compatible while making it clear these are date-based auto-releases. The version is stamped into both `tauri.conf.json` and `Cargo.toml` dynamically in the workflow.
|
||||
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). Stamped into `tauri.conf.json` and `Cargo.toml` dynamically.
|
||||
|
||||
### Universal Binary
|
||||
|
||||
macOS builds produce both `aarch64-apple-darwin` and `x86_64-apple-darwin` in parallel. The release job merges them with `lipo` — copying the arm64 `.app` as the base and replacing only the main executable with a universal fat binary. The per-arch updater tarballs are also uploaded so the Tauri updater downloads only the relevant architecture (smaller download).
|
||||
|
||||
### Updater Endpoint
|
||||
|
||||
The Tauri updater plugin is configured to fetch:
|
||||
```
|
||||
https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json
|
||||
```
|
||||
|
||||
This JSON manifest contains `version`, `pub_date`, `notes`, and per-platform entries (`darwin-aarch64`, `darwin-x86_64`) with `url` and `signature` fields. The updater compares the manifest version against the running app version, downloads the matching platform artifact, verifies the signature, and installs it.
|
||||
|
||||
### In-App Update UI
|
||||
### In-App Updates
|
||||
|
||||
```
|
||||
App startup (3s delay)
|
||||
→ useUpdater.check()
|
||||
→ idle (no update) → no UI shown
|
||||
→ available → UpdateBanner: "Laputa X.Y.Z is available" + Release Notes + Update Now + X
|
||||
→ user clicks Update Now → downloading → progress bar
|
||||
→ download complete → ready → "Restart to apply" + Restart Now button
|
||||
→ user clicks Restart → relaunch()
|
||||
→ network error / 404 → fail silently, no UI
|
||||
→ idle (no update) → no UI
|
||||
→ available → UpdateBanner with release notes + "Update Now"
|
||||
→ downloading → progress bar
|
||||
→ ready → "Restart to apply" + Restart Now
|
||||
→ network error → fail silently
|
||||
```
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `useUpdater` | `src/hooks/useUpdater.ts` | State machine: idle → available → downloading → ready → error |
|
||||
| `UpdateBanner` | `src/components/UpdateBanner.tsx` | Top-of-app notification bar |
|
||||
| `restartApp` | `src/hooks/useUpdater.ts` | Calls `@tauri-apps/plugin-process` relaunch |
|
||||
|
||||
### GitHub Pages
|
||||
|
||||
Release history site at `https://refactoringhq.github.io/laputa-app/`. Auto-updated by the workflow after each release. The page loads `releases.json` (deployed alongside) and renders each release with date, notes, and `.dmg` download links. Linked from the in-app "Release Notes" button.
|
||||
|
||||
@@ -7,6 +7,7 @@ How to navigate the codebase, run the app, and find what you need.
|
||||
- **Node.js** 18+ and **pnpm**
|
||||
- **Rust** 1.77.2+ (for the Tauri backend)
|
||||
- **git** CLI (required by the git integration features)
|
||||
- **qmd** (optional — for search indexing; auto-installed if missing)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -24,7 +25,7 @@ pnpm tauri dev
|
||||
# Run tests
|
||||
pnpm test # Vitest unit tests
|
||||
cargo test # Rust tests (from src-tauri/)
|
||||
pnpm test:e2e # Playwright E2E tests
|
||||
pnpm playwright:smoke # Playwright smoke tests
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
@@ -33,39 +34,94 @@ pnpm test:e2e # Playwright E2E tests
|
||||
laputa-app/
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # Entry point (renders <App />)
|
||||
│ ├── App.tsx # Root component — orchestrates 4-panel layout
|
||||
│ ├── App.tsx # Root component — orchestrates layout + state
|
||||
│ ├── App.css # App shell layout styles
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, GitCommit, etc.)
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
|
||||
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
|
||||
│ ├── theme.json # Editor theme configuration
|
||||
│ ├── index.css # Global CSS variables + Tailwind setup
|
||||
│ │
|
||||
│ ├── components/ # UI components
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + section groups
|
||||
│ ├── components/ # UI components (~98 files)
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
|
||||
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
|
||||
│ │ ├── NoteList.tsx # Second panel: filtered note list
|
||||
│ │ ├── Editor.tsx # Third panel: tabs + BlockNote + diff
|
||||
│ │ ├── NoteItem.tsx # Individual note item
|
||||
│ │ ├── PulseView.tsx # Git activity feed (replaces NoteList)
|
||||
│ │ ├── Editor.tsx # Third panel: tabs + editor orchestration
|
||||
│ │ ├── EditorContent.tsx # Editor content area
|
||||
│ │ ├── EditorRightPanel.tsx # Right panel toggle
|
||||
│ │ ├── editorSchema.tsx # BlockNote schema + wikilink type
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── EditableValue.tsx # Inline value editor component
|
||||
│ │ ├── DiffView.tsx # Git diff viewer
|
||||
│ │ ├── ResizeHandle.tsx # Draggable panel divider
|
||||
│ │ ├── StatusBar.tsx # Bottom status bar
|
||||
│ │ ├── QuickOpenPalette.tsx # Cmd+P command palette
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
│ │ ├── AIChatPanel.tsx # AI chat (API-based)
|
||||
│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── SearchPanel.tsx # Search interface
|
||||
│ │ ├── SettingsPanel.tsx # App settings
|
||||
│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── TabBar.tsx # Tab management
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
|
||||
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
|
||||
│ │ ├── ThemePropertyEditor.tsx # Interactive theme editor
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
│ │ ├── CommitDialog.tsx # Git commit modal
|
||||
│ │ ├── Toast.tsx # Toast notifications
|
||||
│ │ ├── Editor.css # Editor layout styles
|
||||
│ │ ├── EditorTheme.css # BlockNote theme overrides
|
||||
│ │ └── ui/ # shadcn/ui primitives (button, dialog, etc.)
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
│ │ ├── CreateTypeDialog.tsx # New type modal
|
||||
│ │ ├── UpdateBanner.tsx # In-app update notification
|
||||
│ │ ├── inspector/ # Inspector sub-panels
|
||||
│ │ │ ├── BacklinksPanel.tsx
|
||||
│ │ │ ├── RelationshipsPanel.tsx
|
||||
│ │ │ ├── GitHistoryPanel.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
|
||||
│ │
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries, git status, content
|
||||
│ │ ├── useNoteActions.ts # Tab management, frontmatter CRUD, navigation
|
||||
│ │ └── useTheme.ts # Flattens theme.json into CSS variables
|
||||
│ ├── hooks/ # Custom React hooks (~87 files)
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries + content
|
||||
│ │ ├── useVaultSwitcher.ts # Multi-vault management
|
||||
│ │ ├── useVaultConfig.ts # Per-vault UI settings
|
||||
│ │ ├── useNoteActions.ts # Tab management, navigation, CRUD
|
||||
│ │ ├── useTabManagement.ts # Tab ordering + lifecycle
|
||||
│ │ ├── useAIChat.ts # AI chat state
|
||||
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
│ │ ├── useConflictResolver.ts # Git conflict handling
|
||||
│ │ ├── useEditorSave.ts # Auto-save with debounce
|
||||
│ │ ├── useTheme.ts # Flatten theme.json → CSS vars
|
||||
│ │ ├── useThemeManager.ts # Vault theme lifecycle
|
||||
│ │ ├── useIndexing.ts # Search indexing management
|
||||
│ │ ├── useNoteSearch.ts # Note search
|
||||
│ │ ├── useCommandRegistry.ts # Command palette registry
|
||||
│ │ ├── useAppCommands.ts # App-level commands
|
||||
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
|
||||
│ │ ├── useMcpBridge.ts # MCP WebSocket client
|
||||
│ │ ├── useMcpStatus.ts # MCP registration status
|
||||
│ │ ├── useUpdater.ts # In-app updates
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML frontmatter parser
|
||||
│ │ └── wikilinks.ts # Wikilink preprocessing + word count
|
||||
│ ├── utils/ # Pure utility functions (~48 files)
|
||||
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML parser
|
||||
│ │ ├── ai-agent.ts # Agent stream utilities
|
||||
│ │ ├── ai-chat.ts # Chat API client + token estimation
|
||||
│ │ ├── ai-context.ts # Context snapshot builder
|
||||
│ │ ├── noteListHelpers.ts # Sorting, filtering, date formatting
|
||||
│ │ ├── themeSchema.ts # Theme editor schema builder
|
||||
│ │ ├── configMigration.ts # localStorage → vault config migration
|
||||
│ │ ├── iconRegistry.ts # Phosphor icon registry
|
||||
│ │ ├── propertyTypes.ts # Property type definitions
|
||||
│ │ ├── vaultListStore.ts # Vault list persistence
|
||||
│ │ ├── vaultConfigStore.ts # Vault config store
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── lib/
|
||||
│ │ └── utils.ts # Tailwind merge + cn() helper
|
||||
@@ -78,28 +134,58 @@ laputa-app/
|
||||
│ ├── build.rs # Tauri build script
|
||||
│ ├── tauri.conf.json # Tauri app configuration
|
||||
│ ├── capabilities/ # Tauri v2 security capabilities
|
||||
│ │ └── default.json
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Entry point (calls lib::run())
|
||||
│ │ ├── lib.rs # Tauri command registration (9 commands)
|
||||
│ │ ├── vault.rs # Vault scanning + markdown parsing
|
||||
│ │ ├── frontmatter.rs # YAML frontmatter manipulation
|
||||
│ │ └── git.rs # Git CLI operations
|
||||
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
|
||||
│ │ ├── commands.rs # All Tauri command handlers
|
||||
│ │ ├── vault/ # Vault module
|
||||
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
|
||||
│ │ │ ├── cache.rs # Git-based incremental caching
|
||||
│ │ │ ├── parsing.rs # Text processing + title extraction
|
||||
│ │ │ ├── trash.rs # Trash auto-purge
|
||||
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
|
||||
│ │ │ ├── image.rs # Image attachment saving
|
||||
│ │ │ ├── migration.rs # Frontmatter migration
|
||||
│ │ │ └── getting_started.rs # Getting Started vault creation
|
||||
│ │ ├── frontmatter/ # Frontmatter module
|
||||
│ │ │ ├── mod.rs, yaml.rs, ops.rs
|
||||
│ │ ├── git/ # Git module
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── github/ # GitHub module
|
||||
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
|
||||
│ │ ├── theme/ # Theme module
|
||||
│ │ │ ├── mod.rs, create.rs, defaults.rs, seed.rs
|
||||
│ │ ├── search.rs # qmd search integration
|
||||
│ │ ├── indexing.rs # qmd indexing + progress streaming
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── ai_chat.rs # Direct Anthropic API client
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + registration
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
│ │ ├── vault_config.rs # Per-vault UI config
|
||||
│ │ ├── vault_list.rs # Vault list persistence
|
||||
│ │ └── menu.rs # Native macOS menu bar
|
||||
│ └── icons/ # App icons
|
||||
│
|
||||
├── e2e/ # Playwright E2E tests
|
||||
│ ├── app.spec.ts # App loading tests
|
||||
│ ├── core-flows.spec.ts # Main user workflows
|
||||
│ ├── keyboard-shortcuts.spec.ts
|
||||
│ ├── quick-open.spec.ts
|
||||
│ ├── screenshot.spec.ts # Visual regression screenshots
|
||||
│ └── ...
|
||||
├── mcp-server/ # MCP bridge (Node.js)
|
||||
│ ├── index.js # MCP server entry (stdio, 14 tools)
|
||||
│ ├── vault.js # Vault file operations
|
||||
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
|
||||
│ ├── test.js # MCP server tests
|
||||
│ └── package.json
|
||||
│
|
||||
├── e2e/ # Playwright E2E tests (~26 specs)
|
||||
├── tests/smoke/ # Smoke tests (~10 specs)
|
||||
├── design/ # Per-task design files
|
||||
├── demo-vault-v2/ # Getting Started demo vault
|
||||
├── scripts/ # Build/utility scripts
|
||||
│
|
||||
├── package.json # Frontend dependencies + scripts
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── playwright.config.ts # E2E test config
|
||||
├── CLAUDE.md # Project instructions for Claude
|
||||
├── ui-design.pen # Master design file
|
||||
├── CLAUDE.md # Project instructions
|
||||
└── docs/ # This documentation
|
||||
```
|
||||
|
||||
@@ -109,9 +195,10 @@ laputa-app/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/App.tsx` | The root component. Shows how the 4-panel layout is assembled and how state flows between components. |
|
||||
| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how all features connect. |
|
||||
| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. |
|
||||
| `src-tauri/src/lib.rs` | All 9 Tauri commands in one place. This is the frontend-backend API surface. |
|
||||
| `src-tauri/src/commands.rs` | All 61 Tauri command handlers. This is the frontend-backend API surface. |
|
||||
| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. |
|
||||
|
||||
### Data layer
|
||||
|
||||
@@ -119,31 +206,55 @@ laputa-app/
|
||||
|------|---------------|
|
||||
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
|
||||
| `src/hooks/useNoteActions.ts` | Tab management, wikilink navigation, frontmatter CRUD. The biggest hook. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, Getting Started vault. |
|
||||
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
|
||||
|
||||
### Backend
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src-tauri/src/vault.rs` | Vault scanning, frontmatter parsing, entity type inference. The core backend logic. |
|
||||
| `src-tauri/src/frontmatter.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git.rs` | All git operations. Shells out to git CLI. |
|
||||
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
|
||||
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
|
||||
| `src-tauri/src/search.rs` | qmd search integration (keyword/semantic/hybrid). |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
|
||||
### Editor
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/Editor.tsx` | BlockNote setup, custom wikilink schema, tab bar, breadcrumb bar, diff toggle. |
|
||||
| `src/utils/wikilinks.ts` | The wikilink preprocessing pipeline (markdown → BlockNote blocks with wikilinks). |
|
||||
| `src/components/EditorTheme.css` | BlockNote CSS overrides for typography and styling. |
|
||||
| `src/components/Editor.tsx` | BlockNote setup, tab bar, breadcrumb bar, diff/raw toggle. |
|
||||
| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. |
|
||||
| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). |
|
||||
| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. |
|
||||
|
||||
### Styling
|
||||
### AI
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties (colors, spacing). The design token source of truth. |
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. |
|
||||
| `src/components/AIChatPanel.tsx` | AI chat panel — API-based chat without tools. |
|
||||
| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
|
||||
### Styling & Themes
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties. The design token source of truth. |
|
||||
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
|
||||
| `src/hooks/useTheme.ts` | Converts theme.json into CSS variables for the editor. |
|
||||
| `src/hooks/useThemeManager.ts` | Vault theme lifecycle (switch, create, apply, live preview). |
|
||||
| `docs/THEMING.md` | Full theme system documentation. |
|
||||
|
||||
### Settings & Config
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault UI preferences (zoom, view mode, colors). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
@@ -169,13 +280,15 @@ No global state management (no Redux, no Context). `App.tsx` owns the state and
|
||||
|
||||
```typescript
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' }
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string }
|
||||
| { kind: 'entity'; entry: VaultEntry }
|
||||
| { kind: 'topic'; entry: VaultEntry }
|
||||
```
|
||||
|
||||
This pattern makes it easy to handle all selection states exhaustively.
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
@@ -183,26 +296,28 @@ This pattern makes it easy to handle all selection states exhaustively.
|
||||
# Unit tests (fast, no browser)
|
||||
pnpm test
|
||||
|
||||
# Rust tests
|
||||
cd src-tauri && cargo test
|
||||
# Unit tests with coverage (must pass ≥70%)
|
||||
pnpm test:coverage
|
||||
|
||||
# E2E tests (requires dev server)
|
||||
pnpm test:e2e
|
||||
# Rust tests
|
||||
cargo test
|
||||
|
||||
# Rust coverage (must pass ≥85% line coverage)
|
||||
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
|
||||
# Playwright smoke tests (requires dev server)
|
||||
BASE_URL="http://localhost:5173" pnpm playwright:smoke
|
||||
|
||||
# Single Playwright test
|
||||
npx playwright test e2e/screenshot.spec.ts
|
||||
|
||||
# Visual verification screenshots
|
||||
npx playwright test e2e/screenshot.spec.ts
|
||||
# Screenshots saved to test-results/
|
||||
BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a new Tauri command
|
||||
|
||||
1. Write the Rust function in `vault.rs`, `git.rs`, or a new module
|
||||
2. Add `#[tauri::command]` wrapper in `lib.rs`
|
||||
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
|
||||
2. Add a command handler in `commands.rs`
|
||||
3. Register it in the `generate_handler![]` macro in `lib.rs`
|
||||
4. Call it from the frontend via `invoke()` in the appropriate hook
|
||||
5. Add a mock handler in `mock-tauri.ts`
|
||||
@@ -217,6 +332,25 @@ npx playwright test e2e/screenshot.spec.ts
|
||||
### Add a new entity type
|
||||
|
||||
1. Create the folder in the vault (e.g., `~/Laputa/mytype/`)
|
||||
2. Add the folder → type mapping in `vault.rs:parse_md_file()` (the `match` on folder names)
|
||||
3. The sidebar section groups are defined as `SECTION_GROUPS` in `Sidebar.tsx` — add it there
|
||||
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it
|
||||
2. Create a type document: `type/mytype.md` with `Is A: Type` frontmatter (icon, color, order, etc.)
|
||||
3. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
|
||||
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
|
||||
|
||||
### Add a command palette entry
|
||||
|
||||
1. Register the command in `useAppCommands.ts` via the command registry
|
||||
2. Add a corresponding menu bar item in `menu.rs` for discoverability
|
||||
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
|
||||
|
||||
### Add or modify a theme
|
||||
|
||||
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `Is A: Theme` frontmatter
|
||||
2. **Programmatic**: Edit defaults in `src-tauri/src/theme/defaults.rs`
|
||||
3. See `docs/THEMING.md` for the full property reference
|
||||
|
||||
### Work with the AI agent
|
||||
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
|
||||
|
||||
@@ -231,3 +231,30 @@ test('full create note flow', async ({ page }) => {
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
|
||||
})
|
||||
|
||||
// --- Flow 8: Wiki-link navigation ---
|
||||
|
||||
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
|
||||
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
|
||||
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify we opened the right note
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
|
||||
|
||||
// Click the wikilink — use mouse.click to fire real mousedown
|
||||
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
|
||||
await expect(wikilink).toBeVisible()
|
||||
const box = await wikilink.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// New tab should open with the target note active
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
|
||||
|
||||
// Editor should show the target note's content
|
||||
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'coverage']),
|
||||
globalIgnores(['dist', 'coverage', 'src-tauri/resources/', 'src-tauri/target/', 'tools/']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Laputa MCP Server — provides vault operation tools for AI assistants.
|
||||
* Laputa MCP Server — lightweight vault tools for AI agents.
|
||||
*
|
||||
* Usage:
|
||||
* VAULT_PATH=/path/to/vault node index.js
|
||||
* The agent has full shell access (bash, read, write, edit).
|
||||
* These MCP tools provide Laputa-specific capabilities that
|
||||
* native tools cannot replace:
|
||||
*
|
||||
* Tools:
|
||||
* - open_note: Open and read a note by path
|
||||
* - read_note: Read note content (alias for consistency)
|
||||
* - create_note: Create a new note with title and optional frontmatter
|
||||
* - search_notes: Search notes by title or content
|
||||
* - append_to_note: Append text to an existing note
|
||||
* - search_notes: full-text search across vault notes
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - open_note: signal Laputa UI to open a note as a tab
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
@@ -18,49 +19,47 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
|
||||
import WebSocket from 'ws'
|
||||
import { searchNotes, getNote, vaultContext } from './vault.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
let uiSocket = null
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function connectUiBridge() {
|
||||
try {
|
||||
const ws = new WebSocket(WS_UI_URL)
|
||||
ws.on('open', () => {
|
||||
uiSocket = ws
|
||||
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
|
||||
})
|
||||
ws.on('close', () => {
|
||||
uiSocket = null
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
})
|
||||
ws.on('error', () => {
|
||||
// Silent — bridge may not be running yet, will retry
|
||||
})
|
||||
} catch {
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
connectUiBridge()
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
|
||||
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open and read a note from the vault by its relative path',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'read_note',
|
||||
description: 'Read the full content of a note',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_note',
|
||||
description: 'Create a new note in the vault with a title and optional frontmatter',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path for the new note (e.g. "note/my-idea.md")' },
|
||||
title: { type: 'string', description: 'Title of the note' },
|
||||
is_a: { type: 'string', description: 'Entity type (Project, Note, Experiment, etc.)' },
|
||||
},
|
||||
required: ['path', 'title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_notes',
|
||||
description: 'Search notes in the vault by title or content',
|
||||
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -71,37 +70,63 @@ const TOOLS = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'append_to_note',
|
||||
description: 'Append text to the end of an existing note',
|
||||
name: 'get_vault_context',
|
||||
description: 'Get vault orientation: entity types, total note count, top-level folders, and 20 most recently modified notes.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'get_note',
|
||||
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
text: { type: 'string', description: 'Text to append' },
|
||||
},
|
||||
required: ['path', 'text'],
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
|
||||
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Optional specific note path that changed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: handleReadNote,
|
||||
read_note: handleReadNote,
|
||||
create_note: handleCreateNote,
|
||||
search_notes: handleSearchNotes,
|
||||
append_to_note: handleAppendToNote,
|
||||
}
|
||||
|
||||
async function handleReadNote(args) {
|
||||
const content = await readNote(VAULT_PATH, args.path)
|
||||
return { content: [{ type: 'text', text: content }] }
|
||||
}
|
||||
|
||||
async function handleCreateNote(args) {
|
||||
const frontmatter = {}
|
||||
if (args.is_a) frontmatter.is_a = args.is_a
|
||||
const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter)
|
||||
return { content: [{ type: 'text', text: `Created note at ${absPath}` }] }
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
@@ -112,15 +137,38 @@ async function handleSearchNotes(args) {
|
||||
return { content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
async function handleAppendToNote(args) {
|
||||
await appendToNote(VAULT_PATH, args.path, args.text)
|
||||
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
|
||||
async function handleVaultContext() {
|
||||
const ctx = await vaultContext(VAULT_PATH)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
|
||||
}
|
||||
|
||||
async function handleGetNote(args) {
|
||||
const note = await getNote(VAULT_PATH, args.path)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
|
||||
}
|
||||
|
||||
function handleOpenNote(args) {
|
||||
// Refresh vault first so the new/modified note appears in the note list,
|
||||
// then signal the UI to open it in a tab.
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleRefreshVault(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.1.0' },
|
||||
{ name: 'laputa-mcp-server', version: '0.3.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
|
||||
109
mcp-server/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
},
|
||||
@@ -110,6 +111,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -334,6 +344,19 @@
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -425,6 +448,18 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -544,6 +579,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -637,6 +687,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@@ -658,6 +717,19 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -670,6 +742,15 @@
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -902,6 +983,19 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
@@ -1046,6 +1140,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1055,6 +1155,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { readNote, createNote, searchNotes, appendToNote, findMarkdownFiles } from './vault.js'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
let tmpDir
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
|
||||
// Create test vault structure
|
||||
await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
|
||||
@@ -33,6 +34,19 @@ is_a: Note
|
||||
# Daily Log
|
||||
|
||||
Today I worked on the MCP server implementation.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
title: Second Project
|
||||
type: Project
|
||||
status: Draft
|
||||
belongs_to:
|
||||
- "[[project/test-project]]"
|
||||
---
|
||||
|
||||
# Second Project
|
||||
|
||||
Another project for testing list and context.
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -43,45 +57,30 @@ after(async () => {
|
||||
describe('findMarkdownFiles', () => {
|
||||
it('should find all .md files recursively', async () => {
|
||||
const files = await findMarkdownFiles(tmpDir)
|
||||
assert.equal(files.length, 2)
|
||||
assert.equal(files.length, 3)
|
||||
assert.ok(files.some(f => f.endsWith('test-project.md')))
|
||||
assert.ok(files.some(f => f.endsWith('daily-log.md')))
|
||||
assert.ok(files.some(f => f.endsWith('second-project.md')))
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNote', () => {
|
||||
it('should read a note by relative path', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('Test Project'))
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
describe('getNote', () => {
|
||||
it('should read a note with parsed frontmatter', async () => {
|
||||
const note = await getNote(tmpDir, 'project/test-project.md')
|
||||
assert.equal(note.path, 'project/test-project.md')
|
||||
assert.equal(note.frontmatter.title, 'Test Project')
|
||||
assert.equal(note.frontmatter.is_a, 'Project')
|
||||
assert.ok(note.content.includes('test project for the MCP server'))
|
||||
})
|
||||
|
||||
it('should throw for missing notes', async () => {
|
||||
await assert.rejects(
|
||||
() => readNote(tmpDir, 'nonexistent.md'),
|
||||
() => getNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should create a note with frontmatter', async () => {
|
||||
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
|
||||
assert.ok(absPath.endsWith('new-note.md'))
|
||||
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('title: My New Note'))
|
||||
assert.ok(content.includes('is_a: Note'))
|
||||
assert.ok(content.includes('# My New Note'))
|
||||
})
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('# Deep Note'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should find notes matching title', async () => {
|
||||
const results = await searchNotes(tmpDir, 'Test Project')
|
||||
@@ -100,16 +99,46 @@ describe('searchNotes', () => {
|
||||
})
|
||||
|
||||
it('should respect limit', async () => {
|
||||
const results = await searchNotes(tmpDir, 'note', 1)
|
||||
const results = await searchNotes(tmpDir, 'project', 1)
|
||||
assert.ok(results.length <= 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToNote', () => {
|
||||
it('should append text to a note', async () => {
|
||||
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
|
||||
const content = await readNote(tmpDir, 'note/daily-log.md')
|
||||
assert.ok(content.includes('## Evening Update'))
|
||||
assert.ok(content.includes('Finished testing.'))
|
||||
describe('vaultContext', () => {
|
||||
it('should return types, recent notes, and vault path', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(Array.isArray(ctx.types))
|
||||
assert.ok(Array.isArray(ctx.recentNotes))
|
||||
assert.equal(ctx.vaultPath, tmpDir)
|
||||
})
|
||||
|
||||
it('should include known entity types', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.types.includes('Project'))
|
||||
assert.ok(ctx.types.includes('Note'))
|
||||
})
|
||||
|
||||
it('should cap recent notes at 20', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.recentNotes.length <= 20)
|
||||
})
|
||||
|
||||
it('should include path and title in recent notes', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
for (const note of ctx.recentNotes) {
|
||||
assert.ok(note.path)
|
||||
assert.ok(note.title)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include folders', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.folders.includes('project/'))
|
||||
assert.ok(ctx.folders.includes('note/'))
|
||||
})
|
||||
|
||||
it('should report correct note count', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.equal(ctx.noteCount, 3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Vault operations — file I/O for Laputa markdown vault.
|
||||
* Vault operations — read-only helpers for Laputa markdown vault.
|
||||
* Write operations are handled by the agent's native bash/write/edit tools.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
/**
|
||||
* Recursively find all .md files under a directory.
|
||||
@@ -25,36 +27,20 @@ export async function findMarkdownFiles(dir) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a note's content by path (absolute or relative to vault).
|
||||
* Read a note with parsed frontmatter and content.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @returns {Promise<string>}
|
||||
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
|
||||
*/
|
||||
export async function readNote(vaultPath, notePath) {
|
||||
export async function getNote(vaultPath, notePath) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
return fs.readFile(absPath, 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new note with optional frontmatter.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} relativePath
|
||||
* @param {string} title
|
||||
* @param {Record<string, string>} [frontmatter]
|
||||
* @returns {Promise<string>} The absolute path of the created file.
|
||||
*/
|
||||
export async function createNote(vaultPath, relativePath, title, frontmatter = {}) {
|
||||
const absPath = path.join(vaultPath, relativePath)
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true })
|
||||
|
||||
const fmEntries = { title, ...frontmatter }
|
||||
const fmLines = Object.entries(fmEntries)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n')
|
||||
|
||||
const content = `---\n${fmLines}\n---\n\n# ${title}\n\n`
|
||||
await fs.writeFile(absPath, content, 'utf-8')
|
||||
return absPath
|
||||
const raw = await fs.readFile(absPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
return {
|
||||
path: path.relative(vaultPath, absPath),
|
||||
frontmatter: parsed.data,
|
||||
content: parsed.content.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,17 +77,54 @@ export async function searchNotes(vaultPath, query, limit = 10) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to the end of a note.
|
||||
* Get vault context: unique types, note count, top-level folders, and 20 most recent notes.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @param {string} text
|
||||
* @returns {Promise<void>}
|
||||
* @returns {Promise<{types: string[], noteCount: number, folders: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
|
||||
*/
|
||||
export async function appendToNote(vaultPath, notePath, text) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
const current = await fs.readFile(absPath, 'utf-8')
|
||||
const separator = current.endsWith('\n') ? '\n' : '\n\n'
|
||||
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
|
||||
export async function vaultContext(vaultPath) {
|
||||
const files = await findMarkdownFiles(vaultPath)
|
||||
const typesSet = new Set()
|
||||
const foldersSet = new Set()
|
||||
const notesWithMtime = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
if (type) typesSet.add(type)
|
||||
const rel = path.relative(vaultPath, filePath)
|
||||
const topFolder = rel.split(path.sep)[0]
|
||||
if (topFolder !== rel) foldersSet.add(topFolder + '/')
|
||||
const stat = await fs.stat(filePath)
|
||||
notesWithMtime.push({
|
||||
path: rel,
|
||||
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
|
||||
type,
|
||||
mtime: stat.mtimeMs,
|
||||
})
|
||||
}
|
||||
|
||||
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
|
||||
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
|
||||
|
||||
// Read config files for AI agent context
|
||||
const configFiles = {}
|
||||
try {
|
||||
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
|
||||
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
|
||||
configFiles.agents = agentsContent
|
||||
} catch {
|
||||
// config/agents.md may not exist yet
|
||||
}
|
||||
|
||||
return {
|
||||
types: [...typesSet].sort(),
|
||||
noteCount: files.length,
|
||||
folders: [...foldersSet].sort(),
|
||||
recentNotes,
|
||||
configFiles,
|
||||
vaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -5,32 +5,53 @@
|
||||
* Exposes vault operations over WebSocket so the Laputa app frontend
|
||||
* can invoke MCP tools in real-time without going through stdio.
|
||||
*
|
||||
* Usage:
|
||||
* VAULT_PATH=/path/to/vault WS_PORT=9710 node ws-bridge.js
|
||||
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
|
||||
* Port 9711: UI bridge — Frontend listens for UI action broadcasts.
|
||||
*
|
||||
* Protocol:
|
||||
* Usage:
|
||||
* VAULT_PATH=/path/to/vault WS_PORT=9710 WS_UI_PORT=9711 node ws-bridge.js
|
||||
*
|
||||
* Protocol (tool bridge):
|
||||
* Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }
|
||||
* Server sends: { "id": "req-1", "result": { ... } }
|
||||
* On error: { "id": "req-1", "error": "message" }
|
||||
*
|
||||
* Protocol (UI bridge):
|
||||
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
|
||||
import {
|
||||
getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
|
||||
read_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
|
||||
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
for (const client of uiBridge.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
function buildFrontmatter(args) {
|
||||
const fm = {}
|
||||
if (args.is_a) fm.is_a = args.is_a
|
||||
return fm
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
@@ -50,6 +71,43 @@ async function handleMessage(data) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to start the UI bridge WebSocket server.
|
||||
* Returns a Promise that resolves to the WebSocketServer or null if the port
|
||||
* is unavailable (e.g. another Laputa instance owns it).
|
||||
*/
|
||||
export function startUiBridge(port = WS_UI_PORT) {
|
||||
return new Promise((resolve) => {
|
||||
const httpServer = createServer()
|
||||
|
||||
httpServer.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`)
|
||||
} else {
|
||||
console.error(`[ws-bridge] UI bridge error: ${err.message}`)
|
||||
}
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
wss.on('connection', (ws) => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
// Relay: when a client sends a message, broadcast to all OTHER clients.
|
||||
// This allows the MCP stdio server (connected as a client) to reach the frontend.
|
||||
ws.on('message', (raw) => {
|
||||
for (const client of wss.clients) {
|
||||
if (client !== ws && client.readyState === 1) client.send(raw.toString())
|
||||
}
|
||||
})
|
||||
})
|
||||
uiBridge = wss
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
resolve(wss)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
const wss = new WebSocketServer({ port })
|
||||
|
||||
@@ -75,5 +133,5 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startBridge()
|
||||
startUiBridge().then(() => startBridge())
|
||||
}
|
||||
|
||||
16
package.json
@@ -6,12 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test tests/smoke/",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -20,6 +22,12 @@
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.16",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -48,7 +56,10 @@
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
@@ -62,8 +73,10 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
@@ -74,6 +87,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
testDir: './tests/smoke',
|
||||
timeout: 15_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5201',
|
||||
headless: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
port: 5173,
|
||||
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
|
||||
url: process.env.BASE_URL || 'http://localhost:5201',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
|
||||
1132
pnpm-lock.yaml
generated
@@ -1,2 +1,5 @@
|
||||
packages:
|
||||
- mcp-server
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
45
scripts/bundle-mcp-server.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Bundle the mcp-server Node.js files into self-contained CJS bundles
|
||||
* that can be shipped as Tauri resources inside the .app bundle.
|
||||
*
|
||||
* Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js}
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const SRC = join(ROOT, 'mcp-server')
|
||||
const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server')
|
||||
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
// Tell Node.js that this directory contains CJS bundles, even if the
|
||||
// root package.json declares "type": "module".
|
||||
writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' }))
|
||||
|
||||
const shared = {
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
// Mark optional native bindings as external — ws works fine without them
|
||||
external: ['bufferutil', 'utf-8-validate'],
|
||||
logLevel: 'warning',
|
||||
}
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'index.js')],
|
||||
outfile: join(OUT, 'index.js'),
|
||||
})
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'ws-bridge.js')],
|
||||
outfile: join(OUT, 'ws-bridge.js'),
|
||||
})
|
||||
|
||||
console.log('mcp-server bundled → src-tauri/resources/mcp-server/')
|
||||
155
scripts/bundle-qmd.sh
Executable file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bundle qmd into a self-contained directory for Tauri resource embedding.
|
||||
#
|
||||
# Output: src-tauri/resources/qmd/
|
||||
# qmd — compiled standalone binary
|
||||
# node_modules/sqlite-vec/ — JS shim for sqlite-vec
|
||||
# node_modules/sqlite-vec-darwin-arm64/ — native .dylib (arm64)
|
||||
# node_modules/sqlite-vec-darwin-x64/ — native .dylib (x64)
|
||||
# node_modules/node-llama-cpp/ — stub (keyword search only)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$SCRIPT_DIR/.."
|
||||
OUT="$ROOT/src-tauri/resources/qmd"
|
||||
|
||||
# ---------- locate tools ----------
|
||||
find_bun() {
|
||||
for c in \
|
||||
"$HOME/.bun/bin/bun" \
|
||||
"/opt/homebrew/bin/bun" \
|
||||
"/usr/local/bin/bun"; do
|
||||
[[ -x "$c" ]] && { echo "$c"; return 0; }
|
||||
done
|
||||
command -v bun 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
BUN=$(find_bun) || { echo "ERROR: bun not found — install from https://bun.sh" >&2; exit 1; }
|
||||
echo "Using bun: $BUN"
|
||||
|
||||
# ---------- locate qmd source ----------
|
||||
# Prefer bundled source in tools/qmd/ (works in CI and dev),
|
||||
# then fall back to globally installed qmd on dev machines.
|
||||
QMD_SRC=""
|
||||
for c in \
|
||||
"$ROOT/tools/qmd" \
|
||||
"$HOME/.bun/install/global/node_modules/qmd" \
|
||||
"/opt/homebrew/lib/node_modules/qmd" \
|
||||
"/usr/local/lib/node_modules/qmd"; do
|
||||
[[ -f "$c/src/qmd.ts" ]] && { QMD_SRC="$c"; break; }
|
||||
done
|
||||
|
||||
[[ -n "$QMD_SRC" ]] || { echo "ERROR: qmd source not found. tools/qmd/ is missing or incomplete." >&2; exit 1; }
|
||||
echo "Using qmd source: $QMD_SRC"
|
||||
|
||||
# Install qmd dependencies if needed (for CI where node_modules don't exist yet)
|
||||
if [[ ! -d "$QMD_SRC/node_modules" ]]; then
|
||||
echo "Installing qmd dependencies..."
|
||||
(cd "$QMD_SRC" && "$BUN" install --frozen-lockfile)
|
||||
fi
|
||||
|
||||
# ---------- compile ----------
|
||||
echo "Compiling qmd with bun build --compile..."
|
||||
mkdir -p "$OUT"
|
||||
|
||||
(cd "$QMD_SRC" && "$BUN" build --compile \
|
||||
"src/qmd.ts" \
|
||||
--outfile "$OUT/qmd" \
|
||||
--external node-llama-cpp \
|
||||
--external sqlite-vec \
|
||||
--external sqlite-vec-darwin-arm64 \
|
||||
--external sqlite-vec-darwin-x64)
|
||||
|
||||
chmod +x "$OUT/qmd"
|
||||
|
||||
# ---------- bundle sqlite-vec ----------
|
||||
echo "Bundling sqlite-vec native extensions..."
|
||||
|
||||
# Find sqlite-vec packages — prefer node_modules in QMD_SRC (after bun install),
|
||||
# fall back to bun global cache for dev machines.
|
||||
NM="$QMD_SRC/node_modules"
|
||||
|
||||
find_pkg() {
|
||||
local pkg="$1"
|
||||
# Check node_modules from bun install in QMD_SRC first
|
||||
if [[ -d "$NM/$pkg" ]]; then
|
||||
echo "$NM/$pkg"; return 0
|
||||
fi
|
||||
# Fall back to bun global cache
|
||||
local cache_dir
|
||||
cache_dir=$(find "$HOME/.bun/install/cache" -maxdepth 1 -name "${pkg}@*" -type d 2>/dev/null | head -1)
|
||||
[[ -n "$cache_dir" ]] && echo "$cache_dir" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# sqlite-vec JS shim
|
||||
SQLVEC_DIR=$(find_pkg "sqlite-vec") || { echo "ERROR: sqlite-vec not found" >&2; exit 1; }
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec"
|
||||
cp "$SQLVEC_DIR/index.mjs" "$OUT/node_modules/sqlite-vec/index.mjs"
|
||||
cp "$SQLVEC_DIR/package.json" "$OUT/node_modules/sqlite-vec/package.json"
|
||||
[[ -f "$SQLVEC_DIR/index.cjs" ]] && cp "$SQLVEC_DIR/index.cjs" "$OUT/node_modules/sqlite-vec/index.cjs"
|
||||
|
||||
# sqlite-vec-darwin-arm64
|
||||
ARM64_DIR=$(find_pkg "sqlite-vec-darwin-arm64") || true
|
||||
if [[ -n "$ARM64_DIR" ]]; then
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-arm64"
|
||||
cp "$ARM64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-arm64/vec0.dylib"
|
||||
cp "$ARM64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-arm64/package.json"
|
||||
echo " ✓ arm64 dylib"
|
||||
fi
|
||||
|
||||
# sqlite-vec-darwin-x64
|
||||
X64_DIR=$(find_pkg "sqlite-vec-darwin-x64") || true
|
||||
if [[ -n "$X64_DIR" ]]; then
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-x64"
|
||||
cp "$X64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-x64/vec0.dylib"
|
||||
cp "$X64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-x64/package.json"
|
||||
echo " ✓ x64 dylib"
|
||||
fi
|
||||
|
||||
# ---------- stub node-llama-cpp ----------
|
||||
echo "Creating node-llama-cpp stub (keyword search only)..."
|
||||
mkdir -p "$OUT/node_modules/node-llama-cpp"
|
||||
|
||||
cat > "$OUT/node_modules/node-llama-cpp/package.json" << 'PJSON'
|
||||
{"name":"node-llama-cpp","version":"0.0.0-stub","type":"module","main":"index.js"}
|
||||
PJSON
|
||||
|
||||
cat > "$OUT/node_modules/node-llama-cpp/index.js" << 'STUB'
|
||||
// Stub: node-llama-cpp not bundled — semantic search unavailable, keyword search works.
|
||||
const unavailable = (name) => (...args) => {
|
||||
throw new Error(`${name}() unavailable: node-llama-cpp not bundled. Keyword search still works.`);
|
||||
};
|
||||
export const getLlama = unavailable("getLlama");
|
||||
export const resolveModelFile = unavailable("resolveModelFile");
|
||||
export class LlamaChatSession {
|
||||
constructor() { throw new Error("LlamaChatSession unavailable"); }
|
||||
}
|
||||
export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 };
|
||||
STUB
|
||||
|
||||
# ---------- code signing (macOS) ----------
|
||||
# In CI (APPLE_SIGNING_IDENTITY set): sign with Developer ID + hardened runtime (required for notarization)
|
||||
# In dev (no identity): ad-hoc sign to remove quarantine
|
||||
if [[ "$(uname)" == "Darwin" ]] && command -v codesign &>/dev/null; then
|
||||
SIGN_ID="${APPLE_SIGNING_IDENTITY:--}"
|
||||
if [[ "$SIGN_ID" != "-" ]]; then
|
||||
echo "Signing bundled binaries with Developer ID: $SIGN_ID"
|
||||
SIGN_OPTS=(--force --sign "$SIGN_ID" --options runtime --timestamp)
|
||||
else
|
||||
echo "Ad-hoc signing bundled binaries (dev mode)..."
|
||||
SIGN_OPTS=(--force --sign -)
|
||||
fi
|
||||
codesign "${SIGN_OPTS[@]}" "$OUT/qmd" 2>/dev/null && echo " ✓ qmd signed" || echo " ⚠ qmd signing failed (non-fatal)"
|
||||
while IFS= read -r -d '' dylib; do
|
||||
codesign "${SIGN_OPTS[@]}" "$dylib" 2>/dev/null && echo " ✓ $(basename "$dylib") signed" || echo " ⚠ $(basename "$dylib") signing failed (non-fatal)"
|
||||
done < <(find "$OUT/node_modules" -name "*.dylib" -print0)
|
||||
fi
|
||||
|
||||
# ---------- summary ----------
|
||||
echo ""
|
||||
echo "qmd bundled → $OUT/"
|
||||
du -sh "$OUT/qmd"
|
||||
du -sh "$OUT/node_modules"
|
||||
echo "Done."
|
||||
4
src-tauri/.gitignore
vendored
@@ -2,3 +2,7 @@
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
|
||||
# Generated by build scripts
|
||||
/resources/mcp-server/
|
||||
/resources/qmd/
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
fn main() {
|
||||
// Ensure resource directories exist for the Tauri build.
|
||||
// These are gitignored and populated by scripts (bundle-qmd.sh, bundle-mcp-server.mjs).
|
||||
// Without a placeholder, `tauri build` / `cargo test` fails if the scripts haven't run.
|
||||
for dir in ["resources/qmd", "resources/mcp-server"] {
|
||||
let path = std::path::Path::new(dir);
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path).ok();
|
||||
std::fs::write(path.join(".placeholder"), "").ok();
|
||||
}
|
||||
}
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.4 KiB |