Skip to content

Latest commit

 

History

History
85 lines (45 loc) · 10.1 KB

File metadata and controls

85 lines (45 loc) · 10.1 KB

Decisions

Every choice made where the build spec was silent, plus the things I think are worth revisiting. Written for you to disagree with.

Repo location

The spec said to work in an empty directory. The directory you pointed me at was not empty, it had your three handover documents in it, so the repo is a whatcha/ subdirectory. Move it wherever you want before git remote add.

App

A "ping me now" control. Not in the spec. A hiring manager opening the demo will not wait 45 minutes to see what a ping looks like, and without this the sampling screen is unfalsifiable from the outside. It sits next to Stop while sampling is running.

A person filter on the log view. Only appears in demo mode. Reading 121 interleaved entries from four people is hard, and being able to read one person's week straight through is what makes the repetition visible to a human reader. It is a read-only control on the log, not a feature of the instrument. If you think it violates the one-function rule, it is one <div className="filters"> block in App.tsx and deleting it costs nothing.

Demo data is never written to localStorage. It is derived from the bundled JSON at render time. Real entries live under a separate key. The two can never mix, and clearing the demo cannot destroy anything.

Pressing "Start sampling" switches out of demo mode automatically. Otherwise your first real entry lands at the bottom of a synthetic week. There is a "show the synthetic week" link on the log view to get it back, since nothing was deleted.

Timestamps are stored as local wall clock ISO strings with no timezone offset. So 2026-03-02T09:14:00, not ...+01:00 and not UTC. The export then shows the time the person was actually pinged, rather than a time shifted into whoever opens the file. It also makes the export byte-identical wherever it runs. The cost is that the log is not meaningful across timezones, which does not matter for a week of sampling one person.

Speech failure drops straight to the text field. If the browser returns not-allowed or service-not-allowed, the app switches to typing immediately, keeps the error visible, and remembers it for the rest of the session rather than asking for the microphone again on every ping. Losing the ten seconds to a permissions dialog is worse than typing.

Answer window ends by itself. Ten seconds elapsed, or Done pressed. An empty answer is discarded rather than logged as a blank entry, and skipping is explicit.

Notification permission is requested when sampling starts, not on page load. A demo that asks for notifications before you have pressed anything is the wrong first impression.

Dark mode. prefers-color-scheme, about fifteen lines of CSS. Costs nothing and the thing is meant to be lived with.

Accent colour is a muted vermilion, #b0391a on light and #e0693f on dark. One accent, near-monochrome otherwise, as specified. No strong opinion, change it if you hate it.

Kept oxlint because it came with the Vite template and passes clean. Nothing else was added.

Data

121 entries, not 120. Amaury has seven pings on the Wednesday because he was out at a customer dinner and the day ran longer. Everyone else has thirty.

Ping gaps in the synthetic data are all between 45 and 90 minutes, verified with a script, so the dataset is consistent with the app's own default window. This constrained the working day to about seven and a half hours of sampling per person, which is why some days stop mid-afternoon.

Sampled days are Monday 2 to Friday 6 March 2026, real weekdays, matching the date in your spec's example export.

Names, systems, products, customers and sites are all invented. Verrand, the Nantes, Lyon and Genk plants, the Roussillon R&D centre, Sylla, Orbit, Kestrel, Doculis, the M3 sheet, Verax, Corveel, the PK series, Halbeck, Cortelli, Ruysdael, Norvent, Delmar. Regulatory instruments referred to in passing (CLP, REACH, KKDIK, Annex VI, poison centre notification, UFI) are real, because inventing those would have made the regulatory persona read as fiction.

Two entries were cut for announcing the finding. One had the scientist saying nobody would ever find a document by searching, one had the planner saying "same as always" about a manual correction. Both stated the conclusion the analysis is supposed to derive. Everything else survived.

Cross-persona threads are deliberate and unlabelled. The Turkish safety data sheet crosses Amaury and Ines and is still not where Amaury thinks it is on Friday. The Corveel relabelling crosses Ines, Dragan and Peter in quality. The Halbeck reformulation question crosses Amaury and Rikke. None of it is signposted. An analysis that finds these is doing real work, one that misses them is doing keyword clustering.

