Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-shared-library
A customer-readable note on a negotiated agreement
The internal `notes` field is where staff record what was conceded and to whom. The customer's own plan page needs prose it can show them, and one careless sentence in a field written for colleagues was the only thing between the two. So the customer-facing sentence is its own column, which somebody has to deliberately write. Null is the default and the common case: an agreement with no customer note still renders in full from its structured terms, it just renders without the prose.
· kamo-internal
The shelf, its slide-out pane, and its place on the first row
Every app the member may open, with a pin, a search, and a pane that slides out of whichever side has room for it. Locked to the launchpad's first row by lib/homeFirstRow, applied to the rendered order in page.tsx.
· kamo-internal
The model — sub-apps, search, collections, flyout geometry
· kamo-internal
A system widget has no rights, and a rows-only resize handle
· kamo-internal
First-row arithmetic for a pane locked to row one
· kamo-marketing
Dress the site for eighteen days of the year
On ~84 days a year the marketing site now decorates itself, automatically, from the reader's own calendar. Snow falls through December and settles on the cards it lands on. Two beams of light stand still on September 11. Easter hides eggs out and hands you a lantern. Eighteen scenes, one per occasion, each a different mechanic rather than the same particle field in a different colour. WHY THE CLIENT DECIDES THE DATE Two independent reasons, both of which look fine in development. These routes are statically prerendered — check-static-routes.mjs exists to keep them that way — so a page built in August carries August's answer forever, and Christmas would arrive at the next deploy rather than on December 1st. And "is it Christmas" is a question about the READER's calendar: a server in UTC tells someone in Auckland it is still Christmas Eve for most of their Christmas Day. HolidayLayer reads new Date() after mount, re-resolves at the reader's own local midnight, and nothing in the tree touches cookies() or headers(). All 187 routes stay prerendered. WHAT IT COSTS ON AN ORDINARY TUESDAY Nothing eager. The resolver rides in ChromeBundle, which was already fetched at idle on every page, for the reason written on that file: two lazy chunks that always load together are one request too many. Past a further dynamic() sit the greeting and eighteen separately-split scenes, ~8-20 KB each, so a visitor on Halloween downloads the pumpkins and not the snow. Verified: no holiday chunk is referenced anywhere in the prerendered HTML. THE WINDOWS DO NOT COLLIDE, AND THAT IS NOT AN ASSUMPTION Four windows are pinned to moving feasts. Easter slides 35 days dragging Mardi Gras with it; Thanksgiving slides 7 dragging Black Friday and Cyber Monday, which can land inside the Christmas window; and the football championship is the second Sunday in February, which IS February 14 in 2027 and 2038. check-holiday-calendar.ts walks 2026-2040 and asserts the resolution. It earned its place immediately: it found three years where a scene vanished for the WHOLE year — the Big Game in 2027 and 2038, Mardi Gras in 2040 — each swallowed by Valentine's. The fix is not cosmetic. With one shared day-of boost the constraints are unsatisfiable: for the game to hold its Saturday its base must exceed Valentine's, and for Valentine's to hold the 14th its base must exceed the game's. A shared boost cancels and the two contradict. So the boost belongs to the holiday, and the number now says something true — how hard a day insists on being itself. The two remembrance days sit at priority 95 and the guard asserts separately that nothing ever covers them, because a priority is easy to edit without noticing what it was protecting. TONE IS ENFORCED, NOT IMPLIED patriot-day and veterans-day are tone: "solemn": no bursts, no confetti, no sparkle, no click reward, and copy with no exclamation mark. The greeting drops its glow and its coloured title for them. The site marks those days; it does not sell them. LICENSING Every scene is drawn at runtime from canvas paths and gradients authored here. No third-party artwork, fonts, logos, photographs, audio or character likenesses. Two names are deliberate: "The Big Game" never reaches for the championship's actual trademarked name, and Black Friday's price tags carry no number or percentage, because a discount on screen is a claim and this is decoration. THE FOOTER PICKER IS TEMPORARY Seventeen of the eighteen are unreachable on any given day, so the footer carries a "Seasonal scene" control, and ?holiday=<id> makes a dressed page shareable. HolidayPicker.tsx lists the three deletions that remove it. Defects found and fixed on the way, recorded because each was invisible to the compiler: reduced-motion still frames drew before useShelves had sampled (child effects run before the parent's); quality resolved in an effect AFTER onResize had sized the particle field, so a desktop drew a phone's worth of snow for the life of the page; and the overlay layers originally sat above the cookie consent banner and the toast stack, which meant Halloween's dusk sheet was dimming a visitor's consent choices by 34%. Dictionary first: the greeting copy landed in kamo-translation-dictionary as 52974da5 before this commit, because messages/ is gitignored here and copied in at image-build time. copy.ts carries an English fallback for that window anyway — the greeting asks t.has() before t(), so a dictionary one deploy behind can never put a key path on screen. tsc clean, all 14 build guards green, next build succeeds, 187 routes prerendered.
· kamo-internal
Fix Verified, a reason on every denial, and a Requires Verification filter
The status menu on /support?tab=system-bugs leads with "Requires Verification" and a rule, then the individual statuses. It stands for NEW, IN_PROGRESS and FIXED — every report whose fix nobody has confirmed — so it gets no colour swatch: a dot would claim it was a status, and it is a question. Its count is summed from the three grouped counts /status-counts already returns, so the number is a promise about what picking it shows without a second round trip. The Divider is a direct child of the Select, not nested in a fragment; nested, it is not a child of the menu at all and the separator silently disappears. Fix Verified is drawn from the SERVER's canVerifyFix and nothing else. For the member who filed the report that flag is true while canManage, canEdit and canVoid are all false — so any attempt to derive the button from the other flags gets exactly the case it exists for wrong, and gets it wrong silently, because the platform-administrator case still works. Picking Won't Fix / Denied opens a prompt for an explanation before anything is written. The failure that pins is the one that looks like it works: a status change fired straight from onChange, with the reason box shown afterwards over a decision already saved and an email already sent. The reason is optional, and what is typed reaches both the reporter's email and the report itself. The stored reason renders under Change Status for whoever may retriage, and under the description for the person who filed it, who has no such control — one placement, two readings, because the block follows whatever came before it. The AI dispatch history is gone from the drawer, and with it `dispatches` from the detail type. Three statuses go. A saved filter naming one lands on "every status" rather than an error, and VERIFIED reads "Verified as Fixed" under a NEW dictionary key — the enum name is untouched, because the server stores statuses by name.
· InitializerService
Add denial_reason, and sweep the three retired status names
status_name holds a NAME read back through @Enumerated(STRING), so a row still saying TRIAGED after the constant is gone does not read as a default — it throws `No enum constant` and takes down every query that touches the row, the paged listing included. That is the failure the name-over-ordinal decision traded FOR: an ordinal shrink is silent, a name shrink is loud. The sweep runs AFTER the ordinal backfill, which is frozen history and still maps the column as it stood — rewriting that backfill to skip the three would silently reclassify rows on any database that has not run it yet. TRIAGED and CANNOT_REPRODUCE both meant open work, so both land on NEW where somebody will see them again; AWAITING_INFO does too, since NEW is the state that reopens the reporter's edit window, which is what that status existed to do. The dispatch audit's copy goes to NULL instead — that column records what an operator CHOSE, and null already means "they left the status alone", which is nearer the truth than claiming they picked one that no longer exists. denial_reason is nullable TEXT with no default. Unlike published, no default is needed for the ALTER to succeed on a populated table: Postgres only refuses ADD COLUMN without one when the column is NOT NULL. Applied by hand to the live database, which held zero rows in all three statuses. The statements ship anyway — "there were none when I looked" is not a property a migration can rely on.
· MediaService
Let the reporter confirm their own fix, and carry a denial's reason
Adds POST /{id}/verify-fix — the one status change in this feature that is not gated on MANAGE_SYSTEM_BUGS. A separate endpoint rather than a hole in the one beside it, and that is the whole design: changeStatus takes a status name from the body, so relaxing its gate would have handed the member who filed a report the ability to set any status on it, including Won't Fix on somebody else's decision. This takes nothing. The destination is VERIFIED, the departures are REQUIRES_VERIFICATION, and the caller is the reporter or a platform administrator. There is no parameter here for a future change to widen. mayVerifyFix answers both halves — whether to draw the button **************** and whether to honour the press — because two implementations is how a button appears for an action the endpoint refuses, and the version of that bug found last is the one where it works for administrators and not for the reporters it was built for. changeStatus carries the denial reason, and applyStatus writes it for WONT_FIX and clears it for everything else in the same write as the status. A caller with nothing to say passes null rather than skipping it: a denial with no reason still has to clear whatever a previous denial left behind. The status filter accepts REQUIRES_VERIFICATION as a reserved token in ?status=. Not a SystemBugStatusType — it is a saved question rather than a state a report can be in, and adding it to the enum would have put it in the triage dropdown, on every card, and in the status_name of any row somebody selected it on. An unknown or retired status name still reads as "every status", so a bookmark naming one of the three just-retired ones lands on a full list rather than an error. The detail no longer projects the AI dispatch history. It told a reader nothing about the report — which model a hand-off used is operational detail for the person making the hand-off, and Send To AI's own preflight already serves it from SecurityService. The audit rows are untouched; a query per drawer open goes.
· kamo-shared-library
Retire three statuses, name what Verified verified, and record why a report was denied
Removes TRIAGED, AWAITING_INFO and CANNOT_REPRODUCE. Each described how confident an operator felt rather than where the report had got to, and none of them moved it anywhere: a triaged bug is one somebody is about to work, which IN_PROGRESS already says, and one nobody could reproduce is either still open or deliberately not being done. AWAITING_INFO took the second half of isReporterEditable() with it — NEW alone now, and an operator who needs more detail moves the report back there, which reopens the same window. status_name stores a NAME, so a surviving row holding one of the three does not degrade to a default: it throws `No enum constant` and takes down every query that touches it. SystemBugSchemaMigration sweeps them to NEW, after the ordinal backfill that can still write them (that backfill is frozen history and must keep mapping the column as it was). The live database held none. VERIFIED keeps its constant and gains the label "Verified as Fixed" under a NEW dictionary key — the wording alone left the reader guessing what had been verified, and translate.ts only visits keys it has not seen, so editing English in place would have pinned all 21 locales to the old word forever. WONT_FIX now carries the operator's reason. It is written with the status in one call, because the reporter's letter goes out on that write — a reason arriving a moment later would be missing from the one place it was asked for. Any move off the status clears it, so a report denied, reopened and later fixed does not keep showing last month's refusal. Optional, deliberately: refusing to record a denial until somebody types a sentence only teaches people to type a full stop. REQUIRES_VERIFICATION is the set with something left to confirm — NEW, IN_PROGRESS, FIXED. One definition, because two surfaces read it and have to agree: the console filter that lists them and the Fix Verified button that acts on one. findVisible narrows by a SET now rather than a single status, never null and never empty — "any status" is the whole enum, which sidesteps the null-collection parameter Hibernate cannot express. The email's callout moves from AWAITING_INFO to FIXED and names the button, since a control the reporter would never go looking for is no use unmentioned.
· kamo-internal
Lock the platform rights an operator may not grant
SecurityService now refuses to grant a right the caller does not hold themselves — an administrator may create a peer or a lesser, never a superior. The Manage Rights dialog has to draw the same line, or the operator meets it as an unexplained 403 after ticking boxes that looked available. Those switches are locked rather than hidden, with the sentence saying why in place of the right's description and a count in the header. A control that vanishes reads as a missing feature; one that explains itself reads as a rule. Grant all grants what this editor may grant, and the row click is closed off too, so the switch is not reachable around the side. A right the target ALREADY holds stays live even for an editor who lacks it. The dialog sends the complete set rather than a diff, so that right appears in every save; locking it would make every administrator more privileged than the person editing them impossible to edit at all. That is exactly the delta the endpoint judges, so the two agree switch for switch.
· SecurityService
An administrator may grant a peer or a lesser, never a superior
MANAGE_PLATFORM_ADMINS is the right to administer the platform's access list, not a right to invent access. Without a ceiling it was silently every right at once: whoever held it could grant themselves IMPERSONATE, or grant it to somebody who would hand it straight back, and handing out rights one at a time meant nothing. Both writing endpoints now refuse to grant a right the caller does not hold themselves, naming the ones they refused so the console can say which. God is the one exemption, the same one that already governs editing a god's rights. The rule is judged on the delta rather than the submitted set. The dialog always sends the complete set it wants, so a right the target already holds is not being granted by this request even though it appears in the body — reading the whole set as a grant would freeze every administrator more privileged than the person editing them, since no ordinary change to them could be saved at all. Taking a right away stays open to anyone: revocation is de-escalation. The comparison is against the caller's resolved rights, which is what leaves an owner of the top-level organization and an open god-mode window unrestricted without a special case for either.
· MediaService
Relay coder usage onto a per-member topic
menu subscribes to over the existing chat STOMP connection. Built as a twin of the training relay — same subject shape, same single-token rule, same core-NATS-with-no-stream decision — because it is the same kind of thing: a small live number a console seeds over REST and then keeps current. The destination is guarded in StompDestinationAuthz on SUBSCRIBE and on SEND, and it needs its own reason rather than an analogy to the badges above it. A member's remaining allowance is a live readout of how much they have been working and how hard, refreshed while they type. Unguarded it lets anyone on the socket watch a colleague's working day; and because the simple broker routes by destination without caring which command carried the frame, a guard on SUBSCRIBE alone would still let a client write a colleague a comfortable number minutes before they run out.
· kamo-shared-library
List every provisioned account on one dev machine
findEnabledProvisioned asks existsEnabledProvisioned's two conditions of a can put one question to the agent per tick rather than one per member.
· SecurityService
Sweep the dev machine for coder usage and publish what moved
Feeds the two meters the Linux Desktop menu now draws — how much of a member's A sweep rather than a push, because the numbers are born on the machine and statusLine command, so a status line records them and the agent reads the recording; the agent has no NATS client and no credential for this service, and giving it one would open an inbound trust direction the design deliberately does not have. So this asks — once per tick for the WHOLE machine, which keeps the request count off the size of the fleet. It publishes only when a number actually moves. A window moves when a session spends it: a handful of times a minute while somebody is working, and never while nobody is. That is what makes a ten-second sweep feel live — the sweep is how fast we notice, not how fast the browser is told — and it is why the subject is silent by construction rather than by throttling. Per member because a colleague's remaining allowance says what hours they have been working and how hard. MediaService relays it to against the subscriber's own id. its first frame — core NATS keeps no history, so a console that only subscribed would show nothing until the member next spent something. It takes no member id: the Linux account comes from the caller's own session, which is the only reason a per-member number is safe to serve at all. The sweep never throws. An unreachable dev machine is an expected state, not a fault — it means the meters hold their last value, which is what they should do — and an exception escaping a @Scheduled method would log a stack trace per tick for as long as the machine stayed off.
· kamo-internal
Show how much coder allowance is left, live, in the Linux Desktop menu
windows is left, which window resets when, and whether what is on screen is current. It is the fact that decides whether starting a session is worth doing, so it is on screen the moment the menu opens rather than behind a chevron — a member four percent from their weekly limit should learn it while reaching for the thing that would spend it, not from a session that stops mid-answer. Live over the channel the HR badges already use. SecurityService sweeps the number actually moves; MediaService relays it onto a per-member STOMP topic this subscribes to over the EXISTING chat client rather than opening a fourteenth socket for two percentages. Core NATS has no stream behind it, so a frame published while the tab was asleep is gone — hence the REST seed on mount and on every reconnect, with a slow floor under staleness. Three things it refuses to do: gives a satisfying full bar that says "your whole allowance is here" to somebody who is being told nothing. The absence is preserved through every layer and this is the last place it could have been lost. * Round a member id through a number on the way to the topic. That is what check-int64-topic-ids.mjs exists for: the subscription would succeed and silently address nobody. * Throw inside the stompjs handler. An exception there takes the frame loop down and every other subscription on the shared client with it. `NavToolItem.render` is new and narrow: a row that draws itself, for reading rather than for choosing, non-interactive by construction. All three renderers — light popover, dark rail, launchpad card — pass it the surface it is on, so one node cannot be legible on one and invisible on another. Verified in a browser rather than in jsdom, which does no layout: the track was too faint against white for a 4% bar to read as "nearly out", and the two meters ran together as one four-line list. Both fixed and re-shot.
· kamo-marketing
Carry the no-credit-card mark on the nav Start Free too
The hero CTA changed but the nav button kept a Rocket, so the same action wore two different marks on the same screen — and the nav one is the button a reader sees on every page, not just the home hero. Both the desktop pill and the mobile sheet now use NoCreditCardIcon at 16px. 16 rather than the 14 the neighbouring nav icons use: the card carries interior detail (the stripe, the strike) that closes up below that, and the neighbours are single shapes that do not. The icon takes an optional `style` because SharedNavigation sizes its icons inline rather than with utility classes.
· kamo-internal
A tab per open window above the maximized pane
A maximized tool window with a navigator down its left side now gets a row of tabs across the top of the column beside it — one per OPEN window of the type the navigator's selected tab names. Clicking a tab gives it the pane; dragging reorders; each tab closes from its own x; and two arrows at the far right slide the row when it outgrows the width available. Which windows appear is decided by tool, from one table. BUCKET_FOR_TOOL moved out of ChatSwitcherColumn into toolBuckets.ts and is now read in both directions from a single literal — the navigator needs bucket-for-tool, the strip needs tools-for-bucket, and two hand-written maps for one fact fail silently in the worst way: a tool added to one and forgotten in the other simply never appears on the strip, with nothing to say why. Both chat buckets list every open chat, because a chat window carries a sessionGuid and no memberType and so cannot be put on one side of the teamMembers/members split; the terminal session and a plain shell are the same kind of window. Minimized windows get a tab like any other. That is the point rather than an edge case — the dock parks a minimized window off-viewport instead of unmounting it, so it is still open, and the strip is now a way back to it that does not involve hunting for its hexhead. The strip drags the store's existing window order rather than keeping one of its own, so there is no second source of truth to drift and nothing new to persist. Store order is the dock's, packed right-to-left from index 0, and the strip reads left-to-right — so it is that subsequence reversed, and `spliceStripOrder` undoes the reversal on the way back, writing the new order into exactly the array slots the strip's own members occupied. Every other window keeps its index, which is also what keeps the result the permutation setWindowOrder insists on. Closing the tab that holds the pane promotes the tab to its right, or to its left if it was last; only an emptied strip releases the pane. Mounted at dock level beside ChatNavigatorPanel, never inside ToolWindowShell. A strip inside the shell would be torn down and rebuilt by every tab click — the member navigating by demolishing the thing they navigate with — which is the same lesson the navigator was moved out for. Its scroll position survives a swap for the same reason, and no window's tree is touched by one. ToolWindowShell gains topInset beside leftInset: the strip is a dock-level pane above the window, so a maximized window starts at it and stops short by it, or the strip is drawn over the window's own title bar. The unread badge is fed through toolWindowUnreadStore rather than read from UnreadContext directly. ToolDock is deliberately mounted above AuthedChrome so it never unmounts across a navigation — which puts UnreadProvider inside it, not above it. useUnread() under the dock does not throw: the context has a full default value that answers zero for every count. A badge built on it would render, look right, and read zero forever. ToolWindowUnreadSync sits on the other side of that boundary, beside HexHeadUnreadSync, and pushes the answer across. Verified in a real browser as well as in jsdom: a pointer drag reorders, a plain click still selects, the x closes without selecting, the arrows scroll and disable at each end, and the strip's bottom edge lands exactly on the window's title bar rather than a pixel over it.
· kamo-marketing
Swap the Start Free arrow for a no-credit-card mark
The hero CTA carried a generic ArrowRight, which told a reader nothing they did not already know from the button being a button. The objection it has to answer at that moment is "what will this cost me" — so it now carries a white credit card struck through by a red diagonal. Hand-drawn as a component rather than imported: lucide-react has `credit-card` but no `credit-card-off`, and a lucide glyph is one `currentColor` throughout, so it could not carry the red strike anyway. The card stays `currentColor` (white on this button); only the strike is hard-coded, because it means "no" on every surface. 20px rather than the arrow's 16 — the card's stripe and strike are illegible at 16 next to 16px bold text.
· kamo-internal
Keep the dock clear of every scrollbar, and let members size it
Three things about the row of docked tool windows. **It stops covering scrollbars.** The dock reserves the viewport's right edge so it does not park a window on the page's bar, but it only ever looked for `.kamo-page-scroll`. This app paints its own 32px bar over every marked scroller — `.kamo-scroll` alone is used at 382 call sites, plus the grids, tables and panels the layer adopts by library class — and any of those flush against the right edge puts a bar exactly where the first window goes. The marker list now lives in `scrollbarModel` and both readers share it. The subtler half was wrong for longer: the gutter the browser reserves was both the test for "is there a bar" and the answer to "how wide", and it is reliably neither. Under overlay scrollbars — the macOS default — a scrolling element reserves no gutter at all, so the dock read zero and parked a window straight over a bar that was plainly painted. Measured in a real browser: scrollHeight 4000 over clientHeight 900, our bar drawn 32px wide, gutter 0, window right edge 1435 against a bar starting at 1408. It now asks whether the element is scrolling and reserves the width actually drawn; the same probe reports the edge at 1403, clear of the bar. Firefox failed the other way, reserving ~15px under the same 32px bar, and is covered by the same change. **Width by dragging a window's left edge.** An 11px band straddling the seam, so a press slightly over the target or over the neighbour to its left both land on it. The row packs right-to-left, so the window's right edge is anchored and pulling left widens it — every window further left shifts along, which is `layoutDock` doing what it already did. Held inside the tool's own `minSize.w`, and remembered per tool: widen a chat window and the next one opens wide. **Height by dragging the top edge — for the whole row at once.** Docked windows sit on the bottom edge with their tops in a line, and a row of different heights is a ragged edge rather than a row. The height lives in `dockSizeStore` and is imposed by `relayoutWindows`, the one choke point every path already runs through: opening, restoring a hexhead, un-maximizing, un-floating, a viewport resize, a snapshot restore. So a hexhead comes back at the height the dock is now, on its first frame, with no code of its own. Persisted to localStorage and read synchronously at module load, so the first window of a session already opens at the member's height rather than resizing a moment after it appears. Both seams light `--color-primary` while they are pulled; a height drag lights every window's top edge, because every window is moving. Both are `separator` roles with arrow keys, and the docked corner grip is gone — one control per property. Verified with headless Chrome against the real layout engine, since jsdom reports no scrollbars at all: the reserve, the overlap it prevents, and both drags (width 460 -> 580 with the right edge held at 1403; height 690 -> 840 with the bottom held).
· KlusterServices
Report each account's coder allowance to the console
The Linux Desktop menu is getting two meters — how much of the member's numbers. transcripts do not carry them, and neither OTel metric is about the subscription window. So a status line records them and the agent reads the recording: The whole machine answers in one call because SecurityService's sweep fans out per member afterwards, and one request per account would put the agent's load on the size of the fleet for a question whose answer is one small file each. Three rules the status line has to keep, all of them because it renders into somebody's working terminal: * It never fails loudly. A traceback here would follow the member through their whole day, so every failure is swallowed and something still prints. * It does not take the slot from a status line they already had. The installer records any command it displaces in kamo-statusline-chain and this passes the same stdin through to it. would tell the console the member has a full, untouched allowance, which is the opposite of "nobody has asked yet". so the next one inherits it, and is re-runnable.
· kamo-internal
Context menus for tool window title bars and hex heads
A member with a dozen windows open could only put them away one at a time, and the only bulk gesture that existed — throwing each hex head at the goal — is a gesture, not a menu row. Right-clicking a title bar did nothing but raise the browser's own menu, which offers Reload and Save As. Right-clicking a title bar now offers minimize-all and close-all, each in two widths: every window, or every window of this tool. Right- clicking a hex head offers the same set the other way round — restore every head, or close every head, by all or by tool. The rows that would only repeat the row above them are hidden rather than shown disabled: with one terminal open, "Close all Terminal windows" and "Close all windows" are the same click, and a menu offering one action twice teaches members to read neither row. Close asks first, but only when there is something to ask about. The probe is the cancelable tool:requestClose the windows already answer, which is the codebase's existing "would closing you now destroy something" — MessageTool has vetoed on unsent text and SoftphoneTool on a live call since the throw-to-goal gesture needed telling. Those three lines are now useCloseGuard, and the windows that hold typed content and never answered do: the AI prompt, the SMS message and CRM notes, the new-ticket form, the bug report, and the chat, support and social composers, whose replies live in composerDraftStore outside any component's state and so answer through a thunk. Deliberately NOT a scan of the persisted draft:* props. Those cells hold a window's whole working state, defaults included — the calculator's display seeds as '0', the unit converter's input as '1', the SMS tab as 'chat' — so a generic "is anything stored" heuristic would confirm on windows holding nothing but what they opened with, and a confirmation that fires every time is one people learn to click through. Only the tool knows which of its own state is content. Terminals get one more row, on both menus. Every other tool's window IS the thing it holds, so closing it is the whole story; a terminal's window is a view onto a tmux session that outlives it, and closing the window leaves that session running and holding a row in the Interaction Center. endTerminalSession is the End button from that list reached from the other direction — DELETE the session, close the window, announce the change so the row goes without waiting for its poll — and takes two clicks to do it, as that button does. The bulk actions dispatch the same events a single window's own chrome does, so there is no second path through the store; the head list comes from the hex-head engine rather than the tool-windows store, because a floating sticky note spawns a head with no window in that store and a menu reading the store would silently leave those behind.
· kamo-internal
Checkerboard behind the logo previews on Behavior & Labeling
The nine logo slots painted their artwork straight onto the card's own white, which hid the two things an admin is looking at: whether the file is actually transparent, and how cleanly it was keyed. A white-on-transparent mark was indistinguishable from an empty slot, and a soft or badly-cut edge disappeared into the background it was fringing against. `ImagePreview` now takes a `checkered` prop painting the same repeating-conic-gradient the image-to-svg tools use, at a 12px square because these previews are a third of their height. Applied to the three read-view slots and the six edit-view ones (company + software), and NOT to: - an empty slot, which has nothing to be transparent about — a checkerboard under its placeholder icon would say it has; - the background-image gallery, whose object-fit: cover photos are opaque and fill the box, so no checker could ever show through. Since the images are object-fit: contain, the checker also shows in the letterbox bars, which makes an image's true bounding box visible. This is the idiom the rest of the branding surfaces already use — the setup wizard's Logos step, network-create's brand fields and the brand-scan asset browser all checkerboard their previews in Tailwind. Behavior & Labeling was the one that did not.
· kamo-apps
Let the lender word the application ribbon
`t("application.ribbon")` becomes the FALLBACK rather than the answer. A lender can set its own wording on the labeling page, which reaches this site through the theme config.json fetched in the browser. Only the band becomes a client component, and it is handed the translated string as a prop rather than calling useTranslations itself — the layout is a server component with the bundle already in hand, so passing the resolved line keeps this the one client boundary the ribbon needs and makes the first paint correct. The tails stay in the layout: they carry no text, and their z-indexes settle paint order wherever they sit. The translated default is the whole reason provisioning omits the config key for an org that has chosen nothing, rather than baking English into every file. This line exists in twenty-two locales; a lender who never opens that settings page must not have it replaced by its English source for every borrower who does not read English, with nothing anywhere to report it. Listening to KAMO_THEME_REAPPLY is not optional here the way it nearly is on the sibling sites. This page resolves its lender from the PRODUCT ID in the URL, which happens after first paint — a reader subscribed only to the first config load would keep the platform's wording for every lender without a domain of its own. The favicon stays on favicon/ and follows the COMPANY brand, like everything else on this site: the visitor is a member of the public filling in a loan application, and the mark in their tab has to be the company that will hold the loan rather than the software it runs on. The tab icon is not an exception to that rule, it is the same rule — now said out loud beside the code.
· kamo-register
Let the organization word the three ribbons, and own the tab icon
The route map in PaneRibbon stays exactly as it was and becomes the fallback layer: each of "/", "/member" and "/team" now pairs the wording this site ships with the settings key an organization can override it from. A route missing from the map still gets no ribbon at all — /sorry, /logout and the verification screens are not places to be cheered along, and nothing on the labeling page can change that. The wording arrives the way the register logo overlay already does: resolved through the parent chain by provisioning, baked into the theme config.json, and parked in sessionStorage by DynamicMetaLoader before it fires the config-loaded event, so the first paint is not a frame behind. An org that has chosen nothing has no key at all, and every screen reads exactly as it does today. useRibbonMessage also listens for a re-apply, because this site repaints itself when a route names an organization the host did not — a ribbon left on the platform's greeting through that transition would be the one piece of the page still unbranded. The lookup runs for every route and the decision to render is taken after it: hooks cannot be called conditionally, and a route with no ribbon still mounts this component. The favicon follows authLogoBrandSource, matching the logos on these screens, and only when provisioning actually generated a software set.
· kamo-login
Let the organization word the ribbon, and own the tab icon
"Welcome Back!" moves out of the root layout into PaneRibbon, a client component, because the wording is now the organization's to choose and this site learns its organization from a config.json fetched in the browser. It is still what this site says — the fallback every org gets until it chooses otherwise, not a placeholder — and the geometry is unchanged: measured against origin/main, the band's box and its clip square are identical to the pixel. All three pieces moved together. They carry explicit z-indexes, so paint order is settled by those rather than by where they sit in the DOM, and keeping them in one component is what lets one place decide what the ribbon says. The favicon now follows authLogoBrandSource, the same selector the logo on this screen already follows, so an org that white-labels the page white-labels the tab beside it instead of pairing a software mark on the screen with the company mark in the tab. Only when provisioning actually generated a software set — otherwise favicon-software/ does not exist and requesting it paints nothing. Verified against a dev server: seeding the wording and firing the theme events swaps the band's text and clearing it restores "Welcome Back!" rather than emptying the ribbon. Worth recording that this only reproduces over `localhost` — over `127.0.0.1` Next blocks its own dev resources, React never hydrates, and every client component on the page looks inert in a way that mimics exactly the bug you would be hunting.
· kamo-internal
Ribbon wording and a tab icon column on Behavior & Labeling
Two additions to the same page, each in the pane it belongs to. **Custom Labels** grows a Ribbon Messages box: five rows, one per public pane screen, in the order a visitor meets them — sign in, or start signing up and branch into the member or team-member funnel; the apps site is its own front door. Each carries the Sync checkbox the label rows above it use, shown only for an org with a parent. The placeholder is the platform's own wording and is doing real work: an empty box does NOT produce a blank ribbon, it produces that line, and nothing else on the screen would say so. The read view falls back to it for the same reason — these screens always show a ribbon, so a blank row would describe something that does not happen. Unlike the label rows, the ribbons default to UNSYNCED for a child org. A branch inheriting its parent's word for "Business" is almost always what was meant; a branch that has never been asked has not implicitly asked to be greeted in its parent's words. Saving this pane now re-provisions the theme, because config.json is the only route these wordings take to kamo-login, kamo-register and kamo-apps. The server starts that and reports `reprovisioning`; we watch the org-scoped progress socket rather than firing our own /provision-theme, which a member with label rights but not branding rights would have been refused. **Branding** goes from two logo columns to three, on both the company and the software row. The switch under each is disabled until an icon is uploaded — greyed out with its reason beneath it, rather than vanishing, which reads as a bug — and accepting a file flips it on, matching what the upload endpoint does server-side. The caption under the SOFTWARE switch is computed live, and it is the reason tabIcon.ts exists as a tested module rather than a ternary buried in the JSX. That column's answer is a three-deep chain whose middle link is a switch in a DIFFERENT column of the same form ("Override company simple logo"), and the most likely case is the least guessable: that switch is off by default, so the usual answer is not "your software simple logo" but whatever the company column resolves to. Left unexplained the screen shows a disabled toggle and says nothing at all about what is used instead. It mirrors OrgThemeProvisionService's faviconSourceKey deliberately — this one runs against the unsaved form so the operator sees the outcome while deciding. faviconDir.ts picks which set to paint. Both conditions are required and the second is the one that matters: a surface selecting SOFTWARE for an org with no separate software icons must read favicon/, because favicon-software/ was never written and asking for it paints nothing at all. Tab icons preview correctly on a cold load, which the software simple logo still cannot — the stored extension is the existence marker that slot has never had.
· SecurityService
Deliver ribbon wording, and generate favicons from a tab icon
Provisioning learns two things: what an organization wants its public corner ribbons to say, and which artwork its browser-tab icons should be cut from. **Ribbons.** kamo-login, kamo-register and kamo-apps have no OrgContext, so the five wordings are resolved through the parent chain here and baked into the theme config.json — the same route the login and register logo overlays already take, and for the same reason. A wording nobody set omits its key entirely rather than carrying the English default, so kamo-apps keeps rendering its own translated line in all twenty-two locales. Because that file is only rewritten by a provision, PATCH /branch-titles now starts one and reports `reprovisioning` back. It fires server-side rather than as a second call from the browser on purpose: this endpoint is gated on "your session's org is the org in the path" while /provision-theme is gated on canManageOrgBranding, so a member who may rename labels without holding the branding right would have watched the save succeed and the follow-up 403 — labels stored, ribbons silently never delivered. It is best-effort: the labels are already saved by then, and an org with no alias to write a theme under must still be able to rename them. **Tab icons.** `faviconSourceKey` is the one place the chain is written down. Company is two states. Software degrades twice — to the software simple logo, but only when softwareLogoSimpleEnabled says that logo actually stands in for the company mark, and otherwise all the way back to whatever the company brand resolves to. Skipping that second condition is the tempting mistake: it paints the software mark on the tab of an org that explicitly said its software logo overrides nothing. favicon-software/ is generated only when the two brands resolve to DIFFERENT source objects, which for an org with no white-label brand is never — so this does not double the cost of a provision. config.json reports that as softwareFaviconEnabled, and a client that selects SOFTWARE without it reads favicon/ rather than requesting a directory nobody wrote and painting nothing. The software set gets its own site.webmanifest: the icon `src` values in there are relative, so pointing a software surface at favicon/site.webmanifest would install the company mark it was configured not to show. Two new upload types, tab-icon and tab-icon-software, keep the real extension rather than normalising to SVG the way the compact logos do — a favicon is only ever rasterised down to 16-180px, so demanding a vector would mean auto-tracing a PNG somebody already drew at the size it will be shown. The upload records the extension AND switches the icon on: uploading one is an unambiguous request to use it, and leaving the switch off would store the file and show the member nothing. Requires kamo-shared-library at 91052925 and the DDL in this commit applied by hand — ddl-auto is none and the initializer is not deployed.
· kamo-shared-library
Per-org ribbon wording and tab icons
Fourteen columns on `orgs`, and two small records to keep their shapes honest. RIBBON_MSG_* carry the corner-ribbon wording for the five public pane screens — the sign-in site, kamo-register's landing, member and team-member routes, and the loan application on kamo-apps — each with the *_SYNCED flag the label columns beside them already use, so a branch can inherit its parent's greeting. They resolve through `resolveTitle`, the same parent walk the branch titles use, but into their own `RibbonMessageResolution` rather than onto `BranchTitleResolution`. That record is copied into the signed-in member's Redis session; these five are read by people who have no session, and on kamo-apps by people who have never heard of this platform. The resolution hands back NULL for a wording nobody set, where the branch titles substitute a default. That difference is load-bearing: kamo-apps's ribbon is a next-intl key translated into twenty-two locales, so baking the English source into every org's config.json would silently un-translate that line for every non-English visitor with nothing to report it. Absent means "use the wording the site ships", and no screen can be given a blank ribbon from here — an empty box normalises back to NULL on the way in. `updateRibbonMessages` is a new two-argument method rather than five more pairs on `updateBranchTitles`, which already takes seventeen positional arguments; `RibbonMessages` pairs each wording with its own flag so transposing the two is not expressible. TAB_ICON_* are the browser-tab icon, per brand. An org's favicon set has always been generated from img/logo.svg — a fine default and a poor requirement, since a mark legible on a masthead is often a smudge at 16px. *_EXT is written only by the upload endpoint and doubles as the "an icon exists" marker; *_ENABLED is the switch, and NULL or FALSE both mean "use the simple logo", which is what every existing row means today. DDL ships with securityservice as **************** and must be applied by hand — ddl-auto is none everywhere and the initializer is not deployed. Every column's absent state is already its correct one, so there is no backfill and nothing changes until an org chooses.
· kamo-apps
Range the application against the rail instead of centring it
The form column's content was centred in its half, which left a wide margin on both sides and no relationship between the questions and the rail that numbers them. It is now ranged against the rail from `lg` up, held off it by a gap, with the slack falling on the outside edge where nothing needs it. `lg` rather than `md` because that is where flex-row puts the rail beside this column at all. Below it the rail becomes the bar across the top, there is no column to range against, and a left-ranged form would leave the right half of a phone empty — so the stacked layout keeps its centring untouched. Logical properties rather than left and right. Arabic is one of the twenty-two locales, flex-row puts the rail on the right there, and the gap has to follow it; `ps` is the side facing the rail whichever way the page runs. Measured: at 1440 the heading starts 96px from the column's edge with 135px of slack after the form, at 1024 it starts 80px, and at 820 and 390 nothing changes — both still report a single column. Arabic at 1440 mirrors cleanly: the column moves to the left of the pane, the rail to the right, and the same 96px gap is measured from the right edge.

Your universe is waiting.
Start free in minutes. Add products whenever you are ready — no migration, no new logins.
Free forever — no credit card required