When software writes a document for you, the hard part is not writing it. It is telling you which bits it made up.
Nakodo starts a campaign by writing a brief, which how it works describes with the line that matters here: everything in it can be changed before anything happens. A brief written from a website always contains guesses, because websites do not state everything, and the app keeps those guesses alongside it. The question is what the screen does with them.
The answer is 133 lines, a panel above the document and a ring around the fields inside it, and most of the decisions in it are about not nagging.
One state, two places, no prop drilling
The panel is at the top of the page and the marks are deep inside an 859-line form, so both read from one context:
type Marks = Map<BriefPart, string[]>; const Marked = createContext<{ marks: Marks; mark: (guesses: Assumption[]) => void }>({ marks: new Map(), mark: () => {}, }); The default value is a working no-op: an empty map and a function that does nothing. That is not defensive code, it is what lets the marked fields be used on a page with no panel at all, with nothing to configure and no "if a provider exists" branch anywhere.
The marked field is the same idea again:
export function Highlight({ part, children, className }) { const notes = useContext(Marked).marks.get(part); if (!notes) return <>{children}</>; return ( <div data-guess className={cn("rounded-md ring-2 ring-offset-4 ring-highlight ring-offset-card motion-safe:animate-in motion-safe:fade-in", className)}> {children} <ul className="mt-2.5 space-y-1 text-[13px] text-muted-foreground"> {notes.map((n) => ( <li key={n} className="flex gap-2"> <AttentionMark label="Guess to check" className="mt-[7px]" /> {n} </li> ))} </ul> </div> ); } Unmarked, it renders its children and nothing else: no wrapper element, no class, no layout change. So all eight places in the form that can carry a guess are wrapped (six or seven of them render, depending on whether the campaign is looking for creators or for businesses), and in the normal case the component is invisible in the output as well as on screen. The ring is additive, which is the only way a highlight can be switched on inside a form without moving anything.
ring-offset-4 with ring-offset-card is the detail that makes it look deliberate: the ring sits four pixels off the field with the card's own colour in the gap, so it reads as a halo rather than a border somebody added to an input.
Two buttons, both final
<Button onClick={() => settle("approved")}>Looks good</Button> <Button variant="outline" onClick={() => settle("fixing")}>Make changes</Button> There is no dismiss cross. A cross invites the one behaviour this panel exists to prevent, which is making it go away without reading it. Instead there are two answers, and the important property is that they are equally final:
const settle = (how: "approved" | "fixing") => { setDone(how); if (how === "fixing") { mark(guesses); if (placed > 0) { requestAnimationFrame(() => document.querySelector("[data-guess]")?.scrollIntoView({ behavior: "smooth", block: "center" })); } } start(async () => { await checkedGuessesAction(campaignId); }); }; One server action for both answers, because the thing being recorded is not which button you pressed. It is that you have been asked. A panel that reappears until you press the "right" button is a panel that gets dismissed reflexively on the second viewing.
Note the order: the local state moves first, the write goes after, inside a transition, and the result is thrown away. The comment above it is the policy:
// Saved as soon as either button is pressed, so the panel is gone next time // too. Nothing is shown if it fails: the guesses simply come back. That is the nicest kind of error handling to write: the failure mode of "we could not record that you read this" is "you are asked again", which is the behaviour you would have chosen anyway. Showing a red toast for it would be telling the user about a problem that is not theirs and has already been handled.
The scroll uses an attribute, not a ref
The panel has to scroll to the first marked field, which lives in a different component several levels down. The usual answers are a ref passed through context or a ref callback registry keyed by part. This does neither: the ring sets data-guess, and the panel queries for it.
That is fine here for a specific reason. The marks render in the same tick as the state change, so a requestAnimationFrame is enough to let them exist before the query runs, and "the first [data-guess] in the document" is exactly the definition wanted: the earliest part of the form with something to check. A ref registry would have given the same answer and a sorting problem.
Two caps on how loud it gets
// Two notes a part at most: past that the brief reads as a page of warnings // instead of fields to change. const MAX_PER_PART = 2; and, for a guess that does not name a part, nothing is ringed at all:
for (const g of guesses) { if (!g.part) continue; // ... } Ringing everything for an unplaced guess would point at the whole document, which tells the reader nothing. Those guesses stay in the panel as text instead, and the follow-up copy branches on how many of each there were:
{placed === 0 ? "Change these in the brief below." : loose.length === 0 ? "We've highlighted the parts you need to change." : "We've highlighted where most of these go. The rest:"} Three sentences for three states, and the third one is the only reason the split exists: a mixed case where some guesses are ringed and some are not would otherwise produce a confident sentence that is partly a lie. The whole state lives under role="status", so a screen reader hears the change rather than silently losing a panel.
Two findings
The list keys are the note text: key={n} in the marks and key={g.note} in the panel list. Two identical guesses about the same part of the brief would collide, which is unlikely and not impossible, and an index key would be fine in a list that never reorders.
The bigger one is that "Make changes" settles the guesses permanently, so somebody who presses it, gets called away and comes back tomorrow has a brief with no rings on it and no way to ask for them again. The honest fix is to record the two answers as different things after all: approved means never again, fixing means show it until the brief is saved. That is one more column and a condition, and it would mean the panel is the only part of this screen that remembers what you meant rather than only what you did.
