Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,45 @@ If you're using the Nix development environment (via `nix develop` or direnv), u
- IMPORTANT: Always try to extract testable logic that can be independent of Obsidian plugins to separate classes or functions and write unit tests for it.
- IMPORTANT: Do not write useless tests just to increase coverage, make them actually useful for catching issues in the code.

## Architecture

The codebase is organized into modular components:

```
src/
main.ts # Plugin entry point, orchestrates components
types.ts # All TypeScript interfaces and types
colorUtils.ts # Color manipulation utilities

parser/ # Parsing logic (Obsidian-independent)
TranscriptParser.ts # Orchestrates line parsing
FrontmatterParser.ts # Parses speech-bubbles frontmatter config
TimestampParser.ts # Parses [HH:MM] timestamps
DateSeparatorParser.ts # Parses --- date --- separators

config/ # Configuration resolution
ConfigResolver.ts # Merges global settings with frontmatter
SpeakerResolver.ts # Resolves speaker colors, sides, icons

renderer/ # DOM rendering
BubbleRenderer.ts # Creates speech bubble elements
DateSeparatorRenderer.ts # Creates date separator elements

settings/ # Plugin settings UI
SettingsTab.ts # Obsidian settings tab

__tests__/ # Jest tests mirroring src structure
colorUtils.test.ts
parser/
config/
```

Key design principles:

- **Separation of concerns**: Parsing, configuration, and rendering are isolated
- **Testability**: Parser and config modules are Obsidian-independent and fully unit-tested
- **Type safety**: All interfaces defined in `types.ts`

## Typescript & Testing

- Strict null checks required (strictNullChecks: true)
Expand Down Expand Up @@ -86,7 +125,7 @@ If you're using the Nix development environment (via `nix develop` or direnv), u
- All CSS classes should have the prefix `speech-bubbles-`.
- All user data attributes should start with `data-speech-bubbles-`
- Do not overwrite Obsidian core styling, always use custom classes or data attributes.
- **Avoid inline styles**: Never assign styles via JavaScript (`element.style.x = y`) or inline HTML style attributes. Move all styles to CSS so themes and snippets can adapt them.
- **Avoid inline styles**: Never assign styles via JavaScript (`element.style.x = y`) or inline HTML style attributes. Move all styles to CSS so themes and snippets can adapt them. Exception: CSS custom properties (`element.style.setProperty('--var', value)`) are acceptable for dynamic values like colors.

## Obsidian API Best Practices

Expand Down
121 changes: 119 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ An Obsidian plugin that renders transcript notes as message app style speech bub
- Different colors for different speakers
- Right-aligned bubbles for the vault owner (configurable)
- Support for multiple speaker aliases
- **Per-speaker custom colors and icons** via frontmatter
- **Inline timestamps** for messages
- **Date separators** between conversation days
- **Speaker groups/sides** for roleplay and D&D scenarios
- **Customizable appearance** (bubble width, radius, compact mode)

## Usage

Expand All @@ -22,15 +27,15 @@ An Obsidian plugin that renders transcript notes as message app style speech bub

2. Add the `transcript` tag to the note frontmatter to enable bubbles in Reading view.

```
```yaml
---
tags: [transcript]
---
```

3. Switch to Reading view to see the bubbles.

### Example
### Basic example

```markdown
[[John Smith]]: Hello!
Expand All @@ -39,12 +44,124 @@ An Obsidian plugin that renders transcript notes as message app style speech bub
[[me]]: I'm doing great, thanks for asking!
```

### Timestamps

Add timestamps to messages using `[HH:MM]` or `[HH:MM:SS]` format after the speaker name:

```markdown
[[John]] [14:32]: Hello!
[[me]] [14:33]: Hi there!
[[John]] [14:35]: How's it going?
```

### Date separators

Add visual date separators between conversation days:

```
--- 2024-01-15 ---
[[John]]: Hello!
[[me]]: Hi!

