She's submitting work that gets run through an AI detector and wants to pre-check she won't be wrongly flagged. Petal should not answer that with a detector of its own: they misfire badly on non-native English (Stanford 2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native writers), so a percentage aimed at an ESL writer is worse than nothing — it either scares her off her own voice or gives false comfort. So the artifact is provenance, not a verdict. Petal already snapshots every ~3 minutes; this turns that history into a standalone printable report: session breakdown, word-count growth, span, active time. No score is emitted anywhere. Two schema additions back it. preserve_history opts a document out of the 40-snapshot prune cap — right for recovery, wrong for provenance, where you want the whole span including the oldest rows. content_hash/prev_hash chain each snapshot to the one before it, so a history edited or thinned after the fact fails verification. Pruning legitimately severs links, so a link break reports as "gaps" unless preserve_history is on; only a hash that fails against its own contents is unconditionally "broken". The chart's x axis is snapshot order, not wall-clock, and that is the load -bearing decision. On a linear time axis an essay written in three sittings across three days renders as three vertical cliffs separated by empty space — visually identical to text pasted in three chunks, i.e. the report would have argued the opposite of the truth. Breaks are compressed into explicitly labelled gutters instead. TestChartGivesWidthToWriting pins it. The report volunteers its largest single word-count jump and states its own limits: it cannot show who was at the keyboard, or whether typed text was composed or copied in. Overclaiming would be self-defeating — a reader who catches it overstating discounts all of it. HTML rather than server-rendered PDF, as with the other exports: a CJK-safe PDF needs an embedded Unicode font or a headless browser. Print styles are there so the browser's Save as PDF is the handoff path. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
388 lines
14 KiB
Go
388 lines
14 KiB
Go
package docs
|
|
|
|
// HTML rendering for the writing passport. Self-contained (no external assets)
|
|
// and styled for print, so the browser's "Save as PDF" turns it into the file a
|
|
// writer actually hands over.
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Chart geometry. The plot is wide and short on purpose: the report's question
|
|
// is "what shape did this document grow in", and a wide aspect makes a steady
|
|
// climb read as steady rather than dramatic.
|
|
const (
|
|
chartW, chartH = 760, 260
|
|
padL, padR, padT, padB = 52, 20, 18, 34
|
|
plotW, plotH = chartW - padL - padR, chartH - padT - padB
|
|
minBandW = 2.0 // so a single-snapshot session still shows
|
|
|
|
// gutterSlots is the space between sessions, in snapshot-slot widths. Wide
|
|
// enough to read as a break and to seat its duration label.
|
|
gutterSlots = 2.5
|
|
)
|
|
|
|
// Palette — the export stylesheet's tokens, reused so a passport looks like it
|
|
// came from the same application as the document it describes.
|
|
const (
|
|
rose = "#b04a6a"
|
|
roseLight = "#f6d6e0"
|
|
roseWash = "#fdeef3"
|
|
surface = "#fffafb"
|
|
)
|
|
|
|
func renderPassport(d passportData) []byte {
|
|
var b strings.Builder
|
|
|
|
fmt.Fprintf(&b, passportHead, htmlEscape(d.Doc.Title))
|
|
|
|
fmt.Fprintf(&b, `<header>
|
|
<p class="eyebrow">Writing passport</p>
|
|
<h1>%s</h1>
|
|
<p class="sub">Generated %s</p>
|
|
</header>
|
|
`, htmlEscape(d.Doc.Title), htmlEscape(formatWhen(d.GeneratedAt)))
|
|
|
|
if len(d.Versions) == 0 {
|
|
b.WriteString(`<p class="empty">This document has no saved history yet, so there is
|
|
nothing to report. History builds up automatically as you write.</p>
|
|
</body></html>`)
|
|
return []byte(b.String())
|
|
}
|
|
|
|
b.WriteString(renderStats(d))
|
|
b.WriteString(renderChart(d))
|
|
b.WriteString(renderSessions(d))
|
|
b.WriteString(renderIntegrity(d))
|
|
b.WriteString(passportLimits)
|
|
b.WriteString("</body></html>\n")
|
|
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// renderStats is the headline row — the numbers a reader wants before deciding
|
|
// whether to study the chart.
|
|
func renderStats(d passportData) string {
|
|
final := d.Versions[len(d.Versions)-1].WordCount
|
|
|
|
tiles := []struct{ value, label string }{
|
|
{fmt.Sprintf("%d", len(d.Versions)), "snapshots saved"},
|
|
{humanDuration(d.Span), "from first to last edit"},
|
|
{fmt.Sprintf("%d", len(d.Sessions)), pluralize(len(d.Sessions), "writing session", "writing sessions")},
|
|
{humanDuration(d.ActiveTime), "spent actively editing"},
|
|
{fmt.Sprintf("%d", final), "words in the final draft"},
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(`<section class="stats">`)
|
|
for _, t := range tiles {
|
|
fmt.Fprintf(&b, `<div class="tile"><span class="v">%s</span><span class="l">%s</span></div>`,
|
|
htmlEscape(t.value), htmlEscape(t.label))
|
|
}
|
|
b.WriteString("</section>\n")
|
|
return b.String()
|
|
}
|
|
|
|
// renderChart draws word count as a step line — the count is only known at
|
|
// snapshot moments, and a step says that honestly where a smooth curve would
|
|
// invent values in between. Shaded bands mark writing sessions.
|
|
//
|
|
// The x axis is snapshot order, not wall-clock time, and this is deliberate. On
|
|
// a linear time axis an essay written in three half-hour sittings across three
|
|
// days renders as three vertical cliffs separated by empty space: all the actual
|
|
// writing is crushed into one percent of the width, and the result looks exactly
|
|
// like text pasted in three chunks — the opposite of what happened. Because
|
|
// auto-snapshots are throttled to roughly one per few minutes of *active*
|
|
// editing, snapshot order is already close to proportional to time spent
|
|
// writing. So the plot gives its width to the writing and compresses the breaks,
|
|
// which are drawn as explicit labelled gaps rather than silently removed.
|
|
//
|
|
// One series, so no legend: the heading names it.
|
|
func renderChart(d passportData) string {
|
|
maxW := 0
|
|
for _, v := range d.Versions {
|
|
if v.WordCount > maxW {
|
|
maxW = v.WordCount
|
|
}
|
|
}
|
|
yTop := niceCeil(maxW)
|
|
|
|
y := func(words int) float64 {
|
|
if yTop <= 0 {
|
|
return padT + plotH
|
|
}
|
|
return padT + plotH - float64(words)/float64(yTop)*plotH
|
|
}
|
|
|
|
// Lay snapshots out in slots: one per snapshot, plus a gutter between
|
|
// sessions for the break marker.
|
|
slots := float64(len(d.Versions)) + gutterSlots*float64(len(d.Sessions)-1)
|
|
sw := plotW / slots
|
|
|
|
xs := make([]float64, len(d.Versions))
|
|
bandStart := make([]float64, len(d.Sessions))
|
|
bandEnd := make([]float64, len(d.Sessions))
|
|
|
|
cursor, vi := 0.0, 0
|
|
for si, s := range d.Sessions {
|
|
if si > 0 {
|
|
cursor += gutterSlots
|
|
}
|
|
bandStart[si] = padL + cursor*sw
|
|
for k := 0; k < s.Snapshots; k++ {
|
|
xs[vi] = padL + (cursor+0.5)*sw
|
|
cursor++
|
|
vi++
|
|
}
|
|
bandEnd[si] = padL + cursor*sw
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<section class="chart">
|
|
<h2>How the draft grew</h2>
|
|
<svg viewBox="0 0 %d %d" role="img" aria-label="Word count at each saved snapshot, grouped into writing sessions">
|
|
`, chartW, chartH)
|
|
|
|
// Session bands sit behind everything; each carries a native tooltip.
|
|
for i, s := range d.Sessions {
|
|
w := bandEnd[i] - bandStart[i]
|
|
if w < minBandW {
|
|
w = minBandW
|
|
}
|
|
fmt.Fprintf(&b, `<rect x="%.1f" y="%d" width="%.1f" height="%d" fill="%s" rx="3"><title>Session %d: %s, %s, %d snapshots</title></rect>
|
|
`, bandStart[i], padT, w, plotH, roseWash, i+1,
|
|
htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())), s.Snapshots)
|
|
}
|
|
|
|
// Recessive gridlines with y labels at 0 / half / top.
|
|
for _, gv := range []int{0, yTop / 2, yTop} {
|
|
gy := y(gv)
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>
|
|
<text x="%d" y="%.1f" class="axis" text-anchor="end">%d</text>
|
|
`, padL, gy, chartW-padR, gy, roseLight, padL-8, gy+4, gv)
|
|
}
|
|
|
|
// Break markers in the gutters, so compressed time is stated, not hidden.
|
|
for i := 1; i < len(d.Sessions); i++ {
|
|
mid := (bandEnd[i-1] + bandStart[i]) / 2
|
|
gap := d.Sessions[i].Start.Sub(d.Sessions[i-1].End)
|
|
fmt.Fprintf(&b, `<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="%s" stroke-width="1" stroke-dasharray="3 3"/>
|
|
<text x="%.1f" y="%d" class="gap" text-anchor="middle">%s</text>
|
|
`, mid, padT, mid, padT+plotH, roseLight, mid, padT+plotH+13, htmlEscape(humanDuration(gap)+" away"))
|
|
}
|
|
|
|
// Step path: hold the previous value until the next snapshot lands.
|
|
var path strings.Builder
|
|
fmt.Fprintf(&path, "M %.1f %.1f", xs[0], y(d.Versions[0].WordCount))
|
|
for i := 1; i < len(d.Versions); i++ {
|
|
fmt.Fprintf(&path, " L %.1f %.1f L %.1f %.1f",
|
|
xs[i], y(d.Versions[i-1].WordCount), xs[i], y(d.Versions[i].WordCount))
|
|
}
|
|
fmt.Fprintf(&b, `<path d="%s" fill="none" stroke="%s" stroke-width="2" stroke-linejoin="round"/>
|
|
`, path.String(), rose)
|
|
|
|
// Pre-empt the obvious question: label the largest single jump when it is a
|
|
// big share of the finished draft, rather than letting a reader find it.
|
|
if d.NoteJump && d.LargestJumpIdx < len(xs) {
|
|
jx, jy := xs[d.LargestJumpIdx], y(d.Versions[d.LargestJumpIdx].WordCount)
|
|
|
|
// Flip the label inboard near the right edge so it can't overflow, and
|
|
// push it below the point when the point sits near the top.
|
|
anchor, dx := "start", 9.0
|
|
if jx > float64(chartW)*0.6 {
|
|
anchor, dx = "end", -9.0
|
|
}
|
|
ly := jy - 10
|
|
if ly < padT+12 {
|
|
ly = jy + 18
|
|
}
|
|
|
|
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="4" fill="%s" stroke="%s" stroke-width="2"/>
|
|
<text x="%.1f" y="%.1f" class="note" text-anchor="%s">largest single addition: +%d words</text>
|
|
`, jx, jy, rose, surface, jx+dx, ly, anchor, d.LargestJump)
|
|
}
|
|
|
|
fmt.Fprintf(&b, `</svg>
|
|
<p class="caption">Each shaded band is one writing session, %s to %s. Width follows
|
|
snapshots saved, so time spent writing gets the space and breaks are compressed to
|
|
the labelled gaps.</p>
|
|
</section>
|
|
`, htmlEscape(formatDay(d.FirstAt)), htmlEscape(formatDay(d.LastAt)))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func renderSessions(d passportData) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<section>
|
|
<h2>Writing sessions</h2>
|
|
<table>
|
|
<thead><tr><th>#</th><th>Started</th><th>Length</th><th>Snapshots</th><th>Net words</th></tr></thead>
|
|
<tbody>
|
|
`)
|
|
for i, s := range d.Sessions {
|
|
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td>%d</td><td>%+d</td></tr>
|
|
`, i+1, htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())),
|
|
s.Snapshots, s.WordsAdded)
|
|
}
|
|
b.WriteString("</tbody></table>\n</section>\n")
|
|
return b.String()
|
|
}
|
|
|
|
// renderIntegrity explains the hash chain in plain language, including when it
|
|
// cannot vouch for something.
|
|
func renderIntegrity(d passportData) string {
|
|
var headline, detail string
|
|
|
|
switch d.ChainStatus {
|
|
case chainVerified:
|
|
headline = "History intact"
|
|
detail = "Every snapshot matches its own contents and links correctly to the one before it. Nothing in this history has been altered or removed since it was recorded."
|
|
case chainGaps:
|
|
headline = "History intact, with gaps"
|
|
detail = "Every snapshot matches its own contents, but some older automatic snapshots have been cleared to save space, so the record is not continuous. Turn on “keep full history” for this document to stop that happening."
|
|
case chainPartial:
|
|
headline = "Partly verifiable"
|
|
detail = fmt.Sprintf("%d snapshot(s) were recorded before this document started tracking integrity, so they cannot be checked. Everything recorded since then matches.", d.UnhashedCount)
|
|
case chainUnverifiable:
|
|
headline = "Not verifiable"
|
|
detail = "These snapshots were recorded before integrity tracking existed. The timeline above is still the record that was saved as you wrote; it simply cannot be checked for later alteration."
|
|
case chainBroken:
|
|
headline = "Integrity check failed"
|
|
detail = "At least one snapshot does not match what was recorded for it. This can mean the history was edited after the fact, or that the database was restored from a backup or copied between machines."
|
|
}
|
|
|
|
return fmt.Sprintf(`<section class="integrity %s">
|
|
<h2>%s</h2>
|
|
<p>%s</p>
|
|
</section>
|
|
`, htmlEscape(d.ChainStatus), htmlEscape(headline), htmlEscape(detail))
|
|
}
|
|
|
|
// passportLimits states plainly what the report does and does not establish.
|
|
// Overclaiming would be worse than useless: a reader who catches the report
|
|
// overstating its case discounts the whole thing.
|
|
const passportLimits = `<section class="limits">
|
|
<h2>How to read this</h2>
|
|
<p>A document written over time leaves a trail: many snapshots, uneven growth,
|
|
words added and cut and added again across separate sittings. A document that was
|
|
pasted in from elsewhere tends to arrive nearly whole, in one or two snapshots,
|
|
with little revision after.</p>
|
|
<p>What this report shows is the record Petal saved automatically while the
|
|
document was open, roughly every few minutes of active editing.</p>
|
|
<p><strong>What it does not show.</strong> It cannot prove who was at the
|
|
keyboard, and it cannot tell whether text typed into the editor was composed
|
|
there or copied from another window. It is evidence of a writing process, not a
|
|
certificate of authorship. It is most useful read alongside the drafts
|
|
themselves.</p>
|
|
</section>
|
|
`
|
|
|
|
// --- formatting helpers -----------------------------------------------------
|
|
|
|
func formatWhen(t time.Time) string { return t.Local().Format("2 Jan 2006, 3:04 PM") }
|
|
func formatDay(t time.Time) string { return t.Local().Format("2 Jan 2006") }
|
|
|
|
// humanDuration renders a span at the coarsest useful precision — a reader cares
|
|
// that a session ran "2h 40m", never that it ran 2h40m12s.
|
|
func humanDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return "under a minute"
|
|
}
|
|
days := int(d.Hours()) / 24
|
|
hours := int(d.Hours()) % 24
|
|
mins := int(d.Minutes()) % 60
|
|
|
|
switch {
|
|
case days > 0 && hours > 0:
|
|
return fmt.Sprintf("%dd %dh", days, hours)
|
|
case days > 0:
|
|
return fmt.Sprintf("%dd", days)
|
|
case hours > 0 && mins > 0:
|
|
return fmt.Sprintf("%dh %dm", hours, mins)
|
|
case hours > 0:
|
|
return fmt.Sprintf("%dh", hours)
|
|
default:
|
|
return fmt.Sprintf("%dm", mins)
|
|
}
|
|
}
|
|
|
|
func pluralize(n int, one, many string) string {
|
|
if n == 1 {
|
|
return one
|
|
}
|
|
return many
|
|
}
|
|
|
|
// niceCeil rounds a maximum up to a round number so gridlines land on values a
|
|
// reader can hold in their head.
|
|
func niceCeil(n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
mag := math.Pow(10, math.Floor(math.Log10(float64(n))))
|
|
return int(math.Ceil(float64(n)/(mag/2)) * (mag / 2))
|
|
}
|
|
|
|
// passportHead is the page shell: one %s for the title. Print rules keep the
|
|
// chart and the caveats on the page rather than letting them break across sheets.
|
|
const passportHead = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Writing passport — %s</title>
|
|
<style>
|
|
:root { color-scheme: light; }
|
|
body {
|
|
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
|
|
line-height: 1.7; color: #463a3f; background: #fffafb;
|
|
max-width: 48rem; margin: 3rem auto; padding: 0 1.5rem;
|
|
}
|
|
header { border-bottom: 2px solid #f6d6e0; padding-bottom: 1rem; margin-bottom: 2rem; }
|
|
.eyebrow { text-transform: uppercase; letter-spacing: .12em; font-size: .72rem;
|
|
color: #b04a6a; margin: 0 0 .3rem; }
|
|
h1 { font-size: 1.8rem; color: #b04a6a; margin: 0; line-height: 1.3; }
|
|
h2 { font-size: 1.05rem; color: #b04a6a; margin: 0 0 .75rem; }
|
|
.sub, .axis, .l { color: #6b5860; }
|
|
.sub { margin: .4rem 0 0; font-size: .9rem; }
|
|
section { margin: 2.25rem 0; }
|
|
|
|
.stats { display: flex; flex-wrap: wrap; gap: 1.25rem 2rem; margin: 2rem 0; }
|
|
.tile { display: flex; flex-direction: column; min-width: 7rem; }
|
|
.tile .v { font-size: 1.6rem; color: #b04a6a; line-height: 1.1; }
|
|
.tile .l { font-size: .8rem; margin-top: .15rem; }
|
|
|
|
.chart svg { width: 100%%; height: auto; }
|
|
.axis { font-size: 11px; fill: #6b5860; font-family: system-ui, sans-serif; }
|
|
.note { font-size: 11px; fill: #463a3f; font-family: system-ui, sans-serif; }
|
|
.gap { font-size: 10px; fill: #6b5860; font-family: system-ui, sans-serif; }
|
|
.caption { font-size: .8rem; color: #6b5860; margin: .5rem 0 0; }
|
|
|
|
table { border-collapse: collapse; width: 100%%; font-size: .9rem; }
|
|
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid #f3cdd9; }
|
|
th { color: #6b5860; font-weight: normal; font-size: .78rem;
|
|
text-transform: uppercase; letter-spacing: .06em; }
|
|
|
|
.integrity { background: #fff2f6; border-left: 3px solid #f3b6c8;
|
|
padding: 1rem 1.25rem; border-radius: .4rem; }
|
|
.integrity.broken { border-left-color: #c2410c; }
|
|
.integrity p { margin: 0; font-size: .92rem; }
|
|
|
|
.limits { font-size: .88rem; color: #6b5860; border-top: 1px solid #f3cdd9;
|
|
padding-top: 1.25rem; }
|
|
.limits strong { color: #463a3f; }
|
|
.empty { color: #6b5860; }
|
|
|
|
@media print {
|
|
body { margin: 0; max-width: none; }
|
|
section, .chart svg, table { break-inside: avoid; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
`
|