Initial scaffold: Marti poetry workbench

Static-analysis-first poetry editor based on Borges architecture.
Three-column layout (sidebar | editor | analysis), AI opt-in and off by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TC
2026-06-14 21:38:55 +10:00
commit dd5fdc1223
28 changed files with 8518 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
out/
dist/
*.tsbuildinfo
.DS_Store

12
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: { plugins: [externalizeDepsPlugin()] },
preload: { plugins: [externalizeDepsPlugin()] },
renderer: {
resolve: { alias: { '@renderer': resolve('src/renderer') } },
plugins: [react()]
}
})

6233
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "marti",
"version": "0.1.0",
"description": "A poet's workbench — static analysis first, AI opt-in",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"package": "npm run build && electron-builder --mac",
"package:win": "npm run build && electron-builder --win",
"package:linux": "npm run build && electron-builder --linux"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"electron": "^33.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "^2.3.0",
"typescript": "^5.4.0",
"vite": "^5.2.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.29.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.10.0",
"@codemirror/search": "^6.5.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.35.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.5.0"
},
"build": {
"appId": "com.marti.editor",
"productName": "Marti",
"icon": "resources/icon.png",
"mac": {
"icon": "resources/icon.icns",
"target": [{ "target": "dmg", "arch": "universal" }]
},
"win": {
"icon": "resources/icon.ico",
"target": [{ "target": "nsis", "arch": ["x64"] }]
},
"linux": {
"icon": "resources/icon.png",
"target": [{ "target": "AppImage", "arch": ["x64"] }]
},
"files": ["out/**/*"],
"extraResources": [{ "from": "resources/icon.png", "to": "icon.png" }]
}
}

35
src/main/aiService.ts Normal file
View File

@@ -0,0 +1,35 @@
import Anthropic from '@anthropic-ai/sdk'
import { BrowserWindow } from 'electron'
import { getApiKey } from './globalConfig'
export interface ChatPayload {
messages: { role: 'user' | 'assistant'; content: string }[]
poemContent: string
}
const MODEL = 'claude-sonnet-4-6'
function client(): Anthropic {
const key = getApiKey()
if (!key) throw new Error('No API key configured')
return new Anthropic({ apiKey: key })
}
export async function streamChatMessage(payload: ChatPayload, win: BrowserWindow): Promise<void> {
const system = `You are a thoughtful reader and craft-focused interlocutor for a poet. The poet has shared their poem with you. Respond with care and curiosity — ask questions, reflect on what you notice, offer observations. Never prescribe what a poem should do. Never use the word "delve."\n\nPoem:\n${payload.poemContent}`
const stream = await client().messages.stream({
model: MODEL,
max_tokens: 1024,
system,
messages: payload.messages
})
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
win.webContents.send('ai:chunk', chunk.delta.text)
}
}
win.webContents.send('ai:done')
}

237
src/main/fileSystem.ts Normal file
View File

@@ -0,0 +1,237 @@
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
import { join, basename } from 'path'
import { getCollectionRoot } from './globalConfig'
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PoemMeta {
title?: string
form?: string
tags?: string[]
notes?: string
}
export interface PoemFile {
id: string
path: string
lineCount: number
wordCount: number
meta: PoemMeta
}
export interface CollectionConfig {
poems: Record<string, PoemMeta>
}
export interface RevisionMeta {
id: string
timestamp: number
lineCount: number
}
// ─── Path helpers ─────────────────────────────────────────────────────────────
const martiDir = (): string => join(getCollectionRoot(), '.marti')
const configFile = (): string => join(martiDir(), 'config.json')
const orderFile = (): string => join(martiDir(), 'order.json')
const sessionFile = (): string => join(martiDir(), 'session.json')
const revisionsDir = (): string => join(martiDir(), 'revisions')
const MAX_REVISIONS = 50
function assertInCollection(filePath: string): void {
const root = getCollectionRoot()
const resolved = filePath.startsWith('/') ? filePath : join(root, filePath)
if (!resolved.startsWith(root)) throw new Error('Access denied: path outside collection')
}
// ─── Collection config ────────────────────────────────────────────────────────
async function readConfig(): Promise<CollectionConfig> {
try {
return JSON.parse(await readFile(configFile(), 'utf-8'))
} catch {
return { poems: {} }
}
}
async function writeConfig(cfg: CollectionConfig): Promise<void> {
await mkdir(martiDir(), { recursive: true })
await writeFile(configFile(), JSON.stringify(cfg, null, 2), 'utf-8')
}
export async function getPoemMeta(poemId: string): Promise<PoemMeta> {
const cfg = await readConfig()
return cfg.poems[poemId] ?? {}
}
export async function setPoemMeta(poemId: string, meta: PoemMeta): Promise<void> {
const cfg = await readConfig()
cfg.poems[poemId] = { ...cfg.poems[poemId], ...meta }
await writeConfig(cfg)
}
export async function getCollectionConfig(): Promise<CollectionConfig> {
return readConfig()
}
// ─── Poem listing ────────────────────────────────────────────────────────────
function countLines(text: string): number {
return text.split('\n').filter(l => l.trim()).length
}
function countWords(text: string): number {
return text.trim() === '' ? 0 : (text.match(/\b\w+\b/g) ?? []).length
}
async function readOrderList(): Promise<string[]> {
try {
return JSON.parse(await readFile(orderFile(), 'utf-8'))
} catch {
return []
}
}
export async function saveOrderList(order: string[]): Promise<void> {
await mkdir(martiDir(), { recursive: true })
await writeFile(orderFile(), JSON.stringify(order, null, 2), 'utf-8')
}
export async function listPoems(): Promise<PoemFile[]> {
await mkdir(getCollectionRoot(), { recursive: true })
const [entries, order, cfg] = await Promise.all([
readdir(getCollectionRoot(), { withFileTypes: true }),
readOrderList(),
readConfig()
])
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('.'))
const poems: PoemFile[] = await Promise.all(
mdFiles.map(async (e) => {
const id = e.name.replace(/\.md$/, '')
const path = join(getCollectionRoot(), e.name)
const content = await readFile(path, 'utf-8').catch(() => '')
return { id, path, lineCount: countLines(content), wordCount: countWords(content), meta: cfg.poems[id] ?? {} }
})
)
const map = new Map(poems.map(p => [p.id, p]))
const ordered = order.filter(id => map.has(id)).map(id => map.get(id)!)
const rest = poems.filter(p => !order.includes(p.id)).sort((a, b) => a.id.localeCompare(b.id))
return [...ordered, ...rest]
}
// ─── Poem CRUD ────────────────────────────────────────────────────────────────
export async function readPoem(filePath: string): Promise<string> {
assertInCollection(filePath)
return readFile(filePath, 'utf-8')
}
export async function writePoem(filePath: string, content: string): Promise<void> {
assertInCollection(filePath)
await writeFile(filePath, content, 'utf-8')
}
export async function createPoem(name: string): Promise<{ id: string; path: string }> {
const id = name
const path = join(getCollectionRoot(), `${name}.md`)
assertInCollection(path)
await writeFile(path, '', 'utf-8')
return { id, path }
}
export async function renamePoem(oldPath: string, newName: string): Promise<string> {
assertInCollection(oldPath)
const newPath = join(getCollectionRoot(), `${newName}.md`)
assertInCollection(newPath)
await fsRename(oldPath, newPath)
const cfg = await readConfig()
const oldId = basename(oldPath, '.md')
if (cfg.poems[oldId]) {
cfg.poems[newName] = cfg.poems[oldId]
delete cfg.poems[oldId]
await writeConfig(cfg)
}
return newPath
}
export async function deletePoem(filePath: string): Promise<void> {
assertInCollection(filePath)
await rm(filePath, { force: true })
const id = basename(filePath, '.md')
const cfg = await readConfig()
delete cfg.poems[id]
await writeConfig(cfg)
}
// ─── Session ──────────────────────────────────────────────────────────────────
export async function readSession(): Promise<Record<string, unknown>> {
try {
return JSON.parse(await readFile(sessionFile(), 'utf-8'))
} catch {
return {}
}
}
export async function writeSession(data: Record<string, unknown>): Promise<void> {
await mkdir(martiDir(), { recursive: true })
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
}
// ─── Revisions ────────────────────────────────────────────────────────────────
function poemSlug(filePath: string): string {
return basename(filePath, '.md').replace(/[^a-zA-Z0-9_-]/g, '_')
}
function shortId(): string {
return Math.random().toString(36).slice(2, 7)
}
export async function saveRevision(filePath: string, content: string): Promise<void> {
assertInCollection(filePath)
const slug = poemSlug(filePath)
const dir = join(revisionsDir(), slug)
await mkdir(dir, { recursive: true })
const timestamp = Date.now()
const id = `${timestamp}_${shortId()}`
await writeFile(
join(dir, `${id}.json`),
JSON.stringify({ id, timestamp, lineCount: countLines(content), content }),
'utf-8'
)
const entries = (await readdir(dir)).filter(e => e.endsWith('.json')).sort()
if (entries.length > MAX_REVISIONS) {
await Promise.all(entries.slice(0, entries.length - MAX_REVISIONS).map(f => unlink(join(dir, f))))
}
}
export async function listRevisions(filePath: string): Promise<RevisionMeta[]> {
assertInCollection(filePath)
const slug = poemSlug(filePath)
const dir = join(revisionsDir(), slug)
try {
const entries = (await readdir(dir)).filter(e => e.endsWith('.json')).sort().reverse()
return Promise.all(
entries.map(async (f) => {
const raw = JSON.parse(await readFile(join(dir, f), 'utf-8'))
return { id: raw.id, timestamp: raw.timestamp, lineCount: raw.lineCount }
})
)
} catch {
return []
}
}
export async function loadRevision(filePath: string, revisionId: string): Promise<string> {
assertInCollection(filePath)
if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
const slug = poemSlug(filePath)
const revPath = join(revisionsDir(), slug, `${revisionId}.json`)
const raw = JSON.parse(await readFile(revPath, 'utf-8'))
return raw.content
}