data/synthetic-week.md has no preamble saying it is synthetic. It is the literal output of the export function, so that what you upload to Dust is exactly what a user would get out of the browser. The JSON alongside it carries the disclaimer in a note field, and the README says it plainly. Worth revisiting if you would rather the uploaded file be self-labelling.

npm run data:render regenerates the markdown from the JSON by importing the app's own toMarkdown, so the two cannot drift. Node runs the TypeScript file directly, which is why src/exportMarkdown.ts imports ./types.ts with the extension.

Dust

The prompt tells the agent to exclude the un-recoverable part of a cluster from its estimate. Without that, the planner's shop-floor walking gets counted as recoverable and his ranking inflates past the regulatory officer's, which would be the wrong answer.

SETUP.md recommends Include over Search on the folder. The analysis needs every entry. Semantic retrieval over a small log will return the nearest few and the clustering will be built on a sample of a sample. I have not verified the exact wording of that control in the current Dust UI, so check step 6 before you follow it.

The agent has now been run twice, on Claude Sonnet 5, and the Frame is public at https://app.dust.tt/share/frame/34222134-7981-44fe-88e2-f071fd6fb609. The first run paraphrased every quotation into tidy written English, which the second run fixed after the verbatim rule was promoted from a two-line footer to a hard rule above the numbered steps. Details in PROGRESS.md.

The surface, 9 August

The visual design came from Claude Design as a stylesheet plus a class contract. src/styles.css is that file, with a short base block at the top (it assumed a reset that did not exist) and a "wiring" section at the bottom holding the additions I had to make. Those are marked as mine so it is clear what was designed and what was implemented.

The additions: the view switcher is a pair of buttons rather than links, because it changes what renders and there is no URL to point an href at, so nav button needed the same treatment as nav a; .shell.wide for the log, whose two-column grid is wider than the 34rem column the other states use; a .row for arranging controls, which the handoff specified but did not lay out; a transition on .seam so the countdown shortens continuously rather than jumping once a second, which the handoff's reduced-motion rule implies but never declares; .entry .who for the person's name under the timestamp in demo mode; and a margin reset on the transcript's paragraph.

Geist Sans variable is bundled at public/fonts/geist-variable.woff2, 69 kB, with its OFL licence beside it. It came from the geist npm package, which I then uninstalled: the font is a file in the repo, not a build dependency.

The ping takes the whole screen. Prompted and answering render outside the shell with no header. Chrome during the ten seconds competes with the only moment in the app where anything happens.

Speech results are split into settled and pending. useSpeech used to return one string. The design shows words the recogniser has committed to in ink and the ones it is still guessing at in a fainter tone, which needs them apart. transcript still returns the combined value for saving.

Three bugs found by testing on a phone, 9 August

An empty transcript was silently discarded. iOS Safari takes the microphone, reports no error, and returns nothing. After ten seconds the app called finish(''), which drops an empty answer, so the entry vanished with no explanation. For a sampling instrument that is the worst available failure: the person answered and the data was thrown away. It now falls through to the text field with the reason on screen.

The prompt screen looked random. The first speech failure in a session latches a speechBlocked flag and every later ping goes straight to typing. That is deliberate, so a denied microphone does not eat ten seconds every time, but it was invisible. It now says why, and offers to try the microphone again.

The typed field did not reliably raise the iOS keyboard. React's autoFocus is unreliable there, which strands anyone dictating into the field. Replaced with a callback ref.

Things you should look at

  1. docs/hero.png does not exist. The <img> tag in the README is commented out until it does, so nothing renders broken. Now that the surface is finished, the shot to take is the answering screen mid-countdown, phone width, dark mode.
  2. Wispr Flow does not work on iOS Safari here, and I do not think it is our bug. Flow is a keyboard extension and the loop you hit, bouncing to the Flow app and back, is the pattern for a keyboard without Allow Full Access granted in iOS Settings. Worth checking, but the honest README line may be that dictation works on the desktop and iOS is typing only.
  3. The .say word animation re-runs if React remounts the list. Keyed by index, so appended words animate and settled ones do not, which is right in practice but is a fragile assumption if the transcript is ever edited mid-flight.
  4. prefers-reduced-motion has not been tested. The rules are there, nobody has looked at them.
  5. The "ping me now" button is visible in the demo. If you would rather the instrument look austere, hide it behind the settings.