# Sauna apps: review desk and interaction examples

Read the [UI and brand guide](https://sauna.ai/brand/apps/guide.md) first for the visual direction, tokens and exact asset URLs. This companion explains the review desk: a working example of those rules applied to editable tasks and approval decisions on mobile and web. Use its individual patterns where they help the person's job. The examples use local sample data; they are not a required app shell or connected product services.

## Start here

- Design foundations: https://sauna.ai/brand/apps
- Review dashboard: https://sauna.ai/brand/apps/review
- Individual job: https://sauna.ai/brand/apps/review/[id]
- Navigation library: https://sauna.ai/brand/apps/navigation
- This guide: https://sauna.ai/brand/apps/jobs-guide.md
- Machine-readable illustrative jobs: https://sauna.ai/brand/apps/examples.json
- Visual design guide: https://sauna.ai/brand/apps/guide.md
- CSS tokens: https://sauna.ai/brand/apps/tokens.css
- Structured tokens: https://sauna.ai/brand/apps/tokens.json

These are canonical public URLs. For local preview, use the same pathname on the local origin. A job ID must come from the repository; do not invent one to build a detail link.

## What this example actually does

The workspace reads and writes browser IndexedDB through a small repository. It seeds illustrative records when its database is first created. Edits, review decisions and activity survive a refresh in that browser. They are not synchronized to a server, another device or a live provider. Clearing this site's browser storage removes the local workspace.

The examples.json resource exposes seven static illustrative records using the same artifact shapes and stable IDs, with explicit sample metadata. It does not read the browser's live IndexedDB, expose a connected account or execute any actions. Use it to understand the record shapes before building an adapter.

Home lists the saved tasks without dashboard counters or a persistent sidebar. Calendar, activity and connection details are available from Home. Each job has its own focused route: a short explanation, the work itself, and the relevant decision. Reading and editing share one surface. Source and history are available in a disclosure. An approval records a decision about the saved revision. It does not send an email, book a meeting, place an order, contact a person, publish a page or post to Slack.

Downloads are real browser exports. Research further creates an editable research job linked to the original record; it does not start an external search or background agent. Be equally clear about capabilities when extending the app.

## Compose for the actual task

This is a vocabulary of possibilities, not a layout every agent must reproduce. First ask what the person needs to understand and decide. Use the fewest elements that make that possible. A single email may need only a message and an action. A large document may need several expandable sections. A batch may benefit from a list beside its detail. Do not add a dashboard just because the reference includes Home.

- Home is the overview of possible work. Individual task links contain that task, not the entire workspace navigation.
- Start a task page with a concise explanation of the purpose and the decision needed. Let the content carry the rest.
- Read and edit in the same place. Use unobtrusive labelled text fields in the reading layout, with a visible focus state. Do not make people compare a separate preview and editor.
- Choose a document, expandable rows or a list/detail composition based on the material. These are alternatives, not mandatory nested layers.
- Keep source details and history available without forcing them beside every artifact. Keep the main action close to the work.
- Counters, sidebars, charts, sticky bars, search and tabs are optional. Add them when scale or a concrete task justifies them.
- Simple icons may help orientation: precise search and code geometry, a softer line elsewhere, and an optional faint circular wash in Forest, Steel or clay. A small transparent painting of a modern sauna can accompany a genuine load. Optional smoke should drift slowly, stop with the operation, and stay still for reduced-motion preferences. Do not manufacture a loading delay to show it. Keep controls square and the page white.

## Shared records

The source of truth is lib/sauna-review-types.ts. Import its types instead of creating parallel interfaces:

```ts
import type {
  ReviewJob,
  JobArtifact,
  JobKind,
  JobStatus,
} from '@/lib/sauna-review-types';
```

ReviewJob contains id, kind, title, summary, source, createdAt, updatedAt, status, version, artifact and activity. JobActivity entries contain id, at, action and an optional note. Timestamps are ISO strings. JobStatus is review, approved or changes_requested. JobArtifact is a discriminated union: branch on artifact.type to render or edit the right content. The artifact type must match the job kind.

| Kind | Artifact content | Useful review surface |
| --- | --- | --- |
| document | heading, body | Readable document with an editable title and body |
| email | subject, body, recipients | Recipient review beside the editable personalized email |
| people | message, people | A shortlist with role, context, verified profile and selection |
| meeting | title, start, end, timezone, location, attendees, agenda, brief | The event and its preparation brief together |
| purchase | item, rationale, criteria, options, selectedOption | Compare evidence and a real quote or explicit cost note |
| research | question, findings, sources, nextSteps | Findings linked to their supporting material |
| html | heading, html, channel, message | An isolated editable HTML surface and accompanying share text |

Use JOB_KIND_LABELS and JOB_STATUS_LABELS for the shared human-readable labels. The names of the seven kinds are stable discriminants, not text that must be displayed verbatim in the app.

## Email batches: review a person and a message together

Each recipient has id, name, email, reason, selected and review. The review state is pending, included or skipped. Optional per-recipient subject and body override the batch defaults. Keep selected true exactly when the recipient is included. The reason explains why the person belongs in the proposed batch, close to the preview the reviewer is evaluating.

On desktop, a recipient list and a large preview share the workspace. Selecting a person shows the actual personalized message and reason. On a phone, show one email and its context at a time. Previous and next controls provide an explicit way through the batch; swiping is an additional convenience. Include and Skip are separate, clearly named decisions. Moving to another recipient must not silently include, skip or approve anyone.

If a large batch benefits from progress, derive it from the current recipients. A counter is optional; never maintain a second counter or invent a batch size. A final batch approval requires every recipient to have a decision, at least one included recipient, valid distinct included email addresses, and complete messages. Save draft changes before approving their revision. A saved approval still does not send the batch.

Render the exact recipient-specific subject and message that will be exported. Support {{firstName}} using the shared personalization helper. Editing an included message or changing the recipient set creates a new revision that needs a fresh approval.

## Repository and state transitions

The implementation is lib/sauna-review-store.ts. These are browser-side module functions, not HTTP endpoints:

```ts
import {
  listJobs,
  getJob,
  saveDraft,
  recordDecision,
  createResearchJob,
  ReviewConflictError,
  ReviewValidationError,
} from '@/lib/sauna-review-store';

async function loadWorkspace() {
  return listJobs(); // Build detail links using the returned IDs.
}

async function loadJob(id: string) {
  const job = await getJob(id);
  if (!job) throw new Error('This job is not in the local workspace.');
  return job;
}

// Wire each function to its own explicit user action.
async function saveChanges(job: ReviewJob, editedArtifact: JobArtifact) {
  return saveDraft(job.id, job.version, editedArtifact);
}

async function approveSavedRevision(job: ReviewJob) {
  return recordDecision(job.id, job.version, 'approved', '');
}

async function requestChanges(job: ReviewJob, note: string) {
  return recordDecision(job.id, job.version, 'changes_requested', note);
}

async function researchFurther(job: ReviewJob) {
  return createResearchJob(job); // A local, initially incomplete brief.
}
```

The snippets illustrate separate user actions, not a sequence to run automatically. Handle an undefined getJob result with a useful missing-job view. Module calls can fail; show an actionable error and preserve the person's unsaved work.

saveDraft accepts incomplete content while retaining a valid artifact shape and type. It increments version, appends activity and returns status to review, including when an earlier revision was approved. recordDecision validates content before approval, requires a note for changes_requested, increments version and records the decision in activity. Approval validates the current saved artifact, not an unsaved editor buffer.

Both operations require expectedVersion and reject stale writes with ReviewConflictError. Offer a deliberate way to load the latest saved revision; do not silently overwrite a newer record. Use the returned version for the next action. Prevent leaving a dirty editor without accounting for unsaved changes. Disable export and decisions while local edits are unsaved or the revision is stale.

Validation lives in lib/sauna-review-validation.ts. validateArtifactShape checks the persisted structure; validateArtifact checks whether the content is ready for approval. Use these same rules in the interface rather than duplicating eligibility logic. Current examples intentionally leave some inputs incomplete, such as unverified profile URLs or quotes that still need research.

## Rendering and export

Shared browser export helpers live in lib/sauna-review-export.ts:

- personalizeEmail supplies the editable message content; renderEmailHtml creates the outgoing HTML from that same text. User text is escaped by escapeHtml before being placed into inline-styled HTML. An email message is text, not arbitrary executable markup.
- selfContainedHtml parses HTML with the browser's DOMParser, retains allowed presentation elements, removes active or navigational content, disables form controls and wraps the result with a restrictive Content Security Policy. Without DOMParser, it displays escaped source text. Keep arbitrary HTML out of the surrounding React document. The editable canvas uses an iframe with sandbox="allow-same-origin" so the parent can make leaf text blocks editable, while scripts, navigation, network access and forms remain disabled by sanitization, sandbox and CSP. Never add allow-scripts. The parent serializes text edits back to the draft without remounting the canvas on each keystroke. Keep full-document styles and body attributes during serialization. A read-only preview can use an empty sandbox. This is an inert artifact canvas, not a host for interactive websites.
- safeWebUrl and safeLinkedInUrl only expose supported links. A name is not evidence of a verified profile. Missing or invalid sources stay visible as incomplete information.
- downloadArtifact exports the saved ReviewJob. Document, people, purchase and research artifacts use Markdown; selected emails use unsent .eml files, bundled into a ZIP for multiple recipients; meetings use .ics; HTML artifacts use self-contained .html.

Meeting start and end values must be valid ISO instants with a UTC offset. timezone is a valid IANA time zone. The calendar renders the intended event time zone; ICS exports use UTC instants, escape content and fold long lines. Downloading an ICS file does not send invitations or confirm a booking.

For email exports, use only included recipients and the same personalized content shown during review. An EML file is an unsent draft; the person chooses how to finish it in their email application. Exporting a shortlist does not contact its people. A purchase export records the comparison and decision without placing an order.

## Choose the navigation that fits

A direct task link often needs only a Home link. No navigation pattern is required. Keep the broader menu on Home unless the work itself needs more.

The interactive specimens live at https://sauna.ai/brand/apps/navigation. They are alternatives to compare, not six controls to add to every screen.

1. Workspace sidebar: several work areas, grouped destinations, or a library that can grow. Groups collapse; the compact drawer uses native dialog focus containment and restores focus when closed.
2. Top navigation: a few primary destinations of similar importance. Give the active page a persistent non-colour cue.
3. Local tabs: sibling views of one object. In the specimen, Left/Right, Home and End move focus; Enter/Space selects. The active tab is stored in the URL and responds to browser history. Use labelled tabs and panels, with one tab stop in the tab row.
4. Breadcrumbs: show ancestry inside a hierarchy and let people move upward. Label the navigation and identify the current location with aria-current.
5. Queue and detail: show the item list beside evidence on a wide screen. On a phone, open the detail separately with a clear way back and restored focus. Previous and next controls preserve the review sequence.
6. Mobile bottom bar: a small set of frequent destinations with visible words and comfortable touch targets. Keep primary actions with the relevant content.

Use links for real routes and buttons for local actions. Keep keyboard focus visible. Name navigation landmarks so multiple menus are distinguishable. Use semantic headings and explicit states, keep touch targets comfortable, and test the workflow at narrow widths. Prefer a clear current location and predictable back navigation over elaborate animation.

Reference principles: [Geist Tabs](https://vercel.com/geist/tabs) distinguishes sibling views from unrelated destinations and documents keyboard and URL behaviour; [Geist Breadcrumbs](https://vercel.com/geist/breadcrumbs) demonstrates hierarchical location. Studio interprets these ideas with original components, neutral surfaces and square geometry. It does not use shadcn.

## Implementation map

- lib/sauna-review-types.ts — shared job and artifact types and labels.
- lib/sauna-review-fixtures.ts — illustrative starting records.
- lib/sauna-review-store.ts — IndexedDB repository, revisions and local decisions.
- lib/sauna-review-validation.ts — shape and approval validation.
- lib/sauna-review-export.ts — safe preview helpers and browser downloads.
- components/brand/apps/review-workspace.tsx and review-workspace.module.css — Home, focused job routes and local review actions.
- components/brand/apps/job-artifact.tsx and job-artifact.module.css — artifact-specific editable reading surfaces.
- components/brand/apps/desk-elements.tsx and desk-elements.module.css — inline text, precise SVG marks and the painted cedar sauna with optional smoke.
- components/brand/apps/studio-navigation.tsx and studio-navigation.module.css — the six interactive navigation specimens.
- lib/sauna-app-resources.ts — resource paths, selected brand assets and app pattern directory shared with the page.
- lib/sauna-app-studio-design.ts — UI and brand guide, CSS and JSON tokens.
- lib/sauna-app-job-guide.ts — this implementation guide.

To connect a real product, replace the local repository with an authenticated data source while retaining the shared records and explicit revision checks. Add delivery as a separate authorized operation with a real result and activity receipt. Do not label the current local repository as connected to email, calendar, Slack, LinkedIn or purchasing services, and do not invent provider APIs from this design reference.
