மாற்று பதிவேடு
என்ன புதிது.
அது கப்பல்கள் போல், தளத்தில் ஒவ்வொரு மேம்பாடு.
· MediaService
A conversation reaches the phone
A message on an Exec2Exec line now goes to the recipients' phones as well as their consoles: the customer's phone when KamoCRM replies, every desk member's phone when a customer writes. It rides the existing presence-gated CHAT_MESSAGE push, deep-links to kamocopilot://exec2exec/<guid> (with the customer's name on the desk's copy), and collapses per conversation so a burst is one notification, not ten. A failed push is logged and never stops the console frames that already went out.
· kamo-internal
NavTop's Exec2Exec control is the desk for a KamoCRM executive
For a member with Exec2Exec Access standing in the platform organization, the control now counts messages from clients they have not viewed (GET **************** and opens Interaction Center > Chats > Exec2Exec instead of their own conversation. Clients see no change. - interactionCenterIntent opens the panel at a tab and bucket. The bucket travels as a pending request the Chats pane honours when the Exec2Exec tab appears, since that tab joins the strip only after platform rights load and a fresh pane would otherwise land on its first tab for good. - The Exec2Exec directory rows badge customerUnreadCount, the same server count, so the badge and the rows it opens onto agree; a colleague's reply no longer badges a row. - useExec2ExecDeskUnread asks again when the desk's unread snapshot moves or the tab comes back, and keeps the last number on a failed ask.
· MediaService
The desk's count of client messages not yet viewed
GET **************** returns how many messages from clients this EXEC2EXEC_ACCESS holder has not viewed, across every conversation, and each roster row carries customerUnreadCount. It is the platform's unread rule narrowed to what the conversation's customer said: the whole desk sits on every chat, so a colleague's reply is unread for the others without waiting on any of them. One query feeds both **************** so NavTop's badge and the Interaction Center's rows agree.
· KlusterServices
Dial 911 and 933 from every extension, with an MLTS email notice
Extensions could not reach 911: no outbound route matched 911 or 933, so from-internal fell through to bad-number and played "cannot be completed as dialed" without ever sending an INVITE. startup.sh now carries the dialplan half of the fix: - 911 and 933 are exact extensions in [from-internal-custom], the context the outbound gate lives in. An exact extension outranks every pattern in its context, so the gate cannot capture them, and they skip the [kamo-outbound-allowed] list: every extension can dial them with no prefix. Each goes straight on to from-internal-additional, where FreePBX's own outbound routing takes over. - [kamo-e911-notify] runs before the call is routed and starts kamo_e911_notify.py detached, through TrySystem (System() hangs the channel up when its command cannot run), with every argument reduced to digits by FILTER. - kamo_e911_notify.py mails the platform administrator mailboxes through **************** with the time, the extension and its name, the callback number and the location conveyed to the 911 center. It refuses any recipient outside kamocrm.com, retries a relay outage three times and logs every outcome to **************** The pod's own postfix is not used: it relays via smtp.gmail.com and its queue is stuck. The notifier is rewritten on every boot; the dialplan block is appended once under its own grep guard, like the attendant block. The routing half is FreePBX data, outside this repo, and is applied on k1m1: outbound route Emergency911 (emergency_route=YES, patterns 911 and 933, trunk 1 (the Telnyx trunk), first in sequence, override CID +19492989960) and devices.emergency_cid +19492989960 on all 22 extensions. freepbx/tests/test_e911.py (stdlib unittest) checks that startup.sh parses as bash and as YAML, models Asterisk's extension selection to show 911 and 933 bypass the gate while 10-digit, 11-digit and 011 numbers still reach it, pins the notify hook to NoOp/TrySystem/Return with filtered arguments, and drives the notifier against a fake relay.
· kamo-marketing
List the phone number on the English pages only
showsPhone(locale) in app/lib/phone.ts is the single rule, currently locale === "en". PhoneLink returns nothing elsewhere, which covers the header, mobile menu and footer, where the number stands alone. PhoneOnly wraps the placements that carry more than the number — the contact card, and the "Give us a call:" lead-ins on the home CTA strip, the Enterprise pricing card and the demo page — so their words and spacing go with it. The Organization JSON-LD adds telephone to the org and its ContactPoints on the same condition, so no other locale's markup claims the number.
· kamo-marketing
Let the logo lead home, and list the phone number where visitors decide
The header's Home entry duplicated the logo, which already links to /. It is gone from the desktop and mobile menus; in its place the logo carries a hover/focus tooltip saying it goes home (left off the homepage itself, where there is nowhere to go back to). (949) 298-9960 is now a tap-to-call link: - header, centred between the nav and the language selector (xl and up; the lg row has no room for it), and in the mobile menu with the CTAs - footer, under the social icons - contact page, as a Call Us card heading the contact options - home CTA strip, Enterprise pricing card and demo page, beside the existing calls to action - Organization JSON-LD, as telephone on the org and both ContactPoints The number lives once in app/lib/phone.ts; PhoneLink renders it and reports clicks through data-track as phone-click with its location.
· kamo-internal
One brand-painted dropdown surface for the whole top bar
Every dropdown that hangs off NavTop — the eight option menus (Mail, Docs, Call / Text, Calculator, Support, Settings, Linux Desktop, Games), the availability pill and the language picker — now opens into ONE shared surface instead of its own white MUI popover. - Painted in the organization's primary colour with white labels and icons. Its top edge sits flush on the bar's bottom edge, in the bar's own colour at that spot (the gradient stops now live in navtop/menu/navTopSeam.ts and NavTop builds its background from them), with square top corners and a concave join either side, so it reads as the bar reaching down rather than a pane laid over the page. A brand too light for white text is darkened in its own hue only as far as WCAG AA needs (a luminance ceiling in CSS relative-colour maths); every other brand is drawn as chosen. - Opens under a resting mouse, follows the pointer along the bar — the surface glides and resizes to the next menu while the content slides in the direction of travel — and folds back into the bar shortly after the pointer leaves both the trigger and the panel. A click or a tap pins it until an outside press, Escape or a choice; a tap never focuses a text field. - Keyboard: Enter / Space / ArrowDown open onto the first row, arrows walk the rows, Home / End, type-ahead, Right / Left open and close groups and step to the neighbouring menu, Escape hands focus back to the trigger. - One highlight that glides from row to row, groups that open beneath their row on a guide thread, a single ring on count badges, the Cheddar balance counting up. All motion off under reduced motion. - Settings gets a search box that searches inside its groups and marks the match; Enter opens the first hit. The language picker is a two-column grid of all 22 languages, searchable by native name, English name or code. Settings pin toggle and the Cheddar wallet turn white on the brand and are unchanged in the rail. The rail fly-outs are unchanged — the rail already peeks open on hover. When a menu opens, switches and closes is a React-free state machine (navtop/menu/hoverIntent.ts) with tests of its own. Verified in real Chrome against the real components (esbuild harness, data sources stubbed): 170 measured checks across 11 brands — hover timing, the flush seam and corners, both concave joins, white text at 4.5:1 or better on every part of the panel, white ink throughout, the glide between menus, the leave grace, a diagonal across a neighbour not stealing the menu, click-to-pin, touch, keyboard, both searches and reduced motion. 287 tests in the nav and navtop suites, tsc, and the image-build guards against the dictionary's current en.json.
· MediaService
Seat the whole desk on every chat, show who is on it, close the chat feeds
- Standing membership: every explicit EXEC2EXEC_ACCESS holder sits on every Exec2Exec conversation. Exec2ExecDeskReconciler seats and unseats from the grants on securityservice's exec2exec.desk.changed event, a 5-minute sweep, startup, and whenever a chat is opened or back-filled; the desk is re-read per conversation so a sweep cannot undo a grant another pod just applied. - GET /chat/{id}/participants lists the KamoCRM executives on a chat (memberId, userId, name, jobTitle). - Sender lines carry senderPhone (the top-level org's Phone (Main)) and senderPhoneExt (the enabled VOIP extension, else the team-member one) for KamoCRM staff. The welcome is always signed by god. - Roster rows carry awaitingDesk (the customer spoke last) for the console's sign-in sweep. - Security: /topic/chat/incoming/{memberId} and /topic/chat/unread/{memberId} may now be subscribed only by that member and never sent to by a client. Both were unguarded, so any signed-in client could follow any member's arrivals and unread by id. - The generic add/remove/leave/rename member routes refuse Exec2Exec sessions (409 MEMBERSHIP_MANAGED): the reconciler owns that membership. - Offline email: a customer's message mails the desk, an executive's reply mails only the customer, instead of every executive about everything. - Back Fill greets and seats in the transaction that opens a chat, so a customer's first reply is never marked read for the desk.
· kamo-marketing
Hand Google the green-tile mark, because it lists the largest icon we declare
Search shows one icon per site and takes the largest one declared, apple-touch-icon included, so the 180px touch icons (ours and the theme's) outranked everything else. A 192px apple-touch-icon now outsizes both. Its pixels are the 96px green-tile mark doubled exactly, so Google's downscale gives back the original. Tabs keep favicon.ico, iOS keeps the exact 180 match for the home screen, and no logo on the page changes. Browser new-tab tiles that want the biggest icon will pick this one up.
· kamo-internal
Show who from KamoCRM is on the chat, and how to reach them
The customer's window now lists the KamoCRM executives on their conversation, re-read whenever somebody is added or removed. The line shows NAMES only, with titles on hover: "Name - Title" per person ran the desk into one run-on that clipped after the first executive, so a customer could no longer see who else was there — the one thing the strip is for. Loading, empty and failed are drawn apart; "nobody from KamoCRM is on this chat" is something a customer acts on. A KamoCRM executive's attribution line now ends in the org's main line and their own extension — "Sage Nadjafinia - KamoCRM Inc. - President - (949) 298-9960 x8080" — through signaturePhone, unchanged, so it prints exactly as an email signature does. The separator is " - " everywhere, to match. Membership frames arrive with id: null, so they are handled before the transcript's "no id, drop it" guard; behind it they were silently discarded.
· SecurityService
Say when the desk changes
Granting or revoking Exec2Exec Access now publishes exec2exec.desk.changed, so MediaService can put an executive on every chat — or take them off — as the toggle on the platform-admins page moves, rather than whenever its sweep next runs. After commit, not inline: MediaService re-derives the whole desk from the database and never trusts the payload, so a rolled-back grant cannot seat anybody — but an inline publish races the commit, and a reconcile that reads first reconciles to the old desk. The payload is for the log, not the logic. It publishes when the right is held before AND after as well: re-saving an executive is the natural way to put them back on the chats after a missed event.
· kamo-shared-library
Carry the executive's phone line on a message
ChatMessageDto gains senderPhone and senderPhoneExt, set only when the sender is KamoCRM staff: the platform org's main line plus the extension that reaches that person, as an email signature already prints it. The relay copies from an allowlist, so a field this class does not declare reaches no browser — history would carry the number while every live message arrived without it. The grants-only finder's comment no longer tells callers it is the wrong answer. For the Exec2Exec desk it is now exactly the right one, deliberately: the requester made the toggle on the platform-admins page the single control over who sits on the executive chats.
· kamo-internal
Stop at a pinned Interaction Center; keep windows under every menu
Two follow-ups to 0c5ef994 (maximize beside the nav). A PINNED Interaction Center now takes its width away from the maximized pane. Pinned, the panel is worked alongside the screen, so a maximized window and its tab strip stop at the panel's left edge instead of running on underneath it. The panel publishes its width on a CSS variable while pinned AND open (useInteractionCenterReserve) and paneGeometry subtracts it, the way the rail's variable already works; the panel is drawn at the same string, so the two cannot disagree. Unpinned, the panel is still a modal over a scrim and the window keeps its width behind it. Unpinning, closing and unmounting all withdraw the reserve. The navigator's width cap measures the room to the panel's edge as well. Menus no longer open behind tool windows. The store's z counter (tool-core, shared with mobile) climbs on every open, raise and focus and is never reset; painted raw, a long session carried windows past MUI's 1300, and every menu, popover and dialog in the app then opened BEHIND them — in the DOM, positioned, invisible. The dock now reads the counter for ORDER only and paints each window at its rank in a small band above 200 (windowStacking.ts), so the stacking the store decided is kept and no window ever reaches a menu's layer. The nav's lift over a maximized pane uses the same painted level, so it stays under the menus too. Verified in real Chrome: the window and strip end at the pinned panel's edge (890px of 1440), an unpinned panel overlays without reflowing, and after 2,000 focus clicks (the counter past 1300) a menu at MUI's default layer still paints over the window.
· kamo-internal
Maximize beside the nav, not over it
A maximized tool window used to take the whole viewport. It, its navigator column and its tab strip were all pinned at (0,0) at the dock's stacking level, over both navs — so a member reading a maximized window could reach no navigation until they put it away. The pane is now everything below NavTop and right of NavPri. The window, the navigator and the strip start at the rail's edge and under the bar, read from the same two CSS variables AuthedChrome offsets the page's own content box by (paneGeometry.ts), so an expanded rail moves the page and the pane together. The navigator and the strip slide inside clipping slots, so they come out from behind the rail and the bar rather than across them, and all three follow a rail toggle with the window's own lead-and-trail timing. While a window is maximized, the nav also has to act on it: - An option that goes to a PAGE announces nav:page first, and every window still on screen is put away as a hexhead — otherwise the page would load underneath the pane. Clicking the page you are already on counts too: it is asking to see that page. - An option that opens a TOOL WINDOW marks its tool:open with origin: 'nav', and the one funnel every open passes through turns that into maximized: true. The new window takes the pane, the old one goes to a hexhead through the store's own swap, and the navigator and tabs follow the new window — to its bucket, to the terminal list, or away for a tool with no navigator. Both apply only while a window holds the pane (the member's call). With nothing maximized a nav click behaves exactly as before. The rules are pure (navPaneRule.ts) and the nav only announces (app/lib/navIntent.ts); **************** fails on a bare tool:open or router.push written into a nav surface. Covered: both navs' options and menus, Settings, the command palette, the logo, KamoMobile, the plan pill, NavTop's links and bell, the Exec2Exec button, and the Interaction Center's rows. Fallout fixed in the same change: - The rail's hover-peek and the Interaction Center (panel and backdrop) sat at z 26-30 under a pane at 201 and climbing, so once the nav was reachable they opened BEHIND it. The nav's layers are lifted above the pane by its own level while one is held, keeping their order (navLayerZ, useMaximizedPaneZ). - The navigator's seam computed its width as the pointer's x, true only while the navigator started at 0. It measures from the navigator's own left edge now, and caps the width at a share of the pane rather than the screen. - Chess and Hold'em chose their layout from window.innerWidth when maximized — too wide by the rail, and stale on a rail toggle. They measure their own root (useMeasuredBox, moved up to tools/ to be shared); **************** keeps it that way.
· kamo-shared-library
List the members who hold a platform right
countHoldersOf answered how many; the Exec2Exec desk needs who. Grant rows alone are the wrong answer to "who works the desk" — the top-level owner holds every platform right with no row at all — so this is one arm of that answer, and the caller adds the owner and the employment check.
· kamo-internal
The executive channel in the console
A direct line between a customer executive and the KamoCRM executives: the chat half of the support window with no ticket, no status, no rating and no queue. THE TOOL WINDOW. Its own directory, because the draft guard resolves a tool's files from its own folder — and passes VACUOUSLY for a chat-only tool, since the composer's textarea lives in app/components/chat. The close guard is therefore hand-written, not inferred from a green tick. THE ATTRIBUTION LINE. Name, organization and official job title on the one line that used to hold a name, and on BOTH sides — the point of the channel is which letterhead somebody was writing under, which applies to the member's own older messages from a different organization too. The side of a message is computed from the sender's USER id at render and never stored; a stored side is what once made bubbles land on the wrong side irreproducibly. Most executives have no job title recorded, so two fields is the normal sight and there is no dangling separator. NAVTOP. Left of the bell, in the fixed chrome and deliberately out of NAV_REGISTRY — there is no "not movable" flag in this codebase, and staying out of the registry IS how an icon is made unhideable. It dispatches its own conversation explicitly: the tool is capped at one instance, and a propless open of a capped tool is a raise, not a navigation, so the button would otherwise re-raise whichever executive the Interaction Center had last opened. Its badge counts the viewer's own conversation, never the desk total — an operator is a party to every conversation they answer, and clicking a personal control cannot clear somebody else's unread. INTERACTION CENTER. A seventh bucket, right of Team Members, visible only to a holder of EXEC2EXEC_ACCESS inside the platform org. It lists USERS, not sessions — most of whom have never written anything — with their owned organizations abbreviated to "Acme +3", the full list in a tooltip, in the title attribute and in the row's aria-label, so it is reachable without a hover. ALL_BUCKETS is both the strip and the saved-preference allowlist, so one derived visible list feeds all three readers and a member restored onto a tab they can no longer see is bounced rather than stranded on a Tabs value with no Tab. WELCOME MESSAGE. The first writable control ever placed on the platform General tab. Back Fill sits behind a confirmation and reports what it did; it is gated on EXEC2EXEC_ACCESS rather than the tab's own right, because pressing it writes a message into people's conversations.
· SecurityService
Store the Welcome Message
The greeting the platform sends first over Exec2Exec, on the platform config singleton where every other platform-wide setting already lives. Two new endpoints rather than two more keys on /system-config, and that is the whole point of the change: the singleton's GET has no per-field filter at all — refuseUnauthorizedFields governs the write only — so admitting PLATFORM_GENERAL to its any-of gate would hand every General operator the KamoPBX base URL, master account id, account name, administrator user, realm and WSS endpoint, plus the extraction model id. One text field they are entitled to, in exchange for the other two tabs. Blank saves as null: not configured and configured-to-nothing are the same instruction, and Back Fill is a no-op in that state. The right itself is enforced in MediaService, where the conversations are, so PlatformRightCoverageTest records it under ENFORCED_ELSEWHERE with the file that holds the gate.
· MediaService
Serve the executive channel
A conversation with its own audience rule and no ticket machinery, following SystemBug's shape: its own package, its own controller, its own visibility, and MediaSession/MediaObj reused for everything underneath. Nothing had to be suppressed from the support queue because a chat with no ticket row never enters it. Eligibility (Exec2ExecEligibility) is a plain Member of the top-level org OR the owner of whatever org they are signed into — and "owner" is two facts, not one: the TeamMember flag AND orgs.user_id_owner, because the platform's own org is owned without its owner necessarily carrying the flag. The platform right gates only the KamoCRM side, and canOperate goes through SessionPlatformFacts, never AuthHelper.hasRight, which a bare GD flag bypasses. Each message carries name, org and official job title, resolved live from the author's member id — members is UNIQUE(ORG_ID, USER_ID), so that id already names, permanently, which organization somebody was writing from. Nothing is snapshotted onto the row. A plain Member has no job title at all, which is the normal case for exactly the population this serves, so the line degrades to two fields rather than trailing a separator. One conversation, two addresses (chat id and session guid), because the browser holds the guid — it is the composer's draft key, the unread key and the STOMP topic — while the row is keyed by id. Both resolve to the same row; neither is to be tidied away. Announcing a message fans out all three frames, not just the session topic: a topic only a mounted window subscribes to is how a badge fails to move until a page reload. The welcome's author is deliberately NOT joined to the session — the platform SYSTEM member is a real signing-in person, and joining them put every conversation on the deployment into that one human's unread snapshot. A second membership row for the same user carries their existing read pointer over. The unread rule is per MEMBER and this feature is the one place one USER holds several, so a fresh pointer counted everything they had written from their first organization as unread against themselves. Eligibility is wider than the greeting on purpose: an org owner gets the channel, but the Welcome Message is addressed, as specified, to kamocrm's own plain members. DDL is by hand (ddl-auto: none, InitializerService undeployed) and already applied to the cluster; **************** is the record.
· kamo-shared-library
The executive channel's foundation
One standing conversation between a customer executive and the KamoCRM executives, keyed on the USER rather than on a member — which is what lets it follow somebody who owns four organizations from whichever one they are signed into, while every party is still stored as a member id so each message records which organization its author wrote it from. - MediaSessionType.EXEC2EXEC(5) — appended, never inserted; the column is @Enumerated(ORDINAL). Not shaped like SUPPORT_TICKET: no status, no queue, no assignment, nothing that closes. - **************** — appended last, because getSortOrder() returns ordinal(). It gates who ANSWERS the desk, never who is entitled to one; conflating the two would hand every executive the desk. - Exec2ExecChat, a satellite on the MediaSession spine the way SystemBug is, with UNIQUE on USER_ID: "there is only ever one Exec2Exec chat" is a database fact rather than a convention, which is what settles the race between the NavTop button and the auto-open resolving the same chat on one sign-in. - ChatMessageDto gains **************** The relay copies from an allowlist, so a field it does not declare reaches no browser at all — history would read correctly while live messages showed a bare name. - **************** the greeting the platform sends first. - findChatMembershipsByMemberId names EXEC2EXEC. That query is an ALLOWLIST, and leaving the type out of it is the whole reason a social conversation could once never show an unread count anywhere.
· kamo-signer-monorepo
The org's wallpaper behind every page, and white copy on it
kamo-sign was the one white-labelled surface still painting a flat grey. It now plays the inviting organization's background photography the way kamo-login, kamo-register, the console and KamoMeet already do — **************** `backgroundImageCount` → `<themeBase>/img/bg/{n}.webp`, `backgroundOrder` honoured, an 8 s dwell with a 1.2 s crossfade that stops under prefers-reduced-motion, 404s dropped, and the org's primary over it at 6%. `OrgBackground` lives in @kamo/theme, beside the rest of the themed-asset convention, so the signer has one copy rather than the fifth hand-rolled one. It resolves the slug on EVERY pass and re-probes on KAMO_THEME_REAPPLY: the layout mounts before any token has been validated, so the sender is unknown at first paint and known only once the session lands. **It renders nothing at all when the org has no wallpapers**, and everything that answers to it is gated on that same fact via `useOrgWallpaper()`. White copy over `--background` — which on a themed tenant is #ffffff — would be invisible, so this must never be an unconditional colour. Mounted at four roots, never inside a screen: the phases are wrapped in a `motion.div` that animates `y`, and a transformed ancestor is a containing block, so a `position: fixed` layer inside one slides with the transition instead of standing still behind it. - **A 6% tint is a tint, not a scrim.** White body copy over `crm`'s wallpaper — a sunlit jungle — measures 1.6:1 with the wash alone, and kamo-login's page-wide `rgba(2,0,36,0.2)→0.05` only reaches ~2:1; it gets away with that because every word it shows sits inside a frosted card. The completion screen has no card, so the darkening is a centred radial under the linear gradient, tuned by sampling rendered pixels. Measured against both real tenants, every line now clears AA: 8.5:1 for the heading, 5.5:1 for the footer, worst case 5.5:1. - **The completion screen goes white** — the heading and the download hint with the three lines reported, since they share one surface and half a fix reads as a bug. - **A logo is whatever colour the tenant chose.** `crm`'s is near-black green, invisible on a photograph under a scrim. ThemeLogo takes an `overlayColor` and masks the artwork to a silhouette, the same mechanism kamo-login and KamoMeet use for the org's own logo-overlay setting; the signer passes #ffffff exactly when a wallpaper is up. - **The welcome screen's "message from" card was a 4% wash over `transparent`** — on the flat background the intended near-white tint, over a photograph 96% photograph, with a dark quote in it. It mixes into #fff on a wallpaper instead. - **The public quote page** gets it too, tinted from its own `--quote-primary` and hidden in `@media print` — what the customer files stays white paper. Its org alias is read out of the `/public/<alias>/` segment of the letterhead URL that `QuoteMapper.themeAsset` already builds, rather than adding a field to `PublicBranding` and redeploying two Java services for a stylesheet. The print rule repeats the `html[data-kamo-wallpaper]` selector at full specificity: a media query adds none of its own, so a bare `.quote-page` there would lose to the screen rule and print a transparent page. Verified in a browser against `crm` and `sign-pink` out of the live theme bucket — welcome, consent, document workspace and completion — with contrast measured from rendered pixels rather than eyeballed.
· kamo-internal
Finish the category page — width, one back arrow, real words
The three managers were written to sit inside a nested tab of a tab. As a page of their own they needed the space they now have. - Configuration capped itself at 560px, so two switches sat in a column with most of a wide screen empty beside them. They are a grid that fills the width and folds back to one column under 360px each. - Two back arrows pointed at the same place — one in the breadcrumb, one beside the title. The trail reads as a trail now; the arrow is the only "back". - Doc Types is the tab most organizations land on with nothing in it, and its empty state, its toggle tooltips and its five error messages were untranslated English. The empty state also told the reader to click a button already on screen; it says what a document type is for instead. - Two buttons were shouting in MUI's default uppercase. Loading the categories no longer sets state synchronously inside the effect — the React Compiler rules reject that as a cascading render, and the retry now bumps a token from the click instead. The fetch cancels on unmount while it is there.
· kamo-internal
Categories are a table, and each one opens as its own page
/settings/features/docs carried eight tabs: General, then seven that each mounted the same three managers against a different ordinal. Seven copies of one screen, and four of them were editing the wrong thing. The ordinals were written when ImageAssocType was six constants long. Appending constants and giving them explicit ids moved CALL_RECORDING from 3 to 7, and the page had no way to notice: the tab labelled "Call Recordings" had been editing MY_DOCS, "My Documents" MEET_RECORDING, "Meet Recordings" SYSTEM_WIDE, "System-Wide" VM_RECORDING, and CALL_RECORDING was unreachable. Nothing failed, because an ordinal is always a valid category. Meanwhile the categories that were never given a tab held the actual work — this organization's thirty-six doc types all sit on LOAN, which the page could not open at all. So the page is two tabs now. General says what document management is; Categories is a table of every category the ORGANIZATION can configure, served by DocsService from the enum itself, with folders, doc types, types defined, stacking orders and documents stored beside each one. A row opens as its own page under **************** the way a phone provider does — breadcrumb back to the table, the same gradient header, and Configuration / Doc Types / Stacking Orders as real tabs rather than grey ones nested inside a panel. The URL carries the enum NAME, never the ordinal, so a link saved today survives the next constant somebody appends. Smaller things this fixes on the way past: - The General tab had been rendering "Description", "Categories Heading" and "Categories Body" — placeholders nobody filled in. - The settings launchpad and the settings menu listed all seven dead tabs. - A category whose doc types can never apply (Marketing Materials) no longer offers a Doc Types tab; one whose doc types are merely switched off says so, next to the switch, rather than letting someone define types that do nothing. - AssocTypeSettings held the old nested tab bar and has no callers left.
· DocsService
Serve the document categories from the enum that defines them
The docs settings page named its own categories and sent the ordinal it believed each one had. Those ordinals were written when ImageAssocType was six constants long; appending constants and giving them explicit ids moved CALL_RECORDING from ordinal 3 to 7, and the page never noticed. The tab labelled "Call Recordings" had been editing MY_DOCS, "My Documents" had been editing MEET_RECORDING, "Meet Recordings" had been editing SYSTEM_WIDE, "System-Wide" had been editing VM_RECORDING, and CALL_RECORDING was unreachable. Nothing failed, because an ordinal is always a valid category — just not the one you meant. GET **************** answers with the list the enum itself defines, so the ordinal and the name always leave here together and a caller never has to guess either. A category qualifies when folders or doc types are on or organization-settable — precisely "there is something here to set", which drops the associations that exist only as plumbing (a bug screenshot, a picture frame photo, a resume on a job application). Each row also carries the counts the settings table shows: doc types defined and active, stacking orders, and documents stored. Three org-wide reads grouped in memory rather than a query per category, so one request fills the whole table where the page previously needed thirty-nine.
· kamo-signer-monorepo
A compact trigger for the signed-copy menu
The sender's envelope list and any table of assigned packages have a row, not a hero slot, and the labelled pill does not fit one. `iconOnly` gives the SAME menu a bare download icon to open from. The alternative was a bespoke single-file download in every tight space, which is how "download the signed copy" came to mean three different things on three screens in the first place.
· kamo-signer-monorepo
The § 7001(c) disclosure ships with the code, in twenty-two languages
`@kamo/esign-core` now carries the consumer disclosure itself — six clauses, every language the product signs in, and `consentDisclosure(locale, brand)` to fill the sender's name in. It was in kamo-signer's dictionary; it belongs here for two reasons. It is shown on TWO surfaces, and a signer following an emailed link must be agreeing to the same words as a member signing the same document from inside the platform. And it is VERSIONED: `CONSENT_DISCLOSURE_VERSION` is written onto the recipient row at consent time and printed on the certificate of completion, so "they accepted the disclosure" names a specific text — which it cannot do if a translation pipeline can revise the words without anyone bumping the version. Changing the wording is therefore a version bump here AND in ESigService's EsignCertificateService. Old certificates keep naming the old version, which is the point of having one. The publish step now accepts either credential the registry takes. NPM_PUBLISH_TOKEN goes out as a bearer token, which is what kamo-js uses and what run 12016 failed for want of — that secret exists on kamo-js and not on this repo. GIT_CLONE_TOKEN, which this repo does have, is a Forgejo user token; the package registry accepts those only as BASIC auth, and answers a bare 401 when one is sent as a bearer, with nothing to say the scheme was the problem. It tries the bearer first, falls back to basic, and proves the credential with `npm whoami` before building anything — so a credential that cannot publish says so instead of failing three steps later inside `pnpm publish`.
· ESigService
A real Certificate of Completion, and the three copies of a signed record
What the flatten pipeline appended was one page per signature carrying seven lines: name, email, member id, timestamp, capture method, IP, user agent. No routing, no consent record, no delivery or view timeline, no signed-document hash, no page numbers — so a reader could not tell whether they were holding all of it — and it existed only INSIDE the executed PDF, where a party who wanted the evidence on its own could not get at it. `EsignCertificateRenderer` draws the certificate: the envelope and both fingerprints, the parties in routing order, and then per party the whole story — role, signing order, status, HOW they were authenticated, the timeline from invitation to signature, IP, device, capture method, and which consent disclosure they accepted in which language. It is used in BOTH places: the flatten pipeline appends these pages to the executed document, and `EsignCertificateService` serves the same pages as a standalone PDF. One renderer on purpose — a second implementation is a second set of audit statements that can disagree with the first, and the one thing an audit record may never do is contradict itself. The certificate is REGENERATED, never stored. Every fact on it is already durable in the recipient and signature rows, so a stored copy would only be a second thing that can go stale; it also means an envelope signed before today gets a full certificate the first time anyone asks. **************** on four surfaces — the signer's session, a staff member's own copy, the sender's dashboard and the internal API — with `both` as a zip. `/signed-document` stays beside it because it is already linked from delivered mail and serialised into other services. Two things the tests caught that would have been live defects: - `Map.of` throws NullPointerException on a null VALUE, and the one request with a null value is the one that branch exists for: an envelope that is not complete has no executed PDF, so `?what=both` on an in-flight envelope would have been a 500 instead of a zip holding the certificate. It degrades to whichever half exists now — somebody chasing a stalled signature is exactly who needs the audit record. - The zip is STORED rather than deflated (both members are already-compressed PDFs) with entries sorted, so the same inputs produce the same bytes and whoever receives it can checksum it. The test parses both members as PDFs rather than counting them: a STORED entry with a wrong CRC unzips to garbage that still looks like a file. (it has no spaces in it, so a word-only wrapper runs it off the edge), states an absent timestamp as an explicit em dash rather than omitting the line — "Signed: —" and no line at all are different statements, and only one of them is evidence — and stamps "Page n of m" after the body, because until the body is finished nobody knows what m is. Consent now carries the locale it was rendered in, so the record says which of the twenty-two translations of the § 7001(c) disclosure the signer actually read.
· kamo-shared-library
Record WHICH consent disclosure a signer accepted, and in which language
`consentedAt` records that somebody ticked a box. 15 U.S.C. § 7001(c) is a list of specific things a consumer must be told BEFORE consenting — the right to paper and any fee, how to withdraw and what that costs them, what the consent covers, how to keep their contact details current, and the hardware and software needed to read AND RETAIN the records. That wording is translated into twenty-two languages and it will be revised, so a certificate produced after a revision would otherwise describe the CURRENT disclosure while the signer had accepted an earlier one, in a language the record does not name. `consent_disclosure_version` and `consent_locale` are written by ESigService at the moment of the first acceptance and never overwritten — re-opening a signing link re-posts consent, and re-dating an old acceptance to whatever wording is current is the one thing these columns exist to prevent. DDL applied by hand on the live cluster (ddl-auto is `none` everywhere): ALTER TABLE esign_envelope_recipients ADD COLUMN IF NOT EXISTS consent_disclosure_version varchar(32); ALTER TABLE esign_envelope_recipients ADD COLUMN IF NOT EXISTS consent_locale varchar(16); Both are nullable, and rows that consented before they existed read as "not recorded" on the certificate rather than being given a version they never saw.
· kamo-signer-monorepo
One signing surface for both apps, and the white label finally paints
The signing UI existed twice — here, and hand-copied into kamo-internal's legal execution wizard — and the two had drifted in both directions. `@kamo/esign-core`, `@kamo/esign-ui` and `@kamo/doc-render` are published packages now, built and pushed to the Forgejo registry by this repo's CI, and kamo-internal consumes them instead of its fork. The merge took the better half of each: kamo-internal's store (format validation, storage guarded on `localStorage` rather than on the DELEGATED and NOT_YET reasons). Components take their strings as PROPS — the two apps translate out of two different dictionaries, and a component that picks its own keys can only live in one. FOUR defects, and one of them caused two of the others: - **Every contained MUI button in this app was transparent.** `AppProviders` passed `enableCssLayer: true`, so all MUI CSS landed in `@layer mui` — and Tailwind's preflight, loaded unlayered by `@tailwind base`, beats layered CSS outright whatever the specificity. Measured: `.MuiButton-containedPrimary` computed `background-color: rgba(0,0,0,0)` with `color: rgba(0,0,0,0.87)`. So "Agree & continue" rendered as plain text, and "Cancel" and "Adopt & sign" sat next to each other as two runs of grey text reading as one sentence — which is exactly how it was reported. Preflight's `p { margin: 0 }` was beating the consent card's `mb` for the same reason, which is why everything on that page ran together. kamo-internal has always mounted its Emotion cache unlayered; matching it fixes all of it at once. - **The logo was a broken image.** `ThemeLogo` fell back to `/img/logo-full.svg`, which this app does not serve, and painted the browser's torn-page glyph in the sender's place. It now walks the extension list for BOTH variants (an org whose mark is a .png had `.svg` requested, 404ed, and stopped there — `sign-pink` is exactly that org), and when nothing resolves it draws the org's short name as a wordmark, or nothing at all before a token has named an organization. - **The favicon and the tab title were never set — nor was the org's brand CSS.** `DynamicMetaLoader` computed `const slug = orgSlug || getThemeSlug()` ONCE, in the effect body, before any token had been validated: `''`. The `KAMO_THEME_REAPPLY` listener, raised the moment the inviting org WAS known, re-ran a closure over that same `''` and returned at the guard. config.json was therefore never fetched on the signing route at all. Resolved per apply now, favicons REPLACE rather than append, and the title is "Sign - {Org Short Name}" as asked. Verified against a real tenant: `Sign - Sign Pink`, four org favicons, the .png mark, and `--color-primary: #452d4e` reaching the tabs, checkboxes and buttons — the MUI palette cannot hold a `var()`, so every brand-bearing component is pointed at it explicitly. - **The language picker was a globe over a list of endonyms**, so a signer who reads none of the Latin scripts had nothing to aim at. Flags and native names now, the same table and the same emoji font stack as kamo-internal's NavTop. It also appears on the welcome and consent screens: it used to arrive only with the TopBar, after the signer had already read an English disclosure. Downloads offer all three copies wherever one was offered: the executed document, the certificate of completion, or both zipped (`/api/sign/download?what=`). The certificate is the half of the record that attributes a signature to a person; handing over only the PDF hands over the half that proves nothing. The consent gate now carries the six clauses 15 U.S.C. § 7001(c) requires a consumer to be shown BEFORE consenting, and the locale it was rendered in rides out with the consent so the certificate can state which of the twenty-two translations was actually read.
· kamo-signer-monorepo
Render the Signature Date field, and make readOnly mean something
Three changes, of which only the first is new work. **`signature_date`** is a field ESigService fills at the instant the signer signs — never the browser, whose clock is settable and which is the one value a party has an incentive to misstate on a legal instrument. So there is no control for it: the signer sees the date it WILL carry, locked, formatted from the session's own locale and time zone, which are the same two values the server formats the stamped date with. The preview and the executed document cannot disagree. **`readOnly` was decorative here.** It is in the design shape, it is a switch in kamo-internal's properties panel and ESigService's FieldCompletion honours it — and InteractiveField never looked at it. A field marked read-only rendered as an ordinary input the signer could type into freely, and since required-ness is not enforced on a read-only field, that free-typed value was the one value on the page nothing checked before it was stamped onto the document. **signStore counted read-only fields as required**, so an envelope carrying one could on a box with no control in it, while the SERVER considered the same submission complete and would have accepted it. kamo-internal's port of this file had already diverged to fix exactly that; the two agree again, and both now match FieldCompletion.
· kamo-internal
A Signature Date field, and a send that admits when nothing was emailed
**"Sent" was not the same thing as "delivered", and the dialog never said so.** ESigService discarded the result of every invitation email, so a send whose mail was refused still showed "Document sent for signature" — the exact failure reported today, where a recipient was never told a document was waiting. The dialog now reads what the send actually reported and, when an invitation did not leave, puts a warning up that STAYS up, names the address, and carries the resend button itself rather than sending the sender off to find the row on another tab. **Signature Date.** A new tool in Signature Tools. The signer is shown no control for it: the server writes the date at the instant they sign, in their own time zone and the organization's locale, so what the executed PDF says cannot be the signing browser's clock. The designer shows it locked, offers no Required/Read-only/validation settings for it (they are questions about an input that does not exist), and the signer sees the date it WILL carry — formatted from the session's own locale and zone so the preview and the document agree. Three things it turned up that were already broken: - **`readOnly` was decorative in both signer UIs.** The flag is in the design shape, it is a switch in the properties panel and ESigService honours it — and neither InteractiveField ever looked at it, so a "read-only" field rendered as an ordinary input the signer could type into freely. Because nothing enforces required-ness on a read-only field, that free-typed value was the one value on the page nothing checked before it was stamped onto the document. - **kamo-signer's signStore counted read-only fields as required**, so a package carrying one could never be finished — Finish stayed disabled and the "next field" submission complete. kamo-internal's port had already fixed this; the two now agree with each other and with FieldCompletion. - **Nothing held the three copies of the field vocabulary together.** Two of them say "PORTED VERBATIM, do not diverge" in their own headers and nothing enforced it; a type added to one alone renders as a bare text input everywhere else, silently, because every renderer has a default branch. There is a ratchet now. The send dialog also labels each recipient row with the signer slot it binds to, and prefills a slot the template already pins to somebody. Binding is by POSITION, and staff never saw the slots at all — so on a two-party template the only thing deciding who got the employer's blocks and who got the employee's was which name happened to be typed first, with nothing on screen saying so.

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