Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4a26ee8
feat(mobile): guided first-run sync onboarding
ddutchie Jul 27, 2026
599acc7
fix(mobile): exclude empty notes from semantic index count
ddutchie Jul 27, 2026
9ad6128
fix(mobile): one un-embeddable note no longer stalls the whole index
ddutchie Jul 27, 2026
1ed057d
fix(mobile): move semantic-index info affordance into the Search header
ddutchie Jul 27, 2026
49e2bc4
fix(mobile): diagnose unindexed TASK CARDS too, tie info button to th…
ddutchie Jul 27, 2026
b079ecc
feat(mobile): archive/delete task cards via board drop zones
ddutchie Jul 27, 2026
083a183
fix(mobile): render board archive/delete zones below the columns
ddutchie Jul 27, 2026
3adeb66
fix(mobile): correct board bottom spacing for columns + action zones
ddutchie Jul 27, 2026
1715f92
polish(mobile): drag card outline + clean dashed->solid zone hover
ddutchie Jul 27, 2026
aa9d036
feat(mobile): add Delete to the task detail menu
ddutchie Jul 27, 2026
f0beaa8
polish(mobile): distinguish Archive from Delete in the task menu
ddutchie Jul 27, 2026
92528c5
Add Proper iOS Icon
ddutchie Jul 27, 2026
7483aa6
fix(notes): render task-list checkboxes inside table cells
ddutchie Jul 28, 2026
63a9aba
feat(mobile): detailed context-usage breakdown in the chat ring
ddutchie Jul 28, 2026
f6900e3
fix(mobile): stop the context ring tap from triggering scroll-to-top
ddutchie Jul 28, 2026
deeae6c
refactor(mobile): show context breakdown in a native glass menu
ddutchie Jul 28, 2026
adb8af8
polish(mobile): two-line context menu rows, drop the colour dot
ddutchie Jul 28, 2026
4b50964
fix(mobile): single-line context menu rows
ddutchie Jul 28, 2026
0f0afbd
polish(mobile): right-align token counts in context menu rows
ddutchie Jul 28, 2026
49c6134
feat(mobile): context breakdown in a native popover with aligned columns
ddutchie Jul 28, 2026
695a5d9
feat(mobile): segmented bar + colour-tinted counts in context popover
ddutchie Jul 28, 2026
0e7069d
polish(mobile): auto zoom-to-fit the knowledge graph on open
ddutchie Jul 28, 2026
d95de61
polish(mobile): auto-fit the knowledge graph on simulation end
ddutchie Jul 28, 2026
1fc6fc8
polish(mobile): auto-fit graph once visually settled, not fully cooled
ddutchie Jul 28, 2026
bafe548
tune(mobile): raise graph auto-fit alpha threshold to 0.15
ddutchie Jul 28, 2026
01c909c
feat: user-selectable accent color with Moss green default
ddutchie Jul 28, 2026
680f85f
assets: refresh desktop/app icons and favicons from new artwork
ddutchie Jul 28, 2026
6260312
test(mobile): mock ./tokens so token-breakdown test runs without js-t…
ddutchie Jul 28, 2026
f7c8cae
fix(graph): fixed Project node colour so it survives green accents
ddutchie Jul 28, 2026
a997f7e
fix: address code-review findings across accent, embeddings, DnD, a11y
ddutchie Jul 28, 2026
a28ef39
fix(mobile): sync scroll-independent zone flag + gate embeddings deta…
ddutchie Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified build-resources/Cairn.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build-resources/Cairn_Stack.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified build-resources/icon.icns
Binary file not shown.
Binary file modified build-resources/icon.ico
Binary file not shown.
Binary file modified build-resources/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed build-resources/icon2.ico
Binary file not shown.
10 changes: 10 additions & 0 deletions changelogs/v2.5.15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Features

- **Choose your accent color.** A new accent-color picker — in both Settings → General and the topbar Quick Settings popover — offers ten curated presets: Sage Moss (the new default), Cairn Teal, Terracotta, Amber Gold, Obsidian Indigo, Slate Blue, Rust Coral, Deep Plum, Pine Green, and Copper Bronze. Each preset ships a separate light- and dark-theme trio tuned for Cairn's warm-neutral surfaces, and every foreground pairing meets WCAG AA contrast. Your choice persists across restarts, follows theme switches (including System auto light/dark), and even tints the launch splash. The mobile app gets the same ten presets from a new Appearance screen (opened from the Projects tab, right of Sync). The default accent is now Sage Moss instead of the old Obsidian-style indigo.

## Fixes

