Turn reflection into a plan, with the user in control.

Writrospect connects journaling, goals and AI-assisted planning. Users can reflect on what happened, review suggested next steps and decide what enters their plan. I designed the review step, so a suggestion becomes a saved change only after the user approves it.

The hosted app has sign-in, journaling, action tracking, the chat interface and the component library. Context selection, user-approved changes and guided planning are validated locally; the next release adds the database migration and rolls them out in stages.

Separate records for writing and plans

Journal entries are often tentative, so I kept writing, habits, tasks and suggested changes in separate records. Users can reread what they wrote without confusing it with a model's interpretation. A task can point back to the entry it came from, and a suggested change stays a separate proposal until the user approves it.

Writrospect mobile journal in Zen Garden with reading notes on Range and quick-check-in controls
The mobile journal, with reading notes and the quick check-in. Captured from the local app with illustrative entries.

User-approved changes

Read tools return information; write tools return pending proposals. The user sees the exact change before approving it. Ownership checks, schema validation, idempotency keys and transactions protect that decision from unauthorized writes, retries and partial updates. I select context for the task at hand and serialize journal text as untrusted data, separate from application instructions.

  1. ProposeTyped tool request
  2. ValidateSchema and ownership checks
  3. ReviewUser approves or rejects
  4. CommitRecheck, then apply in one transaction
Implementation and tests

Alongside journal entries and proposals, the data model stores habits with their motivation and success criteria, tasks with their timing and outcome, and strategies with evidence from earlier attempts.

Each pending proposal carries a payload hash, an expiry and an idempotency key. Approval rechecks the signed-in owner and the schema before applying the change in one transaction.

Field limits bound the selected context, and truncation flags record what was left out. I added a server-only switch that chooses full, task-specific or shadow context assembly; shadow mode compares aggregate metadata without logging prompt text or journal content.

I test these flows with synthetic fixtures instead of real journal entries or production logs. A behavior harness checks context selection, permissions and attempts to override instructions. Lifecycle tests cover proposal ownership, approval states, plan review, transport parsing and the lifecycle APIs. A prompt-size benchmark compares characters, bytes and estimated tokens across matched synthetic cases. None of this measures live-model reliability, production cost, answer quality or how the features work for real users.

Guided planning

The user chooses from suggested resources and forms of support. Writrospect then turns that selection into sessions. Planning from weekly capacity shows a projected finish date; planning toward a target date shows the required pace. Only a title is required, and the user reviews the schedule before saving.

The local weekly-plan review keeps capacity, editable sessions and approval in one place.

Readable generated themes

Writrospect can generate a theme from an uploaded image. I kept color extraction separate from a deterministic contrast gate, which jointly solves foreground and surface luminances and verifies the encoded colors before applying them.

Text roles must reach 4.5:1 against each checked solid surface, and control outlines must reach 3:1. The same gate runs when saved themes load. Separate browser checks catch opacity, rendered backgrounds, placeholders and component styles that could undo a valid palette.

Writrospect actual theme settings after image upload, showing measured contrast checks and the generated custom theme
The local app reports the lowest ratio for each checked role. I generated this theme from a banner, saved it and reloaded it in the settings page.
The math and repair algorithm

1. Define the contract

Every normalized theme must satisfy 17 opaque color-pair constraints: four text roles against three surfaces at 4.5:1, inverse text on two accent fills at 4.5:1, and control outlines against three surfaces at 3:1.

For each 8-bit channel, set c = channel / 255, then convert sRGB to linear light:

linear(c) = c / 12.92, if c ≤ 0.04045 otherwise ((c + 0.055) / 1.055)^2.4 L = 0.2126 Rlinear + 0.7152 Glinear + 0.0722 Blinear contrast = (max(Ltext, Lsurface) + 0.05) / (min(Ltext, Lsurface) + 0.05)

2. Make foreground and background solver variables