--- 2024-01-16 ---
[[John]]: Following up on yesterday...
```

Supported formats:

- `--- YYYY-MM-DD ---` (e.g., `--- 2024-01-15 ---`)
- `--- Month Day, Year ---` (e.g., `--- January 15, 2024 ---`)

### Per-speaker customization

Customize individual speakers with colors and icons via frontmatter:

```yaml
---
tags: [transcript]
speech-bubbles:
speakers:
Gandalf: "#9CA3AF" # Simple color format
Frodo:
color: "#34D399" # Object format with color
icon: "🧙" # Emoji icon
Sauron:
color: "#EF4444"
icon: "[[avatars/eye.png]]" # Vault image as icon
---
[[Gandalf]]: You shall not pass!
[[Frodo]]: But I must destroy the ring...
[[Sauron]]: I see you...
```

### Speaker groups/sides

For D&D, debates, or roleplay scenarios, assign speakers to left or right sides regardless of the owner setting:

```yaml
---
tags: [transcript]
speech-bubbles:
sides:
left: ["Gandalf", "Frodo", "Aragorn"] # Party members
right: ["Sauron", "Saruman"] # Villains
---
[[Gandalf]]: The fellowship must continue.
[[Sauron]]: Your quest is futile.
[[Frodo]]: We will not give up.
```

## Settings

### Identity

- **Your name**: The name used in transcripts to identify you. Messages from this person will appear on the right side with blue bubbles (default: "me").
- **Aliases**: Other names that should also be treated as you (comma-separated).

### Appearance

- **Maximum bubble width**: Maximum width of speech bubbles as a percentage (10-100%, default: 75%).
- **Bubble corner radius**: Corner radius of speech bubbles in pixels (0-30px, default: 18px).
- **Show speaker names**: Display the speaker name above each bubble (default: on).
- **Compact mode**: Use smaller spacing and font sizes for a more compact layout.
- **Your bubble color**: Custom hex color for your speech bubbles (leave empty for default indigo).

### Debug

- **Enable debug logging**: Logs toggle and render details to the developer console for troubleshooting.

## Complete example

```markdown
---
tags: [transcript]
speech-bubbles:
speakers:
DM:
color: "#8B5CF6"
icon: "🎲"
Gandalf:
color: "#9CA3AF"
icon: "🧙"
Frodo:
color: "#34D399"
sides:
left: ["DM"]
right: ["Gandalf", "Frodo"]
---

--- January 15, 2024 ---

[[DM]] [19:00]: Welcome to Middle-earth! You find yourselves at the gates of Moria.
[[Gandalf]] [19:01]: I remember this place... the doors are hidden.
[[Frodo]] [19:02]: What's the password?
[[DM]] [19:03]: Roll an Intelligence check.

--- January 16, 2024 ---

[[DM]] [19:00]: Last session, you entered Moria...
```

## Installation

### From Obsidian Community Plugins
Expand Down
37 changes: 36 additions & 1 deletion src/__tests__/colorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { darkenColor, isOwner, SPEAKER_COLORS, OWNER_COLOR } from "../colorUtils";
import { darkenColor, isOwner, lightenColor, hexToSpeakerColor, SPEAKER_COLORS, OWNER_COLOR } from "../colorUtils";

describe("darkenColor", () => {
it("should darken a light gray color", () => {
Expand Down Expand Up @@ -33,6 +33,41 @@ describe("darkenColor", () => {
});
});

describe("lightenColor", () => {
it("should lighten a dark color", () => {
const result = lightenColor("#000000", 0.5);
expect(result).toBe("#808080");
});

it("should lighten a color by a small amount", () => {
const result = lightenColor("#6366F1", 0.2);
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
});

it("should return the same color for invalid hex", () => {
const result = lightenColor("invalid", 0.5);
expect(result).toBe("invalid");
});

it("should not exceed white", () => {
const result = lightenColor("#FFFFFF", 0.5);
expect(result).toBe("#ffffff");
});
});

describe("hexToSpeakerColor", () => {
it("should create a gradient from a hex color", () => {
const result = hexToSpeakerColor("#6366F1");
expect(result.start).toMatch(/^#[0-9a-f]{6}$/i);
expect(result.end).toBe("#6366F1");
});

it("should have a lighter start than end", () => {
const result = hexToSpeakerColor("#000000");
expect(result.start).not.toBe(result.end);
});
});

describe("isOwner", () => {
it("should return true when name matches owner name", () => {
expect(isOwner("John", "John", [])).toBe(true);
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/config/ConfigResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { resolveConfig } from "../../config/ConfigResolver";
import { DEFAULT_SETTINGS } from "../../types";

describe("resolveConfig", () => {
it("should return config with default settings when no frontmatter", () => {
const result = resolveConfig(DEFAULT_SETTINGS, null);
expect(result.settings).toEqual(DEFAULT_SETTINGS);
expect(result.speakerConfigs.size).toBe(0);
expect(result.sides).toBeNull();
});

it("should merge frontmatter speaker configs", () => {
const result = resolveConfig(DEFAULT_SETTINGS, {
"speech-bubbles": {
speakers: {
Gandalf: "#9CA3AF",
},
},
});
expect(result.speakerConfigs.get("gandalf")?.color).toBe("#9CA3AF");
});

it("should merge frontmatter sides config", () => {
const result = resolveConfig(DEFAULT_SETTINGS, {
"speech-bubbles": {
sides: {
left: ["Gandalf"],
right: ["Sauron"],
},
},
});
expect(result.sides?.left.has("gandalf")).toBe(true);
expect(result.sides?.right.has("sauron")).toBe(true);
});

it("should use provided settings", () => {
const customSettings = {
...DEFAULT_SETTINGS,
ownerName: "Frodo",
compactMode: true,
};
const result = resolveConfig(customSettings, null);
expect(result.settings.ownerName).toBe("Frodo");
expect(result.settings.compactMode).toBe(true);
});
});
Loading