- **Task-list checkboxes inside table cells now render as real checkboxes.** GFM only parses inline content inside table cells, so a checklist crammed into a cell — `- [ ] install - [x] configure`, or a multi-line cell using `<br>` like `- [ ] Create account<br>- [ ] Verify email<br>- [x] Set password` — previously showed the raw `[ ]` / `[x]` text. Cells now recover every checkbox token (optionally dash-prefixed, whether separated by spaces or `<br>`), rendering each as a checkbox in document order. In the note preview they're clickable and toggle the underlying markdown source; in read-only contexts (card previews, chat, PDF) they render as static checkmarks.
- **PDF export renders table checkboxes too.** Desktop PDF inherits the fixed note-preview DOM automatically; mobile PDF now recovers cell checkboxes as print-safe checkbox glyphs (with in-cell line breaks preserved), matching desktop.
- **Refreshed desktop app icon and favicons.** The macOS (`.icns`), Windows (`.ico`), and Linux (`.png`) app icons are regenerated from the new dark-tile Cairn artwork, and the browser-tab favicons (`favicon.ico`, `favicon.svg`) plus the apple-touch icon (`icon.png`) are regenerated from the transparent stack artwork — all replacing the older purple/green version. The macOS menu-bar tray silhouette is unchanged. Removed several dead, unreferenced icon files (`icon2.ico`, `icon_win.png`, `icon_dark.png`) from the build inputs.
- **Knowledge graph stays legible under any accent.** Project nodes now use a fixed violet instead of the accent colour, so they no longer blend into the green Card nodes when a green accent (Sage, Pine, Teal) is selected. The Semantic-connection edges likewise switched from the accent to a neutral dashed line, keeping the four node types and the edge legend cleanly distinguishable on both desktop and mobile regardless of your accent choice.
Binary file added design/Cairn.psd
Binary file not shown.
Binary file added design/Cairn_NB.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
781 changes: 781 additions & 0 deletions design/accent-lab.html

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,23 @@ export function registerAppHandlers(
readWorkspaceConfig(userDataPath) === null
));

