ccexport 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +7 -0
- data/CHANGELOG.md +23 -0
- data/CLAUDE.md +156 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +36 -0
- data/LICENSE +21 -0
- data/README.md +534 -0
- data/Rakefile +8 -0
- data/bin/ccexport +95 -0
- data/ccexport.gemspec +49 -0
- data/exe/ccexport +156 -0
- data/lib/assets/prism-bash.js +1 -0
- data/lib/assets/prism-json.js +1 -0
- data/lib/assets/prism-markdown.js +1 -0
- data/lib/assets/prism-python.js +1 -0
- data/lib/assets/prism-typescript.js +1 -0
- data/lib/assets/prism-yaml.js +1 -0
- data/lib/assets/prism.css +1 -0
- data/lib/assets/prism.js +16 -0
- data/lib/ccexport/version.rb +5 -0
- data/lib/ccexport.rb +9 -0
- data/lib/claude_conversation_exporter.rb +1177 -0
- data/lib/markdown_code_block_parser.rb +268 -0
- data/lib/secret_detector.rb +89 -0
- data/lib/templates/default.html.erb +241 -0
- data/lib/templates/github.html.erb +369 -0
- data/lib/templates/solarized.html.erb +467 -0
- metadata +101 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
class MarkdownCodeBlockParser
|
|
2
|
+
attr_reader :markdown, :matches, :pairs, :blocks
|
|
3
|
+
|
|
4
|
+
def initialize(markdown)
|
|
5
|
+
@markdown = markdown
|
|
6
|
+
@matches = []
|
|
7
|
+
@pairs = []
|
|
8
|
+
@blocks = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def parse
|
|
12
|
+
find_all_code_blocks
|
|
13
|
+
pair_code_blocks
|
|
14
|
+
build_block_structures
|
|
15
|
+
detect_nesting
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def find_all_code_blocks
|
|
22
|
+
# Find all backtick sequences with their positions
|
|
23
|
+
pattern = /(^|\n)(```+)([a-zA-Z0-9]*)?/
|
|
24
|
+
|
|
25
|
+
@markdown.scan(pattern) do
|
|
26
|
+
match = Regexp.last_match
|
|
27
|
+
pos = match.begin(0)
|
|
28
|
+
backticks = match[2]
|
|
29
|
+
lang = match[3] unless match[3].nil? || match[3].empty?
|
|
30
|
+
|
|
31
|
+
@matches << {
|
|
32
|
+
pos: pos,
|
|
33
|
+
ticks: backticks.length,
|
|
34
|
+
lang: lang,
|
|
35
|
+
full_match: match[0],
|
|
36
|
+
index: @matches.length
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pair_code_blocks
|
|
42
|
+
stack = []
|
|
43
|
+
|
|
44
|
+
@matches.each_with_index do |match, i|
|
|
45
|
+
# Heuristic 1: If it has a language specifier, it's definitely opening
|
|
46
|
+
if match[:lang]
|
|
47
|
+
stack.push(i)
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Heuristic 2: Check if this could be a closing block
|
|
52
|
+
if !stack.empty? && i > 0
|
|
53
|
+
last_opener_idx = stack.last
|
|
54
|
+
last_opener = @matches[last_opener_idx]
|
|
55
|
+
|
|
56
|
+
# Get content between them
|
|
57
|
+
content_start = last_opener[:pos] + last_opener[:full_match].length
|
|
58
|
+
content_end = match[:pos]
|
|
59
|
+
content_between = @markdown[content_start...content_end].strip
|
|
60
|
+
|
|
61
|
+
# If there's substantial content and tick counts match, likely a pair
|
|
62
|
+
if content_between.length > 10 && last_opener[:ticks] == match[:ticks]
|
|
63
|
+
@pairs << [last_opener_idx, i]
|
|
64
|
+
stack.pop
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Heuristic 3: Check if this looks like an opening based on what follows
|
|
70
|
+
if i < @matches.length - 1
|
|
71
|
+
next_match = @matches[i + 1]
|
|
72
|
+
content_after_end = next_match ? next_match[:pos] : @markdown.length
|
|
73
|
+
content_after = @markdown[(match[:pos] + match[:full_match].length)...content_after_end].strip
|
|
74
|
+
|
|
75
|
+
# If there's content after that looks like code, this might be opening
|
|
76
|
+
if !content_after.empty? && !content_after.start_with?('#')
|
|
77
|
+
stack.push(i)
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Default: assume it's closing if we have something to close
|
|
83
|
+
if !stack.empty?
|
|
84
|
+
# Try to match with the most recent opener with same tick count
|
|
85
|
+
matched = false
|
|
86
|
+
stack.reverse_each.with_index do |stack_idx, j|
|
|
87
|
+
if @matches[stack_idx][:ticks] == match[:ticks]
|
|
88
|
+
@pairs << [stack_idx, i]
|
|
89
|
+
stack.delete_at(stack.length - 1 - j)
|
|
90
|
+
matched = true
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# No matching tick count, close the most recent
|
|
96
|
+
if !matched
|
|
97
|
+
@pairs << [stack.last, i]
|
|
98
|
+
stack.pop
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_block_structures
|
|
105
|
+
@pairs.each do |start_idx, end_idx|
|
|
106
|
+
start_match = @matches[start_idx]
|
|
107
|
+
end_match = @matches[end_idx]
|
|
108
|
+
|
|
109
|
+
block = {
|
|
110
|
+
start_pos: start_match[:pos],
|
|
111
|
+
end_pos: end_match[:pos] + end_match[:full_match].length,
|
|
112
|
+
language: start_match[:lang],
|
|
113
|
+
tick_count: start_match[:ticks],
|
|
114
|
+
content_start: start_match[:pos] + start_match[:full_match].length,
|
|
115
|
+
content_end: end_match[:pos],
|
|
116
|
+
start_match_idx: start_idx,
|
|
117
|
+
end_match_idx: end_idx
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Extract content
|
|
121
|
+
block[:content] = @markdown[block[:content_start]...block[:content_end]].strip
|
|
122
|
+
|
|
123
|
+
@blocks << block
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def detect_nesting
|
|
128
|
+
@blocks.each_with_index do |block, i|
|
|
129
|
+
block[:nested_blocks] = []
|
|
130
|
+
block[:depth] = 0
|
|
131
|
+
|
|
132
|
+
@blocks.each_with_index do |other, j|
|
|
133
|
+
next if i == j
|
|
134
|
+
|
|
135
|
+
# Check if other is nested inside block
|
|
136
|
+
if block[:start_pos] < other[:start_pos] && other[:end_pos] < block[:end_pos]
|
|
137
|
+
block[:nested_blocks] << j
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Check if block is nested inside other
|
|
141
|
+
if other[:start_pos] < block[:start_pos] && block[:end_pos] < other[:end_pos]
|
|
142
|
+
block[:depth] += 1
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def escape_nested_blocks
|
|
149
|
+
# Calculate maximum nesting depth
|
|
150
|
+
max_depth = @blocks.map { |b| b[:depth] }.max || 0
|
|
151
|
+
|
|
152
|
+
# Create a list of modifications to apply
|
|
153
|
+
modifications = []
|
|
154
|
+
|
|
155
|
+
@blocks.each do |block|
|
|
156
|
+
# Use 3 + (max_depth - depth) backticks
|
|
157
|
+
# So the outermost uses the most backticks
|
|
158
|
+
new_tick_count = 3 + (max_depth - block[:depth])
|
|
159
|
+
old_tick_count = block[:tick_count]
|
|
160
|
+
|
|
161
|
+
if new_tick_count != old_tick_count
|
|
162
|
+
# Record the modification for the opening backticks
|
|
163
|
+
start_match = @matches[block[:start_match_idx]]
|
|
164
|
+
modifications << {
|
|
165
|
+
pos: start_match[:pos],
|
|
166
|
+
old_text: start_match[:full_match],
|
|
167
|
+
new_text: start_match[:full_match].gsub('`' * old_tick_count, '`' * new_tick_count)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Record the modification for the closing backticks
|
|
171
|
+
end_match = @matches[block[:end_match_idx]]
|
|
172
|
+
modifications << {
|
|
173
|
+
pos: end_match[:pos],
|
|
174
|
+
old_text: end_match[:full_match],
|
|
175
|
+
new_text: end_match[:full_match].gsub('`' * old_tick_count, '`' * new_tick_count)
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Sort modifications by position (reverse order to maintain positions)
|
|
181
|
+
modifications.sort_by! { |m| -m[:pos] }
|
|
182
|
+
|
|
183
|
+
# Apply modifications
|
|
184
|
+
result = @markdown.dup
|
|
185
|
+
modifications.each do |mod|
|
|
186
|
+
result[mod[:pos], mod[:old_text].length] = mod[:new_text]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
public
|
|
193
|
+
|
|
194
|
+
def display_analysis
|
|
195
|
+
puts "Detected #{@matches.length} code block delimiters"
|
|
196
|
+
puts "Formed #{@pairs.length} pairs"
|
|
197
|
+
puts "\nCode blocks found:"
|
|
198
|
+
|
|
199
|
+
@blocks.each_with_index do |block, i|
|
|
200
|
+
puts "\nBlock #{i}:"
|
|
201
|
+
puts " Language: #{block[:language] || 'none'}"
|
|
202
|
+
puts " Position: #{block[:start_pos]}-#{block[:end_pos]}"
|
|
203
|
+
puts " Tick count: #{block[:tick_count]}"
|
|
204
|
+
puts " Nesting depth: #{block[:depth]}"
|
|
205
|
+
puts " Contains blocks: #{block[:nested_blocks].join(', ')}" unless block[:nested_blocks].empty?
|
|
206
|
+
|
|
207
|
+
content_preview = block[:content][0..80].gsub(/\n/, ' ')
|
|
208
|
+
puts " Content preview: #{content_preview}..."
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Example usage
|
|
214
|
+
if __FILE__ == $0
|
|
215
|
+
example = <<~MARKDOWN
|
|
216
|
+
Perfect! I've successfully implemented the tool use formatting with collapsed sections as requested. Here's what I implemented:
|
|
217
|
+
|
|
218
|
+
**Key Features:**
|
|
219
|
+
|
|
220
|
+
1. **Tool Use Detection**: The code now detects `tool_use` entries in assistant message content arrays
|
|
221
|
+
2. **Tool Pairing**: When a `tool_use` is found, it looks for the immediately following `tool_result` and pairs them together
|
|
222
|
+
3. **Collapsed Sections**: Uses GitHub's `<details>/<summary>` tags from the cheatsheet
|
|
223
|
+
4. **Formatted Output**: Each tool use gets:
|
|
224
|
+
- A "## Tool Use" header
|
|
225
|
+
- A collapsed section with the tool name as the summary
|
|
226
|
+
- Input parameters displayed in a JSON code block
|
|
227
|
+
- A nested collapsed section for tool results (if present)
|
|
228
|
+
|
|
229
|
+
**Example Output:**
|
|
230
|
+
```markdown
|
|
231
|
+
## Tool Use
|
|
232
|
+
<details>
|
|
233
|
+
<summary>Read</summary>
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"file_path": "/path/to/file.txt"
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
<details>
|
|
242
|
+
<summary>Tool Result</summary>
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
File contents here
|
|
246
|
+
```
|
|
247
|
+
</details>
|
|
248
|
+
</details>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Test Coverage:**
|
|
252
|
+
- All 20 tests pass
|
|
253
|
+
- Tests verify tool use formatting with and without results
|
|
254
|
+
- Tests ensure text content is still extracted cleanly
|
|
255
|
+
- Tests confirm non-text content is preserved appropriately
|
|
256
|
+
|
|
257
|
+
The tool use sections are now cleanly formatted with collapsible sections that show the tool name prominently and hide the technical details until clicked. Ready to continue customizing the formatting further!
|
|
258
|
+
MARKDOWN
|
|
259
|
+
|
|
260
|
+
parser = MarkdownCodeBlockParser.new(example)
|
|
261
|
+
parser.parse
|
|
262
|
+
parser.display_analysis
|
|
263
|
+
|
|
264
|
+
puts "\n" + "="*50
|
|
265
|
+
puts "Escaped version:"
|
|
266
|
+
puts "="*50
|
|
267
|
+
puts parser.escape_nested_blocks
|
|
268
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'tempfile'
|
|
3
|
+
|
|
4
|
+
class TruffleHogSecretDetector
|
|
5
|
+
class Finding
|
|
6
|
+
attr_reader :type, :pattern_name, :match, :detector_name, :verified, :line
|
|
7
|
+
|
|
8
|
+
def initialize(detector_name, match, verified, line = nil)
|
|
9
|
+
@type = 'secret'
|
|
10
|
+
@pattern_name = detector_name
|
|
11
|
+
@detector_name = detector_name
|
|
12
|
+
@match = match
|
|
13
|
+
@verified = verified
|
|
14
|
+
@line = line
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# For backward compatibility with old SecretDetector interface
|
|
18
|
+
alias_method :confidence, :verified
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
# Check if trufflehog is available
|
|
23
|
+
unless system('which trufflehog > /dev/null 2>&1')
|
|
24
|
+
raise "TruffleHog not found. Install it with: brew install trufflehog"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def scan(text)
|
|
29
|
+
return [] if text.nil? || text.empty?
|
|
30
|
+
|
|
31
|
+
findings = []
|
|
32
|
+
|
|
33
|
+
# Create a temporary file with the text
|
|
34
|
+
Tempfile.create(['trufflehog_scan', '.txt']) do |temp_file|
|
|
35
|
+
temp_file.write(text)
|
|
36
|
+
temp_file.flush
|
|
37
|
+
|
|
38
|
+
# Run trufflehog on the temporary file
|
|
39
|
+
output = `trufflehog filesystem --json --no-verification #{temp_file.path} 2>/dev/null`
|
|
40
|
+
|
|
41
|
+
# Parse each line of JSON output
|
|
42
|
+
output.each_line do |line|
|
|
43
|
+
line = line.strip
|
|
44
|
+
next if line.empty?
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
result = JSON.parse(line)
|
|
48
|
+
# Skip non-detection results (like log messages)
|
|
49
|
+
next unless result['DetectorName'] && result['Raw']
|
|
50
|
+
|
|
51
|
+
findings << Finding.new(
|
|
52
|
+
result['DetectorName'],
|
|
53
|
+
result['Raw'],
|
|
54
|
+
result['Verified'] || false,
|
|
55
|
+
result.dig('SourceMetadata', 'Data', 'Filesystem', 'line')
|
|
56
|
+
)
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
# Skip malformed JSON lines
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
findings
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def redact(text, replacement = '[REDACTED]')
|
|
68
|
+
return text if text.nil? || text.empty?
|
|
69
|
+
|
|
70
|
+
findings = scan(text)
|
|
71
|
+
return text if findings.empty?
|
|
72
|
+
|
|
73
|
+
redacted_text = text.dup
|
|
74
|
+
|
|
75
|
+
# Sort findings by match length (longest first) to avoid partial replacements
|
|
76
|
+
findings.sort_by { |f| -f.match.length }.each do |finding|
|
|
77
|
+
redacted_text = redacted_text.gsub(finding.match, replacement)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
redacted_text
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def has_secrets?(text)
|
|
84
|
+
!scan(text).empty?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Alias for backward compatibility
|
|
89
|
+
SecretDetector = TruffleHogSecretDetector
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><%= title %></title>
|
|
7
|
+
<%= include_prism %>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--dark-brown: #592d1c;
|
|
11
|
+
--medium-brown: #cc7c5e;
|
|
12
|
+
--light-brown: #f3dfd8;
|
|
13
|
+
--lightest-brown: #f9efec;
|
|
14
|
+
--light-blue: #a6d2e3;
|
|
15
|
+
--dark-blue: #1c4859;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
|
20
|
+
line-height: 1.6;
|
|
21
|
+
color: var(--dark-brown);
|
|
22
|
+
background-color: var(--lightest-brown);
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 20px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.conversation-content {
|
|
28
|
+
max-width: 900px;
|
|
29
|
+
margin: 0 auto;
|
|
30
|
+
background: white;
|
|
31
|
+
padding: 32px;
|
|
32
|
+
border-radius: 16px;
|
|
33
|
+
box-shadow: 0 4px 6px rgba(89, 45, 28, 0.1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Headers */
|
|
37
|
+
h1 {
|
|
38
|
+
color: var(--dark-blue);
|
|
39
|
+
border-bottom: 2px solid var(--light-blue);
|
|
40
|
+
padding-bottom: 12px;
|
|
41
|
+
margin-bottom: 24px;
|
|
42
|
+
border-radius: 0 0 8px 8px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
h2 {
|
|
46
|
+
color: var(--dark-brown);
|
|
47
|
+
margin-top: 32px;
|
|
48
|
+
margin-bottom: 16px;
|
|
49
|
+
border-bottom: 1px solid var(--medium-brown);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Code blocks - integrate with Prism.js */
|
|
53
|
+
pre[class*="language-"],
|
|
54
|
+
pre:not([class*="language-"]) {
|
|
55
|
+
background: var(--dark-blue) !important;
|
|
56
|
+
border-radius: 12px;
|
|
57
|
+
overflow-x: auto;
|
|
58
|
+
margin: 16px 0;
|
|
59
|
+
padding: 16px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
code[class*="language-"],
|
|
63
|
+
pre:not([class*="language-"]) > code {
|
|
64
|
+
color: var(--light-blue) !important;
|
|
65
|
+
text-shadow: none !important;
|
|
66
|
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', monospace;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Inline code (no language class) */
|
|
70
|
+
:not(pre) > code:not([class*="language-"]) {
|
|
71
|
+
background: var(--light-brown);
|
|
72
|
+
color: var(--dark-brown);
|
|
73
|
+
padding: 2px 6px;
|
|
74
|
+
border-radius: 6px;
|
|
75
|
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', monospace;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Override Prism.js colors for better contrast on dark background */
|
|
79
|
+
.token.comment,
|
|
80
|
+
.token.prolog,
|
|
81
|
+
.token.doctype,
|
|
82
|
+
.token.cdata { color: #a0a0a0 !important; }
|
|
83
|
+
|
|
84
|
+
.token.punctuation { color: #f8f8f2 !important; }
|
|
85
|
+
|
|
86
|
+
.token.property,
|
|
87
|
+
.token.tag,
|
|
88
|
+
.token.boolean,
|
|
89
|
+
.token.number,
|
|
90
|
+
.token.constant,
|
|
91
|
+
.token.symbol,
|
|
92
|
+
.token.deleted { color: #f92672 !important; }
|
|
93
|
+
|
|
94
|
+
.token.selector,
|
|
95
|
+
.token.attr-name,
|
|
96
|
+
.token.string,
|
|
97
|
+
.token.char,
|
|
98
|
+
.token.builtin,
|
|
99
|
+
.token.inserted { color: #a6e22e !important; }
|
|
100
|
+
|
|
101
|
+
.token.operator,
|
|
102
|
+
.token.entity,
|
|
103
|
+
.token.url { color: #f8f8f2 !important; }
|
|
104
|
+
|
|
105
|
+
.token.atrule,
|
|
106
|
+
.token.attr-value,
|
|
107
|
+
.token.keyword { color: #66d9ef !important; }
|
|
108
|
+
|
|
109
|
+
.token.function,
|
|
110
|
+
.token.class-name { color: #e6db74 !important; }
|
|
111
|
+
|
|
112
|
+
.token.regex,
|
|
113
|
+
.token.important,
|
|
114
|
+
.token.variable { color: #fd971f !important; }
|
|
115
|
+
|
|
116
|
+
/* Blockquotes (thinking content) */
|
|
117
|
+
blockquote {
|
|
118
|
+
border-left: 4px solid var(--light-blue);
|
|
119
|
+
margin: 16px 0;
|
|
120
|
+
padding: 12px 20px;
|
|
121
|
+
background: var(--lightest-brown);
|
|
122
|
+
border-radius: 0 12px 12px 0;
|
|
123
|
+
font-style: italic;
|
|
124
|
+
color: var(--dark-blue);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Details/Summary (collapsible sections) */
|
|
128
|
+
details {
|
|
129
|
+
margin: 16px 0;
|
|
130
|
+
border: 1px solid var(--light-brown);
|
|
131
|
+
border-radius: 12px;
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
summary {
|
|
136
|
+
background: var(--light-brown);
|
|
137
|
+
color: black;
|
|
138
|
+
padding: 12px 16px;
|
|
139
|
+
cursor: pointer;
|
|
140
|
+
font-weight: 600;
|
|
141
|
+
border-radius: 12px 12px 0 0;
|
|
142
|
+
transition: background-color 0.2s ease;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
summary:hover {
|
|
146
|
+
background: var(--medium-brown);
|
|
147
|
+
color: white;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
details[open] summary {
|
|
151
|
+
border-radius: 12px 12px 0 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
details > *:not(summary) {
|
|
155
|
+
padding: 16px;
|
|
156
|
+
margin: 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
details > pre {
|
|
160
|
+
margin: 0;
|
|
161
|
+
border-radius: 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Tables */
|
|
165
|
+
table {
|
|
166
|
+
border-collapse: collapse;
|
|
167
|
+
width: 100%;
|
|
168
|
+
margin: 16px 0;
|
|
169
|
+
border-radius: 12px;
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
box-shadow: 0 2px 4px rgba(89, 45, 28, 0.1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
th, td {
|
|
175
|
+
padding: 12px 16px;
|
|
176
|
+
text-align: left;
|
|
177
|
+
border-bottom: 1px solid var(--light-brown);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
th {
|
|
181
|
+
background: var(--medium-brown);
|
|
182
|
+
color: white;
|
|
183
|
+
font-weight: 600;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
tr:nth-child(even) {
|
|
187
|
+
background: var(--lightest-brown);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* Links */
|
|
191
|
+
a {
|
|
192
|
+
color: var(--dark-blue);
|
|
193
|
+
text-decoration: none;
|
|
194
|
+
border-bottom: 1px solid var(--light-blue);
|
|
195
|
+
transition: border-color 0.2s ease;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
a:hover {
|
|
199
|
+
border-bottom-color: var(--dark-blue);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Horizontal rules */
|
|
203
|
+
hr {
|
|
204
|
+
border: none;
|
|
205
|
+
height: 2px;
|
|
206
|
+
background: linear-gradient(to right, var(--light-blue), var(--medium-brown), var(--light-blue));
|
|
207
|
+
margin: 32px 0;
|
|
208
|
+
border-radius: 2px;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Lists */
|
|
212
|
+
ul, ol {
|
|
213
|
+
padding-left: 24px;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
li {
|
|
217
|
+
margin: 8px 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* Strong/Bold text */
|
|
221
|
+
strong, b {
|
|
222
|
+
color: var(--dark-brown);
|
|
223
|
+
font-weight: 600;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
p {
|
|
227
|
+
overflow: scroll;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* Session metadata styling */
|
|
231
|
+
p strong {
|
|
232
|
+
color: var(--dark-blue);
|
|
233
|
+
}
|
|
234
|
+
</style>
|
|
235
|
+
</head>
|
|
236
|
+
<body>
|
|
237
|
+
<main class="conversation-content">
|
|
238
|
+
<%= content %>
|
|
239
|
+
</main>
|
|
240
|
+
</body>
|
|
241
|
+
</html>
|