Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-internal

    Show and enforce the permission hierarchy in all five editors

    Five editors granted 199 rights as five flat lists of checkboxes, and none of them showed that one right can be a prerequisite for another. Granting a child without its parent looked exactly like granting a working permission — which is how the Legal & Compliance tab needed two rights and no editor could say so. RightsTree renders the tree: indentation with a connector spine, collapsible branches, a granted/total pill per branch, search that keeps a match's ancestors visible, and a cascade that is animated because the animation is what teaches the rule — enabling a child lights up what it depends on, disabling a parent dims its subtree, both with an undo toast naming exactly what moved. Honours prefers-reduced-motion. It also stops lying about three states the editors could never express: rights locked by the org's security model, granted-but-inert legacy rows whose parent is denied, and the 58 rights that are grantable but gate nothing anywhere. An admin could not previously tell a real permission from a placeholder. The cascade rules live in app/lib/rightsHierarchy.ts, mirrored from Java's RoleRightType. A copy of a security rule is a liability, so the parity test reads the Java source and fails on any divergence rather than trusting convention. The branch-types template editor gets the ordering and indentation but keeps its own control and does NOT auto-cascade: those are FORCE values, absolute and applied after every level, so cascading them would silently rewrite a deliberate override. The parent relation is still enforced there at resolution time. Design + the reproducible audit: docs/superpowers/specs/

  2. · SecurityService

    Normalise member overrides against the hierarchy

    Member direct rights now go through RightsHierarchy.normalizeLevel before they are persisted: a granted child pulls its ancestors up to GRANTED at the member level, a denied parent pushes NUKE down its subtree there. Server-side rather than trusting the editor, because this endpoint is reachable directly. A level stored with a child but not its parent is precisely what the resolver's closure pass strips later — the member would keep losing a right the UI showed as granted, with nothing to explain it. Collected into a map first so the hierarchy sees the whole level at once; normalising entry by entry would depend on the order the client happened to send.

  3. · InitializerService

    Backfill ancestors so closure cannot remove access

    MemberRightsAppliedService now denies any right whose parent chain is not granted. Every row written before the hierarchy existed knows nothing about ancestors, so a role holding MANAGE_SUBSCRIPTION_PLANS without ACCESS_COMMERCE — entirely normal until now — would lose it on the next recalculation. Across 13 live organizations that is an outage, not a migration. Brings all four levels (org roles, departments, job titles, member overrides) up to the invariant the write paths now maintain, using the same rule: a granted descendant pulls its ancestors up to GRANTED. Step 2 is the one that actually prevents the outage — it flips an explicitly DENIED ancestor to granted, because rows that exist survive step 1 untouched and closure would then revoke every descendant beneath them. Security model templates are deliberately NOT touched: a FORCE_NUKE on a parent is an administrator switching a subtree off on purpose, and closure honouring that is the feature. Step 3 is a GATE, not a log line: it re-checks the invariant and fails the run if any level still holds a granted descendant under a non-granted ancestor. A migration that reported success with a violation left behind would hand the problem to the resolver, where it surfaces as a member quietly losing a permission with nothing to explain it.

  4. · kamo-shared-library

    Parent/child hierarchy for all 199 rights

    RoleRightType has declared a 'parent' field and a public getParent() for years. Every constant passed null and nothing ever called it. This finishes that scaffolding: 135 children, 34 roots, 30 standalone, max depth 3, derived from an audit of all 199 rights over 15,081 source files across the 40 repos. TWO enforcement points, and the split is the design: normalizeLevel() is AUTHORING. It makes ONE level self-consistent — a granted child pulls its ancestors up at that level, a denied parent pushes NUKE down its subtree there. Applied in the role, department and job-title write paths. Because each level stays coherent, a job title that grants a child also grants the parent THERE, and the existing waterfall already lets the more specific level beat a department that denied it. No cross-level precedence rule was added and the waterfall is untouched. applyClosure() is RESOLUTION, appended after applyTemplateRules. It is a safety net for the only two things authoring cannot reach: a security model FORCE_NUKE on a parent (absolute, applied last, so without this a forced-off parent leaves its children granted) and rows written before any of this. Two pairs stay deliberately FLAT because separation of duties depends on it: APPROVE_TIMECARD_ADJUSTMENTS / FINALIZE_PAY_PERIODS, and APPROVE_ORG_PAYOUTS as a sibling of MANAGE_ORG_FINANCES rather than its child — nesting the latter would force every payout approver to also be an issuer. Design: kamo-internal ****************

  5. · kamo-internal

    Repaint the reminder in the org's colour, open its options in place

    The card is the organisation's primary colour now, with every mark on it drawn in --on-primary rather than a literal white — a white-label org that picks a pale primary can redeclare that one token and move the whole card. Chips, rails and borders are color-mixed from the same token so they stay legible either way. The primary action is an inverted pill, matching the idiom the legal signing hero already uses for a button on a coloured band. The per-kind accent spine is gone; the icon already carries the kind, and a second colour on a coloured card only muddied it. Overdue is the exception, and it inverts rather than turning red: red on an arbitrary org colour is unreadable. Its Snooze and More menus were MUI Menus, which portal to the document and bring a scroll lock with them. That lock sets overflow:hidden AND a compensating padding-right on document.body, and padding the body moves every fixed-position element on the page — so the deck, which is fixed at bottom/right 20, jumped sideways the moment anyone reached for Snooze, and the menu covered the card it belonged to. Both are one inline panel now, expanding inside the card with the trigger left lit. No portal, no scroll lock, nothing to cover.

  6. · kamo-internal

    Legal & Compliance settings tab with the org default legal text

    A new tab on /settings/features/hrs holding Default Legal Text, linked from the settings menu and the settings launchpad. Creating a package on /hr/legal-compliance now starts from it, and that field is three rows taller. RIGHTS. The HRS settings page as a whole now REFUSES TO RENDER without MANAGE_HRS_SETTINGS rather than only redirecting: the redirect is an effect, so it cannot run until after a render, and every tab was painting — and firing its mount-time loads — for a member who does not hold the right. The new tab additionally needs MANAGE_LEGAL_AND_COMPLIANCE, held identical in three places (page, menu link, launchpad card) and pinned by a test, because a link offered to someone the page will not render the tab for opens Provider Setup instead: SettingsView resolves an unknown ?tab= to index 0 rather than erroring. The dialog seeds the default only while the author has not typed — the fetch resolves after they could already be writing. seedDefaultAttestation is a pure helper so that rule is testable, and it is applied OUTSIDE the state updater: React double-invokes updaters in development, which left the seeded snapshot advanced and the second call refusing its own change. An EDIT never re-seeds. The package carries its own wording and overwriting it would rewrite what a published version's audience is asked to agree to.

  7. · kamo-internal

    Rich text in the sticky and the previews, and a mode you can switch

    A note now says whether it is rich text or plain text, and both the editor and the floating sticky can switch it. The floating note in rich mode embeds the very same editor /notes uses, so it has every tool the full page has — dressed in that note's own colour through a new optional `tone` prop on KbArticleEditor. The prop is additive: absent, the editor looks exactly as it did, so the knowledge base is untouched. The toolbar wraps and scrolls rather than pushing the writing area off a narrow sticky. Switching rich -> plain asks first, and says plainly that the formatting is discarded and cannot be recovered while the text is kept. It is not hidden — plain mode has nowhere to put it — so the conversion also snapshots a version, leaving the old formatting recoverable from history. Plain -> rich just turns the tools on and needs no warning. Previews on the wall cards and the launchpad chips now show the real content rather than a flattened line. They render through a standalone lexicalToHtml rather than a mounted Lexical instance: a wall is dozens of cards, and each would otherwise drag in the whole editor runtime to display a paragraph. That output is injected, and note content is member-authored and often pasted in, so it is the security-sensitive part and was written test-first — escaping, a scheme allowlist that refuses javascript: and data: (including tab- and case-obfuscated), quote-escaping inside href, and legacy raw-HTML notes shown as text rather than rendered. While a search is running the cards keep the plain highlighted preview: marking the hit matters more mid-search, and it cannot highlight rendered HTML without re-parsing what was just serialized. Also: the floating notes carry the org's configurable background watermark, the same one the content pages use, honouring the org's bgWatermark setting including NONE. Sized as a share of the note so it holds at any size, and a little stronger than the pages use because a sticky sits on saturated paper rather than near-white.

  8. · KBService

    Carry a note's rich-text / plain-text mode through the API

    Create honours the requested mode and defaults to rich, matching the editor a note is created in. Update is null-guarded like every other field on that endpoint, so a caller sending only { isRichText } does not blank the note. The DTO reads through isRichTextOrDefault(), so a row that somehow escaped the backfill is reported as rich. Erring towards keeping formatting is the safe direction: the opposite would present a formatted note as plain and invite the member to flatten it.

  9. · InitializerService

    Add the is_rich_text column, defaulted and backfilled to rich

    Whether a note is edited as rich text or plain text cannot be read back out of its content: a rich note nobody has formatted yet is byte-for-byte a plain one. Without a column the editor loses its toolbar every time the note is reopened. Defaulted TRUE, which is also what lets YugabyteDB add the column to a table that already has rows. TRUE specifically because every note written before the column existed was authored in the rich editor — it was the only editor there was — so backfilling to FALSE would show formatted notes as plain and offer to flatten them. @Order(0) for the usual reason: the moment shared-lib maps the column, every runner that loads a Note SELECTs it, so a late migration is never reached. Ships with the drift guard this package uses — it reflects the column name off the entity rather than restating it, and was checked by breaking the name and watching it fail. Applied against Yugabyte: column present as boolean default true, all existing rows backfilled to true, no NULLs.

  10. · kamo-shared-library

    A note records whether it is rich text or plain text

    The mode cannot be derived from the content. A rich note the member has not formatted yet serializes byte-for-byte identically to a plain one — both are a Lexical document of unstyled paragraphs — so without somewhere to keep it, the editor loses its toolbar every time the note is reopened. Defaults to rich, and the column is backfilled to TRUE, because every note that predates the flag was authored in the rich editor; it was the only one there was. Backfilling to FALSE would present formatted notes as plain and invite the member to flatten them. Nullable on purpose, with isRichTextOrDefault() reading an unset flag as rich: a row that somehow escapes the backfill should err towards keeping formatting, not towards discarding it.

  11. · kamo-internal

    One page for the notifications and the controls over them

    The notifications page wore a header of its own — an eyebrow, a title and three pill buttons inside a bordered card — while every neighbouring page (the account browser, every settings screen) is introduced by the brand gradient hero with a back arrow and a tab bar under it. It read as somewhere else in the product. It now carries the same header, with the unread count as its subtitle and the two actions that operate on the list as the hero's white buttons. Under it, two tabs: Notifications and Settings. The settings are the ones that used to be a tab on the member's own profile at /settings/member/{id}?tab= notifications, which put the controls over notifications on a different screen from the notifications themselves — one subject in two places, neither of which mentioned the other. A member who opens the bell because it will not stop is now already on the page that can stop it. `?tab=` is shared through NOTIFICATION_TABS, so the bar and the surfaces that deep-link into it cannot drift onto a value that selects nothing. Both surfaces that linked to the old location (the toast deck's Settings action, the page's own Settings button, now a tab) go to NOTIFICATION_SETTINGS_HREF. An old bookmark of the member URL still resolves — SettingsView falls back to the first tab for a `?tab=` it does not recognise. The Quick Tips copy that hung off the old tab (settings.notifications.help.*) is not carried over: the help drawer is a SettingsView affordance and this page has none. The keys are left in the dictionary rather than scrubbed.

  12. · DocsService

    GET/PATCH the org's default legal text

    /api/docs/legal/settings, behind the same guarded() funnel as every other mapping on this controller. The read NEVER creates a row: it resolves to LegalDefaults when the org has none. That keeps the package editor's seed fetch side-effect free and is what covers organizations created after the KamoInitializer seed ran — the row is materialized on the first save, where a write belongs. The right set is deliberately wider than the package endpoints'. This endpoint is reached from the Legal & Compliance settings tab (MANAGE_HRS_SETTINGS) and from the package editor (MANAGE_LEGAL_AND_COMPLIANCE); either right alone 403s a screen on a field it owns. Safe to widen because it is one org-level string of boilerplate — no roster, no assignment, no evidence — and a regression test pins that the widening does not reach list/create/audience-preview. PATCH semantics: an absent field is untouched, an empty string is a deliberate clear. Publish already refuses a blank attestation, so a cleared default only makes the author write their own.

  13. · InitializerService

    Seed every organization a default legal text

    One hr_legal_settings row per org, unique on org_id, carrying **************** INSERT-ONLY, never an UPDATE. An org that has edited its own wording owns it; a redeploy silently rewriting the sentence its people are asked to legally agree to would be the worst possible behaviour for this string. That also makes the runner idempotent. Orgs created AFTER this runs are covered by the DocsService read fallback rather than by org provisioning, so a future provisioning path that forgot the call cannot ship an org whose default legal text is blank. Both statements retry 40001: a throw from a CommandLineRunner aborts the whole initializer and skips every runner ordered after it.

  14. · kamo-shared-library

    Org-level default legal text

    LegalSettings is one row per organization carrying the wording a NEW legal package starts with. It is a DEFAULT, never a live reference: creating a package copies the text, and publishing freezes that copy onto a version, so editing it here cannot alter a package that exists or one somebody has already agreed to. LegalDefaults holds the platform wording in ONE place. The KamoInitializer seed, the DocsService read fallback and the settings field all bind to it — three literals would drift and orgs either side of the migration would then disagree about what the default is.

  15. · kamo-internal

    The history becomes a page, and the bell rings

    The bell opened a popover, which is the right shape for "what just happened" and the wrong one for "what did that reminder say on Tuesday". A panel hanging off a toolbar cannot be paged, cannot be filtered without crowding, and closes when you click anything — so a member could see a few recent unread notifications and nothing else. Everything already read was, in practice, gone. That is a poor property for the record of what the application has told someone. /notifications is that record. It reads as a chronology rather than a list, because notifications are events and the question a member brings is nearly always "when": a spine of days with the notifications hanging off it, each dot in its own kind's colour, so a month of history reads as bands before a single word is read. Unread rows carry the ground and read ones carry only the dot, which is the difference being scanned for. The filter rail narrows what the spine holds rather than reordering it — read, unread or both, and by kind with a count against each. The day grouping is calendar-aware rather than elapsed-hours: at half past midnight, something from four hours ago happened yesterday, and "4h ago" is not what a person looking for it will scan for. Anything the server could not date is kept and grouped apart, where it cannot claim a day it may not belong to. The bell is now a link, badged like the other two attention marks in the bar so all three read the same way. What differs is the movement, and deliberately: an outstanding document and a running clock are states, and they pulse a ring that says "still true". An unread notification is an event, and what a bell does when an event happens is ring. So it swings — struck once and settling, amplitudes halving 12° → 9° → 6° → 3° → 1.5°, pivoting at the crown because a bell hangs — and then rests for four seconds. A bell that never stopped would be an alarm, and the count beside it already says the thing persists.

  16. · EmailService

    A paged, filterable history

    The centre could answer one question: the most recent hundred, unread or not, with no way to ask for what had already been read. So anything a member had seen was, in practice, gone — which is a poor property for the record of what the application has told someone. GET /api/notifications/browse pages, reports the total, counts each kind for a filter rail, and takes a status of all, unread or read. The existing list stays as it was: it fills the live deck, which wants the newest few and nothing else. Built from predicates rather than a query per combination of filters, and deliberately not from (:kind IS NULL OR ...) — an untyped null parameter is what made every calendar lookup 500, and an omitted predicate cannot do that.

  17. · kamo-shared-library

    Let the history be queried by specification

    The browsable history filters by kind and by whether a notification has been read, and those combine freely — which as JPQL is a query per combination, or one query full of (:kind IS NULL OR n.kind = :kind). The second is what made every calendar lookup answer 500: a null parameter arrives untyped and PostgreSQL refuses the statement outright. A Specification omits the predicate rather than passing a null, so the fault cannot recur here.

  18. · kamo-internal

    Rebuild /notes as a paper wall with a split-pane editor

    The page was the last screen still on raw Tailwind blue-600/gray-50 while everything around it moved to the house system, and every one of its ~100 `dark:` variants was dead code — globals.css pins `color-scheme: light`. It also ignored `--accent-knowledge`, the accent token reserved for exactly this module. Rebuilt around the metaphor the data already carries: the palette is post-it by construction, so notes render as paper — masonry heights, a deterministic per-id tilt that straightens on hover, a pin head on pinned notes in the note's own deep hue. An uncoloured note gets plain paper rather than resolveStickyColor()'s lemon fallback, which would otherwise paint the whole wall yellow and spend the palette before the member chose anything. Bands follow the active sort instead of being fixed, so a divider only costs space when it says something true: recency under a date sort, initials under a title sort, open-frequency under a view-count sort. Also adds: a command palette (⌘K), a collapsible filter rail with colour swatches and wall stats, multi-select with shift-range and bulk actions, keyboard nav (/ ⌘K N P F X arrows), search highlighting, skeleton loading in the wall's own shape, and separate empty vs no-results states. The editor now opens beside the wall on a draggable divider rather than replacing the page, and is keyed by note id so switching notes resets it. Filtering, sorting, banding, masonry balancing and paper geometry move to app/lib/noteWall.ts behind 48 unit tests. NoteCard's DOM-based content parser is gone — it used document.createElement, so a note whose contentPlain was nulled on save rendered no preview at all server-side. Removes NOTE_SORT_OPTIONS, which shipped hard-coded English sort labels into all 22 locales.

  19. · kamo-internal

    Pin and unpin a note from its floating sticky

    The sticky could recolour, rename, minimize, maximize and close a note but not pin it — so pinning meant going back to /notes for the one thing the note is already in front of you for. Pinning is a property of the note rather than of the sticky, so the toggle writes straight through to the note and back via the shared cache. That is what makes it land everywhere at once: the /notes list, and the pinned-notes band on the home launchpad (where it stays hidden until the sticky is closed, as before). Optimistic, and it puts the pin back if the write fails. Reuses components.notes.card.pin / .unpin and the same icon the editor draws, so a pin means one thing across all three surfaces. No new strings.

  20. · kamo-internal

    One quiet-hours window, governing both notification channels

    There were two. The pair near the top of the page silenced the in-app cards; the pair at the bottom silenced the missed-message emails. Nothing on screen said they were different things, so a member setting quiet hours had to notice the second set existed and set it too — and the reasonable assumption, that the one they had already filled in covered the evening, was wrong. The window at the top is now the only one, and it writes both. They are stored by different services — in-app hours on the member's notification preferences, email hours on the ui-preference MediaService reads — so the control writes twice, which is exactly the plumbing a member should never have to know about. The two stores are reconciled once, on load, because a member can arrive with a window on one side and not the other. Whichever is set wins: an email window is adopted when the in-app side has none, so nothing anyone configured is thrown away, and otherwise the in-app window is pushed outward so the single control on screen is telling the truth about both channels. The email preference is owned by the panel now rather than by its own section. Two components debouncing writes to the same stored key would race and could persist half a shape, and MediaService reads an absent field as a default the member never chose.

  21. · kamo-internal

    Two meanings of 100% — a reading width, and the whole pane

    The percentage reset to one baseline: the page grown to the pane but capped at 920px, which on a wide monitor leaves the sheet floating in a lot of empty desk. That cap is deliberate — a letter page stretched across a 34-inch display is a worse document, not a bigger one, because the eye cannot track a line that long — but it is not always what the member wants. Reading fine print or working a dense form, they want the space they have. So there are now two, and a toggle in the capsule picks which: page the readable width, capped at 920. What the pane opens at. width fills the pane, less a 24px gutter so the sheet's shadow is not clipped against the edge and the page reads as laid down rather than cropped. They are a MODE, not two more stops on the zoom ladder, because they are different intents rather than different magnifications. Zoom multiplies whichever baseline is active, so the readout stays honest about the one it is a multiple of, and + / − still work from either. Switching baseline LANDS on it — zoom returns to 100%. "Fit to width" that left a 250% zoom applied on top would not fit anything, and the member asked for a width, not a multiplier of one. The toggle is one button with `aria-pressed`, and its tooltip names WHAT PRESSING IT DOES rather than what is currently on: "Fit to Width" in page mode, "Reset view" in width mode. Both keys already existed and are non-empty in all 22 locales. maxPageWidthFor covers the edges in tests: a NaN pane measurement falls back to the readable cap rather than reaching PdfPages as maxPageWidth and rendering nothing, and a collapsed pane never returns a width too small to read. One test pins the fact that on a pane narrower than the cap the two modes converge to within the gutter — that is correct rather than a bug to design around.

  22. · kamo-internal

    Refresh the NavTop clock the moment the editor saves

    Voiding the punch a member is currently clocked in under already leaves them OFF server-side: RosterService.clockState reads the latest LIVE punch and skips voided rows, so no clock-out is written and none is needed. A SHIFT_OUT here would be worse than useless — the SHIFT_IN it would pair with was just voided, so it lands as an orphan and raises the missing-punch exception the editor exists to clear. What was actually wrong was the indicator. It polls on a 60s floor with no signal from the editor, so after deleting the entry they were clocked in under the member watched the chrome go on saying they were on the clock. The editor now announces a save that wrote something and the control re-reads. Fired from `finally` and gated on what LANDED rather than on success: a save that stopped half way still changed the record, and that is the case where a stale badge is hardest to notice. The event carries the edited memberId because the NavTop clock is self-scoped (fetchMyClockState takes no member id) while this same editor is used by HR against other members at /hr/attendance. shouldRefreshClockFor compares as STRINGS and refuses a number, for the reason parseClockState already gives: ids are unique_rowid() values past MAX_SAFE_INTEGER and two members can round to the same number. Does not fix the OTHER member's browser when HR edits their card — that needs the timecard.clock.{memberId} push relay, still pending.

  23. · kamo-internal

    Zoom the document, and grab it to move it

    A floating capsule in the bottom-right of the document pane: −, the current percentage, +. The READOUT IS THE RESET — a separate reset button would be a third control for something the member only wants once they can already see they are not at 100%, and where they look to check is the number. It is a real button with a label, so keyboard and screen readers reach it. 100% means FIT-TO-PANE, not actual size. `zoom` multiplies the fit-width scale PdfPages already computes, and the view the member started in is what they mean when they press "100%". Steps are multiplicative (x1.25), not ±0.25: a fixed step is a quarter of the page at 100% and a sixteenth of it at 400%, so zooming back out from far in would take a dozen presses. The maths is in app/lib/pdfZoom.ts with tests, including that + then − returns exactly where you were and that a NaN falls back to fit rather than reaching PdfPages as `scale = naturalScale * NaN`, which renders zero pages and looks precisely like the document failing to load. DRAG TO PAN, anywhere that is not something you operate. useDragToPan writes scrollLeft/scrollTop rather than transforming the content, so it inherits the container's bounds, scrollbars and wheel behaviour for free — and the horizontal axis simply does nothing until the page is wider than the pane, with no condition anywhere to say so. It refuses to start on input, textarea, select, button, a[href], label, [role=radiogroup] and the rest: those are the signature boxes, text fields and checkbox groups InteractiveField renders, and the zoom capsule's own buttons. The pointer is captured on the way down so a drag that leaves the pane keeps tracking and still ends cleanly. MOUSE ONLY. Touch already pans natively with momentum and rubber-banding that no handler here can reproduce, and hijacking it would make the document worse on the devices where dragging is the natural gesture. One layout fix comes with this: PdfPages centres pages with `align-items: center`, and a flex item wider than its container overflows EQUALLY on both sides — the left half of which no scroll position can reach. Zooming past the pane width would have put the left margin of every page permanently out of reach. The page wrapper now grows to the widest page (`width: fit-content`, floored at `min-width: 100%`), so the whole sheet stays inside the scrollable area.

  24. · kamo-internal

    Pinned notes on the launchpad, colour rotation, and a proper send-off

    Three things. Pinned notes reach the home launchpad. Every note pinned on /notes gets a chip there in its own sticky colour, with a short preview; clicking one picks it up onto the overlay, and a corner control opens it in the full editor instead for the things a sticky cannot do. A note that is CURRENTLY FLOATING is left out — it is already on the member's screen, and listing it would offer the same note twice, the same rule that makes its /notes card go inert. Closing the sticky puts the chip back. The band renders only when the notes option survived the nav's own gate, so it decides nothing about access on its own, and it prefers the overlay's cached copy of a note so a chip never shows a title or colour from before it was picked up. New notes rotate through the palette. Each one opens on the colour after the member's most recently CREATED note, wrapping at the end, so a wall of stickies is never a block of identical yellow. Keyed off the notes themselves rather than a stored counter, so it stays right across devices and sessions with nothing extra to persist; an unset or unrecognised previous colour restarts the rotation instead of guessing. Closing a note now crumples it. A beat of anticipation, then it scrunches, spins and drops away, leaving a puff of paper scraps in its own colour over the space it left. The scraps are rendered by the layer, not the note: a child inherits the note's exit transform and would shrink away with it instead of bursting outward, so the note hands over its last rectangle and colour on the way out. A maximized note bursts across the whole viewport rather than the size it would restore down to; a minimized one bursts not at all, having no space on screen to burst over; and prefers-reduced-motion gets a plain fade with no scraps. The scatter is a deterministic hash, not Math.random, which would re-roll every render and jitter the scraps mid-flight.

  25. · kamo-internal

    Notification settings across the width, with a working model

    It was one 760px column of switch rows, which reads as a form to fill in rather than something to tune — and on a wide screen left two thirds of the page empty while the list of notification kinds ran off the bottom. The page's real question is not "what are my settings" but "how hard is this application allowed to interrupt me", so it now opens by answering that in a sentence assembled from the live values — in the app, with a sound, on your desktop, outside your quiet hours — and gives the width to the four decisions behind it: which channels, where they land, when to stay quiet, and what is worth being told about. The centrepiece is a stage: the app's own viewport in miniature, painted in the notification deck's dark tokens while the page around it stays light, because that is the exact contrast a real notification makes when it arrives over a working screen. The corner is chosen by clicking the corner, and the card springs across rather than cutting, so the control and its consequence are one gesture. Four quadrants beat a dropdown here: the choice is spatial, and the words "bottom right" are a worse map of a screen than a picture of one. Beside it, Show me raises a real notification in the real deck at the member's own sound, corner and dismissal time. A settings page for notifications ought to be able to show you a notification, and a mock would be a picture of one — so previewToast puts a throwaway card through the actual deck, writing nothing, publishing nothing, and never reaching the centre. The kinds became tiles where the whole tile is the switch, rather than rows with a 34px toggle at the far right, and the email section adopted the panel's own rows so it reads as part of the page rather than a card that wandered in. Do-not-disturb now appears once. It was in the summary strip and again in the quiet card — the same switch twice on one screen. It lives with the summary, which is the thing it changes; the quiet card is left to the scheduled kind.

  26. · kamo-internal

    Notifications become a tab on the member's own profile

    They were a page of their own at /settings/notifications, which put a preference about one person somewhere other than the page about that person — and left the missed-message email switches stranded on the profile tab, so the two halves of "how do I get told things" lived on different screens. Both are here now, on **************** directly after My Profile. The email switches keep their behaviour exactly — same preference key, same me-scoped route, same debounce and keepalive flush — and are redrawn with the notification panel's own section and row primitives so they read as part of it rather than as a card that wandered in. They stay a distinct section, and say so: that channel reaches you when the application is closed, is written by a different service, and keeps its own quiet hours. Folding the two sets of hours into one control would silently change what one of them governs. The tab is self-only, because every preference behind it is read and written through me-scoped routes — on somebody else's profile these controls would edit the viewer's own while appearing to edit theirs. It fetches the account address itself rather than taking it from the profile tab: settings tabs mount one at a time, so a value lifted from a sibling would be missing exactly when a member opened this tab first, which from a notification's Settings link is every time. The old route is gone, along with its settings-menu topic, and the three surfaces that linked to it now build the path through one helper — a hand-assembled route in three places is a route that eventually disagrees with itself.

  27. · kamo-internal

    Sign a package at /settings/sign, on a route of its own

    Signing was inline on the member's profile tab, so the document sat under the settings tab strip AND the compliance sub-tab strip AND that tab's help panel — three bands of unrelated chrome over the instrument the member is being asked to read, and two headers. It is now **************** The page inherits NavTop and NavPri like every other screen and the wizard's hero band is the only header. Finishing or leaving returns to **************** — the list it came from, on the bucket the package was in. The list navigates with `push`, not `replace`: it is a move to a different screen, so browser Back has to come back to the list. THE ALLOWLIST HAD TO MOVE WITH IT. `RESTRICTED_ROUTE_PREFIXES` is prefix-matched and denies by default, and it is what a member whose employment has ended is confined to. Without `/settings/sign` on it, a former employee could open their package list and be bounced straight back to it by RestrictedMembershipGuard the moment they clicked into one — a redirect loop on the exact surface that tier exists to keep reachable, since compliance acknowledgements are one of the two things it protects. `restrictedRoutes.test.ts` now pins that, including that the match is not a bare startsWith: `/settings/signage` is not `/settings/sign`. No gate on the page itself, deliberately. A member executing their own employment agreement holds no HR right and never will — the package was issued TO them — and every endpoint behind it is self-scoped in DocsService (LegalAssignmentOwnership, not LegalPackageAccess), so the session decides whose assignment it is and someone else's uid answers 404. A bare /settings/sign with no assignment returns to the list rather than reporting an error: there is nothing on the page without one and no way for the member to supply it there. A malformed uid does show the error, because bouncing would hide a bad link somebody is about to send again.

  28. · kamo-internal

    Remember each floating note's size and position per device

    Reopening a detached note now puts it back exactly where it was left, at the size it was left, on that screen. Placement is per DEVICE, deliberately. Which notes are floating is a property of the member and stays server-side in ui-preferences, so it follows them everywhere; where a note SITS is a property of the screen in front of them, and a layout that suits a 27" desktop is wrong on a laptop. So placements live in localStorage, scoped by member because one browser is one device but can be more than one person. Placements outlive the sticky on purpose — closing a note keeps its geometry, which is the whole point — so the store is capped per member and evicts least-recently-placed first, and a note that is deleted (or 404s on restore) gives its slot up immediately. Two things this had to get right: - logout does a wholesale localStorage.clear(), which would have dropped the layout on every sign-out. It now captures and restores this one key the same way it already does for the deep-link stash. Note ids and coordinates only, never content. - hydration waits for the member id instead of running at mount. Placements are member-scoped, so restoring before we know who this is would silently ignore this device's layout and fall back to the cross-device geometry. Precedence on restore is this device's placement, else whatever the cross-device preference last carried — so a brand-new device still opens to a sane layout rather than a cascade — and either way it is re-clamped to the current viewport.

  29. · kamo-internal

    The lead on a callback is read, not remembered — and a call can be reopened

    Two things, both about a calendar event being a poor place to keep a copy of somebody else's record. The event dialog now reads the lead from the CRM. Its name, the number to ring, the email, the best time to call — all fetched when the dialog opens, keyed by the callback id the event already stores. What it does not do is show a copy frozen at the moment the call was booked, which is what the description used to carry and what would have quietly told a member to ring a number nobody had answered in weeks. The description is now the member's own note and nothing else, which cannot go stale because it was never a copy of anything. And a pending call can be opened from the lead page. Clicking one reopens the same calendar event in the same dialog, so the place a member goes to look at a promised call is the place they can move it. Saving goes through SecurityService rather than the calendar API, because the callback record and the event are two views of one promise and only the service that owns both can move them together — the alternative is a lead page and a calendar that disagree about when the call is, and a reminder that fires against whichever the member is not reading. The row itself is the control; the resolve buttons beside it stop the click, so nobody marks a call contacted while meaning to read it.

  30. · EmailService

    Read one event by id

    The lead page holds a callback's event id and wants to reopen it in the same dialog the calendar uses, so it needs to be able to fetch the event on its own. Scoped exactly like the list — the member's own calendars plus the organisation's — so an id belonging to someone else's calendar reads as absent rather than as forbidden.

Your universe is waiting.

Start free in minutes. Add products whenever you are ready — no migration, no new logins.

Free forever — no credit card required