மாற்று பதிவேடு
என்ன புதிது.
அது கப்பல்கள் போல், தளத்தில் ஒவ்வொரு மேம்பாடு.
· kamo-internal
Show the member Careers tab on team membership and an open role
Two conditions, and no right among them: the viewer is a team member, and the organization has at least one posting open and taking applications. The second changed the character of the gate. A tab that opens onto a board with nothing on it is worse than no tab — it reads as a broken feature rather than an absent one — so an organization that is not hiring simply does not carry the surface. The count is of the POSTING, not the caller: a role this member has already applied to still holds the tab open, because counting canApply would delete the tab, and with it their own application, the moment they applied to the last open role. VIEW_JOB_LISTINGS no longer decides this. CareersAccess.requireView moved to the same rule so the two cannot disagree, and the widening is described there. isHrs drops out as an assertion and returns as an implication: a posting is written from the HR module, so an org without it never counts above zero. The not-yet-employed special case goes with it — every team member is admitted now, so a candidate needs no exception. The count is withheld until it arrives rather than assumed zero, so the tab bar timecard gate beside it follows. The effect writes no state synchronously, so it adds no new react-hooks/set-state-in-effect violation. One consequence worth knowing: RestrictedMembershipGuard still sends a non-Active member from the home screen to ?tab=careers, and in an organization with nothing open that tab is now absent. SettingsView falls back to the first tab, so they land on their profile rather than on anything broken.
· DocsService
Gate the member board on being a team member with a role open
The member Careers tab now appears on two conditions and no right among them: the viewer is a team member, and the organization has at least one posting open and taking applications. CareersAccess.requireView is moved to the same rule, because a server set narrower than the tab's leaves a member opening it and collecting a 403 over an empty grid. That IS a widening, and it is worth naming rather than burying: VIEW_JOB_LISTINGS used to decide this, so an organization could keep its people off the internal board and some did while a reorganisation was in flight. It no longer can. The right still gates what an org publishes; what it no longer does is hide an open posting from staff. The blast radius is unchanged — the board is the org's own list of adverts, which it chose to open, and no endpoint on this surface takes a member id, so it cannot return anybody else's submission. Reading who ELSE applied is still MANAGE_JOB_POSTINGS alone. With the rights gate gone, the base-member exclusion is the only thing left between the internal board and somebody with no employment relationship at all — a customer, a portal user, a securityProvider mirror. It is load-bearing now, and pinned as such. openingsAcceptingApplications counts the POSTING, never the caller. A listing's canApply also goes false once THIS member has applied, and counting that way would delete the tab the moment somebody applied to the last open role — taking with it the one surface where their own application can be read, edited or withdrawn. ON_HOLD does not count either: it belongs on the board as information, but nothing can be filed against it. Its own endpoint rather than a field on the board, because the answer is needed before the tab exists and the tab's own first act is to fetch that board — gating on it would mean two identical round trips per profile visit, one thrown away. isHrs drops out of the tab gate as an assertion and returns as an implication: a posting is written from the HR module, so an org without it can never count above zero.
· kamo-register
Carry the job advert to the sign-in link
Plenty of people who press Apply on the careers board already have an account and come here only to discover that. The "Sign in" link now carries the advert they arrived on, so the sign-in can record it and the workspace can open that application form. A SEPARATE value from `loginUrl`, deliberately: that one is also the base for two post-registration redirects which append their own `?linked=1` and `?registered=1`, and a query string baked into it would produce `...?jobId=x?registered=1`. Those redirects want no job id anyway — registration has already recorded it on the account it just created. kamo-login also reads the apex cookie, so every other route to the sign-in screen works without this. This is the explicit one, and an explicit statement of intent on the URL beats a value left lying around: it survives a browser that will not keep the cookie, and it is visible in a bug report.
· kamo-login
Carry the job advert through a sign-in, and land on the Careers tab
Somebody who presses Apply on the public careers board and already has an account reaches the register page, follows the sign-in link, and used to arrive on a dashboard with no sign of the thing they clicked. This carries the advert through the sign-in and puts them where the application form opens. TWO carriers, checked in that order. `?jobId=` is an explicit statement of intent on the URL — it survives a browser that will not keep the cookie, and it shows up in a bug report. The `kamo_apply_job` apex cookie kamo-register wrote is what makes ANY route to this screen work: the header link, the post-registration screen, a logout bounce, or simply typing the address. The user asked for "any link", and only the cookie delivers that. Bound to the UUID shape before it goes anywhere, so a hostile link cannot stuff arbitrary text into a login body. It authenticates nothing and grants nothing; SecurityService validates it against the organization's own board. The landing is /settings/member/me?tab=careers rather than the home screen, and that is what makes this work for an ACTIVE employee applying internally — they are not in the restricted tier, so nothing would redirect them and the prompt would never fire. An explicit ?next= still outranks it: that is the caller's own instruction. Sent on both completion paths. The workspace picker holds the uid in state rather than re-reading it, because the carrier is spent on success and a re-read would work only until something cleared it first; it rides to the workspace actually chosen, so naming a posting while entering a different organization records nothing. Spent on success, deliberately. The stamp RAISES the workspace's prompt flag — pressing Apply is a fresh answer that a flag lowered for another job last month should not override — so a carrier that survived would re-raise a prompt the member had already closed, on every sign-in until it expired.
· SecurityService
Record the advert for a candidate who signs in instead of registering
Registration already wrote recentlyAppliedForId when an account was CREATED. This is the other half of the same funnel: plenty of people who press Apply on the public careers board already have an account, reach the register page, and follow the sign-in link. Nothing was created, so nothing recorded the advert, and they arrived in the workspace with no sign of the thing they had just clicked. AppliedJobStamp records it against the membership of the organization actually entered, from both sign-in completion paths — the direct one and the workspace picker. Both, because somebody who belongs to several organizations only ever arrives through the second, and a hiring funnel that worked exclusively for people with one workspace would fail silently for everybody else. The flag is RAISED, not merely set, even for someone who dismissed a previous prompt. That is the point of this path: pressing Apply on a specific advert is a fresh answer, and a flag lowered for a different job last month is not a reply to it. The cost is that whatever carries the uid must be spent once — kamo-login clears its cookie on success for exactly that reason. appliedJobId is bound on the FORM endpoint as well as the JSON one. Omitting it there is how `org` was once left readable only over JSON, which nothing sends: kamo-login posts form-encoded, so that is the endpoint every browser sign-in actually reaches. Parsed leniently and never fatal, and the stamp runs after the session is minted with every failure swallowed. A sign-in that succeeded must not be reported as a failure because a convenience did not record.
· kamo-register
Frost the whole pane, and let the surface on it turn back to glass
The pane was an opaque white card. It is a sheet of glass now, in two places that do different jobs. .kamo-pane itself goes slightly translucent, so the stage's colour lifts through the card on every route whose content does not cover it — /sorry, /org, /logout. Deliberately WITHOUT a blur: the only thing behind the pane is .kamo-stage's flat colour, and blurring a flat colour buys a compositor layer and changes nothing. What reads as glass at an edge is the inset highlight and the hairline, so that is what it got. --background stays in the mix, so an organization that recolours it still owns the surface. Where there IS detail worth blurring — /team and /member paint the org's photo carousel edge to edge inside the pane — .kamo-pane-frost lays one sheet across the whole card, above the three backdrop layers and below everything you read. The photo keeps its shape and its colour through it; what it loses is the detail that was competing with the form. That frost pays for itself immediately. The content surface had been pushed to 88% white to stop the photo's greens and pinks blooming through it on /member, which darken by only 0.2 — a flat plate resting on a photograph. With the backdrop already evened out there is nothing left to bloom, so it drops back to 78% and blur(20px) and the two now read as what they are: two sheets of glass, the near one carrying the form. Ribbon clearance re-measured across the same 13 viewports after the pane picked up a border; nothing moved.
· kamo-register
One surface, a level-gauge step rail, and chrome the tenant colours
The /team and /member screens were two near-identical slabs of glass stacked with a gap, a MUI Stepper in its default grey, and a progress pill floating inside the header. Everything that carried meaning was carried by size alone. The header and the wizard are now ONE surface. The line that separates them is the progress indicator: full-bleed, and it fills as you advance, so the divider the layout needed anyway is doing the second job instead of a bar parked above it. The header came out ~25px SHORTER than the pair it replaced, which the pane gives straight back to the form. The brand line used to be REPLACED by the step name from step two onward, so a white-labelled signup lost whose signup it was for the rest of the funnel. Both hold their own line now — who you are joining above, where you are in joining below — and the step counter comes with it, which is what lets the phone layout drop the left column entirely rather than degrade it to a lone "Step 1 of 7". The rail is hand-built because the states are four, not the Stepper's two: `visited` — filled in, then walked back from — was previously indistinguishable from a step never reached. Solid brand up to the step on screen, lighter as far as you have ever got, faint beyond: the column reads as a level gauge. Each row draws its own half-segments, so the track stays unbroken at any pane height. A node is always a numbered disc and only its fill and size change, so advancing reads as a transition rather than one control swapped for another. The active row sits on a raised white pill that SLIDES between rows on a step change. No colour here is its own. Every organization ships a globals.css into the theme bucket that redefines --color-primary, so each tint is mixed from whatever the tenant supplied and the whole chrome re-tints per org; the fallbacks under @supports are neutral rather than absent. Verified in brown, blue and the grey platform default. Also fixed, found while measuring: - A tenant wordmark five times as wide as it is tall took a phone's entire header row and wrapped the title to three lines. The logo is bounded on BOTH axes now and keeps its ratio inside that box. - Completed step labels were underlined, which reads as a link, not as done. - The buttons shouted in MUI's uppercase default and hovered to a hardcoded grey that stayed grey whatever an org had branded itself. - "Sign in here" now says what it does: "Sign in". Ribbon clearance re-measured by hit-testing the painted band at 13 viewports from 320x700 to 1440x900: nothing runs under it, and nothing scrolls sideways. Motion is guarded in both CSS and JS; the rail's rows are real buttons with aria-current and a visible focus ring.
· kamo-internal
Choose the support topic a website's chats are filed under
The add/edit dialogs had no way to say which support queue a website's chats belong in, because the setting didn't exist — the widget sent a hardcoded "General Inquiry" and the backend fell back to whichever topic sorted first. Both dialogs now carry a Support topic picker, shown only with Live Support on since it decides nothing without it. Lists the org's OWN active topics, which is exactly the set MediaService will accept: it validates supportTopicId against the caller's org and rejects anything else. "Unset" is a real choice and stays the default, leaving an existing integration on today's behavior rather than silently repointing a live widget's queue.
· MediaService
File a website's chats under the topic its integration chose
createSupportSession took the topic from whatever title the visitor's browser sent — the widget hardcodes "General Inquiry" — and when no topic matched that name it silently used the org's first active topic by sort order. So which team a website's chats reached was decided by a string in public JavaScript and a sort order, and no setting anywhere could change it. resolveTopic() now reads the integration's configured topic first. The old two tiers stay underneath it, because every integration predating the setting has none configured and a chat filed under a guessed queue still beats a visitor who can't reach anyone. A configured topic that no longer resolves — deleted, or moved to another org — logs and falls through rather than refusing the chat: the visitor isn't the right person to absorb a misconfiguration they can't see. The integration lookup moves above the topic resolution, since it's now an input to it rather than just attribution captured afterwards. Writes go through resolveOwnedSupportTopicId, mirroring the sales-agent resolver beside it — the org check belongs at the write, not at some later read that happens to be scoped. Not added to the bootstrap cache-bust list on purpose: this is resolved server-side and never reaches the widget's bootstrap payload.
· kamo-shared-library
Let an integration say which support queue its chats belong in
A web-chat integration had no support topic. The visitor's browser sent a hardcoded topic title instead, so an org whose topics aren't named "General Inquiry" — nearly all of them — had every website chat filed under whichever topic happened to sort first, with nothing in the settings UI even hinting at the choice. Adds **************** (nullable) and surfaces it on the DTO. A plain org-scoped id rather than a relation, matching SALES_AGENT_ID next to it: ownership is enforced at the write, not by an FK. Nullable so the seven existing integrations keep exactly today's behavior until someone picks one. findByIdAndOrganizationId is that write-time check — an unchecked id would let one org file its chats into another org's queue.
· kamo-internal
Park the swarm at the bottom-left, against the nav rail
The home cluster's default spot moves from the bottom-right corner to the bottom-left, sitting one padding clear of the primary rail's right edge — collapsed at 80px, expanded at 260px, or held open by a hover-peek. The rail's width is the whole difficulty, and the two obvious sources both answer wrong. `--nav-pri-width` is the SHELL's offset, not the rail's width: a hover-peek deliberately floats the rail over the content instead of reflowing it, so the variable stays at 80 while the member is looking at 260px of nav. And NavLayoutContext, which does know, is unreachable from here — the hex-head engine mounts outside NavLayoutProvider (AuthedChrome renders the tool wrapper as a sibling of the nav tree, not a descendant), so a hook into it would throw. So the rail is measured. NavPri carries a `data-nav-pri` marker and readNavRailInset() reads the right edge off its own box, in the same "ask the DOM" idiom readToolRects already uses for the windows the swarm avoids. It answers 0 where no rail is painted, which is the correct left bound there. A ResizeObserver on that box is what keeps the two together. It fires once per frame of the rail's 220ms width transition, and each frame re-targets the home spring, so the heads travel alongside the rail rather than teleporting after it has finished. A coalescing rAF makes several observations in one frame cost one placement. Only the HOME cluster is inset. Every other cluster sits where the member dragged or threw it, and a reflow that shoved those off the rail would be moving heads somebody parked on purpose. clampToViewport's x rule is reordered rather than extended: the right-edge correction runs first and the left bound overrides it, instead of the old left-then-else-right. Identical for every case that fits, and it settles the one that does not — a viewport too narrow to hold both the rail and the swarm now spills the heads off the right rather than sliding them under the nav, where they cannot be reached. It also stops the two bounds fighting each other across resolveCollisions' six iterations. Nothing is stranded by the move: head positions are not persisted, so every member gets the new spot on their next load. The tooltip and context-menu placement helpers already derive from where a head actually is — a head in the left third opens its label rightward on its own — so only their comments needed correcting, since both asserted heads park bottom-right. Verified by the numbers, which is what this change is: ten new assertions in hexheadHomePlacement.test.ts pin the inset in all three rail states, the honeycomb's left bound landing on rail+padding when the seat at q=-1 would otherwise reach over it, a tool window pushing the swarm without the rail losing its hold, and the narrow-viewport rule. 373 tests across app/components/chat and app/components/tools pass; tsc is clean over every file touched; check-ssr-safe and check-i18n-keys are clean.
· kamo-apps
The mortgage application sits in a pane, under its own ribbon
Ports the shell from kamo-login and kamo-register: a card inset 15px from the browser edge with a 14px radius, a soft shadow, and a red ribbon across its top-right corner reading "Your Home Awaits". The geometry is copied verbatim — the three sit on one funnel and a borrower crosses between them, so the frame should not shift under them. Applied by the application route's own layout, not the root one. The patient portal, the confirmation screen and the productless landing page share this site and are not part of this; wrapping them all to reach one route would have been the larger edit, not the smaller one. The string went into kamo-translation-dictionary first, and had to. messages/ is gitignored here and CI clones the dictionary into it at build time from an unpinned branch, so a key a component reads must already be there or the build renders the key path at a borrower. What this route forces that the other two did not is the scroll. A sign-in screen fits its pane; a five-step application does not, so the pane is a scroll container rather than a clip. The document then no longer scrolls at all, which makes 100vh the wrong ruler inside it: min-h-screen would leave every screen 30px of scroll it never asked for, and Spine's h-screen sticky rail would hang 30px past the frame. Both are corrected by --pane-h, and corrected in CSS rather than in the components, because those components are shared with routes that have no pane — a percentage height would resolve against nothing there and collapse. --pane-h needs no such chain. Adds ViewportHeight, publishing visualViewport into --app-dvh as the other two do. 100dvh is the fallback but does not account for a phone keyboard, and with the pane pinned at full height its scroll region runs on behind one. The ribbon covers the pane's top-right corner, and MobileRail is pinned to exactly that corner with the language menu at its right edge — it sat under the band. Reserving room clears it down to 430px, below which the reserve stops being payable: the step counter and menu are 153px wide together and the reserve wants 151 more, which on a 330px pane leaves nothing for the lender's mark. The pair takes its own line there instead, ranged left, where the band has already run clear of it. Spine is reached only through ApplicationForm and so only by this route, so editing it touches nothing else. Verified against the real form with the product lookup stubbed, across eleven viewports from 1920x1080 to 320x568: no page text under the band at any of them, the sticky rail measuring exactly the pane's height at every desktop size, the document never scrolling, and the ribbon holding still — 0px of movement — while the form scrolls 400px inside the pane.
· kamo-internal
Send a new starter to the board, and open the advert they came for
A team member whose employment is not ACTIVE is already confined to the restricted tier, and the home screen already bounced them somewhere. It now matters WHERE: everybody whose employment has not STARTED is in that tier too — a candidate who registered from a job advert is PROSPECT — and Careers is the one page they came here for. So the destination splits. From the home screen: the careers tab. From anywhere else: their own compliance record, exactly as before. That second half is the destination for a former employee bounced off a page they may no longer open, and routing those to a job board instead would be both useless and tactless. The choice is a pure function in capabilities.ts rather than an expression in the guard, so a test can pin it. Getting it backwards puts a terminated employee on a hiring page and a new starter on paperwork they have not been given, and neither would ever be reported as a bug. The careers tab now also renders for a non-ACTIVE team member regardless of rights. Not a loosening so much as the gate catching up with where the product sends people: they hold no rights at all, because rights arrive with a role and a role arrives with the job, so a rights-only gate would land the redirect on a page with no careers tab on it. CareersAccess.requireView carries the same three-way test, and remains the thing that actually authorizes. The advert opens from the BOARD now, not from a cookie. The uid is on the member's own record and the flag is lowered server-side, so the prompt is spent by an ANSWER — the application was completed, or the dialog was closed — rather than by a page load. Under a cookie a refresh mid-form meant the candidate never saw it again. While that prompt is up the dialog refuses a backdrop click and Escape. It is their one chance to apply to the role they came for, and a stray click must not be what spends it. The close button still works in every mode, deliberately: a dialog with no way out is a trap, and somebody who does not want to apply right now has to be able to say so. A completed application spends the prompt without waiting for the close. Someone who reads the confirmation and then shuts the tab has unambiguously answered, and re-prompting them for a job they have already applied to would be absurd. The claim-token handoff goes with applying-without-an-account.
· kamo-register
Carry the applied-for job onto the account being created
The careers board sends candidates to /team?jobId=<uid>. That uid is parked in an apex cookie as before, but it is now READ BACK at submit and sent in the registration body as appliedJobId, so SecurityService can write it onto the new team member as recentlyAppliedForId. The cookie was always the carrier, never the destination — it only ever existed because registration is several steps and bounces through email verification, and nothing held in this flow's own state survives that. What changes is where it ends up: the workspace now reads the uid off the member's own record instead of off a cookie the browser is still holding, so the prompt is spent by an answer rather than by a page load. Read at submit rather than captured at mount, for the same reason: the flow is remounted on the way back from verification. The claim-token half goes away with applying-without-an-account. There is no longer an anonymous application row for a new account to adopt.
· DocsService
Retire applying without an account, and notify both sides of one that lands
The public board's Apply button is now a link into registration, so this drops the endpoints behind the old form: /{orgRef}/apply/{uid} in both content types, /{orgRef}/resume-scan, the claim endpoint, PublicApplyService and ResumeScanService. Worth stating plainly, because the removal is not an obvious win and a future reader will wonder: apply-first measurably converted better. What it could not do is know who applied. A row keyed on a typed email address cannot be tracked or withdrawn by the person who sent it except through a token in an inbox, every hire has to be reconciled to an account by hand, and each of those rows is a duplicate waiting to happen. Filing against a session makes the application belong to a member from the first moment. That is the trade. JobApplication's claim columns are left in the table. Dropping columns is destructive and the rows are still records of real applications. ApplicationSubmissionNotifier sends the hiring manager's notice and the applicant's own receipt. Everything it needs is copied out of the entities BEFORE the transaction ends and the sends are deferred to afterCommit: applying stores the résumé inside the same transaction and a REQUIRED one that fails to store rolls the whole thing back, so an inline send would tell a candidate their application is in when it is not. TWO applicant templates, chosen not composed. The renderer has no conditionals, and an ACTIVE team member applying for another role is asking whether it will be held against them — not a clause you can swap into a letter that welcomes a stranger. Both go to the USER account address; a candidate who registered yesterday has no work mailbox. The manager's letter carries no answer from the application itself. A mailbox is not an authorized surface for someone's cover letter or salary expectation, and mail forwards — it carries what is needed to decide whether to open it, and the link does the rest behind a session. CareersAccess.requireView also admits a team member whose employment is not yet ACTIVE. Not a loosening so much as the gate catching up with where the product now sends people: a candidate who registered from a job advert holds no rights at all, because rights arrive with a role and a role arrives with the job. It admits them to the board and to their OWN applications — no endpoint on that surface takes a member id — and never to requireManage.
· SecurityService
Record the advert a candidate registered for, and welcome them when hired
Registration on the /team path now accepts appliedJobId — the uid kamo-register parked when the careers board sent the candidate to /team?jobId= — and writes it onto the new TEAM_MEMBER as recentlyAppliedForId, with isShowAppliedForApp raised. The workspace reads both to open that advert's application form the first time they arrive. Parsed leniently and never fatal. The value arrives from a cookie written by another host and is worth exactly one thing; failing a registration over a mangled link would trade an account for a convenience. TeamMemberActivationNotifier sends the welcome when employment becomes ACTIVE. It is one class rather than an if at each writer because the decision is a TRANSITION, not a state: the member profile page re-saves every field on every edit, so "the status is ACTIVE" is true on thousands of saves that changed a phone number. Only a comparison with the previous value tells those apart, and it has to be made in one place to be made correctly in both. Both call sites read the previous status BEFORE their own write and hand it in. MemberController fires after the save, so a welcome is never sent for a change that failed to commit; MemberCreationService passes null, which is literally true for a member created straight into ACTIVE. The recipient is the address on the USER account, never the work mailbox. At the instant this fires the work mailbox may not be provisioned, and the entire content of the message is "you can sign in now" — sending that to an inbox they cannot reach until they sign in is a closed loop. **************** has to be applied by hand; InitializerService is not deployed.
· kamo-shared-library
Apply from inside the workspace, and say so to both sides
The public careers board's Apply button is now a link into registration, so every application is filed against a session. Two fields carry the candidate from the advert to the form, and four letters tell the people involved. TeamMember gains RECENTLY_APPLIED_FOR_ID and IS_SHOW_APPLIED_FOR_APP. SecurityService writes the first at registration from the uid the careers site parked; DocsService lowers the second once the candidate has answered the prompt one way or the other. The uid is deliberately NOT a foreign key to hr_job_posting: careers lives in DocsService and a posting can be archived between applying and signing in, so a dangling uid must degrade to "no prompt" rather than fail the registration itself. The flag defaults TRUE in the column as well as the field. It only ever SUPPRESSES a prompt, so a row nobody has written yet has to read as "show". Four canonical templates. TWO of them are the applicant's own receipt, not one with a variable in it: the renderer is {{key}} substitution with no conditionals, and an ACTIVE team member applying for another role is asking whether it will be held against them, which is not a clause you can swap into a letter that welcomes a stranger. The third tells the hiring manager, carrying no answer from the application itself — a mailbox is not an authorized surface for someone's cover letter, and mail forwards. The fourth is the welcome sent when employment becomes ACTIVE. HR_CAREERS_APPLICATION_RECEIVED is retained but no longer sent. Every org ever seeded holds a row for it, and dropping the key would strip a template administrators may have customised while leaving the row behind. The DDL is in **************** and has to be applied by hand — InitializerService is not deployed.
· kamo-apps
The chat bubble knows the applicant's name as they type it
Somebody who has just filled in "First name" and opens the chat to ask a question should not be asked who they are a second time, and the agent picking that conversation up should see a name rather than "Visitor". The name follows the same rule as the receipt page — what they asked to be called, falling back to their legal first name — and gains the surname only once it is there, so the header reads "Sarah" while they are still typing it rather than flickering through half of it. Handed over 600ms after typing stops. The widget renames a live support session when the attribute changes, which is a request to the server; without the delay, typing a surname would fire one per keystroke. The element is not rebuilt when the name changes — that would destroy the conversation on every keystroke — so it is set once on creation, for the common case where the widget loads after the name is already typed, and updated in place from then on.
· kamo-register
Seat every screen in the same pane, under a per-step ribbon
Ports kamo-login's shell: every route now renders inside .kamo-pane, a card inset 15px from the browser edge with a 14px radius, a soft shadow and overflow:hidden. The geometry is copied verbatim on purpose — the two products sit either side of one funnel and a user crosses between them mid-flow, so the frame should not shift under them. The ribbon's wording is the part that differs. kamo-login greets you with one line on every screen because there is only one thing you are there to do; registration is a funnel, so PaneRibbon names the step: "Let's Get Started!" on /, "Create Your Org" on /member, "Tell Us More!" on /team. A route missing from that map gets no ribbon at all rather than a generic greeting — /sorry, /logout and the verification screens are not places to be cheered along. Since the tails and the band both carry an explicit z-index, one component can own all three pieces and decide per route whether there is a ribbon at all. Every route sized itself to the viewport and so overflowed the pane by 30px; each root now fills it instead. RegisterFlow needed more than a swap of units: on a phone it was height:auto so the document scrolled, and inside a pane that clips, growing past it would have put the later steps out of reach. The scrolling moves inside the flow, which its own overflowY:auto already provided for. Adds ViewportHeight, publishing visualViewport into --app-dvh as kamo-login does. 100dvh is the fallback and would nearly do, but registration is a long form of text inputs and dvh does not account for a keyboard: with the stage pinned at full height the pane's scroll region runs on behind it, leaving the browser free to scroll a focused field underneath. The ribbon covers the pane's top-right corner, and unlike a sign-in card the registration header reaches that corner. Sweeping every text line on /, /member and /team across 11 viewports found the header's "Sign in here" under the band at 1024x768, 1100x700 and 1280x768 among others — not only a narrow-window problem, because the flow is vertically centred and a short window lifts the header into the band's path. Holding the header's right edge back clears it, except below 400px, where the reserve leaves less room than "KamoCRM" is wide and a single unbreakable word overflows its box rather than wrapping — no amount of padding moved it, so the title scales there instead. The sweep now reports no collisions on any of the 33 route/viewport combinations. Verified in a browser: the pane measures 15px from every edge on all eight routes with zero scroll overflow, the three ribbons carry the right words and fit the band's usable length (131, 117 and 96px against 192), and the other routes render no ribbon and no tails.
· kamo-apps
The support bubble uses the key its product was published with
The chat key is per-PRODUCT, not per-deployment. A lender picks a web chat integration when they publish an application, and the public endpoint resolves that integration's key from the product id in the URL — so the widget moves out of the root layout, which cannot know which application is being read, and into the two pages that do. The deployment-wide PUBLIC_CHAT_API_KEY is gone with it. One env var could only ever name one org's key on a site that serves all of them; the configmap now says so where an operator would go looking for it. Renders nothing when the lender has not attached an integration, or when theirs was deleted or switched off. The application matters more than the bubble — a borrower who cannot apply because a chat widget is misconfigured is the worst of the three outcomes.
· kamo-internal
Publishing an application is a decision, and it comes with a chat
**Vendors & Products no longer prints a link for every product it has.** It printed one for every mortgage product the moment that product existed, which meant every one of them had a live public form nobody had agreed to publish — and a printed link for a page that answers 404 is worse than no link, because somebody sends it. There is now a Publish button, and the address appears only once the page is actually live. **Publishing asks one real question: which web chat answers questions on it.** Someone filling in a mortgage application will have a question, and a form with no way to ask one is where they stop. "Create a new web chat instead" is offered whether or not they already have one — an org running a chat on its marketing site usually wants a separate one for a lender's application, routed and worded differently, and making them go and find the other screen is how that turns into reusing the wrong one. It deep-links into the creator with the dialog already open and brings them back to the product they were publishing. **A chat set up for the org's own site needs a new key to work here**, because a page Kamo hosts resolves the key server-side and that one was shown once and never stored. Reissuing rotates it, so the dialog says so before the button is pressed rather than after. **The new-integration dialog is rebuilt.** Two columns — the decisions on the left, a live preview on the right — and it starts in the org's own brand colours instead of the stock green-on-near-black that belonged to nobody. The preview is here rather than behind "Custom design" because the point of seeding from the brand is that most orgs never need to open the customizer, and they can only know that if they can see it. Surface and text colours are derived by luminance, so a lender with a pale primary gets dark type on it rather than white on yellow. Seeded, never locked: an existing integration is never repainted, since an org that rebrands has already chosen its widget's colours. `npm test` green: 448 files, 5532 tests, all guards, and check-i18n-keys against a fresh clone of the dictionary.
· SecurityService
Publish an application deliberately, and give its page a chat key
`PUT **************** publishes or retires a product's public page and records which web chat its support widget speaks through. Enabling without one is refused: a borrower filling in a mortgage application will have a question, and a form with no way to ask one is where they stop. The public endpoint now 404s a product nobody has published — the same answer as a deleted one, so a link that was live yesterday and retired today says what a mistyped one says. Before this, every mortgage product had a live public endpoint from the moment it was created. And it returns the widget's site key, resolved from the integration the product was published with. That has to happen here: one deployment serves every lender, so the key cannot be a build-time variable — the product id in the URL chooses it, the same way it already chooses the branding and the disclosures. The chat block answers `enabled: false` rather than failing when the integration has been deleted, disabled, belongs to another org, or was never given a hosted key. The application matters more than the bubble; a borrower who cannot apply because a chat widget is misconfigured is the worst of the three outcomes.
· MediaService
Let an integration carry a key a hosted Kamo page can resolve
`publicSiteKey` was persisted only for SPECIFIC_AGENT integrations, because the multi-tenant MLOS portal was the only page that had to resolve its key by Host rather than carry it in its own HTML. The public loan application is the second: one deployment serves every lender, and the key is chosen by the product id in the URL. So `hostedKey` on create opts an integration into the same treatment, and `POST /{id}/hosted-key` gives one to an integration that already exists. That second one ROTATES. A key hash is 1:1 with an integration, so there is no way to add a second key to one — the old key stops working the moment it returns. The settings screen states that and takes a confirmation first; an integration that has never been embedded loses nothing, and one that has is the operator's call. It is idempotent where a hosted key already exists, so opening the picker twice cannot break a working page, and it carries the previous key's origin lock and rate tier forward rather than re-deriving them — quietly widening an origin-locked key at the moment it goes on a public page is the wrong direction to be careless in. Persisting it is safe and already argued in the code this extends: a site key is public by design — origin-locked, PUBLIC_CHAT-scoped, rate-limited — and already ships in the HTML of every page that embeds it.
· kamo-shared-library
A product's application is published, not assumed
`LeadVendorProduct` gains `applicationEnabled` and `webChatIntegrationId`. A product existing in the CRM is not a decision to publish a form the whole internet can submit to — it is an internal category. Deriving one from the other meant every mortgage product silently had a live public endpoint from the moment it was created, which nobody asked for and nobody could see they had. `webChatIntegrationId` is required to publish. A borrower filling in a mortgage application will have a question, and a form with no way to ask one is where they stop; making the chat part of switching the page on is what stops it being the thing nobody gets round to. A soft id, not a `@ManyToOne`: the integration lives in MediaService's half of the schema and an org may delete one, which a foreign key would make fail against a product in another service's aggregate. `setProductApplication` checks BOTH ids against the organization, not merely against each other — the vendor scoping alone would let anyone holding two matching ids edit another tenant's product — and verifies the chat integration belongs to the same org before storing it. An integration id is attacker-controlled, and a product pointing at another tenant's integration would route a borrower's conversation into that tenant's support queue. `WebChatIntegrationDTO` gains `hostedKeyReady`: the boolean, never the key. A settings screen needs to know whether attaching an integration to a page it does not host will work as-is; handing it a live credential for a list view would not help it do that.
· kamo-apps
Twenty-two languages, and the chat bubble
The application is the one surface on this platform whose reader has never signed in to anything and may not read English. It now offers the same twenty-two languages the marketing site does, each named in its own — somebody looking for Korean is looking for 한국어, not for the word "Korean" spelled in an alphabet they may not read. **The locale is a COOKIE, not a URL segment.** The application address is a white-label link a lender prints on a card, puts in an advert and pastes into an email. A `/es/` prefix would change every one of those, invalidate the links already copied out of the settings screen, and make the canonical address depend on who happened to open it. The marketing site prefixes because its pages are indexed per language; nothing here is indexed at all. First visit falls back to `Accept-Language`, so a Spanish reader is spoken to before they find the menu. **The "Preferred language" select is gone.** Asking twice invited two answers — somebody reading the form in Korean would still have been filed as English unless they noticed a second control halfway down step one. The lead's language is now the language they are reading in, which is a better signal anyway: what they did rather than what they said. `lib/language.ts` maps all twenty-two onto the shared library's `LanguageType`. Values that reach the CRM stay English on purpose — `Morning`, `Conventional`, the credit bands. A loan officer reads those in the lead, in their own language, not the borrower's. Only the labels are translated. `kamo-apps` is its own project in kamo-translation-dictionary, beside kamo-marketing and sharing nothing with it; CI clones and copies only that directory, and `messages/` is gitignored here for the same reason it is in every sibling — one copy of the catalogue, in the dictionary. The chat bubble is the marketing site's embed, behaviour unchanged: it waits for a sign that somebody is actually here rather than spending 142 KB during the load of a form they are trying to fill in. Verified: switching sets the cookie, `<html lang>` follows, `dir` flips for Arabic, no console errors, and axe clean at WCAG 2.1 AA on all four pages.
· kamo-login
Fade the glimmer out at two corners, and cap it at half
The sheen now dies completely at the top-right and bottom-left corners and peaks, at half opacity, over the top-left and bottom-right. Each revolution reads as two episodes that rise and fall across a corner rather than one continuous lap, and the corner it dies on is the one the ribbon occupies, so the two never compete. Implemented as a static mask on the wrappers rather than a keyframed opacity, because the angle at which a corner sits depends on the pane's aspect ratio: 58 degrees off vertical at 1440x900, 24 at 390x844. Timed opacity would need retiming per viewport, and would drift the moment the window was resized. `to bottom right` does that geometry itself — CSS places the two corners the gradient line does not run through, here the top-right and the bottom-left, at exactly the 50% stop whatever the shape of the box, so a transparent stop at 50% is transparent at both by construction. Being static, it also costs nothing per frame; the mask is applied once and the spin underneath it is still the only thing that moves. The rim needed a nested element to carry it. Its ring already occupies .kamo-glimmer-edge's own mask property, and stacking the envelope on as a third layer would mean a three-way mask-composite list — whose failure mode, if a browser drops the list as invalid, is every layer compositing with `add` and the gradient filling the entire pane. A nested element multiplies the two masks instead and degrades to nothing worse than a missing fade. Measured by sampling the gap pixel just outside each corner at the moment the core reaches it, against the same pixel with the glimmer removed. Luma delta: top-left +24.0, bottom-right +24.0, top-right 0.0, bottom-left 0.0 — half of the +48.9 the full-strength envelope gave, and gone entirely at the other two.
· kamo-login
Run a glimmer round the pane's gap every five seconds
A conic gradient centred on the pane, spun once per --glimmer-period, painted twice: a sweep filling the 15px gap, and a 2px rim on the pane's own border. The rim is the half that sells it — a glow in the gap alone reads as a background effect, whereas an edge catching the light as the sweep passes reads as a lit object, and the pane's rounded corners lighting up in turn is the whole trick. The stops are a specular sheen rather than a glow: a band of shade, a hard rise through cool into a blown-out core a few degrees wide, a fast fall through warm, then shade again. The shade is not decoration. White on a #cdcdcd gap has almost no headroom to read as a highlight on its own, so the contrast has to be built by darkening what surrounds the core; the first cut had none and read as a wash rather than a glimmer. Written as a rotation, not as an animated angle. The obvious implementation animates a registered <angle> custom property into `conic-gradient(from …)`, and this did, until an interleaved A/B against the same page with the glimmer removed showed the median frame time going from 33.3ms to 50-66ms: that form repaints the gradient across the element's whole box every frame. Rotating an element that carries a fixed gradient makes the raster a one-off the compositor reuses. Chrome's own metrics then show no added main-thread time and no layout at all, and what frame cost remains here is software rasterisation — this machine composites through SwiftShader, with no GPU to do it for free. Dropping @property also removes the one browser-support cliff in the feature. Where it is unsupported the angle cannot interpolate, so the sweep would have stood still rather than degrading to nothing. Each layer is a clipping wrapper around a spinning square. The square is sized in vmax because it must cover its wrapper at every angle, which takes the wrapper's diagonal — at most root two times the larger viewport axis. Under prefers-reduced-motion both are display:none rather than frozen, because a bright arc parked in one corner reads as a rendering fault, not as a decision. Verified at 1440x900 and 390x844 with the animation paused at chosen times: the core crosses the top edge and the top-right corner where the geometry predicts, the rim measures 2px of white at 8x zoom, and reduced motion leaves zero spin animations running.
· kamo-apps
The mark returns to the rail, governed by the Apps Site Logo Overlay
Reverts the relocation in 47e6e39. The invisible-logo problem it worked around is real — a mark drawn in the company's own brand colour can vanish on a rail painted that colour, as ABC Mortgage's navy half did — but the answer belongs to the organization, not to this layout. "Apps Site Logo Overlay" sits beside the sign-in and sign-up overlays in kamo-internal (Settings → Essential → Behavior & Labeling) and flattens the logo to one solid colour on this site. Unset leaves the logo in its own colours, which is right for a mark that already contrasts. The company name remains the fallback, so a rail is never anonymous. Axe still clean on all four pages.
· kamo-internal
One scrollbar, for every scroller in the app
The terminal tool window had a scrollbar worth keeping — 32px, with a button for each END of the scrollback as well as the usual step arrows, because in a long document the two ends are not two more positions, they are the two places you actually want. The rest of the app had thirty-four hand-written copies of a lesser idea: 138 `::-webkit-scrollbar` declarations at six different widths (4, 6, 8, 12, 14, 20px), plus three global classes at a seventh. A guard test already existed because four chat windows had drifted to three widths; it could only ever fix a twelfth of the problem. The bar is now a component, painted OVER the element it scrolls rather than placed inside it. That is the decision everything else follows from. Wrapping each scroller to seat a bar beside the content would be a layout change in all 228 of them — flex children, grid children, `minHeight: 0` columns — and would still not reach the scrollports this app does not own: ag-Grid's, the DataGrid's, Lexical's. Painting over them is a layout change in none. The price is paid in overlayGeometry.ts: clipping by intersecting the clipping ancestors, stacking from the nearest ancestor's z-index plus one (which puts a page's bar under an open dialog and a dialog's bar over it, from one rule), and standing down when MUI marks the content behind a modal aria-hidden. Adoption is by marker class, not by sweeping the document for `overflow: auto`. Both reach every scroller; only one is debuggable, and only one keeps the bar out of every Select menu and 40px popper in the app. scripts/codemod-kamo-scroll.mjs applied it — parsing with TypeScript's own parser and splicing at byte offsets, so formatting and CRLF survive — and stripped the declarations it made dead. It degrades, because it now hangs off a 250px emoji popover as well as a page: buttons and track at 140px or longer, track alone below that, nothing at all when the element is not overflowing. Horizontal is the same component with the icons turned a quarter turn rather than a second set that would drift. A scroller that wants NO bar — the collapsed nav rail, the console mastheads, the filmstrip, the profile rail on a phone — now says so once, with `data-kamo-scrollbar="off"`, instead of in the six different ways it used to be said. Only TerminalTool still names a scrollbar in CSS, to hide xterm's own. TerminalScrollbar is left as an adapter: an xterm buffer into the model, in LINES rather than pixels, and moves back out as xterm calls or the wheel reports tmux binds. It keeps the indeterminate grip for a scrollback that cannot say where it is, and the lit jump-to-end button for output below the fold. Guards: oneScrollbar.test.ts fails if anything outside globals.css styles a scrollbar, if the component's width and the stylesheet's token disagree, or if the layer's marker list and the stylesheet's hide list fall out of step — an element on one and not the other gets two bars or none, and both read as a rendering glitch rather than a missing line. scrollbarView.test.tsx drives the bar from the keyboard, which is the point of the buttons being buttons.
· kamo-apps
Send the first-time-buyer answer with the application
The question was on the form and the answer went nowhere — there was no column for it until the shared library gained one. It changes which programmes are worth quoting, so a lead without it costs the officer a call to ask. Null rather than false outside a purchase: on the lead the field is three-valued, where null reads as "not asked" and false as "they said no".

உங்கள் உலகம் காத்திருக்கிறது.
நிமிடங்களில் இலவசமாகத் தொடங்கவும். நீங்கள் தயாராக இருக்கும் போது பொருட்களை சேர்க்கவும் - எந்த மாற்றமும் இல்லை, புதிய உள்நுழைவுகள் இல்லை.
எப்பொழுதும் இலவசம் — கடன் அட்டை தேவையில்லை