Writing assistance in the editor, versions with their own URLs, and finally keeping an eye on the browser too
Photo: Unsplash
After last week's observability wave, this week felt like the opposite: no new server layers, but a whole series of maturity strokes at spots that caught my eye one after another. A built-in writing assistant in the editor, a clean version-URL scheme for stories, a second visibility layer — this time directly in the browser — and what happens when you put five random content pages side by side and notice they all look different.
Spelling and grammar checking right in the editor
Before this week, the writing editor in OutaStory was a classic WYSIWYG: type, format, save the chapter. What it couldn't do: catch typos. Browser-native spellcheck highlighting helps, but it's language-confused (it follows the OS language, not the story's language), doesn't know grammar rules, and renders markup incorrectly.
The clean answer to that was a self-hosted LanguageTool container running alongside the API in Aspire. The public LanguageTool endpoint would have delivered this faster, but for the read/write traffic of a story editor, you want neither an external quota limit nor every sentence an author hasn't published yet going to a third-party service. A container is a container — self-hosting costs 1 GB of RAM and buys peace of mind in the data-flow story.
The architecture behind it:
- A
LanguageToolcontainer in the Aspire AppHost, mapped to/v2/check. Locally it spins up alongside the firstdotnet run; in Azure it runs as a sidecar container app next to the API. POST /api/text-checkon the API accepts an HTML block, a language (en/de), and a list of manually ignored spans, sanitizes the HTML, sends the plain text to LanguageTool, and translates the response into stable span markers.- Inline markers in the WYSIWYG: typos get red squiggly underlines, grammar hints get blue, style suggestions get gray. Clicking a marker opens a popover with suggestions and an "Ignore" button. Ignoring is persisted per story — a proper noun the author approved in chapter 1 doesn't come back as a typo in chapter 7.
- Manual + automatic: a toolbar toggle decides whether the check runs while typing (debounced, 800 ms after the last keystroke) or only on button press. Writers with a strong need for flow can turn off the background check and trigger it manually at the end of a writing session.
Writing shouldn't feel like proofreading, but a polite hint in the background helps for those who want it. The toggle makes sure both writing styles are served.
Story versions with their own URL per version
Stories don't stay static in OutaStory. An author notices after three weeks that a chapter would carry the introduction better after all; a reader made it halfway through the original edition and wants to keep reading there, even after the new edition has been published. Both need to work correctly at the same time.
The scheme behind this is now fully carried through:
/stories/{slug}always shows the currently published version of a story. Anyone arriving here from a search engine or a social-sharing preview sees whatever the author most recently released./stories/{slug}/v/{N}shows a specific version. Version 1 is always reachable at/v/1, even after version 2 has been published; old bookmarks and links stay valid. On the story page there's a small version switcher (1, 2, 3 …) for readers who explicitly want to see an older edition — say, because that's where they were reading.- Reading progress follows the version chain: someone who read version 1 halfway through and opens version 2 sees a "You were on page 12 in version 1 — keep reading?" prompt. Progress data is linked across the version chain, so every new version doesn't have to start at "page 1 of 47."
Technically, all versions of a story share the same slug. A version table is mapped via the RootStoryId field on the story table: version 1 has RootStoryId = null (it is the root itself), versions 2 and 3 point to version 1's ID. Uniqueness on (Slug, Version) is enforced at the DB level — two different stories can't claim the same slug, but all versions of one story share theirs.
There's a concrete consequence for this on the publish page: if you're writing a new version of an already-published story, the slug field is disabled. The URL is inherited from version 1, with a small hint explaining why — and the /v/{N} scheme makes the difference between versions visible. A story once named cosmic-cabbage stays findable under that URL no matter how many times the author revises it.
Photo: Unsplash
Three-channel notifications: email, in-app, push
Until now, OutaStory had two ways to let you know something happened in your account: toast notifications inside the app itself (live via last week's SignalR hub, plus a 45-second poller as a fallback) and web push notifications for signed-in web readers who granted their browser push permission. Now email has been added as a third channel — mainly for events where you're not currently in the app (someone comments on a story you're following; an author you follow publishes something new).
The /settings/notifications page now carries a 3×N matrix: for each event type (comments, replies, mentions, follow activity, ratings, …) you decide which channels should notify you. Each row has three toggles: in-app, push, email. The defaults are deliberately conservative — only the most direct events (someone replies to you on a comment, someone mentions you) are enabled across all three channels; everything else defaults to in-app only.
The email side runs through SendGrid with clearly structured templates per event type. If you don't want emails, you switch off the column — no hidden mandatory emails, aside from the unavoidable account security notifications that have to go through.
Browser observability: what readers see, we see too
The week before last we wired Sentry server-side into each of the fifteen logical services. What we noticed in the process: a browser click that doesn't do what's expected is often a logged detail server-side in the Aspire log — but not necessarily a Sentry event with the context around it that you need (which page did the reader come from, what other click preceded it, is the replay session saved for exactly that moment).
This week added a JavaScript bridge between the Razor components and the browser Sentry SDK for that. The IClientTelemetry service has two implementations:
- On web it calls into
window.outaStorySentry.*viaIJSRuntime(breadcrumbs, captureMessage, captureException, setUser, clearUser). The Sentry browser SDK, initialized byanalytics-consent.jsafter Cookie-Yes consent, picks up these calls and sends them to theoutastory-web-browserSentry project — separate from the server-side events, with its own replay session and a leading click sequence. - On MAUI the same interface is a no-op. Sentry.Maui in the MAUI app already captures UI interactions natively as breadcrumbs; the JS bridge there would be double bookkeeping and would also need a JavaScript runtime the BlazorWebView doesn't guarantee.
Three concrete spots now send structured browser events:
- Every toast gets mirrored — a "Couldn't set favorite" toast produces a Sentry breadcrumb with the toast type and ID, so the next error capture sees the user trail leading up to it.
- Anonymous reading progress: when the localStorage write fails (private mode, storage full, a browser hotfix clamp), that generates a warning event. Anonymous reading progress has no server backup — if localStorage stays silent, the reader loses hours of reading without anyone knowing. That used to be invisible; now it's a concrete entry.
- Navigation: every route change within a Blazor Server circuit is a breadcrumb. "Viewed story X → opened profile → /publish/42 → clicked Publish" is the sequence that shows up in the next error report.
The bridge is robust on the browser side against the case where the Sentry SDK hasn't initialized yet (every call is a safe pass-through method with its own try-catches). On the Sentry quota side, this counts against the outastory-web-browser project, which has its own limit — even a spike of toast events doesn't put a dent in server-side Sentry.
Boot beacons in every service
A smaller but consistent change: each of the fourteen .NET hosts (web app, API, four init workers, seven Azure Functions, plus the MAUI app) now emits a Sentry warning event on startup, with application name, version, and environment tag. The schema:
Application started: {ApplicationName} v{Version} in environment {Environment}
Sentry groups identical messages into one issue per project. A container app that gets spun up six times over the course of a day (due to auto-scaling, deployments, or restart loops) produces six events within the same issue. Operationally that means: a glance at the issue shows when the service instances started up, how many replicas were in play, and which environments they know about — without having to dig through Container Apps logs.
Alongside that, Sentry.MinimumEventLevel = Warning was set consistently across the board. The previous week, Sentry was on, but too restrictive for most of our logs sitting at information or warning level — Sentry only saw errors. Now warnings flow through as standalone events, with the same tags as the boot beacon, covering part of the "quietly degrading" paths that previously flew under the radar.
Consistent visuals on every info page
A classic polish item with a surprisingly big impact: five content pages (/about/version, /developer/push-test, /developer/auth0-claims, /developer/api-claims, /developer/system-info) each had their own layout, their own CSS file, and their own idea of what an info page should look like. /legal/terms and the other legal pages had a nice box layout — white background, border, shadow — but that layout was copied per page, not shared.
The cleanup:
- A new
.os-pageBEM class intheme.css(i.e., global, not scoped per Razor component) bundles the shared shell — outer container with a max-width of 800px, header with a back button and title, optional subtitle, an.os-page__sectionbox per content block with the intended shadow-and-border look. - The five migrated pages lose their individual shell definitions; the page-specific pieces (form styling on the push-test page, table styles on the claims pages) stay in their scoped CSS, but shorter and less duplicated.
- Playwright tests pin the consistency: a new test block runs through all five pages and checks that each one contains an
.os-pagewrapper, a header with a back button and title, and at least one.os-page__sectionbox with a visible heading. If a later refactor accidentally drops one of the pages out of the scheme, the test flags it immediately.
The /legal/* pages continue to use their own per-page CSS duplicates for now; the migration to .os-page is prepared there but outside the scope of this wave of work. Touching seven more files without opening up new risk is its own small cleanup for a calmer day.
Aspire MCP: local diagnostics without Container App logs
A quality-of-life addition for local development: the Aspire MCP server plugin now provides a direct interface between the AppHost and the development toolchain. When I'm testing a story locally and one of the fourteen service containers misbehaves, I can pull the console log and the structured logs directly from the tooling, without switching to the Aspire dashboard in the browser. That speeds up the "why isn't the boot beacon showing up in Sentry" debugging by a wide margin — I can see in one step whether the service is even running, what the DSN configuration says, and where the Sentry SDK init went astray.
For external developers this is nothing visible — but for me it's the difference between "I assume this works" and "I can see this works." More visibility at every layer of the stack was this week's theme, and the tooling is part of that.
Numbers as they stand
- Unit tests: now 2,490 passing (roughly 2,290 at the start of the week; around 200 new tests spread across LanguageTool integration, versioning, slug inheritance, page-shell consistency, browser telemetry, boot-beacon wiring).
- Playwright suite: 7 new tests added — five for page-shell consistency, one for v2 slug inheritance with a complete end-to-end setup chain, one updating existing tests for the new
.os-pagenaming. - Commits this week: around 35 between the v0.5.2 and v0.5.3 bugfix branches. Most of them are small, targeted fixes or test-coverage additions; the five focus areas above are the thematic clusters.
What's next
The next wave is performance — the platform runs, is instrumented, and now looks consistent everywhere. What it isn't yet: measurably fast. Cold start on the web app on the first container app replica currently sits at around 4 seconds until the first interactive frame; that's acceptable for a closed alpha, but not the goal. A few concrete levers are already identified (DI container tuning, critical-CSS inlining, image lazy-loading on the detail page); how those play out in the Aspire dashboard comes in the next dev log.
Also on the list: the German marketing copy for the store listings (Apple App Store + Google Play), plus one final consistency audit of the mobile-specific layouts. Closed-alpha testers won't notice any of that — but before we kick off the open beta, the store listings should show up in the same language, the same tone, and with the same screenshots.
Until then: if something on the web or Android alpha feels off to you, let me know. The small things are often the ones that shape how the app feels.