47
src/main/globalConfig.ts Normal file
View File

@@ -0,0 +1,47 @@
import { join } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
export interface GlobalConfig {
collectionPath?: string
fontSize?: number
theme?: 'dark' | 'light'
editorFont?: 'serif' | 'mono'
lineWrap?: boolean
ai?: {
enabled: boolean
apiKey?: string
}
}
const CONFIG_DIR = join(homedir(), '.marti')
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
function loadFromDisk(): GlobalConfig {
try {
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
} catch {
return {}
}
}
let _config: GlobalConfig = loadFromDisk()
export const getCollectionRoot = (): string =>
_config.collectionPath ?? join(homedir(), 'Documents', 'marti-poems')
export const getApiKey = (): string | undefined =>
_config.ai?.apiKey ?? process.env.ANTHROPIC_API_KEY
export const isAIEnabled = (): boolean =>
!!(_config.ai?.enabled && getApiKey())
export function readGlobalConfig(): GlobalConfig {
return { ..._config }
}
export function writeGlobalConfig(updates: Partial<GlobalConfig>): void {
_config = { ..._config, ...updates }
mkdirSync(CONFIG_DIR, { recursive: true })
writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8')
}

151
src/main/index.ts Normal file
View File

@@ -0,0 +1,151 @@
import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipcHandlers'
import { getCollectionRoot, readGlobalConfig } from './globalConfig'
app.setName('Marti')
function send(win: BrowserWindow, action: string): void {
if (!win.isDestroyed()) win.webContents.send('menu:action', action)
}
function buildAppMenu(win: BrowserWindow): void {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac ? ([{
label: 'Marti',
submenu: [
{
label: 'About Marti',
click: () => dialog.showMessageBox(win, { type: 'info', title: 'Marti', message: 'Marti', detail: `Version ${app.getVersion()}\n\nA poet's workbench.` })
},
{ type: 'separator' },
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
}] as Electron.MenuItemConstructorOptions[]) : []),
{
label: 'File',
submenu: [
{ label: 'New Poem', accelerator: 'CmdOrCtrl+N', click: () => send(win, 'newPoem') },
{ type: 'separator' },
{ label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => send(win, 'save') },
{ type: 'separator' },
{ label: 'Open Collection in Finder', click: () => shell.openPath(getCollectionRoot()) },
{ type: 'separator' },
...(!isMac ? ([
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
{ type: 'separator' }
] as Electron.MenuItemConstructorOptions[]) : []),
isMac ? { role: 'close' } : { role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo' }, { role: 'redo' }, { type: 'separator' },
{ role: 'cut' }, { role: 'copy' }, { role: 'paste' },
{ type: 'separator' }, { role: 'selectAll' }
]
},
{
label: 'View',
submenu: [
{ label: 'Focus Mode', accelerator: 'CmdOrCtrl+Shift+G', click: () => send(win, 'toggleFocusMode') },
{ type: 'separator' },
{ label: 'Toggle Poem List', accelerator: 'CmdOrCtrl+Shift+1', click: () => send(win, 'toggleSidebar') },
{ label: 'Toggle Analysis Panel', accelerator: 'CmdOrCtrl+Shift+2', click: () => send(win, 'toggleAnalysis') },
{ label: 'Toggle AI Chat', accelerator: 'CmdOrCtrl+Shift+3', click: () => send(win, 'toggleChat') },
{ label: 'Toggle Revision History', accelerator: 'CmdOrCtrl+Shift+R', click: () => send(win, 'toggleRevisions') },
{ type: 'separator' },
{ label: 'Toggle Dark Mode', click: () => send(win, 'toggleTheme') },
{ type: 'separator' },
{ label: 'Increase Font Size', accelerator: 'CmdOrCtrl+=', click: () => send(win, 'fontIncrease') },
{ label: 'Decrease Font Size', accelerator: 'CmdOrCtrl+-', click: () => send(win, 'fontDecrease') },
{ label: 'Reset Font Size', accelerator: 'CmdOrCtrl+0', click: () => send(win, 'fontReset') },
{ type: 'separator' },
{ label: 'Toggle Developer Tools', accelerator: 'CmdOrCtrl+Option+I', click: () => win.webContents.toggleDevTools() }
]
},
{
label: 'Window',
submenu: [
{ role: 'minimize' }, { role: 'zoom' },
...(isMac ? ([{ type: 'separator' }, { role: 'front' }] as Electron.MenuItemConstructorOptions[]) : [])
]
}
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
function createWindow(): BrowserWindow {
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
const config = readGlobalConfig()
const isDark = config.theme !== 'light'
const mainWindow = new BrowserWindow({
width: 1280,
height: 900,
minWidth: 800,
minHeight: 600,
title: 'Marti',
titleBarStyle: 'hiddenInset',
vibrancy: 'sidebar',
backgroundColor: isDark ? '#18181a' : '#f4f1f0',
icon,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => mainWindow.show())
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
return mainWindow
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.marti.editor')
if (process.platform === 'darwin') {
app.dock.setIcon(nativeImage.createFromPath(join(__dirname, '../../resources/icon.png')))
}
app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window))
registerIpcHandlers()
const mainWindow = createWindow()
buildAppMenu(mainWindow)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

72
src/main/ipcHandlers.ts Normal file
View File

@@ -0,0 +1,72 @@
import { ipcMain, BrowserWindow, dialog } from 'electron'
import {
listPoems, readPoem, writePoem, createPoem, renamePoem, deletePoem,
getPoemMeta, setPoemMeta, saveOrderList,
getCollectionConfig,
readSession, writeSession,
saveRevision, listRevisions, loadRevision
} from './fileSystem'
import { analyzePoem } from './staticAnalysis'
import { streamChatMessage } from './aiService'
import { readGlobalConfig, writeGlobalConfig, isAIEnabled } from './globalConfig'
export function registerIpcHandlers(): void {
// Poems
ipcMain.handle('poems:list', () => listPoems())
ipcMain.handle('poems:read', (_, path: string) => readPoem(path))
ipcMain.handle('poems:write', (_, path: string, content: string) => writePoem(path, content))
ipcMain.handle('poems:create', (_, name: string) => createPoem(name))
ipcMain.handle('poems:rename', (_, oldPath: string, newName: string) => renamePoem(oldPath, newName))
ipcMain.handle('poems:delete', (_, path: string) => deletePoem(path))
ipcMain.handle('poems:getMeta', (_, id: string) => getPoemMeta(id))
ipcMain.handle('poems:setMeta', (_, id: string, meta) => setPoemMeta(id, meta))
ipcMain.handle('poems:saveOrder', (_, order: string[]) => saveOrderList(order))
// Collection
ipcMain.handle('collection:getConfig', () => getCollectionConfig())
// Session
ipcMain.handle('session:read', () => readSession())
ipcMain.handle('session:write', (_, data) => writeSession(data))
// Revisions
ipcMain.handle('revisions:save', (_, path: string, content: string) => saveRevision(path, content))
ipcMain.handle('revisions:list', (_, path: string) => listRevisions(path))
ipcMain.handle('revisions:load', (_, path: string, id: string) => loadRevision(path, id))
// Static analysis (run in main to keep renderer fast)
ipcMain.handle('analysis:run', (_, content: string) => analyzePoem(content))
// Config
ipcMain.handle('config:read', () => readGlobalConfig())
ipcMain.handle('config:write', (_, updates) => writeGlobalConfig(updates))
ipcMain.handle('config:pickFolder', async () => {
const win = BrowserWindow.getFocusedWindow()
if (!win) return null
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory', 'createDirectory'] })
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('config:aiEnabled', () => isAIEnabled())
// AI chat (only active if AI is enabled)
ipcMain.handle('ai:streamChat', async (event, payload) => {
if (!isAIEnabled()) throw new Error('AI is not enabled')
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error('No window')
await streamChatMessage(payload, win)
})
// Context menus
ipcMain.handle('menu:poemContext', async (event, _poemId: string) => {
const { Menu, MenuItem } = await import('electron')
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return null
return new Promise<string | null>((resolve) => {
const menu = new Menu()
menu.append(new MenuItem({ label: 'Rename', click: () => resolve('rename') }))
menu.append(new MenuItem({ type: 'separator' }))
menu.append(new MenuItem({ label: 'Delete', click: () => resolve('delete') }))
menu.popup({ window: win, callback: () => resolve(null) })
})
})
}

128
src/main/staticAnalysis.ts Normal file
View File

@@ -0,0 +1,128 @@
export interface LineMetrics {
lineNumber: number
text: string
syllableCount: number
charCount: number
endWord: string
rhymeLetter: string
hasAlliteration: boolean
}
export interface PoemAnalysis {
lines: LineMetrics[]
lineCount: number
wordCount: number
uniqueWordRatio: number
rhymeScheme: string
detectedForm: string | null
avgSyllablesPerLine: number
alliterationCount: number
longestLine: number
shortestLine: number
}
// Vowel-cluster syllable heuristic — good enough for display without a dict dependency
function countSyllables(word: string): number {
word = word.toLowerCase().replace(/[^a-z]/g, '')
if (!word) return 0
if (word.length <= 3) return 1
// Strip common silent suffixes
word = word.replace(/(?:[^laeiouy]es|[^laeiouy]ed|[^laeiouy]e)$/, '')
word = word.replace(/^y/, '')
const matches = word.match(/[aeiouy]{1,2}/g)
return Math.max(1, matches ? matches.length : 1)
}
function lineEndWord(line: string): string {
const words = line.trim().split(/\s+/)
return (words[words.length - 1] ?? '').toLowerCase().replace(/[^a-z]/g, '')
}
function rhymeKey(word: string): string {
if (!word || word.length <= 2) return word
return word.slice(-3)
}
function hasAlliteration(line: string): boolean {
const words = (line.toLowerCase().match(/\b[a-z]+/g) ?? []).filter(w => w.length > 1)
if (words.length < 3) return false
const initials = words.map(w => w[0])
const freq: Record<string, number> = {}
for (const c of initials) freq[c] = (freq[c] ?? 0) + 1
return Object.values(freq).some(v => v >= 3)
}
function buildRhymeScheme(endWords: string[]): string {
const map = new Map<string, string>()
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
let next = 0
return endWords.map(w => {
if (!w) return ' '
const key = rhymeKey(w)
if (!map.has(key)) {
if (next >= letters.length) return '?'
map.set(key, letters[next++])
}
return map.get(key)!
}).join('')
}
function detectForm(lineCount: number, rhymeScheme: string): string | null {
if (lineCount === 3) return 'haiku (3 lines)'
if (lineCount === 5) return 'cinquain'
if (lineCount === 14) {
if (rhymeScheme.startsWith('ABABCDCDEFEFGG')) return 'Shakespearean sonnet'
if (/^ABBAABBA(CDECDE|CDCDCD)/.test(rhymeScheme)) return 'Petrarchan sonnet'
return 'sonnet (14 lines)'
}
if (lineCount === 19) return 'villanelle (19 lines)'
if (lineCount === 6) return 'sestet'
if (lineCount === 8) return 'octave'
return null
}
export function analyzePoem(content: string): PoemAnalysis {
const rawLines = content.split('\n')
const nonEmptyLines = rawLines.filter(l => l.trim())
const endWords = nonEmptyLines.map(l => lineEndWord(l))
const rhymeScheme = buildRhymeScheme(endWords)
const rhemeLettersByLine = rhymeScheme.split('')
let nonEmptyIdx = 0
const lines: LineMetrics[] = rawLines.map((text, i) => {
const isEmpty = !text.trim()
const endWord = isEmpty ? '' : (endWords[nonEmptyIdx] ?? '')
const rhymeLetter = isEmpty ? '' : (rhemeLettersByLine[nonEmptyIdx] ?? '')
if (!isEmpty) nonEmptyIdx++
return {
lineNumber: i + 1,
text,
syllableCount: text.trim().split(/\s+/).filter(Boolean).reduce((s, w) => s + countSyllables(w), 0),
charCount: text.length,
endWord,
rhymeLetter,
hasAlliteration: hasAlliteration(text)
}
})
const words = content.toLowerCase().match(/\b[a-z]+\b/g) ?? []
const uniqueWords = new Set(words)
const nonEmptyLineLengths = nonEmptyLines.map(l => l.length)
const totalSyllables = lines.reduce((s, l) => s + l.syllableCount, 0)
return {
lines,
lineCount: nonEmptyLines.length,
wordCount: words.length,
uniqueWordRatio: words.length > 0 ? +(uniqueWords.size / words.length).toFixed(2) : 0,
rhymeScheme,
detectedForm: detectForm(nonEmptyLines.length, rhymeScheme),
avgSyllablesPerLine: nonEmptyLines.length > 0 ? +(totalSyllables / nonEmptyLines.length).toFixed(1) : 0,
alliterationCount: lines.filter(l => l.hasAlliteration).length,
longestLine: nonEmptyLineLengths.length ? Math.max(...nonEmptyLineLengths) : 0,
shortestLine: nonEmptyLineLengths.length ? Math.min(...nonEmptyLineLengths) : 0
}
}

72
src/preload/index.ts Normal file
View File

@@ -0,0 +1,72 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { PoemFile, PoemMeta, RevisionMeta, CollectionConfig } from '../main/fileSystem'
import type { PoemAnalysis } from '../main/staticAnalysis'
import type { GlobalConfig } from '../main/globalConfig'
import type { ChatPayload } from '../main/aiService'
contextBridge.exposeInMainWorld('api', {
// Poems
listPoems: (): Promise<PoemFile[]> => ipcRenderer.invoke('poems:list'),
readPoem: (path: string): Promise<string> => ipcRenderer.invoke('poems:read', path),
writePoem: (path: string, content: string): Promise<void> => ipcRenderer.invoke('poems:write', path, content),
createPoem: (name: string): Promise<{ id: string; path: string }> => ipcRenderer.invoke('poems:create', name),
renamePoem: (oldPath: string, newName: string): Promise<string> => ipcRenderer.invoke('poems:rename', oldPath, newName),
deletePoem: (path: string): Promise<void> => ipcRenderer.invoke('poems:delete', path),
getPoemMeta: (id: string): Promise<PoemMeta> => ipcRenderer.invoke('poems:getMeta', id),
setPoemMeta: (id: string, meta: PoemMeta): Promise<void> => ipcRenderer.invoke('poems:setMeta', id, meta),
saveOrder: (order: string[]): Promise<void> => ipcRenderer.invoke('poems:saveOrder', order),
// Collection
getCollectionConfig: (): Promise<CollectionConfig> => ipcRenderer.invoke('collection:getConfig'),
// Session
readSession: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('session:read'),
writeSession: (data: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('session:write', data),
// Revisions
saveRevision: (path: string, content: string): Promise<void> => ipcRenderer.invoke('revisions:save', path, content),
listRevisions: (path: string): Promise<RevisionMeta[]> => ipcRenderer.invoke('revisions:list', path),
loadRevision: (path: string, id: string): Promise<string> => ipcRenderer.invoke('revisions:load', path, id),
// Static analysis
analyzePoem: (content: string): Promise<PoemAnalysis> => ipcRenderer.invoke('analysis:run', content),
// Config
readConfig: (): Promise<GlobalConfig> => ipcRenderer.invoke('config:read'),
writeConfig: (updates: Partial<GlobalConfig>): Promise<void> => ipcRenderer.invoke('config:write', updates),
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('config:pickFolder'),
isAIEnabled: (): Promise<boolean> => ipcRenderer.invoke('config:aiEnabled'),
// AI chat
streamChatMessage: (payload: ChatPayload, onChunk: (chunk: string) => void): Promise<void> => {
return new Promise((resolve, reject) => {
const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => onChunk(chunk)
const doneHandler = (): void => {
ipcRenderer.removeListener('ai:chunk', chunkHandler)
ipcRenderer.removeListener('ai:done', doneHandler)
ipcRenderer.removeListener('ai:error', errorHandler)
resolve()
}
const errorHandler = (_: Electron.IpcRendererEvent, message: string): void => {
ipcRenderer.removeListener('ai:chunk', chunkHandler)
ipcRenderer.removeListener('ai:done', doneHandler)
ipcRenderer.removeListener('ai:error', errorHandler)
reject(new Error(message))
}
ipcRenderer.on('ai:chunk', chunkHandler)
ipcRenderer.on('ai:done', doneHandler)
ipcRenderer.on('ai:error', errorHandler)
ipcRenderer.invoke('ai:streamChat', payload).catch(reject)
})
},
// Context menus
showPoemContextMenu: (poemId: string): Promise<string | null> => ipcRenderer.invoke('menu:poemContext', poemId),
// Menu
onMenuAction: (handler: (action: string) => void): (() => void) => {
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
ipcRenderer.on('menu:action', listener)
return () => ipcRenderer.removeListener('menu:action', listener)
}
})

215
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react'
import { useMartiStore } from './store/martiStore'
import { PoemSidebar } from './components/Sidebar/PoemSidebar'
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
import { AnalysisPanel } from './components/Analysis/AnalysisPanel'
import { ChatPanel } from './components/AIChat/ChatPanel'
import { Dashboard } from './components/Dashboard/Dashboard'
import { SettingsDialog } from './components/Settings/SettingsDialog'
import { RevisionPanel } from './components/Revisions/RevisionPanel'
export default function App(): JSX.Element {
const {
sidebarOpen, setSidebarOpen,
analysisOpen, setAnalysisOpen,
chatOpen, setChatOpen,
focusMode, toggleFocusMode,
revisionPanelOpen, toggleRevisionPanel,
theme, toggleTheme,
fontSize, setFontSize,
aiEnabled,
activePoemId, isDirty, activePoemPath, activePoemContent, markSaved,
poems, setPoems,
initPrefs, loadSession, saveSession
} = useMartiStore()
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
async function init(): Promise<void> {
await initPrefs()
const poemsList = await window.api.listPoems()
setPoems(poemsList)
await loadSession()
}
init()
}, [])
// Auto-save on content change (debounced via revision timer in editor)
useEffect(() => {
if (!isDirty || !activePoemPath) return
const t = setTimeout(async () => {
await window.api.writePoem(activePoemPath, activePoemContent)
markSaved()
}, 1500)
return () => clearTimeout(t)
}, [activePoemContent, isDirty])
useEffect(() => {
return window.api.onMenuAction(async (action) => {
if (action === 'save') {
if (activePoemPath && isDirty) {
await window.api.writePoem(activePoemPath, activePoemContent)
await window.api.saveRevision(activePoemPath, activePoemContent)
markSaved()
}
} else if (action === 'newPoem') {
const name = `Untitled ${poems.length + 1}`
const created = await window.api.createPoem(name)
const refreshed = await window.api.listPoems()
setPoems(refreshed)
const p = refreshed.find(x => x.path === created.path)
if (p) {
const content = await window.api.readPoem(p.path)
useMartiStore.getState().setActivePoem(p.path, p.id, content)
}
} else if (action === 'toggleSidebar') {
setSidebarOpen(!sidebarOpen)
} else if (action === 'toggleAnalysis') {
setAnalysisOpen(!analysisOpen)
} else if (action === 'toggleChat') {
if (aiEnabled) setChatOpen(!chatOpen)
} else if (action === 'toggleRevisions') {
toggleRevisionPanel()
} else if (action === 'toggleFocusMode') {
toggleFocusMode()
} else if (action === 'toggleTheme') {
toggleTheme()
} else if (action === 'fontIncrease') {
setFontSize(fontSize + 1)
} else if (action === 'fontDecrease') {
setFontSize(fontSize - 1)
} else if (action === 'fontReset') {
setFontSize(16)
} else if (action === 'openSettings') {
setSettingsOpen(true)
}
})
}, [activePoemPath, isDirty, activePoemContent, sidebarOpen, analysisOpen, chatOpen, focusMode, fontSize, poems, aiEnabled])
useEffect(() => {
const handler = async (e: KeyboardEvent): Promise<void> => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 's') {
e.preventDefault()
if (activePoemPath && isDirty) {
await window.api.writePoem(activePoemPath, activePoemContent)
await window.api.saveRevision(activePoemPath, activePoemContent)
markSaved()
}
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'G') {
e.preventDefault()
toggleFocusMode()
} else if (e.key === 'Escape' && focusMode) {
toggleFocusMode()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [activePoemPath, isDirty, activePoemContent, focusMode])
// Persist session on poem change
useEffect(() => {
if (activePoemId) saveSession()
}, [activePoemId])
const activePoem = poems.find(p => p.id === activePoemId)
return (
<div
className="app-layout"
data-sidebar={sidebarOpen ? 'open' : 'closed'}
data-analysis={analysisOpen ? 'open' : 'closed'}
data-chat={aiEnabled && chatOpen ? 'open' : 'closed'}
data-focus={focusMode ? 'on' : 'off'}
>
{/* Titlebar */}
<div className="app-titlebar">
<span className="app-titlebar-title">
Marti{activePoem ? `${activePoem.meta.title ?? activePoem.id}${isDirty ? ' ●' : ''}` : ''}
</span>
<div className="app-titlebar-right">
<div className="app-layout-toggle">
<button
className={`app-layout-toggle-seg${sidebarOpen ? ' active' : ''}`}
onClick={() => setSidebarOpen(!sidebarOpen)}
title={sidebarOpen ? 'Hide poem list' : 'Show poem list'}
/>
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
<button
className={`app-layout-toggle-seg${analysisOpen ? ' active' : ''}`}
onClick={() => setAnalysisOpen(!analysisOpen)}
title={analysisOpen ? 'Hide analysis' : 'Show analysis'}
/>
{aiEnabled && (
<>
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" style={{ width: 4 }} />
<button
className={`app-layout-toggle-seg${chatOpen ? ' active' : ''}`}
onClick={() => setChatOpen(!chatOpen)}
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
/>
</>
)}
</div>
<button
className={`app-titlebar-btn${revisionPanelOpen ? ' active' : ''}`}
onClick={toggleRevisionPanel}
title="Revision history"
disabled={!activePoemId}
></button>
<button
className={`app-titlebar-btn${focusMode ? ' active' : ''}`}
onClick={toggleFocusMode}
title="Focus mode (⌘⇧G)"
></button>
<button className="app-titlebar-btn" onClick={toggleTheme} title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
{theme === 'dark' ? '☀' : '☾'}
</button>
<button className="app-titlebar-btn" onClick={() => setSettingsOpen(true)} title="Preferences (⌘,)"></button>
</div>
</div>
{/* Sidebar */}
<aside className="sidebar">
<PoemSidebar />
</aside>
{/* Editor */}
<main className="editor-area" style={{ position: 'relative' }}>
{activePoemId ? (
<>
<MarkdownEditor />
{revisionPanelOpen && <RevisionPanel />}
</>
) : (
<Dashboard />
)}
{focusMode && (
<button className="focus-exit" onClick={toggleFocusMode} title="Exit focus mode"></button>
)}
</main>
{/* Analysis panel */}
<aside className="analysis-area">
<AnalysisPanel />
</aside>
{/* AI Chat — only rendered when AI is enabled */}
{aiEnabled && (
<aside className="chat-area">
<ChatPanel />
</aside>
)}
{settingsOpen && (
<SettingsDialog
onClose={async () => {
setSettingsOpen(false)
const refreshed = await window.api.listPoems()
setPoems(refreshed)
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useRef, useEffect, useState } from 'react'
import { useMartiStore } from '../../store/martiStore'
import type { ChatMessage } from '../../types/marti'
export function ChatPanel(): JSX.Element {
const {
chatMessages, chatStreaming, activePoemContent,
addChatMessage, updateLastChatMessage, clearChat, setChatStreaming
} = useMartiStore()
const [input, setInput] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chatMessages])
async function send(): Promise<void> {
const text = input.trim()
if (!text || chatStreaming) return
setInput('')
const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', content: text }
addChatMessage(userMsg)
const assistantMsg: ChatMessage = { id: (Date.now() + 1).toString(), role: 'assistant', content: '' }
addChatMessage(assistantMsg)
setChatStreaming(true)
try {
const history = [...chatMessages, userMsg].map(m => ({ role: m.role, content: m.content }))
await window.api.streamChatMessage(
{ messages: history, poemContent: activePoemContent },
(chunk) => updateLastChatMessage(useMartiStore.getState().chatMessages.at(-1)!.content + chunk)
)
} catch (err) {
updateLastChatMessage(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
setChatStreaming(false)
}
}
return (
<div className="chat-panel">
<div className="chat-header">
<span className="chat-header-title">Conversation</span>
{chatMessages.length > 0 && (
<button className="chat-new-btn" onClick={clearChat} title="Clear"></button>
)}
</div>
<div className="chat-messages">
{chatMessages.length === 0 && (
<div className="chat-placeholder">Ask a question about your poem or just talk through it.</div>
)}
{chatMessages.map(msg => (
<div key={msg.id} className={`chat-message chat-message-${msg.role}`}>
<div className="chat-bubble">
{msg.content || (msg.role === 'assistant' && chatStreaming ? (
<div className="chat-typing"><span /><span /><span /></div>
) : null)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
<div className="chat-input-area">
<div className="chat-input-row">
<textarea
ref={textareaRef}
className="chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
placeholder="Message…"
rows={1}
/>
<button className="chat-send-btn" onClick={send} disabled={!input.trim() || chatStreaming}></button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { useMartiStore } from '../../store/martiStore'
const RHYME_COLORS = [
'#8b75ff', '#ff7b7b', '#5bc4a0', '#ffb347', '#7ec8e3',
'#f9a8d4', '#86efac', '#fde68a', '#c4b5fd', '#fb923c'
]
function rhymeColor(letter: string): string {
const idx = letter.charCodeAt(0) - 65
return RHYME_COLORS[idx % RHYME_COLORS.length]
}
export function AnalysisPanel(): JSX.Element {
const { analysis, analysisLoading, activePoemId } = useMartiStore()
if (!activePoemId) {
return (
<div className="analysis-panel">
<div className="analysis-header"><span className="analysis-title">Analysis</span></div>
<div className="analysis-empty">Open a poem to see analysis.</div>
</div>
)
}
if (!analysis && !analysisLoading) {
return (
<div className="analysis-panel">
<div className="analysis-header"><span className="analysis-title">Analysis</span></div>
<div className="analysis-empty">Start writing to see analysis.</div>
</div>
)
}
return (
<div className="analysis-panel">
<div className="analysis-header">
<span className="analysis-title">Analysis</span>
{analysisLoading && <span className="analysis-loading" />}
</div>
{analysis && (
<div className="analysis-body">
{/* Form */}
{analysis.detectedForm && (
<div className="analysis-section">
<div className="analysis-label">Form</div>
<div className="analysis-form-badge">{analysis.detectedForm}</div>
</div>
)}
{/* Stats row */}
<div className="analysis-section">
<div className="analysis-label">Metrics</div>
<div className="analysis-stats-grid">
<div className="analysis-stat">
<div className="analysis-stat-value">{analysis.lineCount}</div>
<div className="analysis-stat-label">lines</div>
</div>
<div className="analysis-stat">
<div className="analysis-stat-value">{analysis.wordCount}</div>
<div className="analysis-stat-label">words</div>
</div>
<div className="analysis-stat">
<div className="analysis-stat-value">{analysis.avgSyllablesPerLine}</div>
<div className="analysis-stat-label">avg syl/line</div>
</div>
<div className="analysis-stat">
<div className="analysis-stat-value">{Math.round(analysis.uniqueWordRatio * 100)}%</div>
<div className="analysis-stat-label">unique words</div>
</div>
<div className="analysis-stat">
<div className="analysis-stat-value">{analysis.longestLine}</div>
<div className="analysis-stat-label">longest line</div>
</div>
<div className="analysis-stat">
<div className="analysis-stat-value">{analysis.alliterationCount}</div>
<div className="analysis-stat-label">alliterative lines</div>
</div>
</div>
</div>
{/* Rhyme scheme */}
{analysis.rhymeScheme.trim() && (
<div className="analysis-section">
<div className="analysis-label">Rhyme scheme</div>
<div className="analysis-rhyme-scheme">
{analysis.rhymeScheme.split('').map((letter, i) => (
<span
key={i}
className="rhyme-letter"
style={{ color: letter.trim() ? rhymeColor(letter) : 'var(--text3)' }}
title={letter.trim() ? `Rhyme group ${letter}` : undefined}
>
{letter.trim() ? letter : '·'}
</span>
))}
</div>
</div>
)}
{/* Per-line syllables */}
<div className="analysis-section">
<div className="analysis-label">Lines</div>
<div className="analysis-lines">
{analysis.lines.map((line, i) => (
line.text.trim() ? (
<div key={i} className="analysis-line-row">
<span
className="line-rhyme-dot"
style={{ color: line.rhymeLetter ? rhymeColor(line.rhymeLetter) : 'transparent' }}
>
{line.rhymeLetter || '·'}
</span>
<span className="line-text">{line.text}</span>
<span className="line-syl">{line.syllableCount}</span>
{line.hasAlliteration && <span className="line-flag" title="Alliteration">~</span>}
</div>
) : (
<div key={i} className="analysis-line-spacer" />
)
))}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useMartiStore } from '../../store/martiStore'
export function Dashboard(): JSX.Element {
const { poems, setPoems, setActivePoem } = useMartiStore()
const totalLines = poems.reduce((s, p) => s + p.lineCount, 0)
const totalWords = poems.reduce((s, p) => s + p.wordCount, 0)
async function openPoem(path: string, id: string): Promise<void> {
const content = await window.api.readPoem(path)
setActivePoem(path, id, content)
}
async function newPoem(): Promise<void> {
const name = `Untitled ${poems.length + 1}`
const created = await window.api.createPoem(name)
const refreshed = await window.api.listPoems()
setPoems(refreshed)
const p = refreshed.find(x => x.path === created.path)
if (p) {
const content = await window.api.readPoem(p.path)
setActivePoem(p.path, p.id, content)
}
}
return (
<div className="dashboard">
<div className="dashboard-greeting">Good to have you.</div>
<div className="dashboard-stats-row">
<div className="dashboard-stat">
<div className="dashboard-stat-value">{poems.length}</div>
<div className="dashboard-stat-label">{poems.length === 1 ? 'poem' : 'poems'}</div>
</div>
<div className="dashboard-stat">
<div className="dashboard-stat-value">{totalLines}</div>
<div className="dashboard-stat-label">lines</div>
</div>
<div className="dashboard-stat">
<div className="dashboard-stat-value">{totalWords}</div>
<div className="dashboard-stat-label">words</div>
</div>
</div>
<button className="dashboard-new-btn" onClick={newPoem}>New poem</button>
{poems.length > 0 && (
<div className="dashboard-section">
<div className="dashboard-section-title">All poems</div>
{poems.map(poem => (
<div key={poem.id} className="dashboard-row" onClick={() => openPoem(poem.path, poem.id)}>
<span className="dashboard-row-title">{poem.meta.title ?? poem.id}</span>
<span className="dashboard-row-meta">{poem.lineCount} lines</span>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useRef, useCallback } from 'react'
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { useMartiStore } from '../../store/martiStore'
export function MarkdownEditor(): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const { activePoemContent, activePoemId, fontSize, editorFont, lineWrap, setContent, setAnalysis, setAnalysisLoading } = useMartiStore()
const analysisTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const runAnalysis = useCallback(async (content: string) => {
if (analysisTimer.current) clearTimeout(analysisTimer.current)
analysisTimer.current = setTimeout(async () => {
if (!content.trim()) { setAnalysis(null); return }
setAnalysisLoading(true)
try {
const result = await window.api.analyzePoem(content)
setAnalysis(result)
} finally {
setAnalysisLoading(false)
}
}, 400)
}, [setAnalysis, setAnalysisLoading])
useEffect(() => {
if (!containerRef.current) return
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString()
setContent(content)
runAnalysis(content)
}
})
const lineWrapExtension = lineWrap ? EditorView.lineWrapping : []
const state = EditorState.create({
doc: activePoemContent,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
markdown(),
lineNumbers(),
updateListener,
lineWrapExtension,
EditorView.theme({
'&': { height: '100%', background: 'transparent' },
'.cm-scroller': { overflow: 'auto', fontFamily: editorFont === 'mono' ? '"JetBrains Mono", "Fira Code", monospace' : 'Georgia, "Times New Roman", serif', fontSize: `${fontSize}px`, lineHeight: '1.9' },
'.cm-content': { padding: '40px 32px 40px', maxWidth: '640px', margin: '0 auto' },
'.cm-line': { padding: '0' },
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-selectionBackground': { background: 'rgba(139,117,255,0.18) !important' },
'.cm-focused .cm-selectionBackground': { background: 'rgba(139,117,255,0.28) !important' },
'.cm-gutters': { background: 'transparent', border: 'none', color: 'var(--text3)', fontSize: '11px' },
'.cm-lineNumbers .cm-gutterElement': { padding: '0 8px 0 4px', minWidth: '32px' }
})
]
})
const view = new EditorView({ state, parent: containerRef.current })
viewRef.current = view
// Run initial analysis
if (activePoemContent.trim()) runAnalysis(activePoemContent)
return () => {
view.destroy()
viewRef.current = null
}
}, [activePoemId, editorFont, lineWrap])
// Sync font size without rebuilding the editor
useEffect(() => {
if (viewRef.current) {
viewRef.current.dom.style.setProperty('--editor-font-size', `${fontSize}px`)
}
}, [fontSize])
return (
<div className="editor-scroll">
<div ref={containerRef} style={{ height: '100%' }} />
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
import { useMartiStore } from '../../store/martiStore'
import type { RevisionMeta } from '../../types/marti'
export function RevisionPanel(): JSX.Element {
const { activePoemPath, setContent, toggleRevisionPanel } = useMartiStore()
const [revisions, setRevisions] = useState<RevisionMeta[]>([])
useEffect(() => {
if (!activePoemPath) return
window.api.listRevisions(activePoemPath).then(setRevisions)
}, [activePoemPath])
async function restore(id: string): Promise<void> {
if (!activePoemPath) return
const content = await window.api.loadRevision(activePoemPath, id)
setContent(content)
toggleRevisionPanel()
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
return (
<div className="revision-panel">
<div className="revision-header">
<span className="revision-header-title">Revisions</span>
<button className="revision-close" onClick={toggleRevisionPanel}></button>
</div>
<div className="revision-list">
{revisions.length === 0 && <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>No revisions yet.</div>}
{revisions.map(r => (
<div key={r.id} className="revision-item" onClick={() => restore(r.id)}>
<div className="revision-item-time">{formatTime(r.timestamp)}</div>
<div className="revision-item-wc">{r.lineCount} lines</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { useState, useEffect } from 'react'
import { useMartiStore } from '../../store/martiStore'
interface Props {
onClose(): void
}
export function SettingsDialog({ onClose }: Props): JSX.Element {
const { fontSize, setFontSize, editorFont, setEditorFont, lineWrap, setLineWrap, setAIEnabled } = useMartiStore()
const [collectionPath, setCollectionPath] = useState('')
const [aiEnabled, setLocalAIEnabled] = useState(false)
const [apiKey, setApiKey] = useState('')
const [tab, setTab] = useState<'editor' | 'ai'>('editor')
useEffect(() => {
window.api.readConfig().then(cfg => {
setCollectionPath(cfg.collectionPath ?? '')
setLocalAIEnabled(cfg.ai?.enabled ?? false)
setApiKey(cfg.ai?.apiKey ?? '')
})
}, [])
async function save(): Promise<void> {
await window.api.writeConfig({
collectionPath: collectionPath || undefined,
ai: { enabled: aiEnabled, apiKey: apiKey || undefined }
})
const enabled = await window.api.isAIEnabled()
setAIEnabled(enabled)
onClose()
}
async function pickFolder(): Promise<void> {
const path = await window.api.pickFolder()
if (path) setCollectionPath(path)
}
return (
<div className="settings-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="settings-dialog">
<div className="settings-header">
<span className="settings-title">Preferences</span>
<button className="settings-close" onClick={onClose}></button>
</div>
<div className="settings-body">
<nav className="settings-nav">
<button className={`settings-nav-item${tab === 'editor' ? ' active' : ''}`} onClick={() => setTab('editor')}>Editor</button>
<button className={`settings-nav-item${tab === 'ai' ? ' active' : ''}`} onClick={() => setTab('ai')}>AI</button>
</nav>
<div className="settings-content">
{tab === 'editor' && (
<>
<div className="settings-section-title">Collection</div>
<div className="settings-field">
<label className="settings-label">Poems folder</label>
<div className="settings-field-row">
<input value={collectionPath} onChange={e => setCollectionPath(e.target.value)} placeholder="~/Documents/marti-poems" />
<button className="settings-pick-btn" onClick={pickFolder}>Choose</button>
</div>
</div>
<div className="settings-section-title" style={{ marginTop: 20 }}>Editor</div>
<div className="settings-field">
<label className="settings-label">Font size</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button className="settings-pick-btn" onClick={() => setFontSize(fontSize - 1)}></button>
<span style={{ minWidth: 28, textAlign: 'center' }}>{fontSize}</span>
<button className="settings-pick-btn" onClick={() => setFontSize(fontSize + 1)}>+</button>
</div>
</div>
<div className="settings-field">
<label className="settings-label">Font style</label>
<select value={editorFont} onChange={e => setEditorFont(e.target.value as 'serif' | 'mono')}>
<option value="serif">Serif</option>
<option value="mono">Monospace</option>
</select>
</div>
<div className="settings-field">
<label className="settings-label">
<input type="checkbox" checked={lineWrap} onChange={e => setLineWrap(e.target.checked)} style={{ marginRight: 6 }} />
Wrap long lines
</label>
</div>
</>
)}
{tab === 'ai' && (
<>
<div className="settings-section-title">AI opt-in</div>
<p className="settings-hint" style={{ marginBottom: 16 }}>
AI features are off by default. When enabled, your poem text is sent to Anthropic's servers for processing. Your poems are never used to train models.
</p>
<div className="settings-field">
<label className="settings-label">
<input type="checkbox" checked={aiEnabled} onChange={e => setLocalAIEnabled(e.target.checked)} style={{ marginRight: 6 }} />
Enable AI conversation
</label>
</div>
{aiEnabled && (
<div className="settings-field">
<label className="settings-label">Anthropic API key</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder="sk-ant-…"
/>
<div className="settings-hint">Your key is stored locally and never shared.</div>
</div>
)}
</>
)}
</div>
</div>
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button className="btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn-primary" onClick={save} style={{ padding: '0 20px' }}>Save</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { useState } from 'react'
import { useMartiStore } from '../../store/martiStore'
export function PoemSidebar(): JSX.Element {
const { poems, setPoems, activePoemId, setActivePoem, saveSession, isDirty, activePoemPath, activePoemContent, markSaved } = useMartiStore()
const [search, setSearch] = useState('')
const [renaming, setRenaming] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const filtered = poems.filter(p => {
const label = p.meta.title ?? p.id
return label.toLowerCase().includes(search.toLowerCase())
})
async function openPoem(path: string, id: string): Promise<void> {
if (isDirty && activePoemPath) {
await window.api.writePoem(activePoemPath, activePoemContent)
await window.api.saveRevision(activePoemPath, activePoemContent)
markSaved()
}
const content = await window.api.readPoem(path)
setActivePoem(path, id, content)
await saveSession()
}
async function handleContextMenu(e: React.MouseEvent, id: string): Promise<void> {
e.preventDefault()
const action = await window.api.showPoemContextMenu(id)
if (action === 'rename') {
setRenaming(id)
setRenameValue(id)
} else if (action === 'delete') {
const poem = poems.find(p => p.id === id)
if (poem) {
await window.api.deletePoem(poem.path)
const refreshed = await window.api.listPoems()
setPoems(refreshed)
if (activePoemId === id) setActivePoem('', '', '')
}
}
}
async function commitRename(id: string): Promise<void> {
const poem = poems.find(p => p.id === id)
if (!poem || !renameValue.trim() || renameValue === id) { setRenaming(null); return }
await window.api.renamePoem(poem.path, renameValue.trim())
const refreshed = await window.api.listPoems()
setPoems(refreshed)
setRenaming(null)
}
return (
<>
<div className="sidebar-header">
<span className="sidebar-title">Poems</span>
</div>
<div className="sidebar-search">
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search…"
/>
</div>
<div className="sidebar-list">
{filtered.map(poem => (
<div
key={poem.id}
className={`poem-item${activePoemId === poem.id ? ' active' : ''}`}
onClick={() => openPoem(poem.path, poem.id)}
onContextMenu={e => handleContextMenu(e, poem.id)}
>
{renaming === poem.id ? (
<input
className="inline-edit"
value={renameValue}
autoFocus
onChange={e => setRenameValue(e.target.value)}
onBlur={() => commitRename(poem.id)}
onKeyDown={e => {
if (e.key === 'Enter') commitRename(poem.id)
if (e.key === 'Escape') setRenaming(null)
}}
onClick={e => e.stopPropagation()}
/>
) : (
<div className="poem-item-main">
<div className="poem-item-title">{poem.meta.title ?? poem.id}</div>
<div className="poem-item-meta">{poem.lineCount} {poem.lineCount === 1 ? 'line' : 'lines'}</div>
</div>
)}
</div>
))}
{filtered.length === 0 && (
<div className="sidebar-empty">{search ? 'No matches' : 'No poems yet'}</div>
)}
</div>
</>
)
}

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marti</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles/app.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,152 @@
import { create } from 'zustand'
import type { PoemFile, PoemAnalysis, ChatMessage } from '../types/marti'
interface MartiStore {
// Layout
sidebarOpen: boolean
analysisOpen: boolean
chatOpen: boolean
focusMode: boolean
revisionPanelOpen: boolean
theme: 'dark' | 'light'
fontSize: number
editorFont: 'serif' | 'mono'
lineWrap: boolean
aiEnabled: boolean
// Content
poems: PoemFile[]
activePoemId: string | null
activePoemPath: string | null
activePoemContent: string
isDirty: boolean
// Analysis
analysis: PoemAnalysis | null
analysisLoading: boolean
// AI chat
chatMessages: ChatMessage[]
chatStreaming: boolean
// Actions
setSidebarOpen(v: boolean): void
setAnalysisOpen(v: boolean): void
setChatOpen(v: boolean): void
toggleFocusMode(): void
toggleRevisionPanel(): void
toggleTheme(): void
setFontSize(n: number): void
setEditorFont(f: 'serif' | 'mono'): void
setLineWrap(v: boolean): void
setAIEnabled(v: boolean): void
setPoems(poems: PoemFile[]): void
setActivePoem(path: string, id: string, content: string): void
setContent(content: string): void
markSaved(): void
setAnalysis(a: PoemAnalysis | null): void
setAnalysisLoading(v: boolean): void
addChatMessage(msg: ChatMessage): void
updateLastChatMessage(content: string): void
clearChat(): void
setChatStreaming(v: boolean): void
initPrefs(): Promise<void>
loadSession(): Promise<void>
saveSession(): Promise<void>
}
export const useMartiStore = create<MartiStore>((set, get) => ({
sidebarOpen: true,
analysisOpen: true,
chatOpen: false,
focusMode: false,
revisionPanelOpen: false,
theme: 'dark',
fontSize: 16,
editorFont: 'serif',
lineWrap: false,
aiEnabled: false,
poems: [],
activePoemId: null,
activePoemPath: null,
activePoemContent: '',
isDirty: false,
analysis: null,
analysisLoading: false,
chatMessages: [],
chatStreaming: false,
setSidebarOpen: (v) => set({ sidebarOpen: v }),
setAnalysisOpen: (v) => set({ analysisOpen: v }),
setChatOpen: (v) => set({ chatOpen: v }),
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
toggleRevisionPanel: () => set(s => ({ revisionPanelOpen: !s.revisionPanelOpen })),
toggleTheme: () => {
const next = get().theme === 'dark' ? 'light' : 'dark'
set({ theme: next })
document.documentElement.classList.toggle('light', next === 'light')
window.api.writeConfig({ theme: next })
},
setFontSize: (n) => {
const clamped = Math.max(12, Math.min(28, n))
set({ fontSize: clamped })
window.api.writeConfig({ fontSize: clamped })
},
setEditorFont: (f) => {
set({ editorFont: f })
window.api.writeConfig({ editorFont: f })
},
setLineWrap: (v) => {
set({ lineWrap: v })
window.api.writeConfig({ lineWrap: v })
},
setAIEnabled: (v) => set({ aiEnabled: v }),
setPoems: (poems) => set({ poems }),
setActivePoem: (path, id, content) => set({ activePoemPath: path, activePoemId: id, activePoemContent: content, isDirty: false, analysis: null, chatMessages: [] }),
setContent: (content) => set({ activePoemContent: content, isDirty: true }),
markSaved: () => set({ isDirty: false }),
setAnalysis: (a) => set({ analysis: a }),
setAnalysisLoading: (v) => set({ analysisLoading: v }),
addChatMessage: (msg) => set(s => ({ chatMessages: [...s.chatMessages, msg] })),
updateLastChatMessage: (content) => set(s => {
const msgs = [...s.chatMessages]
if (msgs.length > 0) msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content }
return { chatMessages: msgs }
}),
clearChat: () => set({ chatMessages: [] }),
setChatStreaming: (v) => set({ chatStreaming: v }),
initPrefs: async () => {
const cfg = await window.api.readConfig()
const isDark = cfg.theme !== 'light'
document.documentElement.classList.toggle('light', !isDark)
const aiEnabled = await window.api.isAIEnabled()
set({
theme: cfg.theme ?? 'dark',
fontSize: cfg.fontSize ?? 16,
editorFont: cfg.editorFont ?? 'serif',
lineWrap: cfg.lineWrap ?? false,
aiEnabled
})
},
loadSession: async () => {
const session = await window.api.readSession()
const { poems, setActivePoem } = get()
if (session.activePoemId && typeof session.activePoemId === 'string') {
const poem = poems.find(p => p.id === session.activePoemId)
if (poem) {
const content = await window.api.readPoem(poem.path)
setActivePoem(poem.path, poem.id, content)
}
}
},
saveSession: async () => {
const { activePoemId } = get()
await window.api.writeSession({ activePoemId })
}
}))

350
src/renderer/styles/app.css Normal file
View File

@@ -0,0 +1,350 @@
/* ── Reset & base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #18181a;
--bg2: #202024;
--bg3: #28282e;
--border: #35353d;
--text: #d8d4e8;
--text2: #888098;
--text3: #524e60;
--accent: #8b75ff;
--accent2: #6a55d4;
--danger: #b05060;
--success: #5a9070;
--sidebar-w: 210px;
--analysis-w: 260px;
--chat-w: 290px;
--titlebar-h: 38px;
}
:root.light {
--bg: #f4f1f0;
--bg2: #ede9e8;
--bg3: #e4e0de;
--border: #ccc7c4;
--text: #1e1a2a;
--text2: #6a6278;
--text3: #a09aae;
--accent: #6248d4;
--accent2: #4a30b2;
--danger: #902030;
--success: #3a7050;
}
html, body, #root { height: 100%; width: 100%; overflow: hidden; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: subpixel-antialiased;
user-select: none;
}
button { cursor: pointer; border: none; background: none; color: inherit; font: inherit; }
input, textarea, select {
font: inherit;
color: var(--text);
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
outline: none;
}
input[type="checkbox"] { width: auto; padding: 0; }
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
textarea { resize: vertical; }
/* ── Layout ───────────────────────────────────────────────────────────────── */
.app-layout {
display: grid;
height: 100vh;
grid-template-rows: var(--titlebar-h) 1fr;
grid-template-columns: var(--sidebar-w) 1fr var(--analysis-w);
grid-template-areas:
"titlebar titlebar titlebar"
"sidebar editor analysis";
transition: grid-template-columns 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* With AI chat enabled */
.app-layout[data-chat="open"] {
grid-template-columns: var(--sidebar-w) 1fr var(--analysis-w) var(--chat-w);
grid-template-areas:
"titlebar titlebar titlebar titlebar"
"sidebar editor analysis chat";
}
.app-layout[data-sidebar="closed"] { grid-template-columns: 0 1fr var(--analysis-w); }
.app-layout[data-analysis="closed"] { grid-template-columns: var(--sidebar-w) 1fr 0; }
.app-layout[data-sidebar="closed"][data-analysis="closed"] { grid-template-columns: 0 1fr 0; }
.app-layout[data-sidebar="closed"][data-chat="open"] { grid-template-columns: 0 1fr var(--analysis-w) var(--chat-w); }
.app-layout[data-analysis="closed"][data-chat="open"] { grid-template-columns: var(--sidebar-w) 1fr 0 var(--chat-w); }
.app-layout[data-sidebar="closed"][data-analysis="closed"][data-chat="open"] { grid-template-columns: 0 1fr 0 var(--chat-w); }
.app-layout[data-focus="on"] { grid-template-columns: 0 1fr 0 !important; }
.app-layout[data-focus="on"][data-chat="open"] { grid-template-columns: 0 1fr 0 0 !important; }
/* ── Titlebar ─────────────────────────────────────────────────────────────── */
.app-titlebar {
grid-area: titlebar;
display: flex;
align-items: center;
padding: 0 16px 0 80px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
-webkit-app-region: drag;
gap: 12px;
overflow: hidden;
}
.app-titlebar-title {
font-size: 12px;
font-weight: 600;
color: var(--text2);
letter-spacing: 0.08em;
text-transform: uppercase;
flex: 1;
text-align: center;
}
.app-titlebar-right {
display: flex;
align-items: center;
gap: 10px;
-webkit-app-region: no-drag;
}
.app-layout-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.app-layout-toggle-seg {
width: 22px;
height: 18px;
background: var(--bg3);
border: none;
border-left: 1px solid var(--border);
cursor: pointer;
transition: background 0.12s;
}
.app-layout-toggle-seg:first-child { border-left: none; }
.app-layout-toggle-seg.active { background: var(--accent); }
.app-layout-toggle-seg--mid { width: 28px; cursor: default; pointer-events: none; }
.app-titlebar-btn {
width: 26px; height: 20px; border-radius: 4px; font-size: 14px;
color: var(--text2); transition: color 0.12s, background 0.12s;
display: flex; align-items: center; justify-content: center;
}
.app-titlebar-btn:hover { color: var(--text); background: var(--bg3); }
.app-titlebar-btn.active { color: var(--accent); }
.app-titlebar-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
.sidebar {
grid-area: sidebar;
display: flex; flex-direction: column;
background: transparent;
border-right: 1px solid var(--border);
overflow: hidden; min-width: 0;
}
.sidebar-header {
display: flex; align-items: center;
padding: 0 8px 0 12px; height: 38px;
border-bottom: 1px solid var(--border);
flex-shrink: 0; gap: 4px;
}
.sidebar-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; flex: 1; }
.sidebar-search { padding: 6px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.sidebar-search input { width: 100%; font-size: 12px; }
.sidebar-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.sidebar-empty { font-size: 12px; color: var(--text3); padding: 12px; }
.poem-item {
display: flex; align-items: center;
padding: 7px 12px; gap: 8px; cursor: pointer;
transition: background 0.1s; position: relative;
}
.poem-item:hover { background: var(--bg3); }
.poem-item.active { background: var(--bg3); }
.poem-item.active::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--accent); }
.poem-item-main { flex: 1; min-width: 0; }
.poem-item-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.poem-item-meta { font-size: 11px; color: var(--text3); margin-top: 1px; }
/* ── Editor area ──────────────────────────────────────────────────────────── */
.editor-area {
grid-area: editor;
display: flex; flex-direction: column;
overflow: hidden; background: var(--bg); min-width: 0;
}
.editor-scroll { flex: 1; overflow-y: auto; overscroll-behavior: none; }
/* CodeMirror */
.cm-editor { height: 100%; }
.cm-editor.cm-focused { outline: none; }
/* ── Analysis panel ───────────────────────────────────────────────────────── */
.analysis-area {
grid-area: analysis;
display: flex; flex-direction: column;
background: var(--bg2);
border-left: 1px solid var(--border);
overflow: hidden; min-width: 0;
}
.analysis-panel { display: flex; flex-direction: column; height: 100%; }
.analysis-header {
display: flex; align-items: center;
padding: 0 12px; height: 38px;
border-bottom: 1px solid var(--border);
flex-shrink: 0; gap: 8px;
}
.analysis-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; flex: 1; }
.analysis-loading {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent); opacity: 0.7;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 0.3 } 50% { opacity: 1 } }
.analysis-empty { font-size: 12px; color: var(--text3); padding: 16px 12px; }
.analysis-body { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 16px; }
.analysis-section {}
.analysis-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text3); margin-bottom: 6px; }
.analysis-form-badge { font-size: 12px; color: var(--accent); font-weight: 500; }
.analysis-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.analysis-stat { display: flex; flex-direction: column; gap: 2px; }
.analysis-stat-value { font-size: 18px; font-weight: 300; color: var(--text); line-height: 1; }
.analysis-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text3); }
.analysis-rhyme-scheme { display: flex; flex-wrap: wrap; gap: 3px; font-size: 13px; font-weight: 600; font-family: monospace; }
.rhyme-letter { transition: color 0.1s; }
.analysis-lines { display: flex; flex-direction: column; gap: 2px; }
.analysis-line-row {
display: flex; align-items: baseline; gap: 6px;
font-size: 11px; min-height: 18px;
}
.line-rhyme-dot { font-size: 10px; font-weight: 700; font-family: monospace; width: 12px; flex-shrink: 0; text-align: center; }
.line-text { flex: 1; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Georgia, serif; font-size: 11px; }
.line-syl { color: var(--text3); font-variant-numeric: tabular-nums; font-size: 10px; flex-shrink: 0; }
.line-flag { color: var(--accent); font-size: 10px; flex-shrink: 0; }
.analysis-line-spacer { height: 6px; }
/* ── Chat area ────────────────────────────────────────────────────────────── */
.chat-area {
grid-area: chat;
display: flex; flex-direction: column;
background: var(--bg2);
border-left: 1px solid var(--border);
overflow: hidden; min-width: 0;
}
.chat-panel { display: flex; flex-direction: column; height: 100%; }
.chat-header {
display: flex; align-items: center;
padding: 0 12px; height: 38px;
border-bottom: 1px solid var(--border);
flex-shrink: 0; gap: 6px;
}
.chat-header-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; flex: 1; }
.chat-new-btn { font-size: 11px; color: var(--text3); padding: 2px 6px; border-radius: 3px; }
.chat-new-btn:hover { color: var(--accent); background: var(--bg3); }
.chat-messages {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 10px;
}
.chat-placeholder { font-size: 13px; color: var(--text3); line-height: 1.6; text-align: center; margin-top: 24px; }
.chat-message { max-width: 100%; }
.chat-message-user { align-self: flex-end; }
.chat-message-assistant { align-self: flex-start; }
.chat-bubble { padding: 8px 12px; border-radius: 8px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.chat-message-user .chat-bubble { background: var(--accent); color: #fff; }
.chat-message-assistant .chat-bubble { background: var(--bg3); color: var(--text); }
.chat-typing { display: flex; gap: 4px; }
.chat-typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--text3); animation: typing 1.2s infinite; }
.chat-typing span:nth-child(2) { animation-delay: 0.2s; }
.chat-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing { 0%, 80%, 100% { opacity: 0.3 } 40% { opacity: 1 } }
.chat-input-area { border-top: 1px solid var(--border); padding: 10px; flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
.chat-input-row { display: flex; gap: 6px; align-items: flex-end; }
.chat-input { flex: 1; min-height: 36px; max-height: 120px; resize: none; font-size: 13px; padding: 8px 10px; border-radius: 6px; line-height: 1.5; }
.chat-send-btn { width: 32px; height: 36px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: opacity 0.12s; }
.chat-send-btn:hover { opacity: 0.85; }
.chat-send-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Dashboard ────────────────────────────────────────────────────────────── */
.dashboard { flex: 1; overflow-y: auto; padding: 40px 40px; }
.dashboard-greeting { font-size: 22px; font-weight: 300; color: var(--text); margin-bottom: 28px; }
.dashboard-stats-row { display: flex; gap: 28px; margin-bottom: 28px; }
.dashboard-stat { display: flex; flex-direction: column; gap: 2px; }
.dashboard-stat-value { font-size: 28px; font-weight: 300; color: var(--text); line-height: 1; }
.dashboard-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text3); }
.dashboard-new-btn { padding: 8px 20px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 13px; font-weight: 500; margin-bottom: 32px; transition: opacity 0.12s; }
.dashboard-new-btn:hover { opacity: 0.85; }
.dashboard-section { }
.dashboard-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text3); margin-bottom: 8px; }
.dashboard-row { display: flex; align-items: baseline; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); cursor: pointer; }
.dashboard-row:last-child { border-bottom: none; }
.dashboard-row:hover .dashboard-row-title { color: var(--accent); }
.dashboard-row-title { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.dashboard-row-meta { font-size: 11px; color: var(--text3); flex-shrink: 0; }
/* ── Revisions ────────────────────────────────────────────────────────────── */
.revision-panel {
position: absolute; inset-y: 0; right: 0; width: 220px;
background: var(--bg2); border-left: 1px solid var(--border);
display: flex; flex-direction: column; z-index: 10;
}
.revision-header { display: flex; align-items: center; padding: 0 10px; height: 38px; border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 6px; }
.revision-header-title { font-size: 12px; font-weight: 600; color: var(--text2); flex: 1; }
.revision-close { width: 20px; height: 20px; font-size: 14px; color: var(--text3); display: flex; align-items: center; justify-content: center; border-radius: 3px; }
.revision-close:hover { color: var(--text); background: var(--bg3); }
.revision-list { flex: 1; overflow-y: auto; padding: 4px; }
.revision-item { padding: 8px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; margin-bottom: 2px; transition: background 0.1s; }
.revision-item:hover { background: var(--bg3); }
.revision-item-time { color: var(--text2); }
.revision-item-wc { color: var(--text3); font-size: 11px; margin-top: 2px; }
/* ── Settings ─────────────────────────────────────────────────────────────── */
.settings-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
.settings-dialog { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; width: 520px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; }
.settings-header { display: flex; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.settings-title { font-size: 16px; font-weight: 600; flex: 1; }
.settings-close { width: 24px; height: 24px; font-size: 16px; border-radius: 4px; color: var(--text3); }
.settings-close:hover { color: var(--text); background: var(--bg3); }
.settings-body { display: flex; flex: 1; overflow: hidden; }
.settings-nav { width: 130px; border-right: 1px solid var(--border); padding: 12px 0; flex-shrink: 0; }
.settings-nav-item { width: 100%; padding: 8px 16px; text-align: left; font-size: 13px; color: var(--text2); }
.settings-nav-item:hover { color: var(--text); background: var(--bg3); }
.settings-nav-item.active { color: var(--accent); background: var(--bg3); }
.settings-content { flex: 1; overflow-y: auto; padding: 20px; }
.settings-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text3); margin-bottom: 14px; }
.settings-field { margin-bottom: 14px; }
.settings-label { font-size: 12px; color: var(--text2); display: block; margin-bottom: 5px; }
.settings-field input, .settings-field select { width: 100%; }
.settings-hint { font-size: 11px; color: var(--text3); margin-top: 4px; line-height: 1.5; }
.settings-field-row { display: flex; gap: 8px; align-items: flex-end; }
.settings-field-row input { flex: 1; }
.settings-pick-btn { height: 29px; padding: 0 10px; border-radius: 4px; background: var(--bg3); border: 1px solid var(--border); font-size: 12px; color: var(--text2); flex-shrink: 0; }
.settings-pick-btn:hover { color: var(--text); }
.btn-primary { flex: 1; height: 30px; border-radius: 4px; background: var(--accent); color: #fff; font-weight: 600; font-size: 12px; }
.btn-primary:hover { opacity: 0.85; }
.btn-secondary { height: 30px; padding: 0 12px; border-radius: 4px; background: var(--bg3); color: var(--text2); font-size: 12px; border: 1px solid var(--border); }
.btn-secondary:hover { color: var(--text); }
/* Focus mode */
.focus-exit { position: absolute; top: 16px; right: 20px; z-index: 50; font-size: 18px; color: var(--text3); width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; }
.focus-exit:hover { color: var(--text); background: var(--bg3); }
/* Inline rename */
.inline-edit { background: none; border: none; padding: 0; font: inherit; color: inherit; width: 100%; outline: none; border-bottom: 1px solid var(--accent); }
/* Scrollbars */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text3); }

34
src/renderer/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
import type { PoemFile, PoemMeta, RevisionMeta, CollectionConfig } from '../main/fileSystem'
import type { PoemAnalysis } from '../main/staticAnalysis'
import type { GlobalConfig } from '../main/globalConfig'
import type { ChatPayload } from '../main/aiService'
declare global {
interface Window {
api: {
listPoems(): Promise<PoemFile[]>
readPoem(path: string): Promise<string>
writePoem(path: string, content: string): Promise<void>
createPoem(name: string): Promise<{ id: string; path: string }>
renamePoem(oldPath: string, newName: string): Promise<string>
deletePoem(path: string): Promise<void>
getPoemMeta(id: string): Promise<PoemMeta>
setPoemMeta(id: string, meta: PoemMeta): Promise<void>
saveOrder(order: string[]): Promise<void>
getCollectionConfig(): Promise<CollectionConfig>
readSession(): Promise<Record<string, unknown>>
writeSession(data: Record<string, unknown>): Promise<void>
saveRevision(path: string, content: string): Promise<void>
listRevisions(path: string): Promise<RevisionMeta[]>
loadRevision(path: string, id: string): Promise<string>
analyzePoem(content: string): Promise<PoemAnalysis>
readConfig(): Promise<GlobalConfig>
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
pickFolder(): Promise<string | null>
isAIEnabled(): Promise<boolean>
streamChatMessage(payload: ChatPayload, onChunk: (chunk: string) => void): Promise<void>
showPoemContextMenu(poemId: string): Promise<string | null>
onMenuAction(handler: (action: string) => void): () => void
}
}
}

View File

@@ -0,0 +1,49 @@
export interface PoemMeta {
title?: string
form?: string
tags?: string[]
notes?: string
}
export interface PoemFile {
id: string
path: string
lineCount: number
wordCount: number
meta: PoemMeta
}
export interface RevisionMeta {
id: string
timestamp: number
lineCount: number
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
export interface LineMetrics {
lineNumber: number
text: string
syllableCount: number
charCount: number
endWord: string
rhymeLetter: string
hasAlliteration: boolean
}
export interface PoemAnalysis {
lines: LineMetrics[]
lineCount: number
wordCount: number
uniqueWordRatio: number
rhymeScheme: string
detectedForm: string | null
avgSyllablesPerLine: number
alliterationCount: number
longestLine: number
shortestLine: number
}

1
tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{ "files": [], "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] }

5
tsconfig.node.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": { "composite": true, "types": ["electron-vite/node"] }
}

10
tsconfig.web.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": ["src/renderer/**/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": { "@renderer/*": ["src/renderer/*"] },
"types": ["vite/client"]
}
}