mini-diarium / src
CLAUDE.md318 starsChanged 12 days agoMITView on GitHub
For project architecture, command registry, and cross-cutting conventions see the root CLAUDE.md. For durable frontend rules, use Frontend best practices before changing SolidJS reactivity, state ownership, Tauri error UI, TipTap/editor behavior, accessibility, or E2E-visible UI. State modules live in src/state/ — one file per domain, all SolidJS signals. See each file for current signals and exports. Key invariant: session.ts:resetSessionState() orchestrates lock cleanup — it calls reset functions from entries, search, ui, tags, and entryAttachments (also reset by refreshAfterRestore()). If you…
- Reads credentials
In this repository49 other files it gives its agents.
- .agents/skills/unsafe-checker/AGENTS.mdAGENTS.md
- benchmarks/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- e2e/CLAUDE.mdCLAUDE.md
- src-tauri/CLAUDE.mdCLAUDE.md
- website/CLAUDE.mdCLAUDE.md
- website/llms-full.txtllms.txt
- website/llms.txtllms.txt
- ci-gate-diagnosisSkill
- coding-guidelinesSkill
- domain-webSkill
- exploration-modeSkill
- flathub-maintenanceSkill
- m01-ownershipSkill
- m02-resourceSkill
- m03-mutabilitySkill
- m04-zero-costSkill
- m05-type-drivenSkill
- m06-error-handlingSkill
- m07-concurrencySkill
- m09-domainSkill
- m10-performanceSkill
- m11-ecosystemSkill
- m12-lifecycleSkill
- m13-domain-errorSkill
- m14-mental-modelSkill
- m15-anti-patternSkill
- manual-planningSkill
- runbooksSkill
- rust-call-graphSkill
- rust-code-navigatorSkill
- rust-dailySkill
- rust-deps-visualizerSkill
- rust-learnerSkill
- rust-refactor-helperSkill
- rust-skill-creatorSkill
- rust-symbol-analyzerSkill
- rust-trait-explorerSkill
- security-stanceSkill
- seo-auditSkill
- seo-performance-reviewSkill
- skill-improverSkill
- solidjsSkill
- sync-lockfilesSkill
- tauri-agent-devSkill
- tauri-v2Skill
- tiptapSkill
- todo-managerSkill
- unsafe-checkerSkill
The file
We split this CLAUDE.md into the rules it gives an agent
7 rules. About 0 are common, 0 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 20
All UI strings are extracted into src/i18n/locales/en.ts (the canonical English source).
- Very specific: line 24
Add the key under the appropriate namespace in src/i18n/locales/en.ts.
- Very specific: line 55
All component tests use renderWithI18n() from src/test/i18n-test-utils.tsx instead of bare render().
- Very specific: line 71
If a component or feature is not yet ready to ship, gate its render site with import.meta.env.VITE_EXPERIMENTAL rather than leaving it …
- Very specific: line 281
Every backend write is logged at log.info (id + lengths + decision + calling path) so a wild recurrence is diagnosable in a release build — …
- Very specific: line 290
Call setEditable(value, false) so it does not emit at all.
- Very specific: line 303
Do not log passwords, cache plaintext diary content in localStorage, or expose sensitive data in error messages.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Frontend (src/) — Mini Diarium
For project architecture, command registry, and cross-cutting conventions see the root CLAUDE.md. For durable frontend rules, use Frontend best practices before changing SolidJS reactivity, state ownership, Tauri error UI, TipTap/editor behavior, accessibility, or E2E-visible UI.
State Management
State modules live in src/state/ — one file per domain, all SolidJS signals. See each file for current signals and exports.
Key invariant: session.ts:resetSessionState() orchestrates lock cleanup — it calls reset functions from entries, search, ui, tags, and entryAttachments (also reset by refreshAfterRestore()). If you add a module that holds session-scoped data, add its reset call there.
The Preferences interface (fields, types, defaults) is the source of truth in src/state/preferences.ts. Stored in localStorage.
feature-flags.ts holds runtime feature flags (Tier 2 of the feature-flag strategy) in a migration-free open Record<string, boolean> under its own localStorage['feature-flags'] key. isFeatureEnabled(flag) is a reactive read (drives <Show>); setFeatureFlag(flag, enabled) persists. Adding/retiring a flag is just editing the FeatureFlag union, DEFAULTS, and the FEATURE_FLAGS registry — there is deliberately no migration block. Use this (not the Preferences interface) to gate in-progress features that need a runtime toggle. The union and registry are currently empty: inAppMenu, the only flag that ever existed, graduated in TODO-0065, and the module is kept as dormant infra. To flip a flag: in-app via Preferences → Advanced → Experimental Features (unlocked-only, reactive — the section renders from FEATURE_FLAGS and hides itself while that list is empty, which is the state today), or in dev/E2E by seeding localStorage['feature-flags'] before load (e.g. localStorage.setItem('feature-flags', JSON.stringify({ someFlag: true }))). Every flag defaults off. See docs/decisions/2026-06-feature-flags.md (Tier 2 → "Enabling / disabling a flag at runtime") for the full how-to and current-flag inventory.
Settings taxonomy: When adding a new setting, see
docs/decisions/2026-05-settings-storage-taxonomy.mdfor the decision flowchart (localStoragevs.config.jsonvs.db_settingsvs. in-memory).
i18n / Translations
All UI strings are extracted into src/i18n/locales/en.ts (the canonical English source). The i18n system uses @solid-primitives/i18n v2 with a thin context wrapper (src/i18n/index.ts).
Adding new keys
- Add the key under the appropriate namespace in
src/i18n/locales/en.ts. - Key naming:
namespace.camelCase. Suffixes:.label(form labels),.hint(helper text),.placeholder(inputs),.aria(aria-labels). Button text uses the verb directly (common.save). - Interpolation syntax:
{{ name }}(spaces required) — e.g."Hello {{ name }}". - Plurals: use
_one/_otherkey suffixes and select in the component:t(count === 1 ? 'editor.wordCount_one' : 'editor.wordCount_other', { count }) - In the component, call
const t = useI18n()and uset('namespace.key').
mapTauriError(err, t) pattern
mapTauriError is a pure function called outside JSX render (in async handlers). It accepts t: T as a required second parameter. Every call site is inside a component that already calls useI18n():
import { mapTauriError } from '../../lib/errors';
import { useI18n } from '../../i18n';
// In component:
const t = useI18n();
// In handler:
setError(mapTauriError(err, t));For the cross-layer IPC error contract and regression checks, see docs/best-practices/TAURI_BEST_PRACTICES.md.
Module-level arrays using translations
Arrays that contain translated strings must be createMemo inside the component (not module-level consts), so they are evaluated after useI18n() is called. See MONTH_NAMES in Calendar.tsx and FIRST_DAY_OPTIONS in overlays/preferences/PreferencesWritingTab.tsx as reference.
Testing
All component tests use renderWithI18n() from src/test/i18n-test-utils.tsx instead of bare render(). The wrapper provides the I18nProvider context. English strings are identical to hardcoded values, so existing getByText('...') assertions continue to pass.
Validating locale files
Community locale JSON files live in src/i18n/locales/. To check completeness against en.ts:
bun run validate:localesSee docs/TRANSLATIONS.md for the community translator guide.
Conventions
Gating Incomplete UI Behind a Feature Flag
If a component or feature is not yet ready to ship, gate its render site with import.meta.env.VITE_EXPERIMENTAL rather than leaving it unreachable by convention:
import { Show } from 'solid-js';
// Wrap the entry point only — not every sub-component.
<Show when={import.meta.env.VITE_EXPERIMENTAL}>
<YourInprogressFeature />
</Show>import.meta.env.VITE_EXPERIMENTAL is a compile-time boolean injected by the define block in vite.config.ts. When false (all production and CI builds), the bundler tree-shakes the guarded subtree entirely. Activate for local development:
VITE_EXPERIMENTAL=true bun run tauri devThe matching Rust backend command must also be gated with #[cfg(feature = "experimental")] — see src-tauri/CLAUDE.md for the backend checklist. Both gates must move together. See docs/decisions/2026-06-feature-flags.md for the full strategy.
SolidJS Reactivity Gotchas
The durable SolidJS reactivity rules and diagnostic rg commands live in docs/best-practices/FRONTEND_BEST_PRACTICES.md. This domain guide keeps local examples and inventories.
Local note: the <For> callback receives the unwrapped item directly for primitive arrays: (item) => ..., not (item) => item(). Only store/object entries behave like signals.
Error Handling
The durable rules for typed wrappers, sanitized Tauri errors, sensitive UI flow ordering, and regression checks live in docs/best-practices/FRONTEND_BEST_PRACTICES.md. Keep the mapTauriError(err, t) call pattern above aligned with src/lib/errors.ts.
Testing Pattern
Tests use Vitest + @solidjs/testing-library. Tauri APIs are mocked globally in src/test/setup.ts.
import { renderWithI18n } from '../test/i18n-test-utils';
it('renders correctly', () => {
const { getByText } = renderWithI18n(() => <MyComponent prop="value" />);
expect(getByText('expected')).toBeInTheDocument();
});Note the arrow wrapper () => <Component /> — required for SolidJS test rendering. Use renderWithI18n (not bare render) so the I18nProvider context is available.
Keyboard Shortcuts
Every app-level shortcut lives in src/lib/keyboard-shortcuts.ts — one keydown handler on document, registered by MainLayout's onMount via registerKeyboardShortcuts() and torn down in onCleanup. Add new combos there, not in a component. Two rules the file already encodes and new bindings must follow: match brackets on e.code (e.key is {/} when Shift is held) and bail when isAnyOverlayOpen() (src/state/ui.ts) — overlays own the keyboard while open.
The same guard protects MainLayout's global Escape action (e.g. Quit), so it only works if it sees every dialog:
- Every modal's open signal lives in
src/state/ui.tsand is part ofisAnyOverlayOpen()— including component-owned dialogs such as the toolbar's Insert Link / Insert Timestamp (isLinkDialogOpen/isTimestampDialogOpen). Never keep a modal's open state in a local signal. Reset the signal inresetUiState(), and in the owner'sonCleanupif the owner can unmount while the dialog is open. - A dialog's own Escape handler closes it before
handleGlobalEscruns (Solid delegatesonKeyDowntodocumentat import time, beforeMainLayoutmounts). SoMainLayoutrecords "an overlay was open" in awindowcapture-phase listener, before any handler runs, andhandleGlobalEscreads that record. Do not move the overlay check back to read only the live signal. - A non-modal popup that handles Escape itself (e.g. the
EntryTagsdropdown) must callclaimEscape(e)fromsrc/lib/escape-claim.ts; the global Escape handler skips claimed events. It does not readdefaultPrevented, because ProseMirror sets it on every Escape in the editor body (TODO-0150). - Global handlers ignore
e.isComposing— an IME uses Escape to cancel composition.
These were OS-level native-menu accelerators until TODO-0065 reduced the native menu to Preferences + Quit. CmdOrCtrl+, (Preferences) is the one accelerator still handled by the OS.
Menu Event Pattern — Frontend
Menu event names are prefixed menu-. menu-preferences is the only one the backend still emits (TODO-0065); MainLayout.tsx listens for it:
listen("menu-preferences", handler)The backend emits via app.emit("menu-preferences", ()) in menu.rs. See root CLAUDE.md for the full cross-layer pattern.
Verification Commands
For the canonical post-task checklist (tests + formatting + CHANGELOG + TODO), see Post-Task Completion Best Practices.
Frontend-specific:
cmd.exe /c bun run test:coverage # Coverage report (single run, writes coverage/lcov.info)data-testid Attributes
These are used by E2E tests — do not remove from components.
| Component | Element | data-testid |
OnboardingOverlay.tsx | Next / Done button in tour card | onboarding-next-btn |
PasswordCreation.tsx | Password input | password-create-input |
PasswordCreation.tsx | Confirm password input | password-repeat-input |
PasswordCreation.tsx | Create button | create-journal-button |
PasswordPrompt.tsx | Password input | password-unlock-input |
PasswordPrompt.tsx | Unlock submit button | unlock-journal-button |
Header.tsx | Sidebar toggle (hamburger) | toggle-sidebar-button |
Header.tsx | Search button (opens SearchOverlay) | search-button |
Header.tsx | Previous-day button (◀) | header-prev-day-button |
Header.tsx | Date title button (opens GoToDateOverlay) | header-date-title |
Header.tsx | Next-day button (▶) | header-next-day-button |
SearchOverlay.tsx | Search dialog content | search-overlay |
Header.tsx | Lock button | lock-journal-button |
Header.tsx | Timeline toggle button | timeline-toggle-button |
Header.tsx | Streak-milestone Support icon (rendered only when a milestone is pending) | support-milestone-button |
HeaderMoreMenu.tsx | Overflow menu trigger (⋮); also carries data-tour-target="import" for the onboarding tour | header-more-menu-trigger |
HeaderMoreMenu.tsx | Overflow menu dropdown content | header-more-menu-content |
HeaderMoreMenu.tsx | Preferences item in overflow menu | header-more-menu-preferences-item |
HeaderMoreMenu.tsx | Statistics item in overflow menu | header-more-menu-statistics-item |
HeaderMoreMenu.tsx | Import item in overflow menu | header-more-menu-import-item |
HeaderMoreMenu.tsx | Export item in overflow menu | header-more-menu-export-item |
HeaderMoreMenu.tsx | Send feedback item in overflow menu (opens FeedbackOverlay) | header-more-menu-feedback-item |
FeedbackOverlay.tsx | Dialog content root | feedback-overlay |
FeedbackOverlay.tsx | Report a bug button | feedback-bug |
FeedbackOverlay.tsx | Suggest a feature button | feedback-feature |
FeedbackOverlay.tsx | Email the developer button (prefilled mailto:) | feedback-email |
PreferencesOverlay.tsx | Preferences dialog content | preferences-overlay |
ChangePasswordForm.tsx | Persistent snapshot-credential-drift notice (not the auto-dismissing success toast) | change-password-snapshot-warning |
BackupsPanel.tsx | Panel root (Preferences → Backups and the pre-auth view) | backups-panel |
BackupsPanel.tsx | One snapshot row | backups-list-item |
BackupsPanel.tsx | Empty-state message | backups-empty |
BackupsPanel.tsx | "Backups are working" line | backups-health-ok |
BackupsPanel.tsx | Pending-first-backup line (healthy journal, no snapshot taken yet) | backups-health-pending |
BackupsPanel.tsx | Degraded-health line (unreachable folder / failed attempt / over budget) | backups-health-problem |
BackupsPanel.tsx | Back up now button | backups-create-button |
BackupsPanel.tsx | Open backups folder button | backups-reveal-button |
BackupsPanel.tsx | Per-row checked / not-checked badge | backups-item-verified / backups-item-unverified |
BackupsPanel.tsx | Per-row Restore button | backups-restore-button |
BackupsPanel.tsx | Restore success message, naming the safety snapshot | backups-restore-success |
BackupsPanel.tsx | Passwordless-journal notice (UX-7) | backups-local-only-notice |
BackupsPanel.tsx | Reduced-mode explanation for disabled actions | backups-locked-hint |
BackupsPanel.tsx | Per-row "Restore entries…" button (opens BackupInspectDialog; hidden in reduced mode) | backups-restore-entries-button |
BackupsPanel.tsx | Per-row required-credential hint (from auth_slot_types; absent when only auto) | backups-required-credential |
BackupsOverlay.tsx | Standalone backups dialog (pre-auth) | backups-overlay |
BackupInspectDialog.tsx | Per-entry restore dialog root | backup-inspect-dialog |
BackupInspectDialog.tsx | Credential-differs notice (UX-3) | backup-inspect-credential-differs |
BackupInspectDialog.tsx | "View entries" submit button | backup-inspect-open-button |
BackupInspectDialog.tsx | Entry count in snapshot metadata | backup-inspect-entry-count |
BackupInspectDialog.tsx | Empty-state message | backup-inspect-empty |
BackupInspectDialog.tsx | "Select all missing or shorter" button | backup-inspect-select-all |
BackupInspectDialog.tsx | One snapshot entry row | backup-inspect-entry-item |
BackupInspectDialog.tsx | Per-entry status badge (UX-4) | backup-inspect-status-missing / backup-inspect-status-shorter_in_live / backup-inspect-status-present |
BackupInspectDialog.tsx | Restore-selected button | backup-inspect-restore-button |
BackupInspectDialog.tsx | Restore success message | backup-inspect-success |
PreAuthTools.tsx | Row of tools reachable without unlocking | pre-auth-tools |
PreAuthTools.tsx | View-backups entry point on the unlock screen | pre-auth-backups-button |
PreAuthTools.tsx | Send-feedback entry point on the unlock screen (opens FeedbackOverlay) | pre-auth-feedback-button |
StatsOverlay.tsx | Statistics dialog content | stats-overlay |
ImportOverlay.tsx | Import dialog content | import-overlay |
ExportOverlay.tsx | Export dialog content | export-overlay |
TitleEditor.tsx | Title input | title-input |
Calendar.tsx | Each day button | calendar-day-YYYY-MM-DD |
EntryNavBar.tsx | Nav bar container | entry-nav-bar |
EntryNavBar.tsx | Previous entry button (←) | entry-prev-button |
EntryNavBar.tsx | Entry number button N (1-based); active entry has aria-current="true" | entry-number-button-{N} |
EntryNavBar.tsx | Next entry button (→) | entry-next-button |
EntryNavBar.tsx | Delete entry button (−) | entry-delete-button |
EntryNavBar.tsx | Add entry button (+) | entry-add-button |
EntryNavBar.tsx | Lock/unlock entry button; aria-pressed reflects locked state | entry-lock-button |
EntryAttachments.tsx | Attachment strip root (under the editor, beside tags) | entry-attachments |
EntryAttachments.tsx | One attachment chip | entry-attachment-chip |
EntryAttachments.tsx | Attach file button (hidden on a locked entry) | entry-attachment-add-button |
EntryAttachments.tsx | Chip name button (Save a copy…; stays on a locked entry) | entry-attachment-save-button |
EntryAttachments.tsx | Insert inline reference button (hidden on a locked entry) | entry-attachment-insert-ref-button |
EntryAttachments.tsx | Remove attachment button (hidden on a locked entry) | entry-attachment-remove-button |
Timeline.tsx | Passive lock indicator on a locked entry's row | timeline-lock-indicator |
Timeline.tsx | Active tag-filter banner (below lg only; the Sidebar shows its own above) | timeline-tag-filter-banner |
Timeline.tsx | Tag chip under a row (only with the showTimelineTags preference on); click toggles the tag filter | timeline-tag-chip |
Calendar.tsx | Passive lock glyph on a day with a locked entry | calendar-lock-YYYY-MM-DD |
ConfirmDialog.tsx | Dialog content root | confirm-dialog |
ConfirmDialog.tsx | Cancel button | confirm-dialog-cancel-button |
ConfirmDialog.tsx | Confirm button | confirm-dialog-confirm-button |
ProjectSupportOverlay.tsx | Dialog content root | project-support-overlay |
ProjectSupportOverlay.tsx | Per-item action button | support-item-{star|review|share|newsletter|contribute|donate} |
NotificationsOverlay.tsx | Unread dot on one entry | unread-dot-{id} |
NotificationsOverlay.tsx | External link button on one entry | link-{id} |
NotificationsOverlay.tsx | "Read more" button (visible only when entry.body is set; opens NotificationDetailDialog) | read-more-{id} |
NotificationsOverlay.tsx | Mark-read button on one entry | mark-read-{id} |
NotificationsOverlay.tsx | "Mark all read" footer button | mark-all-read-button |
NotificationsOverlay.tsx | Close button | notifications-close-button |
NotificationDetailDialog.tsx | Dialog content root | notification-detail-dialog |
NotificationDetailDialog.tsx | External link button | notification-detail-link |
NotificationDetailDialog.tsx | Close button | notification-detail-close-button |
Gotchas and Pitfalls
- Three-level font model: The editor supports fonts at three hierarchy levels, applied in order: app default (from
preferences.editorFontFamily/editorFontSize), entry default (from encryptedentry_metadata_encryptedin the entry), and inline formatting (TiptapFontFamily/FontSizemarks applied to selections). The toolbar font dropdowns apply inline marks to the selection/cursor, not global preferences. Multi-font loading (@font-face rules) covers all three sources: app default + entry default + inline fonts referenced in the encryptedtextHTML. Custom font changes refresh automatically across all three levels. - Date format is always
YYYY-MM-DD: TheT00:00:00suffix is appended indates.tsfunctions (new Date(dateStr + 'T00:00:00')) to avoid timezone-related date shifts. - TipTap stores HTML: The editor content is stored as HTML strings, not Markdown. This is intentional — the
textfield inDiaryEntryis HTML. - E2E breakpoint planning rule: Default E2E clean mode runs at 800×660 px — below the
lgbreakpoint (1024 px). The sidebar useslg:relative lg:translate-x-0, so it is always in mobile/overlay behavior in E2E. When changing the default value of any UI visibility signal (isSidebarCollapsed, overlay open states, etc.), explicitly audite2e/specs/for interactions that depend on the affected element being visible and update the test accordingly. - Block alignment uses a container model (not per-node): Alignment is applied via
TextAlignon a wrapping container (<figure>,<div>), not on the content element itself. This means:ProseMirror-selectednodeis added to the container, not the inner element- CSS must use
display: inline-blockon the inner element fortext-alignto work - To align a new block type, extend its node to use a wrapper and add its name to the TextAlign
typesarray — see "Adding an Alignable Editor Block Node" below
- Images are stored as base64 in the encrypted HTML
textfield — there is no separate media storage. Users can drag-drop, paste, or pick images; they are auto-resized to max 1200×1200 px and embedded as base64 data URLs. Backendread_file_bytes()reads disk images for drag-drop paths (Tauri drag-drop gives file paths, notFileobjects). Large images significantly increase encrypted entry size. Theme preference and CSS token overrides are separate localStorage keys, independent of the main
'preferences'key. Any code that resets or exports user settings must handle all three keys:'preferences'— thePreferencesinterface (autoLockEnabled, hideTitles, etc.)'theme-preference'—'auto'|'light'|'dark'(managed bysrc/lib/theme.ts)'theme-overrides'— JSON object of CSS token overrides (managed bysrc/lib/theme-overrides.ts)
A fourth independent key,
'feature-flags'(managed bysrc/state/feature-flags.ts), holds runtime experimental flags. Unlike the three above it is not wiped byresetPreferencesand not included in settings export — experimental flags are intentionally ephemeral, so leave it out of reset/export flows.See
docs/decisions/2026-05-settings-storage-taxonomy.mdfor the full taxonomy and decision guide.- TipTap inline styles require
dangerousDisableAssetCspModification: ["style-src"]: Tauri injects a random nonce into all CSP directives at runtime. Per the CSP spec, when a nonce is present instyle-src,'unsafe-inline'is ignored — so TipTap'sstyle="text-align: X"node-attribute rendering is silently blocked by the browser. Thetauri.conf.jsonsecurity section uses"dangerousDisableAssetCspModification": ["style-src"]to prevent nonce injection intostyle-srconly (leavingscript-srcnonce-protected). Do not remove this line or restructure the CSP string without verifying alignment still works — the failure is silent (no console error in dev mode, only in production builds where the nonce is active). See issue #63. - TipTap dialog state capture — snapshot, not memo: Never use
createMemo(() => editor.state.*)inside a dialog component. TipTap collapses the selection when anautofocusinput receives focus, making reactive reads ofeditor.state.selectionunreliable after the dialog opens. Instead, capture editor state once when the dialog opens via acreateEffectthat fires whenisOpentransitions totrue. SeesnapshotEditor()insrc/components/editor/LinkOverlay.tsxas the reference implementation. - The editor's id, title, and body must be committed atomically:
pendingEntryId,title, andcontenttogether answer "which entry is open and what does it contain?", and every flush-before-navigating-away path (date switch, entry switch, lock,beforeunload, unmount) re-reads them from live signals. Writing them at different points in time — e.g. setting the id, thenawaitinggetEntryImages, then setting the content — leaves a window where a flush pairs a real entry's id and title with an empty document, and persistssave_entry(id, title, ''): the row survives, the title survives, the body is wiped (TODO-0089). Rules:- Resolve everything the commit needs (image refs included) before touching a signal, re-check the request/token guard on the line above, then write through
commitEntryToEditor/clearEntryFromEditorineditor-panel/entryHydration.ts— never with loose setters. - The write path itself lives in
editor-panel/useEntryPersistence.ts(useEntryLifecyclekeeps load/creation/teardown and drives it). A write is additionally gated onhydratedEntryId, which is only set by those two helpers; the coordinator refuses anyentryIdthat does not match it. ItssaveCurrentByIdstays private — reach the backend throughflushCurrent/debouncedSaveonly, so nobody can hand-assemble a payload. - The save-vs-delete decision travels with the payload (
isEmptyandmetadatacaptured at snapshot time), never re-derived when the 500 ms debounce fires. UsecomputeIsEmpty(editor, content)at the capture site;lifecycle.flushCurrent(path)andcaptureCurrentSnapshot()already do this. - Every backend write is logged at
log.info(id + lengths + decision + calling path) so a wild recurrence is diagnosable in a release build —createLoggercompilesdebugout of production. - Extension (TODO-0104): leaving a real entry blank is no longer silently swallowed by the atomic-commit machinery above —
canLeaveCurrentEntry()(editor-panel/useEntryLifecycle.ts) is registered as a navigation guard (src/state/entries.ts's guard registry, mirroringregisterCleanupCallback/registerReloadCallback) and runs before every guarded navigation entry point. It reads the on-disk row live viaentry_has_content(id)rather than trusting the in-memory snapshot, showsconfirmInApp()when real content would otherwise be dropped, and performs an explicit hard-delete only on confirmation. This guard is deliberately not wired into the auto-lock orbeforeunloadpaths (Non-Goals indocs/entry-persistence-consent-gate-plan.md) — those rely solely on the backend on-disk refusal indelete_entry_if_empty_inner(src-tauri/src/commands/entries.rs), never on a dialog. On cancel, it now also restores the entry's real on-disk content into the editor via arestoreEntryFromDiskhelper (targeting the exact entry being restored, not just the day's newest — seedocs/entry-persistence-cancel-restore-plan.md) before denying the navigation, so Cancel no longer leaves the editor visibly blank while the disk row underneath it is intact.
- Resolve everything the commit needs (image refs included) before touching a signal, re-check the request/token guard on the line above, then write through
- SolidJS cleanup order differs between dev and release builds:
createComponentcreates an owner only insolid-js/dist/dev.js(devComponent);dist/solid.jsuses a bareuntrack. So in dev, a child'sonCleanup(e.g.DiaryEditor'seditor.destroy()) runs before the parent's (EditorPanel'slifecycle.dispose()); in release builds it runs after. Cleanup code must never depend on that ordering: read the editor defensively (editor && !editor.isDestroyed, with acontent()-signal fallback), and capture anything a teardown handler needs synchronously at the top of the handler, before anyawait. Unit tests run the dev build, so they exercise only one of the two orders. - Always import
open/save/confirmfromsrc/lib/dialog.ts, never directly from@tauri-apps/plugin-dialog: native dialogs are separate OS windows that steal focus from the main window, which would trigger the focus-loss auto-lock (src/lib/focus-lock.ts, TODO-0068) mid-export/import if left unguarded.dialog.tswraps the same three functions with a shared open-dialog counter thatfocus-lock.tsconsults viaisDialogOpen()to suppress the lock while one of the app's own dialogs is open. A new call site importing the plugin directly reopens the false-positive-lock gap this wrapper exists to close.ConfirmDialog/confirmInApp()(src/state/confirm-dialog.ts, TODO-0104) is in-app@kobalte/core/dialogcontent, not a native OS window — it deliberately does not go through this wrapper orisDialogOpen(), since it never steals OS-level focus and cannot trigger the focus-loss guard. Do not route it throughdialog.ts. - A TipTap
updateevent is not proof the document changed: TipTap also emitsupdatefor state-only events —setEditable()emits one synthetically, bypassingdispatchTransaction'sdocChangedcheck. Treating one as a keystroke is a body-wipe vector: it resetscontent()to the pre-load document, and the blank is then persisted over the real entry (TODO-0089). Rules:- Gate
DiaryEditor'sonUpdateonisDocumentChange(transaction, appendedTransactions)—editorUpdateGuard.tsowns the mechanism (with the TipTap-source reasoning in its doc comment) andeditorUpdateGuard.test.tspins the cases. Both arguments matter: a plugin-appended transaction can be the only one carryingdocChanged. - Call
setEditable(value, false)so it does not emit at all. Thelockedeffect re-runs on everydayEntries/currentIndexchange, so the default fires a stream of fake edits. commands.setContent(html, { emitUpdate: false })is safe by contrast — the guard recognises it.
- Gate
onMount, notcreateEffect, for one-shot DOM side effects like.focus(): acreateEffectthat reads any prop derived from app state re-runs whenever that state changes, even when the prop's value is unchanged — SolidJS tracks the underlying signals, not the computed result.TitleEditor's auto-focus was acreateEffectreadingprops.readOnly(derived fromdayEntries/currentIndex), so every entry-list change re-focused the title input — including the one that fires mid-typing, moving the caret out of the body (TODO-0089;TitleEditor.test.tsxcovers it).onMountreads props untracked and fires exactly once. Applies to any imperative one-time DOM call, not just focus.
Common Task Checklists
Adding an Alignable Editor Block Node
Wrap the node in a <figure class="X-container"> container, register it in TextAlign.configure({ types: [..., 'yourNodeName'] }), and add CSS: figure.X-container { display: block } + .inner-element { display: inline-block } (container's text-align propagates). ProseMirror-selectednode lands on the container, not the inner element. See Gotcha #5 above and AlignableImage in DiaryEditor.tsx as the reference implementation.
Security
Do not log passwords, cache plaintext diary content in localStorage, or expose sensitive data in error messages. Pass all Tauri error strings through mapTauriError() before displaying. See root CLAUDE.md for the full Security Rules.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