The base background is not fixed. The solver can move all three surfaces Bᵢ and five forward roles Fⱼ, including the outline. It minimizes the total squared change from their original luminances:

E = Σᵢ(Bᵢ − Bᵢ₀)² + Σⱼ(Fⱼ − Fⱼ₀)² 0 ≤ Bᵢ, Fⱼ ≤ 1 rⱼ = 4.5 for text; 3 for the outline Dark-text constraints: Bᵢ + 0.05 ≥ rⱼ(Fⱼ + 0.05) Light-text constraints: Fⱼ + 0.05 ≥ rⱼ(Bᵢ + 0.05)

Each named role has equal weight. For a palette needing repair, polarity follows the original base's better black-or-white contrast to retain the source scheme. With that choice fixed, the constraints are linear and the feasible set is convex. Black text on white surfaces, or its inverse, proves it is nonempty. If all fifteen forward and outline pairs already pass, their original colors are preserved; inverse text is still checked separately.

3. Solve the constraint directly

A single separating luminance m reduces the eight-variable problem to one dimension. For fixed m, each original coordinate is projected onto its allowed interval:

Dark text, m ∈ [0.175, 1]: Bᵢ(m) = max(Bᵢ₀, m) Fⱼ(m) = min(Fⱼ₀, (m + 0.05) / rⱼ − 0.05) Light text, m ∈ [0, 1.05 / 4.5 − 0.05]: Bᵢ(m) = min(Bᵢ₀, m) Fⱼ(m) = max(Fⱼ₀, rⱼ(m + 0.05) − 0.05)

Every value produced this way is feasible. Conversely, every feasible palette supplies m as its minimum surface luminance for dark text, or maximum for light text. Coordinate-wise clipping cannot increase its squared error. The joint problem and this scalar problem therefore have the same minimum.

Substituting these coordinates into E gives a convex, piecewise quadratic function. Its breakpoints are where a coordinate starts or stops moving. The solver evaluates every interval's stationary point and the boundaries, then selects the least-error candidate. No iterative color tweaking or arbitrary convergence tolerance is needed.

This minimizes continuous luminance error in the selected polarity, before encoding. It is not a perceptual-distance minimum, a discrete RGB optimum, or an optimization over mixed-polarity schemes.

4. Preserve the bound in the returned color

Convert each solved luminance into a linear-RGB blend toward black or white. Because luminance is a weighted sum, the blend reaches its target directly:

L((1 − t)v₀ + tvendpoint) = (1 − t)L₀ + tLendpoint t = (Ltarget − L₀) / (Lendpoint − L₀)

Encode back to sRGB. For dark text, floor foreground channels and ceil surface channels; reverse those directions for light text. Monotone decoding and positive luminance weights preserve the inequalities in real arithmetic. Unchanged colors avoid an unnecessary encode/decode round trip. Then recompute contrast from the encoded 8-bit color against every surface. A value of 4.499:1 fails; results are not rounded into a pass. Floating-point and encoding-boundary edge cases fall back only to an endpoint already checked against those same surfaces.

Inverse text has a separate witness: both solved accent fills already pass against the base, and contrast is symmetric. The base therefore passes as text on either fill. A custom inverse color is retained only if it also passes both. This inverse role and decorative colors are not part of the eight-variable objective.

Numerical regressions check that the JavaScript implementation matches this construction.

A generated-palette regression

A synthetic regression uses dominant #322CD4 and accent #EBF126 as palette inputs. The joint repair changes both the accent text and its inset surface. These are the colors the generator returned, checked after 8-bit encoding.

TextSurfaceContrast
Generated#8A8D35#B7B6D31.7915:1
Fail
Joint repair#55571D#C8C7DD4.5783:1
Pass

WCAG contrast calculation · Non-text contrast requirements

Writrospect was built with AI coding agents, and I led product direction, architecture, review and testing. It runs on Next.js and TypeScript, with Prisma on Neon Postgres, Stack Auth for sign-in, Vitest for tests and Vercel for hosting.