registerIpcHandle("app:setTheme", (_e, theme: string) => handle(() => {
// Merge a partial update into theme.json without clobbering the other keys.
// theme.json holds BOTH `theme` and `accent` so the boot splash can restore
// them; writing one setting must preserve the other (see app:setTheme /
// app:setAccent below).
const mergeThemeFile = (patch: Record<string, unknown>) => {
const themeFile = path.join(userDataPath, "theme.json");
fs.writeFileSync(themeFile, JSON.stringify({ theme }), "utf8");
let existing: Record<string, unknown> = {};
try {
if (fs.existsSync(themeFile)) existing = JSON.parse(fs.readFileSync(themeFile, "utf8"));
} catch {
// ignore malformed file — overwrite below
}
fs.writeFileSync(themeFile, JSON.stringify({ ...existing, ...patch }), "utf8");
};

registerIpcHandle("app:setTheme", (_e, theme: string) => handle(() => {
mergeThemeFile({ theme });
// On Windows, update the native title bar overlay to match the new theme.
// Use --surface values (not backgroundColor) to match TitleBar's bg-[var(--surface)].
// height:39 not 40 — Windows 1px window border makes 40 clip the border-b below the bar.
Expand All @@ -122,6 +136,12 @@ export function registerAppHandlers(
}
}));

registerIpcHandle("app:setAccent", (_e, accent: string) => handle(() => {
// Persist the accent id alongside the theme so the next boot's splash can
// render the right accent. Merge into the existing theme.json.
mergeThemeFile({ accent });
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

registerIpcHandle("app:initWorkspace", (_e, { workspacePath: newPath }: { workspacePath: string }) => handle(async () => {
writeWorkspaceConfig(userDataPath, newPath);
fs.mkdirSync(newPath, { recursive: true });
Expand Down
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ const api = {
getWorkspacePath: () => invoke<string | null>("app:getWorkspacePath"),
needsWorkspaceSetup: () => invoke<boolean>("app:needsWorkspaceSetup"),
setTheme: (theme: string) => invoke("app:setTheme", theme),
setAccent: (accent: string) => invoke("app:setAccent", accent),
initWorkspace: (workspacePath: string) => invoke<{ requiresRestart: boolean }>("app:initWorkspace", { workspacePath }),
relaunch: () => invoke("app:relaunch"),
resetAllData: () => invoke("app:reset"),
Expand Down
33 changes: 21 additions & 12 deletions electron/splash/bootsplash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { app, BrowserWindow } from "electron";
import * as fs from "fs";
import * as path from "path";
import { resolveAccentPreset } from "../../shared/ui/accents";

export type SplashStep =
| "update"
Expand Down Expand Up @@ -315,10 +316,27 @@ ${initialVars}
function getThemeColors(): Record<string, string> {
// Mirrors the app's real design tokens in src/app/globals.css so the splash
// matches the window that follows it (light branch = [data-theme="light"]).
// The accent is the user's chosen preset (persisted in theme.json by
// setAccent), resolved for the active theme; falls back to the default.
let accentId: string | undefined;
let isLight = false;
try {
const themeFile = path.join(app.getPath("userData"), "theme.json");
if (fs.existsSync(themeFile)) {
const j = JSON.parse(fs.readFileSync(themeFile, "utf8"));
isLight = j.theme === "light";
accentId = typeof j.accent === "string" ? j.accent : undefined;
}
} catch {
// ignore — use dark defaults
}
const preset = resolveAccentPreset(accentId);
const accent = isLight ? preset.light.accent : preset.dark.accent;

const light: Record<string, string> = {
text: "#1a1917", // --text-primary (light)
"text-dim": "#9e9a94", // --text-tertiary (light)
accent: "#6457e8", // --accent (light)
accent, // --accent (light, chosen preset)
"progress-bg": "#f0eeeb", // --surface-2 (light)
success: "#1a9e68", // --success (light)
error: "#dc2626", // --danger (light)
Expand All @@ -327,22 +345,13 @@ function getThemeColors(): Record<string, string> {
const dark: Record<string, string> = {
text: "#e8e4dc", // --text-primary (dark)
"text-dim": "#66635f", // --text-tertiary (dark)
accent: "#7c6af7", // --accent (dark)
accent, // --accent (dark, chosen preset)
"progress-bg": "#1a1a1a", // --surface-2 (dark)
success: "#3ecf8e", // --success (dark)
error: "#ef4444", // --danger (dark)
background: "#0d0d0d", // --background (dark)
};
try {
const themeFile = path.join(app.getPath("userData"), "theme.json");
if (fs.existsSync(themeFile)) {
const t = JSON.parse(fs.readFileSync(themeFile, "utf8")).theme;
if (t === "light") return light;
}
} catch {
// ignore — use dark defaults
}
return dark;
return isLight ? light : dark;
}

export class BootSplash {
Expand Down
1 change: 1 addition & 0 deletions mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"userInterfaceStyle": "automatic",
"icon": "./assets/icon.png",
"ios": {
"icon": "./assets/CairnMacIcon.icon",
"supportsTablet": true,
"bundleIdentifier": "com.gerardslee.cairn",
"infoPlist": {
Expand Down
3 changes: 3 additions & 0 deletions mobile/app/(tabs)/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ export default function ChatScreen() {
promptTokens={usage.promptTokens}
contextLimit={usage.contextLimit}
estimated={usage.estimated}
breakdown={usage.breakdown}
completionTokens={usage.completionTokens}
reasoningTokens={usage.reasoningTokens}
/>
)
: undefined,
Expand Down
7 changes: 7 additions & 0 deletions mobile/app/(tabs)/projects/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { SkeletonList } from "@/components/Skeleton";
import { ProjectIcon } from "@/components/ProjectIcon";
import { useSyncBadge } from "@/components/SyncStatusBadge";
import { WorkspaceHeaderMenu } from "@/components/WorkspaceHeaderMenu";
import { ICON_SETTINGS } from "@/components/toolbar-icons";
import { toolbarPress } from "@/haptics";
import { useRefreshOnFocus } from "@/sync/useSyncStatus";
import { useTheme, elevation, type as typeScale } from "@/theme";

Expand Down Expand Up @@ -39,6 +41,11 @@ export default function ProjectsScreen() {
<WorkspaceHeaderMenu />
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button {...syncBadge} />
<Stack.Toolbar.Button
icon={ICON_SETTINGS}
accessibilityLabel="Appearance settings"
onPress={toolbarPress(() => router.push("/settings/appearance"))}
/>
</Stack.Toolbar>
</>
);
Expand Down
100 changes: 82 additions & 18 deletions mobile/app/(tabs)/search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { useCallback, useMemo, useRef, useState } from "react";
import { Text, View, FlatList, StyleSheet, RefreshControl, Pressable, Alert, Keyboard } from "react-native";
import { Stack, useRouter, useFocusEffect, type Href } from "expo-router";
import type { SearchBarCommands } from "react-native-screens";
import { searchNotes, searchTasks, listWorkspaceIds, embeddingIndexStats, type NoteRow, type CardRow } from "@/db/queries";
import { searchNotes, searchTasks, listWorkspaceIds, embeddingIndexStats, listUnindexedNotes, listUnindexedCards, type NoteRow, type CardRow, type UnindexedNote } from "@/db/queries";
import { ResultRow } from "@/components/ResultRow";
import { TabScreen } from "@/components/TabScreen";
import { EmptyState } from "@/components/EmptyState";
import { IndexingBar } from "@/components/IndexingBar";
import { GlassBar, glassActive } from "@/components/GlassBar";
import { KeyboardStickyView } from "react-native-keyboard-controller";
import { Sparkles } from "lucide-react-native";
import { Sparkles, Info } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { semanticSearch, semanticSearchTasks, catchUpIndex, finalizeRanking, type SemanticHit } from "@/notes/embeddings";
import { haptics } from "@/haptics";
Expand Down Expand Up @@ -45,6 +45,7 @@ export default function SearchScreen() {
const [hits, setHits] = useState<SemanticHit[]>([]);
const [reindexing, setReindexing] = useState(false);
const [stats, setStats] = useState<{ live: number; indexed: number } | null>(null);
const [unindexed, setUnindexed] = useState<UnindexedNote[]>([]);
const styles = useMemo(() => makeStyles(t), [t]);
const searchRef = useRef<SearchBarCommands>(null);
const insets = useSafeAreaInsets();
Expand Down Expand Up @@ -148,12 +149,15 @@ export default function SearchScreen() {
const refreshStats = useCallback(() => {
let live = 0;
let indexed = 0;
const missing: UnindexedNote[] = [];
for (const ws of listWorkspaceIds()) {
const s = embeddingIndexStats(ws);
live += s.liveNotes + s.liveCards;
indexed += s.indexedNotes + s.indexedCards;
missing.push(...listUnindexedNotes(ws), ...listUnindexedCards(ws));
}
setStats({ live, indexed });
setUnindexed(missing);
}, []);

// Pull-to-refresh on the Semantic list: force a full catch-up (embeds any
Expand All @@ -173,6 +177,49 @@ export default function SearchScreen() {
}
}, [query, typeFilter, run, refreshStats]);

// Explain what isn't indexed and why (info affordance in the header).
const showIndexInfo = useCallback(() => {
const withContent = unindexed.filter((n) => n.contentLen > 0);
const emptyCount = unindexed.length - withContent.length;
const lines: string[] = [
"Only items with body text an embedding can be built from are added to the semantic index.",
];
if (emptyCount > 0) {
lines.push(
`\n${emptyCount} empty item${emptyCount === 1 ? "" : "s"} (no body text) — nothing to index. These aren't counted in the total.`,
);
}
if (withContent.length > 0) {
const names = withContent.slice(0, 8).map((n) => `• ${n.title || "Untitled"}`).join("\n");
const more = withContent.length > 8 ? `\n…and ${withContent.length - 8} more` : "";
lines.push(
`\n${withContent.length} item${withContent.length === 1 ? "" : "s"} with content that hasn't been embedded yet. Reindex to try again — if one keeps failing, its body may have no indexable words (only symbols, links, or an image). Add some text and reindex to include it:\n\n${names}${more}`,
);
}
if (unindexed.length === 0) {
const gap = stats ? stats.live - stats.indexed : 0;
if (gap > 0) {
lines.push(
`\n${gap} item${gap === 1 ? "" : "s"} counted as not indexed, but every note and task with content already has an index entry. This usually means stale index rows from a previous on-device model — tap Reindex to rebuild.`,
);
} else {
lines.push("\nEverything with content is indexed.");
}
}
const gap = stats ? stats.live - stats.indexed : 0;
Alert.alert(
"Semantic index",
lines.join("\n"),
withContent.length > 0 || gap > 0
? [
{ text: "Reindex now", onPress: () => void forceReindex() },
{ text: "OK", style: "cancel" as const },
]
: [{ text: "OK", style: "cancel" as const }],
);
}, [unindexed, forceReindex, stats]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.


// Refresh stats whenever semantic mode is on or the tab regains focus.
useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -261,23 +308,37 @@ export default function SearchScreen() {
hideWhenScrolling: false,
onChangeText: (e) => onChange(e.nativeEvent.text),
},
// ✨ Semantic ranking toggle (on by default when supported). Hidden
// entirely when the device can't do on-device embeddings. Sits at the
// trailing edge beside the full-width search field; iOS 26+ wraps it in
// its own glass toolbar button, so we render just the bare icon and let
// colour (accent vs tertiary) signal on/off.
// Header trailing controls: an optional info button (shown when
// semantic mode is on and some items aren't indexed — always visible
// here, unlike an empty-state child that can hide behind the list/tab
// bar) plus the ✨ semantic ranking toggle. The toggle is hidden
// entirely when the device can't do on-device embeddings. iOS 26+
// wraps each in its own glass toolbar button.
headerRight: semanticAvailable
? () => (
<Pressable
onPress={toggleSemantic}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Semantic search"
accessibilityState={{ selected: semanticMode }}
style={styles.semBtn}
>
<Sparkles size={18} color={semanticMode ? t.accent : t.textTertiary} />
</Pressable>
<View style={styles.headerRight}>
{semanticMode && stats && stats.indexed < stats.live ? (
<Pressable
onPress={showIndexInfo}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Why aren't all items indexed?"
style={styles.semBtn}
>
<Info size={18} color={t.warning} />
</Pressable>
) : null}
<Pressable
onPress={toggleSemantic}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Semantic search"
accessibilityState={{ selected: semanticMode }}
style={styles.semBtn}
>
<Sparkles size={18} color={semanticMode ? t.accent : t.textTertiary} />
</Pressable>
</View>
)
: undefined,
}}
Expand Down Expand Up @@ -351,7 +412,9 @@ export default function SearchScreen() {
{emptyHint.secondary ? <Text style={styles.statHint}>{emptyHint.secondary}</Text> : null}
</View>
) : (
// Resting (no query) → branded Cairn empty state.
// Resting (no query) → branded Cairn empty state. The "why not all
// indexed?" affordance lives in the header (see headerRight) so it's
// always visible and never hides behind the list or tab bar.
<EmptyState title={emptyHint.primary} subtitle={emptyHint.secondary} pinned insetTop={insets.top} />
)
) : null}
Expand Down Expand Up @@ -419,5 +482,6 @@ function makeStyles(t: Theme) {
hintBias: { height: "25%" },
hint: { textAlign: "center", color: t.textTertiary },
statHint: { ...typeScale.caption, textAlign: "center", color: t.textTertiary, marginTop: 8 },
headerRight: { flexDirection: "row", alignItems: "center", gap: 8 },
});
}
13 changes: 8 additions & 5 deletions mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { startAutoSync, onDataChanged } from "@/sync/controller";
import { catchUpIndex } from "@/notes/embeddings";
import { UpdateBanner } from "@/components/UpdateBanner";
import { ToastProvider } from "@/components/Toast";
import { SourcePicker } from "@/components/SourcePicker";
import { OnboardingGuide } from "@/components/OnboardingGuide";
import { useTheme } from "@/theme";

// Keep the native splash up until the DB is ready, so there's no flash between
Expand All @@ -27,7 +27,7 @@ export default function RootLayout() {
const scheme = useColorScheme();
// Bootstrap the meta DB and re-open a previously-selected source (if any)
// during first render. Multi-source: a fresh install has NO active source yet,
// so we show the SourcePicker instead of the app until one is chosen.
// so we show the OnboardingGuide instead of the app until one is chosen.
const [{ ready, error }] = useState<{ ready: boolean; error: string | null }>(() => {
try {
initDatabase();
Expand Down Expand Up @@ -108,13 +108,15 @@ export default function RootLayout() {
);
}

// No source selected yet — show the picker (scans the shared folder). Once a
// source is chosen, getDb()/getEngine() resolve and the app proceeds.
// No source selected yet — show the guided onboarding (scans the shared
// folder, walks the user through connecting the desktop, and lets them tap a
// discovered workspace to connect). Once a source is chosen, getDb()/
// getEngine() resolve and the app proceeds.
if (!hasSource) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<SourcePicker onSelected={() => setHasSource(true)} />
<OnboardingGuide onConnected={() => setHasSource(true)} />
</SafeAreaProvider>
</GestureHandlerRootView>
);
Expand Down Expand Up @@ -148,6 +150,7 @@ export default function RootLayout() {
<Stack.Screen name="conflicts" options={{ title: "Sync Conflicts" }} />
<Stack.Screen name="settings/ai" options={{ title: "AI settings", presentation: "modal" }} />
<Stack.Screen name="settings/tools" options={{ title: "Tools & Services", presentation: "modal" }} />
<Stack.Screen name="settings/appearance" options={{ title: "Appearance", presentation: "modal" }} />
</Stack>
</ToastProvider>
</ThemeProvider>
Expand Down
Loading