One prototype, one Organisation, one shell — walk between destinations the way
a user would. Nothing is saved, nothing is real.
Built: Calendar (STORY-014) · Events and guests
(EPIC-002, EPIC-003) · Venues (STORY-013) · People (STORY-015) · Money (EPIC-004) ·
Inventory (EPIC-006) · Tasks (EPIC-005) · Settings → Currencies and Timezones
(STORY-041). Every destination in the navigation is now real. The "not
prototyped" screen stays wired up for the next one: a destination is marked unbuilt
in one place, and that is what keeps a scope boundary visible on screen rather than
only written down. Signing up happens before this shell exists, so it stays a separate
prototype: signup-flow.
390 × 860 — phone
desktop — drag the bottom-right corner to resize
What merging the four prototypes found
Three of them each kept their own Events list, and the ids disagreed.e1 was "Late-summer supper club" in the money prototype and "Spring supper
club" in the core-loop one. Merged onto one list of five, which is why an Event now has a
venue and a budget and a loadout at the same time.
The money prototype had fabricated venues. Three of them, invented so
the Timezones screen had something to count, because the real Venue records lived in a
different prototype. That screen was teaching something slightly false.
Eight identifiers were defined twice. Four —
dateLabel, emptyCard, fieldError,
clearError — were byte-identical copies of the same helper. The rest were
per-destination state pretending to be shared.
Two different "event detail" screens exist and neither knew about the other.
The core loop's shows guests, pax and attendance; money's shows budget, spend and profit.
Open question: is that one screen with two sections, or two screens?
Nothing forces the answer yet, and the merge is what made the question visible.
Half answered 2026-08-13: the money plan — planned budget
and expected revenue — now lives on the Event, and actual income, expense and profit
stay on Money. What is still open is whether those actuals should also appear there.
The Venues screen was wrong in three ways and was rewritten rather than
ported: it hardcoded four timezones, counted events by matching the venue name as
a string, and predated the enabled set. It is now where the timezone enabled set is
actually consumed — the proof STORY-041 earns its keep.
Equipment is now reachable from the Event, not only from Inventory.
Packing for a night is something you do while looking at the night; going to Inventory and
finding the event in a list is the long way round for the commoner case. Both entry points
open the same panels — a second "take equipment" flow would drift from the first
within a week. Now in STORY-029.
Marking an item returned asked for its condition on one screen out of
three. The loadout panel offered "any damage?"; the "still out" screen never did,
and the Event's new section did not either. Two different causes, one lesson.
The Event section had a hand-rolled copy of the assignment row — the exact
drift this merge existed to remove, reintroduced while adding a feature. It now uses the
canonical row.
The "still out" screen had a subtler one: the list is "what has not come back", so a
row stops qualifying the instant you tap it and vanishes, taking the unanswered
question with it. Just-returned rows now linger until the follow-up is answered
or dismissed, and the count excludes them. Found by marking the same item returned from
three places and getting three different outcomes — which is only possible to try once
the three places are in one prototype.
None of these were visible while
the prototypes were separate. Each one looked internally consistent.
What the Calendar found
The venue-less fallback was the device's zone while the screen said it was
the workspace's. EPIC-002 risk 2 asked what an Event's zone is before a Venue
exists; the form answered "the workspace timezone", the label under the title said
"workspace time", and the code used MY_TZ — whichever zone the browser
happens to be in. Identical in Amsterdam, wrong everywhere else, and invisible to
anyone testing from the office.
Resolved 2026-08-14: the zone is derived on every read — the
Venue's, falling back to the Organisation's — and never written onto the Event. So
attaching a venue in Istanbul relabels 20:00 and does not move it. At Idea a
time is a plan, not an agreed instant, and a time that shifts on its own because
somebody picked a venue is the worse surprise.
The timezone criterion is answered by the data model, not by code.
STORY-014 warns that an Event at 00:30 must not land on the previous day. It cannot
here: an Event's date is already the calendar day at its Venue — that is
how it is stored, and the instant is built from it. So grouping by
ev.dateis grouping in the Venue's zone. The bug the criterion
describes only appears if something converts an instant back to a day with
toISOString. Written down because the obvious way to "handle timezones
properly" here is to add the very conversion that breaks it.
The agenda is the screen; the grid is a locator. Intended as the
phone's answer to a 390px cell, it turned out to be the primary reading surface at
every width — chips truncate to two or three words in a 1100px window with a sidebar,
so even on desktop the grid tells you where and the list below tells you
what. One list doing both jobs, rather than a phone screen with its own
rules.
The day count sat against the wrong day. Number at one edge of the
cell and count at the other put the count hard against the next day's number, and at
390px it read as belonging to that day. Stacked instead. Invisible at desktop width,
where the count is hidden entirely — found only by looking at the phone frame.
Opening an Event from the Calendar returned you to Events. The same
disorientation the loadout panel had, so it got the same fix, generalised: a view can
now be told where "back" goes, exactly as a panel already could. A Task opened from a
day returns to that day.
Tasks with no due date now share the rail — settled 2026-08-14.
Undated Events already had one, because an Idea has no date by definition and
this screen claims to show everything. A Task with no due date was arguably not a
calendar item at all and appeared nowhere — the same argument, answered the opposite
way on one screen. Unfinished ones only: a finished task with no due date is not work
that has been forgotten. STORY-014.
The Event form was missing three fields its own story requires.
Organisers, planned budget and expected revenue were all seeded and none could be
entered — the figures were readable on the Money screens and settable nowhere, and
the criterion that a negative budget is rejected had no field to reject. Added
2026-08-13. Two things fell out of it: the currency picker's enabled-set rule is now
shared with the transaction form rather than copied, and the Organiser is plural
— the glossary always said "the Member or Members", and the story's singular phrasing
would have been built by accident.
Then the money moved off the form onto the Event itself, as a
Budget card between the guest list and Tasks. Putting it on the form was
wrong and the prototype is what showed it: the form is where you go to correct a
name, so a planning decision sitting behind an "Edit" button was hidden well enough
to read as missing entirely — it was reported as missing by someone looking straight
at it. Same argument that brought equipment onto the Event: you plan a night's budget
while looking at the night. This settles half the open question above
— the Event screen owns the money plan, while actual income, expense and
profit stay on Money. Estimates belong to the thing being estimated; bookkeeping
belongs to the books.
An event money screen could not show a plan. It derived its
currencies from the transactions on the event, so an Event with a budget and nothing
spent yet — which is every event, right up until it happens — read "nothing recorded"
and never showed its own figures. Invisible until the form could set a budget: every
seeded event with one also had transactions, so the two sets were never different.
The planning stage now shows the plan, with 0% spent.
An empty workspace is now a starting state, and it found a lie on the first
run. People had one empty card doing two jobs, so a contact book nobody had
touched said "No match. Nobody matches that search" — to someone who had not
searched for anything. Every other destination already splits those two. Split now,
with the search box hidden when there is nothing to search. Reaching that state used
to mean a hundred deletions, which is exactly why it had never been looked at.
Turkish found three bugs that English could not. The search fold
lowercased without a locale, so IŞIK could not find ışık —
Turkish has two i's and they are different letters, which makes
'I'.toLowerCase() wrong here and right everywhere else. The calendar's
month names, weekday names and first-day-of-week were computed once at load, so they
stayed English after a language change — locale data, which no translation
layer can reach. And the money parser's separators are locale-derived, so
1.234,50 is one thousand in Turkish and malformed in English: the same
keystrokes have to mean two different numbers, and now do. None of these are visible
with one language installed. ADR-0014.
Every destination is now built, so the "not prototyped" screen has
no instance. The mechanism stays: the next unbuilt destination is one built:
false away, and deleting it would delete the thing that keeps a scope boundary
visible on screen.
The two numbers
Guests invited is how many invitations you are managing.
People attending is how many bodies are in the room. Forty guests is
routinely sixty-something people, and the two drive different decisions — so they are
never merged into one figure.
The count includes Confirmed and Attended. Both: someone who said
yes and someone who turned up are equally a head, and the total must not collapse
while rows move between the two.
Invited and Cancelled stay visible but are
excluded — you still need to see who has not replied and who dropped out.
What differs between the two
Secondary surfaces. The event form and a person's record are one
element with two homes: a full screen with a back button on the
phone, a dialog over the page on desktop, where you would otherwise
lose your place halfway down a guest list. Moved, never duplicated — so the two can
never disagree about a field or a rule.
The guest list. Phone stacks each guest into a row; desktop gets a
real table with column headers. A table squeezed onto 390px is unreadable, and a
stack of cards at 1100px throws away the comparison a table gives for free.
Navigation is a drawer on the phone and a sidebar on desktop. Back
appears before the menu button, and only where there is somewhere to go.
Try this
New event with a name and nothing else — it saves as an
Idea. Ideas are free and never count toward the plan limit.
Set it straight to Confirmed. It works: no status requires any
field. What appears instead is a prompt saying it is Confirmed without a date or a
venue, with a button to add them. Guests can still be invited either way.
The Free limit is the only thing that refuses a status change, and
it is a billing rule — cancel or complete the other event and it goes through.
A time with no date is refused; a date with no time is fine.
Fill the guest list, then move somebody from Invited to Confirmed
and watch both numbers change together.
Open an Event and move between its four tabs. The two numbers stay
above them — they are facts about the night, not about the Guests tab — and Tasks and
Equipment carry counts so a closed tab still says whether there is anything in it.
With ?dev=1, press Fill a guest list: forty guests on
the open event, ten to a page. Change the page size to All and back. Then add one more
guest from the picker and watch the list follow them to the last page.
Click a guest's name. Their record opens — over the list on
desktop, as a screen on the phone. Rename them and the guest list follows, because
it never copied the name.
Delete someone who is on a guest list: refused, and anonymising is offered. Every
total survives it.
Add the same person twice — the second attempt shows them marked
Already invited rather than raising an error.
Set a Pax to 0 or 2.5: refused. Set it to 60:
allowed, with a warning — a real party of sixty exists.
Search andric — it finds Andrić.
Settled here
No transition guards (EPIC-002 risk 1, resolved 2026-08-10). Every
status is reachable from every other and nothing is required to change status. Real
planning is not a state machine — an event gets confirmed on a phone call before a
room is booked, and a tool that argues with that gets worked around. Missing detail
is prompted, never blocked.
Nothing deletes an event.Cancelled is the only exit, and
it keeps the guests and the money attached.
Forty rows, answered by paging
The question was unanswerable by clicking, which is why it stayed open.
The contact book holds seven people and one Attendance per Person per Event caps a
hand-built list at seven — so "is two selects per row too heavy at forty?" could only
be imagined. Fill a guest list in the toolbar builds the crowd that raised it.
Ten a page, and the reader picks otherwise — decided 2026-08-14. The
row keeps both selects: attendance and payment both change on the night, and demoting
either to a panel costs a tap in the one flow that is actually timed. Ten rows of it is
not heavy. Page size is 10 / 20 / 50 / 100 / All and persists between Events, because
it is a preference about how someone works rather than a fact about an event.
Adding a guest follows them to their page. New Attendances append, so
adding one from page 1 of 4 changed nothing on screen and read as having failed.
The two headline numbers count the list, never the page, and they sit
above the tabs for the same reason.
Still unanswered
Payment is a flag with no amount — deliberately, so one figure does
not live in two places. Does the strip of counts tell you enough on the night?
Should the guest list have its own filters, or is sorting by status enough? Paging
makes this more pressing, not less: finding one guest on page 3 of 4 by
clicking next is worse than scrolling to them.
The question this exists to answer
The reference workbook tracked valuable kit on one sheet while its companion sheet —
the one built to record what went where — held a header, a formula, and
zero rows. The team designed the control and could not sustain it by
hand.
So EPIC-006's open question 4 is not about modelling:
“if the flow is slower than a text message to a colleague, it fails the same way
the sheet did.” That is only answerable by doing it.
Hence the tap counter in the prototype toolbar. It counts every
interaction inside the product — the toolbar itself is excluded — so the flows can be
measured rather than argued about.
What it measured
Flow
Taps
Take three items to an event, from the item list
6
Pack down — mark a four-item loadout returned
1
Mark one item returned
1
Typing the same three item names into a message
~20
Three ticks plus event plus confirm is N + 3 for any
loadout, and packing down is one. That clears the bar the epic set. The
number to defend in review is the +3, not the ticks.
The decisions that make it fast
Multi-select is the primary path, not a convenience. The whole row is
the checkbox target, not a 16px box beside it.
The selection bar is stuck to the bottom, not the top. On a phone held
one-handed the bottom of the screen is the only part the thumb reaches without
regripping — and this screen is used standing next to a van.
Marking something returned is one tap. Capturing condition is offered
afterwards, never required. Requiring it is precisely the friction that emptied
the original sheet.
Bulk return is grouped by event, because packing down happens all at
once, not item by item.
Returning is idempotent. A second tap changes nothing — a double-tap
on a phone is normal, not an error.
Try this
Tick three items, Take to an event, confirm. Watch the tap counter.
Reset it and try the same loadout from Loadouts → open an event → Add
equipment — that path costs one more tap, which is the argument for keeping the
item-list entry point.
Add an item that is already on the chosen loadout: it says so and skips
it rather than erroring or duplicating. One assignment per item per event; quantity
covers “more than one”.
Take the Knife roll (marked Lost) to an event. It warns and
allows it — you may simply have found it. Then mark it returned: that one asks
what condition it came back in, because it cannot come home and stay Lost.
Resolves EPIC-006 OQ3.
Mark any healthy item returned. The condition follow-up appears, phrased as optional,
with “Leave it as it is” beside it.
Try to delete the Speaker: refused, because it is on a loadout. Marking
it Lost is offered instead — the record stays, its cost stays in the history, and it can
be found again.
Press Bring everything back. “Nothing is out” reads as a
good outcome, in the success tone — not as an error or an absence.
Look at the two Induction hobs: same name, same cost, same purchase
date, one Excellent and one Problematic. Two records, two ids. A quantity field on the
item would lose that, which is the modelling decision STORY-028 turns on.
Look at the Speaker: owner and holder differ. They are equal on almost
every other row, which is exactly why the distinction is easy to collapse — and the case
where they differ is the whole point of the module. EPIC-006 OQ1.
The Coffee urn has no cost. It was a gift; cost is optional.
The Serving ware cost is in JPY. Item cost is money and follows
ADR-0005 exactly like a Transaction — same parser, same formatter, digit count from the
currency. It is also the one linked item: its cost is read from the
Kappabashi expense and its own cost field is read-only. Edit that transaction in Money
and the item follows; try to delete it and you are refused, by name. Then open an
induction hob — the expense that bought both is not offered to either.
Raised by building it — not yet in a story
“Still out” and “packed for later” are not the same thing. STORY-030
says the screen lists every unreturned assignment. Read literally that files next
month's loadout under things to chase. The prototype splits them: kit for an event that
has already happened is out; kit for one that has not is a packing list,
and the tab's count excludes it. STORY-030 needs this distinction written in.
There is no record of when kit physically left. Days-out is computed
from the event date, which is the only date available — so an item loaded into
the van three days early reads as out for three fewer days than it was. Good enough, and
worth stating rather than discovering.
Quantity is on the assignment but not on the item, per STORY-029.
The Chafing dish set × 4 is the only honest use of it — an item record that genuinely
represents a set. Is “× 4” on an assignment worth the confusion it invites against
STORY-028's one-record-per-object rule?
An item may now name the Transaction that bought it — decided 2026-08-14.
Both held a cost and nothing joined them, so correcting one left the other stale with
nothing to say so. It was explicitly out of scope in STORY-028, and it only became
visible once both modules existed in one prototype. The link is optional both ways and
1:1, and where it exists the books win: cost and purchase date
are read from the Transaction on every render rather than copied, so drift is not
expressible. Deleting a linked expense is refused and names the item — the same rule as
deleting an item that is on a loadout.
The two induction hobs are the counter-example and are left
unlinked on purpose: one receipt bought both, and a 1:1 link cannot say what each cost.
Letting them share it would double-count the purchase. Splitting an expense across items
is now named as out of scope rather than unnoticed.
Two bugs worth recording
Presenting a second panel over a first deleted
the first from the document. The panel-in-two-homes pattern moves one element
between a phone view and the dialog. Presenting swapped the modal slot's contents with
replaceChildren(), which does not put the displaced panel anywhere — it drops
it. Opening a loadout and then “Add equipment” therefore destroyed the loadout panel, and
the next lookup inside it returned null. That is a crash on the one path the
whole module is built around. Presenting now returns the current panel to its own home
first.
dialog.close() does not fire its
close event synchronously — the event is queued. So “dismiss this panel, then
immediately present the next one” (which is what confirming a loadout does) races: by the
time the queued handler runs, it restores the new panel to its off-screen home and
the dialog goes blank. A programmatic dismiss now marks its own close as handled; Escape and
the backdrop still fall through, which is the case the handler exists for. The same
dismiss/present pattern was also in money-flow/ and core-loop/, where
it had not yet triggered; those prototypes are gone and the fix travelled with the shell
when it was merged, which is the whole argument for there being one.
Components this found
Selectable row — whole-row checkbox target with title, meta and a
trailing badge. Used by the item list and both pickers.
Selection bar — bottom-sticky, count on the left, actions on the right,
stacking to full-width buttons on a phone.
Group heading with a bulk action — accent edge, title, meta, and the
action pushed to the end.
Members — what building it settled
Reading the roster is not an Owner action. The seven Owner-only
actions are about changing the team, not seeing it, and a workspace that hides your
colleagues from you reads as somebody else's. So a Member gets the full list and a
sentence saying who may change it — never a disabled control with no reason, and
never a blank area. Join requests are the one thing a Member does not see at all:
knowing who is trying to get in is itself information about the team's boundary.
The seat refusal is not a Free problem, which is how it was written.
EPIC-001 phrased it as "on Free, approvals always fail". Building it showed the same
refusal on Pro the moment its three seats are taken — so the copy names the plan's
seat count rather than naming Free, and the same sentence works on every tier.
A refused approval keeps the request. Dropping it would make a
billing limit destructive, and an Owner who upgraded would have nothing left to
approve.
The invitation is refused at the invitation, never at acceptance.
Counted against seats plus invitations already out, so two invitations on a
two-seat plan cannot both be accepted. Failing on the invitee's click would be our
billing limit landing on somebody who has no idea we have one.
Inviting someone already on the team succeeds and does nothing. It
has to: "that person is already a Member" tells anyone who can reach the form who is
on the team. The screen says the invitation was sent, and no row appears.
"Remove" reads as "delete everything they did", and it is not. The
confirmation says what survives — tasks, transactions and events stay, still
attributed — and that any other workspace they belong to is untouched. Two guards
refuse outright rather than warning: the last Owner cannot be demoted or removed, and
the last Member cannot leave.
Three Members on Free is a real state, and it is seeded on purpose.
A workspace that downgrades keeps everyone it already has — the cap stops the next
person, exactly as the contacts cap already says out loud. Seeding it under the limit
would have hidden the difference between "blocked" and "removed".
"View as Member" now demotes you for real here. The toolbar switch
and this screen were about to disagree about your own row, so the row reads from the
switch and changing your own role writes back to it — and so does the drawer, which
states your Role.
There was no way out of the product. STORY-039 has always asked for
the workspace switcher and the account menu to be reachable from the menu; the drawer
held destinations and nothing else, so the app had no sign-out at all and nobody had
noticed, because every prototype session starts signed in. An account block now sits at
the foot of the drawer with your name, your address, your Role in this
workspace, and the two ways out. Last in the tab order: least used, most costly to hit
by accident.
Clicking it does not evidence STORY-002. A static prototype has no
session to invalidate and a back button that cheerfully returns — the half of that
criterion that matters cannot be shown here at all.
Account — closing one is not a confirmation
It sits outside Settings, and that is the first decision. Settings is
the workspace — its Members, its currencies, its name. The Account is the person, and a
person outlives any workspace: the same account holds every Membership. The screen lists
workspaces the app holds no data for, which is the proof it cannot live inside one. The
API draws the same line, where account routes never set tenant context.
A single red button models it wrongly. Almost all of STORY-010 is
invisible — hard deletion across backups, caches and indexes, the statutory window, the
audit entry that outlives the deletion it records. The part that is a screen is the
decision structure, and it is one question per workspace you are the last Owner
of. Until every one is answered there is nothing to confirm, and the button says
which answer is missing rather than sitting greyed out.
“I will hand it over first” is not an answer. It is a statement of
intent, so it links to Members and leaves the account blocked. Promote somebody there
and the question disappears by itself on the next render — sole ownership is derived,
never stored, which is what makes the two screens impossible to leave disagreeing.
Not a dialog, and the story assumed one. STORY-010's accessibility row
called for a focus-trapped destructive confirmation. A dialog you cannot answer without
leaving it to go and promote a colleague is a trap in the bad sense. Corrected in the
story.
The export says what it does not contain. Somebody asking for
"everything about me" reasonably expects the workspace's guests and money too — and
those are the customer's data, not ours. Saying so on the screen costs a sentence and
saves the support email.
The grace period is the whole reason this is not a foot-gun, so the
confirmation names the date and keeps a way back on screen rather than mentioning
thirty days somewhere in the small print.
General — the last screen, and the most destructive
Deleting an Organisation sat behind a disabled tab until the end. It is
the most destructive action in the product and the easiest to defer, because nobody demos
it — which is how it ends up designed in a hurry during implementation, with a
confirmation that is one button.
Three different acts, and each one says which of the other two it is not.
Leaving a team (Members), closing a workspace (here), and closing your account
(Account) destroy different things and survive differently. The Account screen points
here for the workspace; this screen points at Members for merely leaving.
Typing the name is not theatre. It is the one action in the product a
colleague cannot undo for you after the grace period, and it takes other people's work
with it. Matched case-insensitively through the same fold the search uses — a
confirmation is not a spelling test, and an exact match would be wrong in Turkish for
the two-i reason this prototype has already been bitten by once.
The consequences are counted, not described. "Everything in it" is
abstract until it reads five events, seven contacts, thirteen transactions and ten items
of equipment.
A Member is not shown a disabled closure form. There is nothing they
could do with it, and a greyed-out destructive control is an invitation to try. The name
stays visible and read-only, because it is a fact about the workspace they are in.
Two sweeps, run once, worth more than the clicking
Both are things design.md and
i18n.md ask for and the prototype had never had. Between them they found
three defects that months of clicking had not, because all three look correct on screen.
A duplicate id, and a button wired to nothing. Money's empty state and
Inventory's both used clearFiltersBtn. Every lookup found Money's, so
Inventory's "Clear the filters" did nothing at all and Money's ran two handlers. Invisible
to a click-through: the button is there, it looks right, and the screen it is on is one
people rarely reach with filters applied.
Fifty-one strings with no Turkish at all — the person form, the
transaction form, Venues, the Inventory empties. Coverage looked complete because the
screens that get demoed are the screens that were translated. Written now; the real
lesson is that a person sweeping once will not sweep again next week, which is the
argument for the build-failing lint i18n.md requires.
Two sentences split by markup. A <strong> around
one word and a <span> around a number each cut a sentence into three
text nodes, and one of the fragments was ". Paid plans are unlimited…" — a
fragment beginning with a full stop, which no catalogue can hold and no language can
reorder. Fixed at the source, which is the only fix. This is precisely what
i18n.md means by never concatenating fragments, and it arrived through
markup rather than through string concatenation.
Straight versus curly apostrophes silently miss. Two keys were typed
with ’ and their markup with '. A gettext-style catalogue
matches exactly, so the string falls back to English with no error anywhere — the same
failure mode as changing the English and forgetting the catalogue, which this file's own
header warns about.
An empty aria-labelledby on the dialog, which is a
reference to nothing rather than no reference. Removed; present() sets it.
And a lie on the one screen nobody can reach. The "not prototyped"
screen still said the prototype covered "the money capability" and offered to send you
back to Money. It has had no instance since every destination was built, so nobody read
it for two days.
Turkish found one more, in our own patterns
A greedy capture ate a separator. The translation
layer matches whole strings first and only then splits meta lines on · ,
so the pattern for “Free includes 1 seat” — written with (.+) —
matched “1 Member · Free includes 1 seat” with the plan name captured as
“1 Member · Free”. Half the line came back translated and half did not, with no
error anywhere. The fix is [^·] rather than . in any pattern
whose text can sit second in a joined line, and the item cost added the same day had the
same bug. It is the fragment-joining hazard i18n.md names, arriving from the
one direction nobody watches: the translation catalogue itself.
Tasks — what building it settled
Journal is not in this product. Decided 2026-08-12, before any of
this was built. It is a capability in the reference workbook and dropping it is a
deliberate departure, recorded in EPIC-005 and in the epics README rather than left
as a silent gap.
Overdue is the viewer's day. The glossary said the viewer's; the
epic and both stories said the Organisation's. One line against three, and the
glossary won — a due date is a fact about a person's day, where an event time is a
fact about a place. The cost is that two Members in different zones can disagree by
one day, and that is accepted.
One select per row, not two. The guest list left this open —
"two selects per row is visually heavy at forty guests". Status changes inline
because STORY-026 asks for it; priority is a badge, editable in the panel, because
it is set once and rarely revised. At the prototype's density — five to six visible
rows — it reads distinctly lighter than the guest list's two selects plus a pax
input. But the guest list's question was specifically about forty rows, and this
seed never tries forty: suggestive, not settled. Nothing here argues against
carrying it to the guest list; it just has not been tried at that count.
The default hides work nobody owns yet. "Mine and unfinished"
hides "Renew the public liability cover" — High priority, Open, one
day overdue, assigned to Jo Anderson. An overdue High-priority item is invisible on
the default screen purely because it belongs to someone else. The trade cuts both
ways: the default exists so nobody reads forty rows to find their own work. Whether
it needs a nudge toward unassigned or overdue-but-not-mine work is open — see
"Raised by building it" below, which already notes nothing surfaces unowned work.
Try this
Open Tasks. The overdue row is first and says how late it is in
words — the colour is the third signal, not the only one.
The task due today is not overdue. That boundary is the whole
timezone decision.
Set a due date eighteen months out: warned, not refused, and the
button becomes "Save it anyway".
Set a due date before the start date: refused.
Open an Event. Its tasks are there, above Equipment, and repeat no
event name on their rows. "Add a task" opens the same panel with the event filled
in — dismissing returns to the event, not to Tasks. The same task renders
identically in both places.
Switch View as Member. Nothing on this screen changes: Tasks are
not an Owner-only capability, and if anything moves, that is a bug.
Measured with the tap counter: creating a task is 3 taps (New
task, click the description field, Save); an inline status change is 1
tap. Two caveats about the counter itself, both pre-existing: it counts the
space bar, so a multi-word description inflates the number; and a native
<select> popup is drawn outside the page, so picking the option
fires no page pointerdown — a status change is two physical actions but registers
one.
Raised by building it — not yet in a story
The fourth section settled it: one Event screen, four tabs. The
merge found two "event detail" screens that did not know about each other — guests and
attendance in one, budget and profit in the other. Tasks made three, Equipment four,
and four cards on one scroll is not a screen: the fourth is below the fold on every
phone, and Equipment is the one used standing next to a van. Decided 2026-08-14. Counts
sit on Tasks and Equipment, whose contents you otherwise cannot see; Guests needs none,
because the tally directly above it is the same number said better.
Unowned work is now counted out loud, not modelled. Every Task has
exactly one assignee by design, so "nobody has picked this up" is still not
expressible — and STORY-026's default hid other people's overdue work as well. Decided
2026-08-14: keep the single assignee, and have the list say how many overdue tasks the
filters are hiding, with a control to show them. Counted against what is on screen
rather than against "not mine", because a priority or event filter hides an overdue
task just as completely. Making the assignee optional was the alternative, and it buys
an unassigned bucket at the price of every task being allowed to belong to nobody.
What differs between the two
Navigation. Seven destinations is past what a phone tab bar holds,
so the phone gets a drawer behind the menu button. Desktop shows the same list as a
persistent sidebar — hiding navigation where there is room to show it
costs discovery. Same destinations either way.
Filtering. Phone gets one toggle, “show completed and cancelled”.
Desktop gets multi-select status, venue, relative date range, and sort. Same data and
same results — only the controls differ.
Primary actions go full width on phone, inline on desktop.
The auth card is the same component at both sizes — it is
max-inline-size, so it simply fills a narrow screen.
Try this
Add sample events (top bar) seeds four events across every status,
so the filters and the default view do something visible.
Note the phone shows 2 of 4 and desktop 4 of 4 —
the phone hides completed and cancelled by default.
Event times show the venue’s zone, with yours alongside only when
it differs.
Still unanswered
The empty workspace is the highest-churn screen. Does “No events
yet” give you enough to act on?
The Free limit appears on a second event. Helpful, or nagging?
Request to join ends in a waiting state that grants nothing. Clear,
or a dead end?
The three figures
Per-event profit attributes costs to events. Business profit sums everything. Any cost
belonging to no particular event lands in the second and not the first, and the two drift
apart by exactly that amount, silently. That is the state the reference
workbook was in.
The fix is not to force attribution — Overhead is real. The fix is to show
it as a line, so the arithmetic is visible:
attributed + overhead = organisation.
The identity is checked on every render. If it ever failed, the summary
withholds all three figures and says so, rather than showing one that is plausible and
wrong. Press Break the identity in the prototype toolbar to see that state.
It holds per currency. Nothing is ever summed across currencies and no
exchange rate is applied.
The amount field
Every amount here is an integer of minor units plus a currency code.
Parsing goes string → integer and formatting goes integer → string → Intl.
There is no point in the file where a fractional quantity exists to be rounded.
Decimal places come from the currency. The seed data is EUR (2 digits)
and JPY (0) deliberately. Switch the currency on a typed amount and watch
12.50 become invalid rather than silently becoming ¥13.
Separators come from your locale, not from a guessed regex. The hint
under the field shows what your browser thinks 1234.5 looks like.
Direction carries the sign, so the amount is always positive and a
typed minus is refused with an explanation rather than quietly absorbed.
Telling the currencies apart
The real risk with per-currency figures is not ugliness, it is a reader carrying the
EUR total in their head three inches down the page and reading it against JPY. A muted
“In EUR” heading is nowhere near enough separation for that.
So on Dashboard and Summary there is a
currency deck: one currency at a time, in a card that names it in 28px
with its full name beside it, stepped through with prev/next arrows and a “1 of 2”
count.
The usual objection to a carousel is that it hides things people never
find. The answer here is that each control carries that currency's
headline total — JPY ¥83,000 on the dashboard, its
reconciled organisation profit on the summary. You can see that JPY exists and roughly
what it holds without switching to it. It never auto-advances, and arrow keys work.
The choice follows you between the two tabs. Switching from Dashboard
to Summary must never silently put you in a different currency.
Where two currencies genuinely do belong on one screen — the transactions strip, an
event with costs in two — they stay side by side, but the divider gains an
accent edge and full text weight instead of being a grey line.
It scales: a third currency is a third chip, and the strip scrolls.
The dashboard
A bento grid: the chart takes two columns and two rows, the lead
figures take two, the rest take one. It collapses to two columns and then to one, and
the spans collapse with it.
One chart per currency: profit by event. The reader's job is "which
nights made money and which lost it", which is polarity — so it is a
diverging bar either side of a zero baseline, not five bars all
starting from the left edge. Blue and red rather than green and red: they read as
opposite without being the one pair red-green colour blindness collapses.
Colour is never the signal. The side of the baseline carries the
sign, every bar is labelled with its own value outside the mark, and a loss is worded.
Nothing is lost in greyscale. Each bar is a button that opens that event, and its
accessible name carries income, expense and profit in full.
Budget utilisation is a meter, not a chart — a single ratio against a
limit. The track runs to whichever is larger, the limit or the actual, so the JPY
overrun shows as length rather than as a bar that has simply stopped.
Every chart has a table twin — “Show as a table”. The tooltip
enhances; it is never the only way to reach a value.
What is deliberately not charted. Income, expense and profit are
three headline numbers, and three headline numbers are a stat tile each — a grouped bar
of them would be a chart of nothing. There is no time series either: no date range is in
scope, and a trend line over five events would be an invention.
What differs between the two
Secondary surfaces. The transaction form and an event's money panel are
one element with two homes: a full screen with a back button on the phone,
a dialog over the page on desktop. Moved, never duplicated.
The transaction list. Phone stacks each row; desktop gets a real table
with column headers, because comparing amounts down a column is the whole job.
Filtering. Phone gets one control — direction, or overhead only.
Desktop gets direction, event, currency and category. Same data, same results.
The chart's labels. On desktop the event name sits beside its bar;
on a phone a 9rem name column would leave the plot about 60px, which is not a chart, so
the name moves above the bar and the track takes the full width. The comparison the
chart exists for survives — only the layout changes.
Navigation is a drawer on the phone and a sidebar on desktop, with the
same eight destinations as the signup prototype.
Try this
Record an expense of 0. Refused — a zero row reads as unfinished, and
allowing it makes "unfinished" and "genuinely zero" indistinguishable (EPIC-004 OQ2).
Type -40. Refused, and the message points at the direction control rather
than just saying "invalid".
Enter 12.505 in EUR. Refused rather than rounded — nothing you typed is
silently discarded.
Type 12.50, then switch the currency to JPY. It goes
invalid immediately, with the reason.
Switch Money out to Money in with a category chosen.
The category clears and says why — a category belongs to one direction.
Leave Attributed to an event as Overhead. It saves without complaint,
then appears on the Summary tab as its own line.
On Summary, open See the transactions behind it. Overhead is
never an unexplained lump — and one of the rows is income, because nothing
requires an unattributed receipt to be a cost.
On Dashboard, read every basis line. Average profit says over
how many events and which are excluded; Budget utilisation names its denominator.
Press Force a tie. Most profitable shows both events, not an arbitrary
first.
Press Empty it. Counts read zero, no average is attempted, and budget
utilisation reads Not set — never 0%.
Open Tokyo pop-up from the dashboard, then Late-summer supper
club. The second has spend in two currencies against a budget in one.
Delete a category that is in use: refused, with the count. Add
ingredients as an expense category: refused as a duplicate of
Ingredients. Add it as an income category: allowed — the rule is per
direction.
Nothing here is gated
The workspace is on Free and sees every figure. pricing.md
is explicit: no feature gating at launch — financial reporting, inventory,
multi-currency and export are all on Free. You pay for scale, not for
capability.
What Free limits is active Events (1), seats (1) and contacts (100).
None of those are consumed by anything on these screens, so no limit prompt belongs
here.
The entitlement seam still exists (STORY-033) — a capability is a
yes/no lookup that currently answers yes on every tier, so gating something later is a
row in the plan catalogue rather than a refactor. It is just never answered no
today, and the UI must not pretend otherwise.
Settled here
Overhead is a separate line, never apportioned across events (EPIC-004
OQ1, resolved 2026-08-10). Every figure stays traceable to rows.
Zero amounts are refused (EPIC-004 OQ2, resolved 2026-08-10).
A broken identity is a hard failure, not a caveat. The figures are
withheld.
Every figure states its basis in the UI. A bare number on a dashboard is
how a wrong one survives for months.
Raised by building it — not yet in a story
"Most profitable event" cannot rank across currencies. STORY-024 treats
it as one metric; with a EUR event and a JPY event there is no honest single winner without
a rate. The prototype shows one per currency. This needs a line in STORY-024.
An event's budget is one amount in one currency; its spend need not be.Late-summer supper club has EUR and JPY costs against a EUR budget, so its JPY
utilisation reads "not set for JPY". STORY-022 does not cover this case.
STORY-024 lists charts as out of scope. This prototype has one, and
it earns its place — it is what makes "most profitable event" and the spread the average
sits in legible at a glance. If the chart stays, the story's scope needs amending; if the
story is right, the chart comes out. Not decided here.
Where does the dashboard live? It sits as a tab under Money here to
avoid inventing a ninth navigation destination. Its three event-count tiles are not
really "money", so the placement is a question, not a decision.
Editing and deleting a transaction need an actor and timestamp
(STORY-020). Nothing in the UI shows that trail yet — is it a per-row history, or an
organisation-level audit screen?
Settled by clicking it — now in STORY-041
The Member hard stop stays. Enabling is Owner-only and there is no
inline escape, so a Member who needs an un-enabled currency is stopped twice — they
cannot add it, and cannot reach the screen that would. STORY-013 states the opposite
principle for venues: that sending someone to settings mid-task loses what they were
writing. Walked as a Member here and kept anyway — the everyday list
staying genuinely short is worth more than the rare mid-task block. Press
“View as Member” to see what was judged.
Renamed zones needed a small alias map — and it is not the committed list
i18n.md forbids.supportedValuesOf('timeZone') lists
only one half of each IANA backward pair, and which half is
version-dependent: this browser has Asia/Calcutta and not
Asia/Kolkata; a newer one reverses it. So “Kolkata” found nothing. Matching
the zone's long name does not help either — that is “India Standard Time”.
Fixed with ~14 pairs applied in both directions, and the row now
shows the current city name with the identifier beside it: heading a Kolkata search with
“Calcutta” makes a user doubt they found the right place. Try kolkata,
calcutta, kyiv, kiev, yangon. The map is presentation
only — the stored value is always the runtime's own identifier, and every zone stays
selectable whether or not it appears in it.
Components this found
Figure — label, value, and basis. Used by every tile, every total, and
every reconciliation row. The most reused thing in the flow and the one most worth
promoting into design/components/.
Money input — amount plus currency as one control, with
currency-derived precision. STORY-020 calls it "the highest-risk field in the product".
Segmented radio group — real radios in a fieldset, used for Direction
here and reusable wherever a choice is binary and worth showing both sides of.
Diverging bar and meter — the two chart forms this
product actually needs. Both are plain HTML and CSS with no library, and both are
theme-aware because every value is a token.
Currency deck — the switcher plus its card. Its one real idea is that
the controls carry each option's headline figure, so nothing is hidden by being
off-screen. Reusable anywhere one of several parallel sets of figures must be read
without being confused for another.
Two chart-spec deviations, on purpose
No 48px hero figure. The type ladder tops out at 36px and
design.md rejects off-scale values, so the lead tiles use 28px and the
emphasis comes from the grid instead. Rules outrank a skill.
The meter's unfilled track is the neutral sunken surface, not a
lighter step of the fill's own hue. There is no light accent step among the semantic
tokens, and reaching into the private palette is forbidden.
Both mark colours were
measured, not eyeballed: --color-accent is 4.62:1 light / 7.13:1 dark against
the card, --color-danger 5.44 / 7.48, and the zero baseline in
--color-border-strong 3.23 / 3.01 — all clear of the 3:1 floor for a
meaningful boundary.