Skip to content

Frontend spec (Angular + React/Excalidraw, TypeScript 6) ​

Owner ​

Frontend

Responsibilities ​

Angular shell app embedding a React/Excalidraw canvas component, Yjs client for CRDT sync, WebSocket client for presence, RxJS for state management. See ADR 0002.

Routes ​

URLResult
/board/:boardIdThe board with that id. The route param is called boardId so withComponentInputBinding() hands it straight to Board.boardId, which becomes the board-id attribute of <elysion-canvas> and therefore the Yjs room. The id is URL-decoded (/board/q3%20plan%2Fv2 is the room q3 plan/v2).
/board/%20 (whitespace only)Redirected to the board list by boardIdGuard: the id becomes a room name, so an empty one is never connected to.
/The board list (BoardList): the boards from GET /api/boards, newest first, each a card linking to its board; see Board list below.
/favoritesThe same BoardList with roomId = favorites: only the starred boards (#730).
/admin, /admin/users, /admin/groups, /admin/roomsThe administration (lazy ADMIN_ROUTES, behind the login guard); see Administration below. An unknown address under /admin goes to the overview.
any unknown pathRedirected to /. The room default is not a stored board but still opens at /board/default.

This is what makes collaboration links possible (#101).

Board list ​

BoardList (src/app/board-list/) is the home page, in the look of the top bar on the shared tokens. It loads the boards through BoardApi.list() and has three states: loading ("Loading boards…"), error (a message with "Try again", the BFF being down must not leave a blank page) and ready, which is either the grid of board cards or the empty state with a hint and a secondary "Create your first board" button.

Header (#560): the brand on the left; on the right New board (the primary button) and the user menu. New board is there whenever the session is ready, so also while the list loads, when it failed and when there are no boards; below 480 px it shrinks to a "+" icon (its accessible name stays "New board"). Pressing it while the form is open changes nothing.

Page: the content uses the full width (24 px side padding, 16 px on a phone). The title row shows "Boards" and the count ("12 boards", "1 board") as quiet text and has no button. The grid is repeat(auto-fill, minmax(200px, 1fr)) with a 16 px gap, so a wide screen shows many columns and a phone one.

Rooms (#569, ADR 0019): the page has a sidebar (RoomSidebar) with "All boards", "Not in a room" and the user's rooms, each with its board count. Each entry is a link to a view of the same list, and the route says which: / (all boards), /rooms/none (boards in no room) and /rooms/:roomId (one room), so a view survives a reload and can be linked. They are one route with a URL matcher (boardListMatcher), so BoardList stays in place while the user moves between views and the list is not loaded again; roomId is an input (undefined, none or an id). The boards (GET /api/boards) and the rooms (GET /api/rooms) load apart: a failing room list leaves the boards usable and the sidebar says so with "Try again". A room view whose room is not among the user's rooms (it does not exist, or the user has no role in it) says "Room not found". Below 720 px the sidebar becomes a row of chips above the grid. Empty states: no rooms ("No rooms yet…"), an empty room, and "Every board is in a room" in the no-room view.

  • New room in the sidebar opens an inline field (focused; Enter creates, Escape or Cancel closes; a blank name is refused before the request; the backend's message is shown), then the app opens the new room's view. Rooms are listed by name.
  • In a room view the title is the room's name and the title row has the actions the user's role allows: Rename (Editor and Owner; an inline field, Enter saves, Escape cancels, an unchanged name sends nothing), and for an Owner Members (the Share dialog with scope="room", which talks to /api/rooms/:id/members and says "Share this room") and Delete room, which asks first ("Its n boards stay, but no longer in a room…"), then deletes, takes the boards out of the room in the list and goes back to all boards. Failures are shown above the boards, with the backend's message where it has one, never swallowed.
  • A viewer of a room gets no New board (header, or the create-first button), no Rename, Members or Delete, and the move menus offer no way to put boards into that room.
  • Moving a board: a card's move menu (a folder icon in its corner, aria-haspopup="menu"; Escape or a click elsewhere closes it) lists "Move to <room>" for every room the user may write in (all but the board's own) and "Remove from room" for a board that is in one; this is the keyboard and screen reader way. Dragging a card onto a room in the sidebar, or onto "Not in a room", does the same (HTML5 drag events; only the entries that can take the board are marked and accept the drop): the drag is an extra. Both call PUT /api/boards/:id/room; the backend decides whether the user may (a board they may not write is 403, shown as "could not be moved"). In the all-boards view a card shows the name of its room as a small label.
  • New board in a room view creates the board and puts it in the room before opening it (two calls, the board first; the dialog says "In the room “…”"). If the second call fails, the board exists outside any room, the page stays on the list, the list is loaded again and a message says so, so the user can move it from its card. The template choice works as before.

Cards: a tile that is one link to the board: a preview area at the top (the board's initials, up to two letters, on a tint of one of the --c-node-* tokens chosen from the board id, so cards differ but stay the same; real thumbnails are not part of this), below it the name (one line, ellipsis, the full name as title) and the creation date. Duplicate and Delete are icon buttons in the preview's corner, outside the link; they show on hover and keyboard focus and are always visible on touch devices (@media (hover: none)).

New board opens the create form as a modal dialog (role="dialog", aria-modal; our own markup, not the browser's dialog element): a card at most 560 px wide, centered over the dimmed page, with the name preset to "Untitled board", focused and selected; Enter or Create sends POST /api/boards (the name is trimmed; blank or longer than 120 characters is refused before the request), Escape (from any field), a click on the dimmed area or Cancel closes it. While the request runs the form is disabled; if it fails the form stays open with a message so the user can retry. On success the app opens the new board. The list shows the boards the user owns or is a member of.

Templates (#332): the form also offers Start from, a radiogroup of template tiles (the native radio inputs stay in the tiles, visually hidden, so arrow keys and screen readers work as for a radio list) with "Blank" (the default, selected again whenever the form opens) and the templates of GET /api/templates (name and description). A catalog that cannot be loaded leaves only "Blank"; creating a board never depends on it. With a template chosen the board is created as usual and the app opens it with the template id in the navigation's history state ({ templateId }).

Applying the template is done by the browser that created the board, through the canvas's importFile, not by the server: the stored document is Yjs state that only the canvas can produce from an .excalidraw scene, and the persistence design (ADR 0011) keeps it opaque to the backend. The board page takes the id from the history state and, once the canvas is ready and the connection is connected (and the user is not a viewer), fetches GET /api/templates/:id and imports the scene. The marker is cleared from the signal and from the history state first, so a reconnect or a reload does not apply it again; a second person opening the new board has no marker, so only one browser ever writes the content, and everybody else receives it through the sync. Scene elements keep their ids, so even a repeated import would reset, not duplicate, them. If the template cannot be fetched or imported the board stays blank and the page says so.

Templates menu (#336): the top bar has a Templates menu (TemplateMenu, next to Import and Export; not shown to viewers). It loads the catalog when first opened (loading and error states, "Try again") and emits the chosen template; Board.addTemplate fetches GET /api/templates/:id and calls the element's insertFile, which adds the scene to the board without replacing anything: new ids everywhere (cloneForInsertion remaps containers, bound texts, arrow ends, groups and frames, so a template can be inserted any number of times), centered on the middle of what the user sees, selected, one undo step. A failure is shown in the notice under the bar.

Own templates (#340): the Templates menu lists the user's own templates after the built-in ones (it asks for the list every time it opens), each with a delete button that asks once more ("Delete “…”?") before DELETE /api/templates/:id; built-in ones have none. Save board as template… and Save selection as template… (needs a selection) open a form under the bar with the name (preset to the board's name, or "Selection") and an optional description. Saving calls the element's exportBoard('excalidraw', { selectionOnly }) and sends the file to POST /api/templates; an empty board or selection is told in the notice, a failure keeps the form open with the typed values. Templates are private to their owner.

The brand (#555, shared/app-brand.ts) is the project icon, two sticky notes on the gradient square, plus the name; it links to /. The source of the icon is apps/frontend/public/icon.svg; pnpm make:icons (scripts/make-icons.mjs) makes favicon.ico, icon-192.png, icon-512.png, icon-maskable-512.png and apple-touch-icon.png from it, and index.html links them with manifest.webmanifest (served as application/manifest+json). The mark in AppBrand is the same drawing as inline SVG, so change the icon in both places (the candidates and the owner's choice are in docs/brand/README.md).

The top bar of the board page links back: the brand and an "All boards" button (an icon only below 860 px) both go to /.

Duplicating a board ​

Every card has a Duplicate button (labelled "Duplicate the board <name>", beside Delete). It sends POST /api/boards/:id/duplicate, which creates "<name> (copy)" with a copy of the content, and opens the copy. While the request runs the Duplicate buttons are disabled so a double click cannot make two copies; if it fails the list stays with "The board “<name>” could not be duplicated. Try again."

Deleting a board ​

Every card in the list has a Delete button (a trash icon with the label "Delete the board <name>", beside the card's link, not inside it). It opens a confirmation in the page, never a browser dialog: "Delete “<name>”? The board and everything on it will be gone. This cannot be undone." with Delete and Cancel. Confirming sends DELETE /api/boards/:id (the business backend deletes the stored document with the board); the board then leaves the list, and deleting the last one shows the empty state. While the request runs the buttons are disabled; if it fails the board stays and the confirmation shows "The board could not be deleted. Try again."

Board not found ​

Board asks the BFF about a stored board's id (BoardApi.find: found, missing on a 404, unavailable when the BFF fails, room for ids that cannot be a board's, such as default). On missing the page shows "Board not found. This board does not exist, or it was deleted." with a link to the list, and never starts the canvas: connecting would create a room, and once something is drawn a stored document, for a board that is gone. A stored board's canvas starts when the BFF has answered; a room such as default starts at once; if the BFF fails the canvas starts anyway, named by its id. The tab is named "Board not found · Elysion".

Renaming a board ​

The board's title in the top bar is a button when the board is a stored one (a room such as default has no name to change and stays plain text). Clicking it turns the title into a field with the name selected; Enter or leaving the field saves, Escape cancels, and a name that did not change sends nothing. An empty name is refused: Enter keeps the field open with "A board needs a name.", leaving the field with an empty name just cancels. The field takes up to 120 characters, the backend's limit.

Saving is optimistic: Board.rename shows the new name at once and sends PATCH /api/boards/:id (BoardApi.rename); the name in the answer (trimmed by the backend) replaces it. If the request fails the previous name comes back and the banner says "The board could not be renamed." The browser tab is named after the board, <name> · Elysion (Elysion for a room without a name, Boards · Elysion on the list).

Search, sort and favorites (#730): above the grid a search field and Sort by (newest, name, opened). The search filters the loaded boards on their name, ignoring case; it is mirrored in ?q= of the address (q is an input of BoardList, so a link, a reload and the back button restore it) and Escape or the clear button empties it. / focuses the field unless the focus is in a field. The order is remembered in localStorage (elysion.board-sort, list-preferences.ts; a stale value is newest). sortBoards puts the starred boards first in the all-boards view whatever the order; opened puts the never opened last. Each card has a star (aria-pressed), shown once set and on hover or focus; it flips at once and is undone with a message when saving fails. Favorites is an entry of the sidebar above "Not in a room" with its count and the route /favorites. The board page has the same star in the top bar and tells the backend when a stored board is opened (POST /api/boards/:id/open, a failure is ignored). The BFF and the backend also take GET /boards?query=, for clients that do not load the whole list.

Audit log (#669) ​

AdminAudit (/admin/audit, src/app/admin/admin-audit.*) lists the instance's audit log, newest first, 25 to a page with the pager of the other admin pages. Each entry is a sentence made by describeAuditEntry (src/app/audit/audit.ts, one $localize message per action, English and German; an action this version does not know is shown as its own name). The filters are two dates (the end date is a whole day) and two chips: a click on the board, room, group or person in a row filters by it (subject or actor), and the chip's × takes the filter away, so nobody has to type an id. The Share dialog has History (app-share-history, src/app/share/history.*) at the bottom, for boards and rooms: a toggle that loads the last 20 entries when it is opened (GET /api/boards|rooms/:id/history), so a dialog that only adds a person costs no extra request. Only owners get the dialog, and a failed load says so with a retry.

Administration ​

The pages for the administrators of the instance (ADR 0027, #659), in src/app/admin/. They show metadata only (names, numbers, roles): an administrator has no role on a board or a room by being one, and the pages have no way to read a board.

  • Entry. Account (src/app/auth/account.ts) asks GET /api/auth/me once (the app root does it as soon as somebody is signed in, AdminLayout does it if it is opened first) and keeps isAdministrator (null until known, false after a failure). The user menu shows "Administration" (/admin) only when it is true. This is a hint for the interface; the business backend answers 403 to everybody else on every admin call.
  • Frame. AdminLayout is the parent of the lazy routes: the brand, "All boards", the title, the user menu and a left navigation (Overview, Users, Groups, Rooms) that is a row of chips below 720 px. While the answer of auth/me is not in it says "Checking your access…"; a person who is not an administrator gets "You are not an administrator" with a link back, and none of the pages is created (so no 403 page).
  • Overview. The four numbers of GET /api/admin/stats as tiles (people with the number seen in the last 30 days, groups, rooms, boards) and the identity provider: the issuer and the idpHint of /auth-config.json (the file the login reads).
  • Lists. Users, groups and rooms are PagedLists: a search form (role="search", submitted with Enter or the button, the text trimmed to 100 characters), 25 rows a page, "Previous / Next" with where the reader is, an error with "Try again", and a request overtaken by a newer one is ignored. The tables have a visually hidden caption, column headers and the name as the row header; they scroll sideways inside their frame on a phone.
  • Users. Name (opens a dialog with the rooms and boards the person has directly, with roles; roles through a group or a room are not listed), email, groups as chips, last seen, created. A deactivated person has a "deactivated" chip (#668). The dialog has Deactivate… (a question first, which says the account has to be disabled at the identity provider as well) and, for a deactivated person, Reactivate; for a deactivated person who owns something it also has Hand over: the counts of boards and rooms owned (the creator counts), a search for the receiver (never the giver, never a deactivated person), and a question before POST /api/admin/boards/transfer. The group member picker does not offer deactivated people and marks the ones that are members.
  • Groups. Name, source ("Local", or the provider's name from idpHint, else "Identity provider"), external id, members, rooms. New group (local), Rename in the row, Link (a dialog with the warning that from now on the provider owns the members, and the id of the provider's group; 409 for an id another group has is shown), Unlink (the group becomes local and keeps its members), Delete (says how many memberships go and how many rooms lose the role). The name opens the members: for a local group with a search of the user list (GET /api/admin/users?query=, eight results) and Add / Remove, for a linked group read-only with the note that the provider owns them.
  • Rooms. Name, owner, members, groups, boards, created; no board names. Share (the room's Share dialog) only for a room the administrator owns themselves (found in GET /api/rooms); deleting a room as an administrator is not part of this (#666).
  • Dialogs. AdminDialog is the one dialog of these pages: role="dialog", aria-modal, named by its heading, the focus goes in and comes back to the button that opened it, Tab stays inside (also Shift+Tab), Escape and a click outside close it.
  • Accessibility. Every control has a name (the row buttons say which group or person), status and errors are role="status" and role="alert", and the browser test (admin.spec.ts) runs axe on each page and on a phone-wide viewport.

A deactivated account (#668) ​

When an administrator deactivates a person, every /api call of theirs answers 403 with code: "account-deactivated". showDeactivatedPage (src/app/auth/show-deactivated-page.ts, an HTTP interceptor between restartLoginOn401 and the token interceptor) sends them to /deactivated once instead of showing an error in each place; any other 403 passes untouched, and it does nothing on that page (no loop). Deactivated (src/app/deactivated/) asks GET /api/rooms once: a person whose account works is sent on to /, so the address is no way to see a message that is not true; otherwise it says "This account has been deactivated", what to do and offers Log out. The share dialog shows a member who is deactivated with a "deactivated" label and keeps them in the list.

Facilitation: timer ​

The first facilitation tool (#579, ADR 0020: the state lives in the board's Yjs document, so it syncs, is persisted and survives a reload with no change to the realtime service or the backend). The canvas keeps the state and announces it as the timer event ("The shared timer, in the canvas" below the contract table); the shell shows and controls it.

Top bar (topbar/timer-menu.*, TimerMenu, after the Export menu): editors and owners get a Timer button that opens a menu with the presets 1, 2, 5, 10 and 15 minutes (a preset fills the field), a Minutes field (any number from 1 minute to 24 hours, a comma is accepted as the decimal sign; anything else is refused with a message and starts nothing) and Start, which asks the canvas to start the timer for everybody. While a timer exists the button is replaced by the countdown chip: mm:ss (h:mm:ss from an hour on, rounded up to the second so 00:00 means over), role="timer" and aria-live="off", with who started it as the tooltip, updated every second from the shared state and the local clock. Editors and owners also get Pause (Resume while paused; the chip then stands still and says "paused"), +1 min and Stop; viewers see the chip and nothing else, because the canvas rejects a viewer's changes. The board page passes the timer event's state in (Board.timer) and calls the element's methods (Board.timerCommand); a refusal ("This board is read-only.", "The canvas is not ready yet.") is shown in the banner.

The end. At zero the chip turns red and shows 00:00 for five seconds, then the shared state is cleared by the client that started the timer; if that client is gone, by any other client that may write, which waits three seconds longer (so a second client only steps in when the first did not). Clearing is idempotent, so two clients doing it do no harm, and a client that cannot write (a viewer) never clears. Extending the timer in those seconds cancels the clearing. A short chime (two tones made with the Web Audio API, TimerChime: no audio file, no network) plays once on every client for a timer that ended just now, not for one that had ended before the client looked, unless muted. The mute toggle is in the menu and next to the chip, for viewers too, and is kept per browser in localStorage under elysion.timer.muted (guarded: a browser that will not store it keeps the choice for the page). Browsers refuse sound before the user has interacted with the page: such a client gets no sound and no error.

Reload and late joiners. The voting and the person's own votes are asked from the server (votingApi.get()) when the connection is up and after every reconnect, so a reload, a second tab or a late joiner gets them; the person is told apart by the server, from the token.

Clock skew. The end is worked out on every client from startedAt with its own Date.now(), because the realtime service has no time API. A client whose clock is off by a few seconds shows a few seconds too much or too little, and the red zero comes that much early or late there. This is accepted for a workshop timer; a server-time offset can be added later without changing what is stored.

Facilitation: voting ​

The second facilitation tool (#582, moved by #732 to the server: ADR 0029, superseding ADR 0020 for the votes: the votes live in the business backend, so viewers vote and the votes are secret while a voting runs). How the state is kept, the vote mode and the badges on the canvas are in "Dot voting, in the canvas" below the contract table; this is what the shell shows.

Top bar (topbar/voting-menu.*, VotingMenu, after the Timer): editors and owners get a Voting button. Without a voting it opens a form: a name (default "Voting"), the votes per person as the presets 3, 5 and 10 or a field (a whole number from 1 to 100; anything else is refused with a message), a checkbox Show who voted for what in the result (off by default: a named voting shows the voters under each result), and Start. While a voting is open the button says "Voting · 3 left" (the caller's votes left, never below zero) and its menu shows the name, "3 of 5 votes left" with how to vote and take a vote back, and End voting; there is no second voting while one is open. When a voting ends the results open by themselves, for everybody on the board, in the dialog below: when an editor or owner presses End voting, and when everybody present has used all their votes (see "Dot voting, in the canvas"). Somebody who joins or reloads after it ended finds it closed and opens the results with the Results button. Once it is closed the button says Results and opens the results dialog (role="dialog"): a window of its own with the ranked list of what got votes (the element's text, a sticky note's bound text, else its type, with the count; a click scrolls the canvas to the element through scrollToElement) and the total. The dialog sits on the right of the window at first (16 px from the edge, below the top bar) and can be moved: by dragging its title, or with the arrow keys on its move button (16 px at a time); it is kept inside the window so its title can always be reached, and it keeps its place when it is closed and opened again. It has no backdrop and does not block the board, so the results can be read next to the elements they are about; it closes with its × button, Escape or the Results button, not with a click elsewhere, and goes away when the voting is cleared or another one starts. Editors and owners also get Clear results in it, which asks first (a second press confirms, closing the dialog forgets the question), and New voting…, which opens the form for another voting under the button. A viewer votes on the canvas like anybody else; the top bar shows the state ("Voting: <name> · 3 left", nothing to start or end) and, once it is closed, the Results button with the dialog and no controls, because the canvas rejects a viewer's start, end and clear. The board page passes the voting event's session in (Board.voting) and calls the element (Board.timerCommand); a refusal is shown in the banner. The shell gives the canvas its votingApi (board/voting-api.ts: get, start, vote, retract, close, clear against /api/boards/:id/voting and /votings..., ADR 0029), and says in words what a refusal means (votingErrorMessage).

Secrecy. While a voting is open the shell knows only the caller's own number of votes: it shows no names (not even who started it, the event has it only for tooltips), no one else's counts and no results. After it is closed it shows counts per element, and the voters only when the voting is named. This is a property of the system: the server returns each person their own votes and, once closed, the result (ADR 0029); the board's document holds nothing about the votes.

Reload and late joiners. The open voting and the person's own votes come back with the document (the person is told apart by user-id, so a reload, or a second tab, is the same voter; without a user id the canvas's per-tab id is used and votes are per tab).

Comments ​

Comments on elements and points (#733). Code in apps/frontend-canvas/src/comments/.

Where they live. In the document, in the map session under the key comments (like the timer; ADR 0020's reasoning applies: board state every client needs at once, so it syncs, persists and reloads with the document). The map is outside the Excalidraw binding, so Ctrl+Z never touches it and no PNG, SVG or .excalidraw export contains it. comments.ts holds the model: a thread is a Y.Map {id, anchor, createdAt, resolved, resolvedBy, replies} where anchor is {elementId} (follows the element) or {x, y} (a scene point), and replies is a Y.Array of Y.Map {id, authorId, authorName, text, createdAt, mentions, mentionNames}; the first reply is the comment itself. Everything is nested Yjs types, so two people who answer at the same moment both stay (readers sort the messages by time). A text is trimmed and at most 2,000 characters (CommentError empty / too-long), a board has at most 500 threads and a thread at most 200 messages. A value another client wrote is parsed defensively (parseThread): what is not a thread is left out, a text is cut to the limit.

Who can write. Editors, owners and viewers: a comment is the one thing a viewer writes. The realtime service lets a viewer's update through only when it changes nothing but session.comments (docs/specs/realtime.md). It creates a viewer's first comment only into a map that exists, so a client that may write makes the empty map when it connects (ensureComments in onSynced). The server cannot tell whose message is whose, so "delete your own message, the owner deletes any" (canDeleteMessage; the host sets the boolean attribute can-moderate for owners) is a rule of the interface, not an enforced one. user-id and user-name identify the author.

Mentions. Typing @ in the text box offers people (mentions.ts: activeMention, suggestions, mergePeople, applyMention): at once the host's mentionDirectory (an owner: the members; everybody: authors of earlier comments), and from two typed characters on, after a rest of 200 ms, what the host's mentionSearch(query) finds. The shell implements that search with the people search of the Share dialog (GET /api/users?query=, #663), which every signed-in person may use and which answers at most ten people (a deactivated person is not among them), so an editor or a viewer can mention somebody who has not commented yet. The last answer wins, an error leaves the local list as it is, and a person listed twice (by id or by name) is offered once; the list is announced with a status ("2 people found"). The ids of the search are the business backend's user ids, as in the owners' member list; a picked person is stored as @{id} in the text and shown by name (storeText, renderText). The message also keeps mentions (the ids, for the notifications a later PBI adds; none are sent) and mentionNames (what the ids were called, so every reader sees a name whether or not they can list people). The list comes from the host's property mentionDirectory(): Promise<{id, name}[]>, asked each time the panel opens, plus the authors of earlier comments. The shell offers the board's members to the owner (GET /api/boards/:id/members is owner-only) and an empty list to everybody else; the ids are the business backend's user ids.

On the canvas. CommentLayer draws a marker with the count of messages at the top-left corner of the element (turned with it, so it follows a move; several threads on one element sit side by side) or at the point; a resolved thread is grey; an element that is gone has no marker (its thread stays in the panel as "The element is gone"). While the tool is armed a press on the canvas is taken before Excalidraw sees it (capture phase on the root), so it neither selects nor draws, and a click places the comment on the connectable element under it or on the point; Esc stops. The layer and the panel are no part of the scene.

The panel. CommentPanel, an aside titled "Comments" on the right, above the toolbar: open threads first (heading "Open (n)"), then "Resolved (n)", each thread with its messages (author, time, text), a reply box (not on a resolved thread), Resolve / Reopen (anybody who can comment), and Delete. The box for a new comment appears once a place was clicked; Ctrl+Enter sends. The toolbar button Comments (elysion-tool-comments, also for a viewer) and the key C open and close it. C connects the two selected elements when there are such, and opens the comments otherwise.

Events and attributes. Event comments {total, unresolved} (after connecting and on every change); attribute can-moderate; properties mentionDirectory and mentionSearch.

PDF appendix. The export option comments (the Export menu's "Comments in a PDF", stored with the other export settings, off by default) appends the threads as A4 text pages in Helvetica after the board's pages (appendix.ts, writeAppendix in pdf.ts), the open threads first, with a bookmark. The export of a whole room does not append them.

Top bar and the element contract ​

ADR 0010: the Angular shell owns the top bar (topbar/top-bar.ts, after ariadne's .topbar: brand, board name, sync status, theme toggle), the canvas stays free of board logic.

DirectionWhatContract
inwhich boardattribute board-id
ingatewayattribute yjs-server-url (default: same-origin /yjs, proxied by ng serve, see below)
inthemeattribute theme (light or dark)
inlanguageattribute locale (en or de; anything else, or none, is en; observed, a change re-renders), see "Localization" below
inhow this user appears to othersattributes user-name (shown next to the cursor) and user-color (#rrggbb); optional: a blank name or another color format is ignored and the generated guest identity stays. Changing them does not reconnect
inthe token for the realtime connectionproperty tokenProvider, () => Promise<string | null>: asked before every connection (first and each reconnect); null: do not connect, the shell has said why; a rejection is retried with a growing delay (max 30 s)
ina viewer's canvasboolean attribute readonly: Excalidraw's view mode (viewModeEnabled), the toolbar shows only the zoom, importFile() rejects ("This board is read-only."), no "clear canvas"; presence and cursors still work
inelement limitattribute max-elements (a positive whole number; maxElements on CanvasApp): the most live elements a board holds, 20,000 when unset (ADR 0026). Above it a new element is not written and is taken off the scene, and the element sends notice. For tests mostly
outnoticeevent notice, detail: { message }: something the person should be told that is not a failure of the board (the board is full, a change was too large to send, a library was refused); the shell shows it in the banner. error is for failures and puts the shell into its error state
inimagesboolean attribute images-enabled (imagesEnabled on CanvasApp): offers the image tool (toolbar button, shortcut 9), paste and drop of images. Off (the default), the toolbar has no image tool and Excalidraw refuses a pasted or dropped image with its own "Images are disabled" message (UIOptions.tools.image: false). The shell sets it on a stored board, together with the property fileStore (below); without a file store an image would be seen by its author only and be gone after a reload
inwhere the images are keptproperty fileStore, { put(file: Blob, id: string): Promise<void>, get(id: string): Promise<Blob> } (like tokenProvider, also when set before the element is upgraded): see "Board files"
outan image failedevent fileerror, detail: { message }: an image could not be stored (the element is taken off the board again) or loaded; not a connection failure, so the shell shows it in the banner and not as the state of the board
outwho else is on the boardevent presence, detail: { users: [{ id, name, color }] }: the other people, not the user; sent only when that list changes, settled for 150 ms (not on every pointer move)
outthe element started / failedevents ready; error, detail: { message }, when the connection to the board server fails (once per outage, see below; the canvas keeps retrying)
outYjs connectionevent status, detail: { status: 'connecting' | 'connected' | 'disconnected' }; connecting comes first on start
outtheme switched inside the canvasevent themechange, detail: { theme }; not sent when the host changed the attribute
commandopen or close the library sidebarmethod toggleLibrary() on the element; a no-op until the canvas is up
outlibrary sidebar opened or closedevent librarychange, detail: { open }; sent for the host's toggle and for the user's own (Excalidraw's close button)
commandexport the board or the selectionmethod exportBoard(format, options) (options: selectionOnly, background, theme (current, light, dark), scale (1 to 3, PNG), pdfPages (auto: one page per frame when the board has frames, whole), pageFormat (fit, a4, letter), orientation (auto, portrait, landscape); #726) resolves with a Blob (png, svg, pdf or excalidraw), or null when there is nothing to export or the canvas is not up
commandreplace the board with a filemethod importFile(file) resolves with the number of elements in it; rejects when the file is not an Excalidraw file or the canvas is not up
commandadd a file to the boardmethod insertFile(file) adds an .excalidraw file's elements with fresh ids (and consistent bindings, groups and frames), centered on the middle of the view, selects them, one undo step, keeps what is on the board; resolves with the number added; rejects like importFile (not a file, canvas not up, read-only)
inwho this user isattribute user-id (the identity provider's id, the token's sub): what the shared timer remembers about who started it; a per-tab id when unset
commandthe shared timer (ADR 0020)methods startTimer(durationMs), pauseTimer(), resumeTimer(), extendTimer(ms), stopTimer(): write the board's shared timer for everybody; they reject on a read-only canvas ("This board is read-only."), before the canvas is up and for a duration or extension that is not a number of milliseconds between 1 and 24 hours
outthe shared timerevent timer, detail: { state } with state being { durationMs, startedAt, pausedAt, remainingAtPauseMs, startedBy: { id, name } } or null when there is none; sent whenever it changes (here or on another client) and once after the connection was made, so somebody who joins while it runs gets it
commanddot voting (ADR 0029)methods startVoting({ name, votesPerPerson, named }) (a whole number of votes from 1 to 100; named shows the voters in the result; rejects while a voting is open, and without a votingApi), endVoting() (a no-op when none is open) and clearVotingResults() (removes the results: every closed voting with its votes, so an older one does not show up after the last was cleared; an open voting stays): for everybody on the board; they reject on a read-only canvas and before the canvas is up
commandscroll to an elementmethod scrollToElement(id) scrolls the view to the element (the results panel's click), also on a read-only canvas; rejects when the element is not on the board
outthe dot votingevent voting, detail: { session }: session is { id, name, votesPerPerson, status: 'open' | 'closed', named, startedBy: { id, name }, myVotes, tally? } or null when there is none; myVotes is the number of votes the caller placed, tally ([{ elementId, count, label, voters? }], ranked, voters only in a named voting, label being the element's text or type) only once it is closed; nothing about anybody else's votes while it is open. Sent whenever it changes and once after the connection was made
outhow many elements are selectedevent selectioncount, detail: { count } (not selectionchange: that is a DOM event for text selection and this one bubbles)
DirectionWhatContract
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
inwhich boardattribute board-id
ingatewayattribute yjs-server-url (default: same-origin /yjs, proxied by ng serve, see below)
inthemeattribute theme (light or dark)
inlanguageattribute locale (en or de; anything else, or none, is en; observed, a change re-renders), see "Localization" below
inhow this user appears to othersattributes user-name (shown next to the cursor) and user-color (#rrggbb); optional: a blank name or another color format is ignored and the generated guest identity stays. Changing them does not reconnect
inthe token for the realtime connectionproperty tokenProvider, () => Promise<string | null>: asked before every connection (first and each reconnect); null: do not connect, the shell has said why; a rejection is retried with a growing delay (max 30 s)
ina viewer's canvasboolean attribute readonly: Excalidraw's view mode (viewModeEnabled), the toolbar shows only the zoom, importFile() rejects ("This board is read-only."), no "clear canvas"; presence and cursors still work
inelement limitattribute max-elements (a positive whole number; maxElements on CanvasApp): the most live elements a board holds, 20,000 when unset (ADR 0026). Above it a new element is not written and is taken off the scene, and the element sends notice. For tests mostly
outnoticeevent notice, detail: { message }: something the person should be told that is not a failure of the board (the board is full, a change was too large to send, a library was refused); the shell shows it in the banner. error is for failures and puts the shell into its error state
inimagesboolean attribute images-enabled (imagesEnabled on CanvasApp): offers the image tool (toolbar button, shortcut 9), paste and drop of images. Off (the default), the toolbar has no image tool and Excalidraw refuses a pasted or dropped image with its own "Images are disabled" message (UIOptions.tools.image: false). The shell sets it on a stored board, together with the property fileStore (below); without a file store an image would be seen by its author only and be gone after a reload
inwhere the images are keptproperty fileStore, { put(file: Blob, id: string): Promise<void>, get(id: string): Promise<Blob> } (like tokenProvider, also when set before the element is upgraded): see "Board files"
outan image failedevent fileerror, detail: { message }: an image could not be stored (the element is taken off the board again) or loaded; not a connection failure, so the shell shows it in the banner and not as the state of the board
outwho else is on the boardevent presence, detail: { users: [{ id, name, color }] }: the other people, not the user; sent only when that list changes, settled for 150 ms (not on every pointer move)
outthe element started / failedevents ready; error, detail: { message }, when the connection to the board server fails (once per outage, see below; the canvas keeps retrying)
outYjs connectionevent status, detail: { status: 'connecting' | 'connected' | 'disconnected' }; connecting comes first on start
outtheme switched inside the canvasevent themechange, detail: { theme }; not sent when the host changed the attribute
commandopen or close the library sidebarmethod toggleLibrary() on the element; a no-op until the canvas is up
outlibrary sidebar opened or closedevent librarychange, detail: { open }; sent for the host's toggle and for the user's own (Excalidraw's close button)
commandexport the board or the selectionmethod exportBoard(format, { selectionOnly }) resolves with a Blob (png, svg, pdf or excalidraw), or null when there is nothing to export or the canvas is not up
commandreplace the board with a filemethod importFile(file) resolves with the number of elements in it; rejects when the file is not an Excalidraw file or the canvas is not up
commandbring a PDF onto the boardmethod importPdf(file) (#725) opens a dialog with the PDF's pages as thumbnails (pdf.js in a worker, loaded by dynamic import() only when a PDF is chosen or dropped: the initial bundle has none of it); the person picks pages (all by default, at most 50), a width in pixels (1,600 by default) and imports. Each page is rendered in the browser to a PNG (smaller when it is over 4 MB), stored through the file store like any image and put in a frame named Page n (Seite n), the frames in a grid around the middle of the view, in one undo step. Resolves with the number of pages put on the board, 0 when canceled; rejects on a read-only canvas and on a board that cannot take pictures. A dropped PDF opens the same dialog. The PDF is read in the browser only. pdf.js 6 has no eval path, its worker, character maps, standard fonts and decoders are files next to the bundle (pdfjs/, from vite.element.config.ts: a library build would inline a ?url worker as a data: URL, which the CSP refuses), so the Content-Security-Policy needs no exception. A protected, an unreadable or a too large (10 MB) PDF is a notice
commandadd a file to the boardmethod insertFile(file) adds an .excalidraw file's elements with fresh ids (and consistent bindings, groups and frames), centered on the middle of the view, selects them, one undo step, keeps what is on the board; resolves with the number added; rejects like importFile (not a file, canvas not up, read-only)
inwho this user isattribute user-id (the identity provider's id, the token's sub): what the shared timer remembers about who started it; a per-tab id when unset
commandthe shared timer (ADR 0020)methods startTimer(durationMs), pauseTimer(), resumeTimer(), extendTimer(ms), stopTimer(): write the board's shared timer for everybody; they reject on a read-only canvas ("This board is read-only."), before the canvas is up and for a duration or extension that is not a number of milliseconds between 1 and 24 hours
outthe shared timerevent timer, detail: { state } with state being { durationMs, startedAt, pausedAt, remainingAtPauseMs, startedBy: { id, name } } or null when there is none; sent whenever it changes (here or on another client) and once after the connection was made, so somebody who joins while it runs gets it
commanddot voting (ADR 0029)methods startVoting({ name, votesPerPerson, named }) (a whole number of votes from 1 to 100; named shows the voters in the result; rejects while a voting is open, and without a votingApi), endVoting() (a no-op when none is open) and clearVotingResults() (removes the results: every closed voting with its votes, so an older one does not show up after the last was cleared; an open voting stays): for everybody on the board; they reject on a read-only canvas and before the canvas is up
commandscroll to an elementmethod scrollToElement(id) scrolls the view to the element (the results panel's click), also on a read-only canvas; rejects when the element is not on the board
outthe dot votingevent voting, detail: { session }: session is { id, name, votesPerPerson, status: 'open' | 'closed', named, startedBy: { id, name }, myVotes, tally? } or null when there is none; myVotes is the number of votes the caller placed, tally ([{ elementId, count, label, voters? }], ranked, voters only in a named voting, label being the element's text or type) only once it is closed; nothing about anybody else's votes while it is open. Sent whenever it changes and once after the connection was made
outhow many elements are selectedevent selectioncount, detail: { count } (not selectionchange: that is a DOM event for text selection and this one bubbles)

The shared timer, in the canvas (#580, ADR 0020). The state is a plain object under the key timer of the Yjs document's map session (facilitation/timer.ts), next to elements but outside the Excalidraw binding: it syncs, persists and returns with a reload like the scene, Ctrl+Z does not touch it, and no export contains it. remaining(state, now) is the time until startedAt + durationMs, or the time left at the pause for a paused timer (the time paused does not count; resuming moves startedAt); extendTimer adds time to what is left, also to a timer that has just ended. Each command is one Yjs transaction. observeTimer (timer-sync.ts) announces a change, and only a real one, as the timer event. The canvas keeps the state and tells the shell; it draws nothing. Clocks: the end is worked out on every client from startedAt with its own Date.now(), because the realtime service has no time API, so a client whose clock is a few seconds off shows a few seconds too much or too little. That is accepted for a workshop timer, and a server-time offset could be added without changing what is stored. A value in the map that does not have the shape of a timer is treated as no timer.

Dot voting, in the canvas (#583, #584, #732, ADR 0029). The state is on the server, not in the Yjs document: the host gives the element a votingApi property (like fileStore) and the canvas follows it with a VotingController (facilitation/voting-controller.ts): it asks get() when the connection is up (synced), takes the answers of the person's own calls (start, vote, retract, close, each answer is the voting as the server shows it to the caller, and an older answer never undoes a newer one) and the realtime service's message type 5 (voting, a JSON { event, voting } that the WebSocket client hands over, see docs/specs/realtime.md) for what others did; a refused vote (no vote left, closed meanwhile) is answered by asking for the state again. tally (facilitation/voting.ts) counts the result the server sent per element, the most voted first (ties by label, then id), leaving out elements that are deleted or gone; what an element is called is the canvas's business. The old voting key of the document's session map is not read any more: a client that may write deletes it when it syncs (voting-legacy.ts), and if a voting in it was still open it tells the person once, as a notice, that the voting was closed by the update, with its counts.

Vote mode (facilitation/VoteBadges.tsx, a layer over the canvas like the connection points: it catches no pointer event except on the person's own dots, and is no part of the scene, so it is not synced and not in an export). While a voting is open, a click (a press and a release within 4 px) with the selection tool on a connectable element, the rule of the connection points, casts a vote; drawing tools, dragging and text editing keep working. Each element shows the person's own votes as small dots at its top-right corner (up to five, then "+n"); a click on a dot takes that vote back. A hint above the toolbar says "Voting: 3 of 5 votes left (click an element to vote)", and when none is left how to take one back. Once the session is closed every element with votes shows a count instead, and no dots. The badges follow zoom, scroll and moves, and a deleted element shows none. Ending by itself. Every client that may start and end votings watches the voting (facilitation/voting-auto-end.ts) and closes it as soon as everybody present (a viewer too, viewers vote) has used all their votes. Each client tells the others through the awareness who it is and how many votes it has placed in which voting (voter: { id, votingId, votes }, never on what), so the number of people does not have to be known in advance: two tabs of one person are one voter, a person who leaves the board is no longer waited for, and a voting nobody has voted in stays open. Closing is idempotent on the server, so two clients closing it in the same moment do no harm. If nobody present may vote, or somebody has not spent all their votes, the voting stays open until the facilitator ends it; with only one person on the board it ends with their last vote. "Everybody has voted" means all their votes are used, not that everybody voted at least once. A viewer votes and retracts like anybody else (the vote is a request to the server, not a write to the document); only start, end and clear are for editors and owners.

Secrecy. While a voting is open the interface never shows who voted or how many votes others placed, and afterwards counts, and names only in a named voting. The server returns each person their own votes only (ADR 0029); the event voting carries the caller's own number of votes, and the tally only once the voting is closed.

Board (the page) shows the top bar above <elysion-canvas>. The title is the board's name when the BFF has one (BoardApi, GET /api/boards/:id) and the id otherwise: a room such as default is not a stored board, so ids that are not UUIDs are not even asked for, and a 404 or a failing BFF falls back to the id. The sync chip shows Connecting…, Connected or Offline.

Export and import. The top bar has an Import button and an Export menu (ExportMenu, after ariadne's): PNG image, SVG image, PDF document, Excalidraw file, each for the whole board or, with the 'Selection only' option, for the selected elements and the text inside selected shapes (the option needs selectioncount > 0). Board calls the element's exportBoard, names the file after the board (exportFilename: the board's name or id, -selection for a selection) and hands the blob to the browser through a temporary link. An empty board or an empty selection is answered with a short message under the bar, nothing is downloaded. Import asks first (an in-page banner, not a browser dialog) and only then calls importFile; the result or the reason it failed ("This is not an Excalidraw file.") is shown in the same place.

Why the import tombstones. importFile does not simply set the scene to the file's elements. The Yjs binding never removes entries from the shared map (deletion is isDeleted), so an element that just disappeared from one scene would be merged back from the map and stay on every other peer's board. replaceScene therefore marks every live element that is not in the file as deleted, lets the file win for an id that exists in both (with a version above the current one), and adds the new ones. Checked with two tabs on a real realtime service: after an import in one tab the other shows only the imported elements, as does a freshly loaded third.

Decision: client-side export (#212, #234). PNG, SVG, PDF and .excalidraw are produced in the browser; nothing leaves the browser and no service is involved. PNG, SVG and .excalidraw use Excalidraw's own helpers (exportToBlob, exportToSvg, serializeAsJSON). Excalidraw has no PDF export, so the PDF is made from the SVG export: ADR 0013, which compares it with a bitmap PDF, a server-side export and the print dialog. The export service (#23) is therefore not built: it was closed as not needed.

PDF document. pdf.ts converts the SVG export with jsPDF and svg2pdf.js, loaded by a dynamic import() when a PDF is made (separate chunks, not in the initial bundle). The result is a one-page, vector PDF: the page has the size of the SVG (the content with Excalidraw's usual padding and the board's background, like the other exports), one CSS pixel being 0.75 points; shapes, fills, arrows and images are as on the canvas, and text is real text (selectable and searchable). Text is set in the PDF's built-in Helvetica: Excalidraw's font families are unknown to svg2pdf.js, which would fall back to a serif font, so font-family is rewritten before the conversion; Excalidraw's hand-drawn fonts therefore become Helvetica in the PDF. A page may not be larger than 14,400 points (Acrobat's limit): a larger board is scaled down uniformly so its longer side fits. As with the other formats the theme does not change the colours of the export. An empty board or selection answers null like the other formats. Measured in Chrome: 3,500 elements take about 1.5 s and 52 KB, a typical board (700 elements) well under a second. The conversion runs on the main thread (svg2pdf.js needs the DOM), so Board shows the format being prepared (exporting): the Export button reads "Preparing PDF…" and the menu items are disabled until the file is ready; a failure shows "The export failed." In jsdom svg2pdf.js needs SVGElement.prototype.getBBox, which the specs stub, and the Vitest config inlines svg2pdf.js so its ES build is used instead of the UMD one.

Canvas menu. With theme, export, import and the library in the top bar, Excalidraw's hamburger menu only held Reset the canvas and Help, so it is gone: its trigger is hidden (.main-menu-trigger, an internal class, in styles/toolbar.css) and the menu at the end of the toolbar (CanvasMenu.tsx, a "⋯" button after the zoom) took over (Excalidraw's own theme shortcut, Alt+Shift+D, stays). See "Bottom toolbar" below.

Library. The top bar's Library button calls toggleLibrary() on the element (Board finds it with viewChild and the CanvasElement type) and shows aria-pressed from librarychange. Inside, CanvasApp hands the host a small controls object once (onControls, which the element turns into its methods; later commands such as export join it) and reports appState.openSidebar for the default sidebar. Excalidraw's own floating Library button is not hidden with CSS on an internal class: CanvasApp renders its own <DefaultSidebar.Trigger style="display: none">, which takes the place of Excalidraw's fallback trigger (a test fails if that stops working after an upgrade). The sidebar itself is Excalidraw's, with its search and library tabs, its dock and close buttons.

Theme. ThemeService is app-wide: an explicit choice (top bar, or Excalidraw's own toggle, which arrives as themechange) is remembered in localStorage (elysion.theme, tolerant of blocked storage); without one the system preference is followed live. It sets data-theme on <html> for the tokens and the canvas theme attribute. The theme is switched in the top bar or with Excalidraw's own shortcut (Alt+Shift+D); themechange keeps the shell in step with the second. CanvasApp recognizes a switch by the user from a change of Excalidraw's own appState.theme between two onChange calls, not from a difference to its own state: after the host changes the attribute, Excalidraw's value lags behind for a moment, and treating that as a user switch made the two flip each other.

Layout. The canvas root is position: absolute; inset: 0 and fills the element it is in; the element needs a size of its own. Board is a flex column (top bar, then <elysion-canvas> with flex: 1); the standalone dev entry gives #root the viewport. Excalidraw's own UI (hamburger, Library) therefore sits below the bar.

Dev proxy. apps/frontend/proxy.conf.json (wired in angular.json) forwards /api to the BFF (3000) and /yjs (WebSocket) to realtime (3001), so the Angular app talks to both through its own origin as it will behind Traefik, and two tabs on one board URL sync without extra parameters. It is read when ng serve starts.

Board files (images) ​

An image on the board is a file of the object store, not part of the shared document (#702, business backend spec). Excalidraw keeps the images of a scene in its files (BinaryFiles: id, data URL, type); ExcalidrawYjsBinding (src/yjs/excalidraw-binding.ts) turns that into this:

  • Insert, paste, drop: Excalidraw adds the file to its cache and the image element refers to it by fileId, a hash of the content. When onChange shows a file that an image element uses and the document does not list yet, the binding sends it to the host's fileStore.put(blob, id) (the shell: PUT /api/boards/:id/files/:fileId through the BFF) and, only after that succeeded, writes { mimeType, created } into the document's files map (a Y.Map next to elements). No bytes are ever in the document (a test asserts it).
  • The others: the element arrives through elements, the reference through files. For every image element whose id the map lists and Excalidraw does not have, the binding calls fileStore.get(id) and gives the result to api.addFiles; it asks again at the next change when that failed, and never for an id the map does not list yet (no 404 while the upload is still running). A late joiner and a reload take the same path.
  • Failure: when put rejects (too large, a type that is not allowed, the board is full, the network), the binding marks the element deleted (the deletion syncs like any other) and announces fileerror with the message, which the shell shows in its banner (FilesApi turns the status into words: "The image is too large.", "Only PNG, JPEG, GIF and WebP images can be added.").
  • Export (PNG, SVG, PDF) still includes the images: it reads Excalidraw's cache, which onChange and the loading keep filled.
  • A viewer gets the images through get and cannot add any (readonly).

The binding keeps the scene's own element objects: it does not hand a stored copy that equals the local element (same version and nonce, our own write coming back) to reconcileElements, which would pick it and replace the scene's element with a clone. Excalidraw changes an image element in place once the file is read (it sets fileId), and on an orphaned object that change never reaches the board: the image stayed a gray placeholder with no file. A regression test covers it, and apps/e2e/tests/files.spec.ts shows two browsers, an image and a reload.

Canvas embedding ​

The React/Excalidraw canvas lives in its own workspace package, apps/frontend-canvas, and is embedded via a custom element (<elysion-canvas>), not Angular Elements:

  • apps/frontend-canvas builds two ways from the same CanvasApp React root: a standalone Vite dev app (for iterating on the canvas in isolation) and a library-mode bundle (build:element) that registers <elysion-canvas> as a custom element, bundling its own React/ReactDOM runtime and CSS so the host page needs nothing extra.
  • apps/frontend's Board component (board/) loads that bundle at runtime via a <script> tag (CanvasElementLoader, pointed at CANVAS_ELEMENT_SRC, default /canvas/elysion-canvas.js) and renders <elysion-canvas> in its template, with CUSTOM_ELEMENTS_SCHEMA so Angular's compiler allows the unknown tag.
  • The Angular↔React contract is a board-id attribute in, and ready/error custom events out (surfaced as a Board.status signal) — kept minimal on purpose so the Yjs and presence features have a place to attach later.
  • Build integration: apps/frontend's prebuild npm script builds the element bundle and copies the whole dist-element/ directory into public/canvas/, so pnpm --filter @elysion/frontend build (and the Dockerfile, which now also installs apps/frontend-canvas) produce a single deployable artifact with the canvas bundle already in place — no manual copy step. The element bundle is built as an ES module, not iife: Excalidraw's optional heavy features (mermaid/cytoscape/katex diagram import, image resizing) need to stay as separate lazy chunks loaded only on demand, which iife's single-file output can't do — that's why the copy step grabs the whole directory rather than one file.

This was chosen over Angular Elements (which wraps an Angular component as a custom element) because the piece being embedded is a React tree; a plain custom element wrapping a React root needs no Angular-specific tooling and keeps the two frameworks' build pipelines fully independent.

Accessibility ​

The shell and the canvas element's own controls meet WCAG 2.1 level AA, checked by apps/e2e/tests/accessibility.spec.ts (axe with the tags wcag2a, wcag2aa, wcag21a, wcag21aa) on the board list, a room, its members dialog, the new board dialog, the board page with the templates, export, timer and voting menus, the share dialog and the sticky note menu, and on the board page, the share dialog and the board list in the dark theme (#739).

  • The allowlist (AXE_ALLOWLIST in apps/e2e/tests/helpers.ts) is the part that Excalidraw owns: its <canvas> elements (a drawing has no text alternative) and the hidden inputs behind its own tool buttons (.excalidraw input[aria-keyshortcuts], no label) and the menu triggers of its Radix menus (.excalidraw [aria-controls^="radix-"]: they point at content that is not rendered while the menu is closed). Everything else is checked. Add to the list only with a reason here.
  • Contrast: text reaches 4.5:1 in both themes. --c-primary (white text on it) and --c-text-subtle in the light theme of the design tokens are a little darker than ariadne's for that; check a new color pair before it is used.
  • Landmarks: header (the top bar or the page header), nav (the rooms), main (the board list, and the board's canvas element as role="main" named "Board canvas"). Every control has an accessible name; the connection status is a role="status" region, the timer has role="timer", the voting note is a status.
  • Keyboard: the menus open with Enter on their button and close with Escape, and the focus goes back to the button that opened a dialog or a menu (the share dialog does it when it is destroyed, the new board dialog when it closes; a new dialog does the same). A card is a link: Tab, then Enter. The focus ring comes from --c-focus.
  • Reduced motion: with prefers-reduced-motion: reduce transitions and animations of the shell and the canvas end at once (styles.scss).
  • By hand, not automated: a pass with a screen reader (NVDA or VoiceOver) is not part of the tests; it is done before a release that changes the page structure.

Localization ​

The app is available in English (the source language) and German (#738).

  • Shell. Every visible string is marked with i18n / i18n-<attribute> in a template or $localize in TypeScript, with a stable custom id (@@topbar.export.button). pnpm --filter @elysion/frontend i18n:extract writes src/locale/messages.xlf; the German texts are in src/locale/messages.de.xlf. The production build produces one copy of the app per language (dist/frontend/browser/en and /de, both with baseHref: ""); a missing translation fails the build (i18nMissingTranslation: error) and so does src/locale/locale.spec.ts in pnpm test, which names the ids. ng serve is English only.
  • Serving. The URL is not localized. nginx (apps/frontend/nginx.conf) serves the copy of the cookie elysion_lang (en or de), else of the first language in Accept-Language, else English; the responses carry Vary: Accept-Language, Cookie. The language entry in the user menu sets the cookie (one year, SameSite=Lax) and loads the page again (LanguageService).
  • Canvas. The shell sets the attribute locale (en or de) on <elysion-canvas>; the canvas has its own dictionary for its strings and gives Excalidraw its langCode (see apps/frontend-canvas/AGENTS.md).
  • Dates and numbers go through Angular's pipes or Intl with the language of the app, never a fixed format.
  • Adding a text: mark it, run i18n:extract, add the German text to messages.de.xlf, run pnpm --filter @elysion/frontend test.
  • The user guide stays English; a German page covers the basics.

Headers ​

Every response of the frontend image (apps/frontend/nginx.conf) carries the headers apps/frontend/security-headers.sh writes at container start into /etc/nginx/conf.d/security-headers.inc (an include in the server and in each location that sets a header of its own, because a location's add_header drops the server's): X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Permissions-Policy: camera=(), microphone=(), geolocation=(), Cross-Origin-Opener-Policy: same-origin, Vary: Accept-Language, Cookie, Strict-Transport-Security only when HSTS_MAX_AGE is set (seconds; set it only when TLS is on), and the Content-Security-Policy. Traefik adds the plain headers and HSTS to the API's answers too (security-headers middleware, first in the chain so that a 401 or 429 carries them; gateway).

CSP_MODE is enforce (default, Content-Security-Policy), report (Content-Security-Policy-Report-Only: nothing is blocked, the browser reports to the BFF's POST /api/csp-report, which logs the directive and the blocked URI at warn) or off. The origin of the identity provider is taken from OIDC_ISSUER_URL (the variable of auth-config.json), or KEYCLOAK_ORIGIN when it differs.

default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;
font-src 'self' data:; connect-src 'self' <idp> https://libraries.excalidraw.com https://raw.githubusercontent.com; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none';
base-uri 'self'; form-action 'self' <idp>; report-uri /api/csp-report; report-to csp

What the policy keeps open, measured in report mode with the whole browser suite (a violation fails a browser test, failOnCspViolation in apps/e2e/tests/helpers.ts):

  • 'unsafe-inline' in style-src: Angular injects component styles at run time, and the canvas bundle injects Excalidraw's CSS as a <style> element (vite-plugin-css-injected-by-js); Excalidraw also sets inline style attributes. A nonce would need the server to rewrite index.html per request. No script gets this.
  • 'wasm-unsafe-eval' in script-src: Excalidraw compiles WebAssembly (font subsetting for the SVG and PDF export). It allows WebAssembly only, not eval or new Function; there is no 'unsafe-eval' and no 'unsafe-inline' for scripts.
  • data: in font-src and img-src, blob: in img-src and worker-src: Excalidraw turns a font file into a data: URL for the exports, draws images from data: and blob: URLs, and the export runs in a blob worker.
  • No wss: in connect-src (#781): the realtime connection is to the page's own host (/yjs behind the same edge), which 'self' covers for ws: and wss: in current browsers; a wss: source would let a script that got in connect to any host. The shell never sets yjs-server-url, so a deployment with the realtime service on another host is not supported by the policy.
  • https://libraries.excalidraw.com and https://raw.githubusercontent.com in connect-src: the canvas fetches a library the user chose on the library site (see "The library"). Nothing else on those hosts is used; a closed network without them just cannot add libraries.

Two things had to change for the policy to hold. Angular's critical-CSS inlining put an onload handler into index.html (an inline script): inlineCritical is off in angular.json. And Excalidraw loads its fonts from a CDN (https://esm.sh/@excalidraw/excalidraw@…) unless told otherwise, which the policy refuses and a closed network cannot reach: the element build ships the fonts next to the bundle (dist-element/fonts, vite.element.config.ts) and vite-plugin-excalidraw-local-assets.ts points the library's fallback at the folder the bundle was loaded from (it fails the build when the library's code changes, like the grid plugin).

The library ​

Excalidraw's library (the shapes sidebar) is the user's own: library-store.ts keeps it in this browser's local storage (elysion:library, for every board, not in the board's document), the canvas loads it when it starts and saves it on every change (onLibraryChange). "Browse libraries" opens libraries.excalidraw.com in a new tab; Add to Excalidraw there opens Elysion again with #addLibrary=<url>&token=<id> (the new tab signs in silently and returns to the same board). The canvas reads that address, cleans it, fetches the library from the allowed hosts only (isAllowedLibraryUrl: https libraries.excalidraw.com or raw.githubusercontent.com/excalidraw/excalidraw-libraries/), asks to confirm (Excalidraw's own prompt, unless it is the tab that sent the user away) and merges it into the library. Excalidraw's useHandleLibrary is not used on purpose: it cleans the address with history.replaceState(…, '#…'), which a page with <base href="/"> resolves against the root, so a reload would open the board list. Each try opens one more tab of the board: the same person then shows as several avatars until the tabs are closed (#770).

Theming (ariadne design) ​

The canvas UI follows the design of GravionLabs/ariadne:

  • packages/design-tokens/tokens.css (@elysion/design-tokens) holds ariadne's --c-* design tokens (light and dark). The Angular shell loads it on <html> (angular.json styles), the canvas bundle imports it and applies it to its own .elysion-canvas root as well, so the bundle works without the host's copy and nothing leaks into the host page. Keep the names and values in sync with ariadne's apps/web/src/styles.scss.
  • styles/excalidraw-theme.css maps those tokens onto Excalidraw's own CSS custom properties (--island-bg-color, --color-primary*, --color-surface-*, --shadow-island, radii, ...). Scoping under .elysion-canvas beats Excalidraw's .excalidraw / .excalidraw.theme--dark rules by specificity, independent of stylesheet order. Excalidraw's DOM classes are not public API, so only its documented-by-use variables are overridden; hardcoded spots would need targeted selectors and are an upgrade risk.
  • The theme attribute on <elysion-canvas> (light | dark, also an input on Angular's Board) sets data-theme on the root and Excalidraw's theme prop. Without it the canvas follows prefers-color-scheme live. Because a theme is always passed to Excalidraw, its own light/dark toggle (main menu, Alt+Shift+D) is enabled explicitly with UIOptions.canvasActions.toggleTheme (Excalidraw otherwise shows it only when no theme is given, #197); CanvasApp keeps the active theme from appState.theme, so the tokens and the minimap follow a toggle, and a changed attribute or system preference still wins over it.

Localization (#738) ​

The canvas speaks the language of the locale attribute (en default, de; ?locale= in the dev entry). src/i18n.tsx holds the dictionaries: en is the source, de is typed Messages (typeof en), so a missing or an extra key is a compile error, and i18n.spec.tsx checks it again at runtime. Strings with a hole are functions (votesLeft(left, total)). Components read the dictionary with useI18n() (English without a provider, so unit tests of a single component need no wrapper); CanvasApp wraps its tree in I18nProvider. Excalidraw's own UI follows through its langCode prop (de-DE for de, en for en). Numbers go through Intl.NumberFormat with the locale (the zoom: 100% / 100 %). A new user-visible string goes into both dictionaries, never inline.

Not translated on purpose: text stored in the board (a new note's text "Note", the sticky note color names used as stored keys), the messages of errors thrown deep in board-io.ts, facilitation/ and the Yjs binding (they reach the host as an error or fileerror event; the shell shows its own text), and keyboard shortcut letters. The errors CanvasApp and the element raise themselves (read-only board, canvas not ready, element not on the board) are translated.

Bottom toolbar ​

Excalidraw's own top tool island is hidden (.shapes-section, in styles/toolbar.css) and replaced by Toolbar.tsx, a bottom-centered floating pill styled after ariadne's toolbox (surface background, 1px border, --radius-xl, --shadow-md, 34px icon buttons, dividers between groups, --c-primary-soft for the active tool, wrapping on narrow viewports). Excalidraw's DOM classes are not public API, so the island is hidden rather than restyled; its keyboard shortcuts keep working.

  • Clicking a button calls excalidrawAPI.setActiveTool({ type }).
  • The active button is derived from appState.activeTool.type in onChange, so shortcuts and any other tool change stay in sync.
  • The pill is a role="toolbar" with aria-label, aria-pressed and title="<name> (<shortcut>)" per button and a :focus-visible outline.
  • Excalidraw handles shortcuts only while its own container has focus (handleKeyboardGlobally is off); the toolbar does not change that.
  • Undo, redo and zoom are in the pill too (undo and redo before the tools, zoom out, the zoom level, zoom in and fit after them), so Excalidraw's own cluster in the bottom-left corner (.layer-ui__wrapper__footer-left, an internal class like .shapes-section, so an Excalidraw upgrade can bring it back) is hidden and the corner belongs to the minimap. The shortcuts are unchanged.
    • Zoom uses the public API: updateScene with a new zoom and a scroll that keeps the middle of the view where it is (zoom.ts, unit-tested), in Excalidraw's own steps of 10% between 10% and 3000%. The level button resets to 100%; fit is scrollToContent with fitToViewport (90% of the view), and on an empty board it resets the zoom.
    • Undo and redo have no public API in Excalidraw 0.18 (api.history only has clear). The buttons therefore press the shortcut on the canvas (a keydown for Ctrl or Cmd + Z, with Shift for redo, on the .excalidraw container), so Excalidraw's own handler does exactly what the keyboard does and keeps the history in one place. The buttons cannot show that nothing is left to undo, because that state is internal too; pressing one then does nothing.
  • The canvas menu (CanvasMenu.tsx, #535) is the "⋯" button at the end of the pill (aria-label "Canvas menu", aria-haspopup="menu", aria-expanded). It opens a list above the toolbar (role="menu"; items are menuitem, menuitemcheckbox or menuitemradio, with headings between), closes on Escape (the focus returns to the button), on a click outside and after an item, and moves with the arrow keys (wrapping), Home and End; the first item has the focus when it opens. CanvasMenu only draws the CanvasMenuItems it is given (CanvasApp makes them); an item can ask first (confirm: a question with two answers replaces the list), a radio item can keep the menu open, a check item can be disabled.
    • Help (?) opens Excalidraw's help dialog. There is no API for it, so the menu presses ? on the canvas, as undo presses Ctrl+Z.
    • Clear canvas… (not for viewers) asks "Remove everything from this board, for everyone? You can undo it." and then writes tombstones (clearScene in board-io.ts: every live element isDeleted: true with a newer version, none dropped) in one updateScene with IMMEDIATELY, so it is one undo step and the others on the board see it empty too. Excalidraw's resetScene() is not used: it replaces the elements with an empty list, which the Yjs binding never removes (the same reason an import tombstones).
    • A viewer gets the menu with Help only.
  • Sticky notes (#541). The sticky button is a split button: the main part makes a note in the current color at once (title "Sticky note (N)", the key N does the same, not while typing, editing text or with a modifier, and not on a viewer's canvas), the small arrow beside it opens the color menu (role="menu", one menuitemradio per color, the current one aria-checked). The menu shows little sticky-note icons in each color (the paper with its border and the folded corner), not circles; choosing a color makes a note in it and remembers it. The icon of the main button always shows the current color. The color is kept per browser (localStorage key elysion.sticky.color, the color's name; yellow when none is stored or the value is unknown; canvas-settings.ts).
    • The look (sticky-note.ts, the one place): the color is the note's background (paperColor, the accent color mixed 55 % into white, so the dark text stays readable) and the border is a darker shade of the same hue (borderColor, 20 % black). The colors are Yellow (first, the default), Orange, Red, Pink, Purple, Blue, Teal and Green. stickyColorOf (connection-drop.ts, for the note a connector is dragged into empty space) recognizes a note by its background, and still by its stroke (a note made before this look, or a shape drawn in that color), otherwise yellow.
    • The three built-in templates were recolored with this look (same ids, positions and texts; the migration UpdateBuiltInTemplateColors updates the seed). Notes already on boards keep their old look.
  • The grid (#538, dots #753, per board #754) is in the canvas menu under "View": Show grid, Snap to grid and the grid size (10, 20 or 40 px, 20 by default; a size keeps the menu open so that it can be tried). The three are settings of the board, in the document's map meta under grid (board-settings.ts; see "Board settings in the document" in the realtime spec), so everybody on the board sees the same grid, it persists with the document and a copy keeps it. On by default: a board without the key (every new board and every board from before) shows the grid and snaps to it, 20 px. Editors change it, everybody sees the change at once; a viewer sees the grid and the items are disabled ("Grid size (set by the editors)"). A change is one write of the whole object, only when something differs, and is outside Excalidraw's undo. The browser's own keys (elysion.grid.*) are removed when the canvas starts and nothing is stored in the browser any more. The defaults apply until the document has synced.
    • Alignment guides (#755): Snap to objects (guides in meta.grid, on by default, editors only like the rest) is Excalidraw's object snapping: while an element is dragged or resized it snaps to the edges and centers of other elements and equal gaps, with red guide lines (readable in both themes, drawn on the interactive canvas, so never in an export). Excalidraw reads the objectsSnapModeEnabled prop only for its first state, unlike gridModeEnabled, so the setting is put into its state with updateScene when it changes, and onChange puts it back when Excalidraw's own Alt+S or menu item changed it; a viewer's canvas has it off. It does not snap a shape that is being drawn, only dragging and resizing (checked in a browser). Both can be on at once (the default); which of the two decides when a guide and a grid point are both within reach was not measured, and with the grid snap off only the guides act. jsdom cannot show the guides: the unit tests check the state, the browser test (grid.spec.ts) drags a rectangle next to another and finds the centers aligned.
    • The dots are the background of the board, drawn by CSS (styles/grid.css, grid-dots.ts), not by Excalidraw: the static canvas is transparent (viewBackgroundColor: 'transparent'), so the .excalidraw container's background shows through, --c-bg with, when the grid is shown (data-grid="dots" on .elysion-canvas), one radial-gradient dot per tile. applyGridDots sets --grid-spacing (size times zoom) and --grid-offset-x/y from onChange, so that a dot lies on every multiple of the size in scene coordinates at any zoom and scroll; below 8 screen pixels the dots fade out (data-grid-faded). The colors are tokens (--c-border-muted mixed with transparent), so both themes work (the dark theme's filter touches the drawing, not this background). Exports bring the board's color (board-io.ts sets viewBackgroundColor to #f4f5f7 for PNG, SVG, PDF and .excalidraw), the dots are not in them. Excalidraw's shortcut Ctrl+' toggles the dots (caught on the window in the capture phase; it does not turn snapping on).
    • Excalidraw's own line grid is switched off in the build, because its grid mode both draws the lines and snaps and the two cannot be separated through the API (the lines are drawn on the static canvas with constant colors). vite-plugin-excalidraw-no-grid.ts (in vite.config.ts and vite.element.config.ts, also for the dev server's pre-bundling) replaces the one call renderGrid: isGridModeEnabled(this) (minified: renderGrid:gn(this)) in the library's entry file with renderGrid:false; a library version without that call fails the build rather than showing two grids. A pnpm patch was tried first and dropped: the minified file is a single line of a megabyte, which pnpm's patch file truncates. Snapping is Excalidraw's grid mode, set from the setting through the gridModeEnabled prop (snap and not a viewer's canvas), so the user's own keys and menu cannot change it; this replaced the earlier trick that turned the mode on only while a pointer was down.
    • In Excalidraw's compact layout (a phone, a short window) its own bottom bar already has undo and redo, so the toolbar hides its pair there (and the divider after it); the zoom buttons stay, because that bar has none.

The colors in the menu and on the canvas (#note colors, #737). A note's stored color (its paper, a tint of the node color, and its border) is the same in both themes, because it is shared by everybody on the board and the document never holds anything else. Excalidraw's dark theme darkens the whole canvas with a filter (invert(93%) hue-rotate(180deg) on the static canvas, THEME_FILTER in the library): light colors turn dark and the hue stays, so the pale yellow paper #f3d577 would be drawn as a muddy brown. The filter cannot be switched off for one kind of element, so the notes are handed to it in colors that come out as the dark palette (--c-node-* in the dark block of the design tokens): sticky-dark.ts has a finite, invertible table of the 16 stored colors (the paper and the border of the 8 notes) and the scene color for each, worked out by running the filter backwards (preImage); the paper is a deep shade of the dark token (so that the note's text, which the filter turns light, stays readable), the border the token itself or, for a bright saturated one the filter cannot reach (yellow, orange, teal, green), its least darker shade (reachableShade, the hue is kept). A color that is not in the table is never touched.

The mapping is applied where the scene meets the document, in ExcalidrawYjsBinding (setTheme): what comes in is mapped to the scene colors of the theme, everything written is mapped back to the stored colors, and a switch of the theme maps the scene again without writing anything or changing a version. A note drawn, pasted from the other theme or undone across a switch arrives in the wrong form and is put right right after that change (canonicalize, not an undo step). Exports go through colorsForExport (board-io.ts): a file, the thumbnail and a light export have the stored colors; a dark export (also from a light screen) the scene colors, which the export's filter turns into the palette. The sticky note button and its swatches show each color as it is seen in the current theme (seenColor in sticky-dark.ts), and stickyColorOf reads a note's color through sceneToStored. Known limit: Excalidraw's own copy puts the scene's colors on the clipboard, so notes copied in the dark theme and pasted into another Excalidraw have the dark scene colors; pasted into Elysion they are put right.

Bundles ​

The first load is the shell and the login: 404 kB raw, 100 kB transferred (was 527 kB raw before #710, which made the budget warning). The two pages are lazy chunks (loadComponent in app.routes.ts): board (about 75 kB raw, 15 kB transferred; it holds the top bar, its menus, the timer and the voting) and board-list (about 44 kB, 10 kB). The Share dialog is a @defer (when ...) block inside the board and loads the first time it is opened. The canvas is a separate script (/canvas/elysion-canvas.js, see "Embedding"), requested by the board page only, and its heavy features (PDF, diagram import, formulas) are chunks of their own. angular.json makes a regression fail the build: the initial budget is 450 kB (warning) and 500 kB (error), and the board (150 kB) and board-list (100 kB) chunks have their own. The browser test apps/e2e/tests/performance.spec.ts loads the list on Chrome's "Fast 3G" profile with a cold cache: its Largest Contentful Paint has to be under 2.5 s and no /canvas/ file may be requested.

Minimap ​

Minimap.tsx is an overview of the whole scene at the bottom left, modeled on ariadne's (.minimap in editor.scss): 160x120, --c-surface-1, 1px --c-border, --radius-md, --shadow. Elements are drawn as grey rectangles, the visible area as a --c-primary frame. Excalidraw has no minimap (ariadne's comes from f-flow), so it is built here.

  • CanvasApp copies the live elements and the scroll, zoom and canvas size from onChange into a SceneStore, which lives outside React state: a drag on the canvas then re-renders only the minimap, not CanvasApp and Excalidraw. Redraws are throttled to one per animation frame.
  • minimap-geometry.ts holds the maths, unit-tested without a DOM: scene bounds (elements plus viewport, so the frame never leaves the minimap, and at least two viewports wide and high, like ariadne's fMinSize, so the frame stays at most about half the minimap instead of filling it when the content is small), the scale, and the mapping back to scene points. Bounds use a loop, because spreading hundreds of thousands of elements into Math.min overflows the call stack. Layout plus mapping took 4 ms for 5,000 and 9 ms for 50,000 elements.
  • Clicking or dragging in it pans the canvas: the picked scene point is scrolled to the center through updateScene({ appState: { scrollX, scrollY } }). Scroll is local state and is not written to Yjs. While dragging, the mapping (scale and origin) is frozen, because it includes the viewport and would otherwise rescale under the pointer, but the frame keeps following the view (followViewport); the elements therefore stay where they are until release; the pointer is clamped to the minimap, so dragging out stops at its edge instead of flying the view away from the content. On release the layout is unfrozen and the minimap re-fits to the current scene (it must redraw then even if no scene change follows, #190).
  • It is hidden while the scene is empty. It sits in the bottom-left corner, 16px from the edges like ariadne's (--elysion-minimap-bottom), since the zoom and undo controls moved into the toolbar, and below Excalidraw's UI layer, so the property panel is never covered. Next to it the toolbar wraps instead of running under it (its max-width leaves 192px on each side), and on a canvas up to 1100px wide the minimap moves above the toolbar (112px, room for two rows).
  • jsdom does not fire Excalidraw's onChange after updateScene, so the wiring into CanvasApp is checked in the browser, not in a unit test.

Canvas element style ​

Excalidraw draws elements with roughjs onto a <canvas>, so their look is element properties, not CSS (unlike the UI chrome above):

  • element-style.ts sets defaults for newly drawn shapes through initialData.appState: roughness: 0 (flat instead of hand-drawn), solid fill, 1px stroke, round corners, ariadne's text color and Helvetica. The canvas background is ariadne's --c-bg. Users can still change any element's style in Excalidraw's property panel; the defaults only make the ariadne look the path of least resistance. Excalidraw has no public prop to replace its color-picker palette, so the picker keeps its default swatches.
  • Sticky notes are not a native Excalidraw element. sticky-note.ts builds one as a rectangle with a bound, centered text (via convertToExcalidrawElements): the color as the background and a darker shade of it as the 1px border (see "Sticky notes" under "Bottom toolbar" for the colors, the split button and the shortcut). A note is inserted at the viewport center, selected (press Enter to edit its text). Being ordinary elements, notes sync through the Yjs binding unchanged.
  • Colors are stored in light-theme space. Excalidraw's dark theme inverts canvas colors with a filter (invert(93%) hue-rotate(180deg)), which turns the same values into dark equivalents; the accent hues survive the hue rotation. The sticky notes are the exception that is mapped so that they read as the dark palette (sticky-dark.ts, below).

Small screens ​

Excalidraw switches to a compact layout on its own, for a phone or a short landscape window, and offers no option to turn that off. There it renders a mobile toolbar of its own on top, a loose hand tool beside it and its menu in a bar along the bottom edge. For the canvas this means:

  • Excalidraw's mobile toolbar island (.App-toolbar--mobile) and its loose tools (.mobile-misc-tools-container) are hidden like .shapes-section on desktop; our toolbar is the only tool bar (bug #176). Like the desktop one, these are internal classes and an upgrade risk.
  • The toolbar takes width: max-content instead of the half of the width that left: 50% leaves, so a phone gets two rows, not four.
  • The compact layout is recognized by Excalidraw's own container for it (:has(.mobile-misc-tools-container)), not by guessed thresholds. Then the toolbar sits 72px above the bottom edge so it does not cover Excalidraw's menu bar, and the minimap is left out (it would sit on the toolbar).

Checked in a browser at 390x740 (phone), 800x480 (short landscape) and 1100x700 (desktop): no Excalidraw tool icons, no overlap between toolbar, minimap, menu and footer. CSS is not exercised by the unit tests (jsdom has no layout), so a regression here shows up in a browser, not in pnpm test.

Why Excalidraw, not tldraw ​

The decision, the comparison with Foblex f-flow and the cost of replacing the canvas are recorded in ADR 0004.

The canvas was originally built on tldraw (see GitHub Feature #25), but tldraw's SDK is source-available, not open source: its license prohibits use in a "Production Environment" without a paid or non-commercial License Key, and enforces this with a "Get a license for production" watermark. Elysion is meant to be a genuinely open-source Mural alternative, so the canvas was swapped to Excalidraw (@excalidraw/excalidraw), which is MIT-licensed (see GitHub Feature #68). The embedding architecture above is unaffected by that swap — it only changed what renders inside CanvasApp.

Connectors ​

Connecting two elements the way Mural does it: shapes show connection points, dragging from one to another shape creates a connector (Feature #512). A connector is an ordinary Excalidraw arrow with elbowed: true and a startBinding / endBinding to the two shapes: it follows them when they move, and it syncs, exports and undoes like any element; there is no new element type and nothing changes in the backend or the realtime service. This section is the outcome of the spike #513 (a throwaway prototype on the branch spike/connectors, checked in the dev canvas and, with two windows, in the real stack); the PBIs #516, #519, #522 and #525 follow it.

The technique ​

  • Overlay. A React component mounted in CanvasApp right after <Excalidraw> (like the minimap): an absolutely positioned div over the canvas with pointer-events: none, whose circles are pointer-events: auto (touch-action: none). Everything that is not a circle goes through to Excalidraw: drawing, selecting, moving and resizing shapes behaved as before with the overlay present. A circle handles its own pointerdown (stopPropagation, setPointerCapture), so pressing one never selects or deselects anything. Screen position of a scene point inside the overlay: ((x + scrollX) * zoom, (y + scrollY) * zoom); a client position back to the scene: (clientX - rootLeft) / zoom - scrollX.
  • Which shape is hovered. Excalidraw has no public hover callback for the selection tool. The prototype listens to a native pointermove on the canvas root and hit-tests the scene point against api.getSceneElements() (topmost first) in the shape's own, rotated coordinate frame, with a margin of about 20 screen px so that the pointer can travel from the shape to a circle that sits outside its border. A hit test costs about 3 µs with 500 elements, and React re-renders only when the hovered id changes (300 pointer moves inside one hover state caused no render). onPointerUpdate (scene coordinates) would also do; the native event was simpler and does not depend on Excalidraw's throttling.
  • The drag. Our own: pointer capture on the circle, a dashed preview line drawn by the overlay (SVG), and on release one convertToExcalidrawElements([...shapes, arrowSkeleton], { regenerateIds: false }) followed by one api.updateScene (so one history entry; the PBI's tests assert it). Handing the gesture over to Excalidraw's own arrow tool was not tried: it would need synthetic pointer events, and our own drag produced correct bindings.
  • Points of a shape. The middle of the four sides of the element's box, taken in the element's frame and rotated around its center by element.angle. For a diamond these are its corners and for an ellipse its extreme points, so the same four points are right for rectangles, diamonds and ellipses. (Rotated shapes are covered by the unit tests of #520, not by the spike's browser check.)
  • Cost. The prototype re-rendered from a polling loop; production reads the scene and view from the SceneStore that the minimap already uses (it is fed by Excalidraw's onChange), so the overlay costs one render per scene or view change, and only the shapes that show points (at most 10) are drawn.

What the spike found that the PBIs must respect ​

  1. convertToExcalidrawElements does not route elbow arrows. Given only start and end it returns a straight two-point arrow that is merely flagged elbowed (it is drawn diagonally). The route has to be computed and passed as points (a start segment out of the source side, one or two bends, an end segment into the target side; [[0,0],[dx,0],[dx,dy]] for side to top or bottom, [[0,0],[dx/2,0],[dx/2,dy],[dx,dy]] for facing sides, and so on). Once the arrow is bound, Excalidraw re-routes it itself when either shape is moved (checked in two windows). Routes that leave and enter on the same side (U-turns) were not handled by the prototype and need a five-point route in createConnector.
  2. The side a connector is attached to comes from the arrow's end coordinates. The converter derives fixedPoint (the relative position on the shape) from where the arrow starts and ends: an arrow that starts at the middle of the right side and ends at the middle of a top side gets startBinding.fixedPoint = [1, 0.5] and endBinding.fixedPoint = [0.5, 0]. So createConnector chooses the sides and places the arrow's first and last point on those side midpoints; nothing else is needed to keep the connector on the side where the user dropped it, and it stays there when the shapes move.
  3. Choosing the target side by "nearest circle to the pointer" is wrong when the pointer is over the middle of the shape: the prototype picked the bottom side for a target to the right, and the connector crossed the shape. The side is the one facing the source (the larger of |dx| and |dy| between the centers decides), unless the pointer is within a circle's radius, in which case that circle wins (#522).
  4. Bindings and sync. A connector made this way appeared in a second window on the same board, and moving a shape in either window re-routed it in the other.
  5. Layering. The overlay sits above the canvas but must stay below Excalidraw's own panels and menus; the production CSS gives it a z-index below them and clips it to the canvas area.
  6. Large shapes such as the template columns (rectangles) get circles as well; with a frame selected the circles are far apart, which is acceptable.

The connector module (src/connector.ts) ​

The one definition of a connector (#516). CONNECTOR_STYLE is how it looks (elbowed, flat, arrowhead at the end only; the toolbar's arrow tool draws the same thing through the new-arrow defaults in element-style.ts, currentItemArrowType: 'elbow'). createConnector(elements, sourceId, targetId, sides?) returns { arrow, updated: [source, target] }: the elements to write back into the scene in one updateScene call. It binds the arrow to both shapes on the given sides, or on the sides that face each other (facingSides: the larger distance between the centers decides), places the first and last point on the middle of those sides (sidePoint, rotation included) and gives the arrow a right-angled route (elbowRoute: an L or a Z when the sides face each other, a straight line when they are in line, otherwise a detour that leaves and enters straight; the detour may run along a shape, and Excalidraw tidies a bound connector the next time a shape moves). Text that is already bound inside a shape stays bound. It throws for the same element twice, an element that is not on the board and anything that is not connectable (isConnectable: rectangles, diamonds, ellipses, images, frames and free text, not arrows and not text inside a shape). The toolbar entry of the arrow tool is called "Connector" (shortcut A, tool id arrow); a test draws two rectangles and a connector with the real tool and finds both bindings.

Connection points and dragging (src/connection-points.ts, src/ConnectionPoints.tsx, #519) ​

When they show. With the selection tool active and the board not read-only, the hovered shape and every selected shape (at most ten, the hovered one never left out) show four circles, one at the middle of each side, POINT_OFFSET (8) screen pixels outside the border. They hide while a shape is being dragged, resized or rotated, while text is edited and while a new shape is being drawn (and a pressed mouse button means one of those, so hovering is only tracked with no button down). Every connectable element gets them: rectangles (so sticky notes too), diamonds, ellipses, images, frames and free text; not arrows, lines, free-draw strokes or text bound inside a shape. A viewer's canvas does not mount the overlay at all.

How they look. 10 px circles at every zoom (the offset in scene units shrinks as the zoom grows), the surface color with a 1.5 px border in the primary color (design tokens, so both themes), 1.3 times bigger on hover with a crosshair cursor. They follow the shape through zoom, scroll and rotation (connectionPoints rotates the side midpoints and their outward direction with the element). The layer is aria-hidden, has z-index: 3 (below Excalidraw's UI at 4, so its property panel is never covered), pointer-events: none and clips to the canvas.

The drag. Pressing a circle starts a connector (stopPropagation, pointer capture), moving it draws a dashed preview line from the circle to the pointer. Releasing over another connectable element (topmost first, never the source) calls createConnector with the dragged circle's side as the start and, as the end, the side of the target nearest to where the connector leaves the source (#522 refines this with snapping to the nearest circle), writes the arrow and the two updated shapes in one updateScene with CaptureUpdateAction.IMMEDIATELY (so one undo step; the call replaces the shapes in getSceneElementsIncludingDeleted() and keeps the tombstones) and selects the connector. Releasing on the source, Escape and a cancelled pointer change nothing. A drag shorter than 8 screen pixels (a click on a circle) makes nothing either.

The target while dragging (#522). The shape under the pointer, or within reach of its circles (they sit outside the border), is the target (dropTarget; never the source): it shows its four circles, and the one the connector would end at is filled and enlarged (data-target, data-active); the preview line ends on that circle. The end side (dropSide) is the circle the pointer is within 16 screen pixels of, otherwise the side of the target nearest to where the connector leaves the source. (Taking the circle nearest to the pointer would send a connector across the shape when the pointer is over its middle: the spike's finding 3, refined here to "nearest to the source side".)

Released on empty canvas (#522). A new sticky note is created centered on the release point, in the color of the source when that is a note (stickyColorOf matches the border color, otherwise the first color), connected from the dragged side to the opposite side of the note. Note and connector are written in one update, so one undo removes both. The note is selected and its text is put in edit mode, by pressing Enter on the canvas (Excalidraw has no method for it; what the keyboard does is the only way).

What is not synced. The circles and the preview line are local to a window; the connector is a normal element and appears for everybody on the board, and moving a shape in either window re-routes it in the other (checked in the real stack with two windows). The overlay is not part of the scene and not in any export (PNG, SVG, PDF).

Connecting the two selected elements (#525) ​

With exactly two connectable elements selected (the same rule as for connection points; text bound inside a selected shape is not a third element) and the board not read-only, the toolbar shows a Connect button ("Connect the two selected elements (C)"), and the key C does the same. The key is ignored with Ctrl, Cmd or Alt (Ctrl+C is copy), while a field or a text editor has the focus and with any other selection, so typing the letter c into a sticky note connects nothing.

Direction (connectorDirection): from the element picked first to the one picked second. The order comes from selection-order.ts, which follows the selection through Excalidraw's onChange (shift-clicking one after the other; it is unknown when several elements joined at once, as with select all or a rubber band). When it is not known, the connector goes from the left element to the right one, or from the upper to the lower when they are one above the other. The connector is made with createConnector (the sides that face each other) and written by commitConnector in one update, so it is one undo step, and it becomes the selection. If the two elements are already connected in that direction (findConnector), nothing is added: the existing connector is selected.

Limits ​

Connectors between two points in the same direction (U-turns) and connectors to the shape itself are not supported at first. Nothing of this is shown to viewers.

Yjs client integration ​

apps/frontend-canvas connects the Excalidraw scene to apps/realtime's Yjs sync gateway (Feature #16):

  • yjs/YjsWebsocketClient.ts speaks the same sync sub-protocol as the gateway over a plain WebSocket — not y-websocket's WebsocketProvider, whose room-in-path URL convention the gateway can't route (see the gateway's own doc comment for why). On open, it sends its own sync-step-1 in addition to replying to the server's — without that, a client joining a board with existing history never asks for it and stays empty.
  • yjs/excalidraw-binding.ts's ExcalidrawYjsBinding keeps a Y.Map<elementId, element> in sync with the scene: local onChange writes changed elements in (compared by version, cloned via structuredClone before storing — Excalidraw mutates its element objects in place, so storing the live reference would alias Y.Map.get()'s result with the object Excalidraw keeps mutating, permanently freezing the version comparison after the first write), and remote map changes are merged back via Excalidraw's own reconcileElements + updateScene(captureUpdate: NEVER).
  • The same aliasing existed in the other direction and was found by the two-tab check (#173): reconcileElements returns the objects stored in the Y.Map themselves, so Excalidraw's in-place edits (a move) landed in the map behind Yjs's back and were never written. This hit the writer too, since map observers also fire for local writes. #handleRemoteChange therefore hands the scene clones of every stored object; the two-peer test moves an already-synced element back and forth to cover it.
  • CanvasApp wires a Y.Doc + client + binding together in a useEffect (not render-body lazy-init): React StrictMode's dev-only mount→cleanup→mount for effects means a "create once in render, null out in cleanup" pattern leaves the binding permanently null after the simulated cleanup, since nothing re-populates it without a following render.
  • The Yjs server URL is a yjsServerUrl prop on CanvasApp, threaded out as the yjs-server-url attribute on <elysion-canvas> (mirroring board-id) and a matching Angular input on Board; defaults to a same-origin /yjs path when unset (production routing through Traefik isn't settled yet — Feature #28).

Presence in the client (awareness) ​

YjsWebsocketClient also speaks the awareness half of the protocol (message type 1, the same bytes as apps/realtime's gateway) and exposes the y-protocols Awareness of its doc as client.awareness. Callers only set their state (awareness.setLocalState, setLocalStateField) and read the others' (awareness.getStates(), the change event); they never send anything by hand.

  • Outbound: every local change of the awareness (including the library's own heartbeat about every 15 s) is encoded and sent. What was applied from the server carries the client as its origin and is not sent back, so nothing echoes.
  • Inbound: awareness messages are applied with applyAwarenessUpdate. The snapshot of who is already in the room, which the gateway pushes on connect, arrives as ordinary awareness messages, so a joining client sees the others at once instead of at their next movement.
  • When the connection drops the client removes the other clients' states locally, since it cannot know who is there until it is connected again; its own state stays.
  • After a reconnect it announces its own state again. The gateway removed that state when the old socket closed, and removing a state raises the server's clock for the client by one; an announcement with the old clock would be ignored until the next heartbeat, up to 30 s later. The client therefore sets its state twice, which raises its clock past the server's, and each setting is sent. A client without a state announces nothing. This was found by the reconnect test, which mirrors the gateway's removal.
  • destroy() removes the client's own state and sends that removal while the socket is still open (the gateway also drops the state when the socket closes), then stops the awareness timers.

What is built on it, in order: the collaborators on the canvas (#109) and their path through the element to the shell (#110). yjs/test-yjs-server.ts, the test stand-in for the gateway, speaks awareness the same way (snapshot on connect, relay, removal when a socket closes, dropConnections() to simulate a network failure). WS auth (Feature #18) is not wired in on the frontend yet.

Collaborators on the canvas ​

src/presence/ turns the awareness states into what Excalidraw draws (cursor with a name pill, selection outlines) and publishes the local user's own state.

State shape (PresenceState, one per client in awareness.getStates()):

FieldMeaning
user{ id, name, color }: who this is. Required; a state without a valid user is ignored.
pointer{ x, y, tool } in scene coordinates; tool is pointer or laser.
buttonup or down.
selectedElementIds{ [elementId]: true }, the elements the user has selected.
  • Identity: until sign-in exists (Feature #18), every canvas session makes a random id; name (Guest 1000-9999) and color (from the sticky-note palette) follow from a hash of it, so a user keeps the same look in every tab of that session.
  • Rendering: toCollaborators maps the states of the other clients to Excalidraw's collaborators map (Excalidraw draws the local cursor itself). It treats remote data as untrusted: only hex colors, finite pointer numbers and true selection entries pass, names are cut at 40 characters. PresenceSync pushes the map with updateScene({ collaborators, captureUpdate: NEVER }), so it never touches undo history, and redraws only when another client's state changed.
  • Publishing: pointer moves are throttled to one message per 50 ms (the last position is always sent); the selection is sent only when it changed.
  • Removal: a client that leaves (tab closed, connection lost, destroy()) is removed by the awareness protocol and disappears from the canvas.

Presence in the shell ​

presence carries only who is here (id, name, color), never pointers or selections: those stay inside the canvas, so Angular is not rendered on every mouse move. PresenceSync compares the list (ordered by name and id) with the last one it sent and reports a change after 150 ms without further changes, so several people joining at once make one event. Board provides a PresenceStore (board/presence-store.ts, a signal service per ADR 0009, scoped to the page so it starts empty for each board) and feeds it from the event; the store drops entries that are not an id, a name and a hex color, because the event crosses an app boundary. The top bar shows the others as avatars with initials in their cursor color (three at most, then +N), with their names as tooltip and aria-label; nothing is shown while nobody else is there. The user themself is not in the list.

YjsWebsocketClient reports a failure through onError once per outage (the socket could not be opened or broke, or the gateway closed it with 1011 because it could not load the board) and again only after the connection was up in between; the element sends it as error with a message. A plain drop is a disconnected status, not an error. Board sets its status to error for it and back to ready when the canvas reports connected again.

Working through an outage (#735) ​

The Yjs document of the canvas already keeps what is drawn while the connection is down and merges it when the connection is back, as long as the tab stays open (Yjs merges; there is no conflict dialog). yjs/offline-work.ts (OfflineWork) adds the two things that were missing: the work survives a tab that is closed or reloaded, and the person is told what is going on.

  • When it stores. From the moment the connection breaks after the board was loaded once (the first sync gave the document's generation), the document is mirrored into IndexedDB with y-indexeddb: database elysion-board:<boardId>:<generation> (- for a document without a generation). Creating the store writes the document as it is and every later change is added. Before the first sync there is nothing to keep, and a read-only canvas stores nothing (it cannot write).
  • Keyed by generation (ADR 0026). A document that was rebuilt while the tab was away has new client ids; stored updates of the old one would bring back what the rebuild removed, which is what the 4409 close protects against. So on the first sync of a tab the store of the current generation is opened (its updates are applied to the document and go to the server like any local change) and the stores of other generations of that board are deleted, with a notice ("The board was rebuilt while you were offline, so the changes saved on this device could not be added."). A 4409 (onStaleCopy) deletes all stores of the board too, and the new document is filled from the scene as before (so the person's changes survive in the scene).
  • When it removes the store. Three seconds after a sync with the server completed (the stored changes are on their way; a connection that breaks again within that time keeps the store). The names of the stores are also kept in localStorage (elysion.offline-stores) because indexedDB.databases() is not available everywhere; the shell's clearOfflineStores() (auth/offline-data.ts) deletes every database with the prefix elysion-board: at logout, before the identity provider's logoff.
  • What it never stores. A token or any credential: only the Yjs document of the board, which the person was allowed to read. IndexedDB that cannot be opened (a private window, blocked storage) is detected once and everything is a no-op: the canvas behaves as before and the bar still counts the changes.
  • Contract. <elysion-canvas> fires offline with { offline, since, pending, sent }: offline: true with since (epoch ms) when the connection breaks after the board was loaded, again with a growing pending (changes made on this device since) and, when the connection is back, offline: false with sent = the changes that were pending. YjsWebsocketClient.generation exposes the generation that was learned.
  • In the shell. app-offline-bar (a polite live region that is always in the page): after 10 s offline it says "Working offline. Your changes are saved on this device and sent when the connection is back." with the number of changes waiting; after 5 min it offers Download a copy (the Export menu's .excalidraw export, same file name); when the connection is back it says "Back online, n changes sent." for six seconds (not for a blink of a second with nothing to send). The status chip in the top bar says Offline (not "Connecting…") from the moment the connection broke until it is connected again, and has a tooltip that explains each state.
  • Not covered. Opening the board while the realtime service is down: the page comes up but the board is only loaded, and the stored changes applied, once a connection exists. Edits made before the board was ever loaded in a tab are not stored.

Login ​

The shell logs in with the authorization code flow and PKCE against the Keycloak client elysion-frontend, using angular-auth-oidc-client (ADR 0016, accepted). Compared: angular-oauth2-oidc (no guard or interceptor included), keycloak-angular with keycloak-js (ties the shell to Keycloak) and a hand-written client on oidc-client-ts. Code: apps/frontend/src/app/auth/.

  • Settings (/auth-config.json, loaded when the app starts): { authority, clientId, idpHint? }, so one build runs against any identity provider. In the container nginx answers it from OIDC_ISSUER_URL, OIDC_CLIENT_ID and AUTH_IDP_HINT (the optional alias of the realm's identity provider, microsoft, #644: when set, the login request carries kc_idp_hint and Keycloak forwards to the company's login; a value that is not an alias is ignored) (nginx.conf is a template; the compose file sets them to http://localhost:8081/realms/elysion and elysion-frontend: the address the browser uses); ng serve uses the static public/auth-config.json. The redirect and post-logout URL are the page's own origin; the realm accepts http://localhost:4200/* and http://localhost/*. Scope openid profile email, silent renewal with the refresh token.
  • Guard: autoLoginPartialRoutesGuard is on every page (buildRoutes), so a user who is not signed in is sent to Keycloak and comes back to where they were; the callback is handled before the first route (withAppInitializerAuthCheck).
  • Interceptor: the library's authInterceptor() adds Authorization: Bearer ... to calls under /api only (secureRoutes); the token is never sent to another URL. restartLoginOn401 starts the login again when the API answers 401 (for /api calls only); a second 401 within 15 seconds is passed on as an error instead of redirecting forever, because a server that keeps refusing a fresh token is not fixed by another login.
  • Where the tokens are: in memory only. The library keeps all of its state in one JSON object under one key and defaults to sessionStorage, readable by any script on the page. TokenSafeStorage splits that object: tokens and what is derived from them (authnResult, authzData, userData, expiry, refresh token, session_state) stay in memory; the state of a login under way (code verifier, state, nonce, which cannot survive a redirect in memory) goes to sessionStorage. Tokens found in sessionStorage (a leftover) are removed on sight. A reload loses the tokens, and the next page load logs in again, with a redirect to Keycloak and back without a form as long as its own session lives. provideLogin() registers the storage after provideAuth, which registers a sessionStorage default of its own that a provider placed before it would silently lose to (found in the browser, covered by a test).
  • Session (SessionService, signals, ADR 0009): user() is { id (sub), name, email, color } from the ID token (username, else name, else email, else id); the cursor color is derived from the id, so a user looks the same everywhere. Components use this service, not the library.
  • UI: the top bar and the board list header show the user menu (UserMenu: avatar with initials in the user's color, name, a menu with the email and Log out, which ends the Keycloak session too). The board page sets user-name and user-color on <elysion-canvas>, so other people see the real name at the cursor (#110); without a user the canvas keeps its generated guest identity.
  • The WS token (#312): the board page gives <elysion-canvas> a tokenProvider that calls POST /api/realtime/token (BoardApi.realtimeToken) for the open board before every connection, so a reconnect an hour later works with a fresh token (they live for about a minute). A 403 (no role on the board any more) shows "You no longer have access to this board." in the banner and answers null, so the canvas stays disconnected instead of asking again and again; any other failure is passed on and retried with a growing delay (a 401 has already restarted the login). Only stored boards (UUIDs) have members, so a room without a board record such as default gets a 403 and no longer connects. The standalone dev entry takes a token with ?token=<ws token> (it lives for about a minute, so only the first connection works).
  • Roles and sharing (#324): the board page asks the BFF for the user's role (GET /api/boards/:id/membership/me: owner, editor, viewer, BoardApi.myRole) and does not start the canvas before the answer, so a viewer's canvas never starts as an editor's. An owner gets the Share button in the top bar; it opens ShareDialog: the members with name, email and a role select, a person field with a role to add somebody, a Remove button per member. The person field is a Typeahead (share/typeahead.ts, the ARIA combobox pattern: the field keeps the focus, aria-activedescendant names the active option of the listbox, Arrow keys move, Enter picks, Escape closes the list and only the list, a click picks, and a visually hidden role="status" says how many matches there are or that there are none): after 250 ms without typing it calls GET /api/users?query= (from 2 characters; stale answers are dropped) and lists name and email of the people found, leaving out those who are members already. A person chosen from the list is added by userId; a full email address typed in still works as before (email); a name that was not chosen is refused with a message. A failing search does not block adding by email. The API's messages are shown inline next to what caused them (no user with this email has logged in yet; the creator's role cannot change; the last owner stays; already a member), a refused role change puts the old role back, and the controls wait while a change is on its way. Invite with a link (#734, InviteLinks, share/invite-links.ts, inside the dialog for boards and for rooms): role (Viewer by default, or Editor), lifetime (1 hour, 1 day, 7 days by default, 30 days) and an optional number of uses (1 to 1000) go to POST /api/boards|rooms/:id/invitations; the new address <origin>/invite/<token> is shown once in a read-only field with Copy (the Clipboard API; where the browser refuses, the status line says to copy it by hand), the active links (GET, no token) are listed with role, expiry and used n of m and have Revoke (DELETE). A link never gives Owner. The route /invite/:token (Invite, behind the login guard so the link survives the round trip to the identity provider) takes the token out of the address with Location.replaceState('/invite') before it calls POST /api/invitations/:token/accept, so a reload is the board list and not a second use, then goes to /board/:id or /rooms/:id with replaceUrl; 410 (expired, revoked, used up), 404 (unknown) and other failures each have a message and a link to the boards. The frontend's Referrer-Policy: no-referrer keeps the address from other sites, and nothing logs it. Groups and the room (#652, ADR 0027): the dialog of a room has a Groups section (RoomGroups, share/room-groups.ts) under the people: a Typeahead that searches GET /api/groups?query= (name and where the group comes from, "from Microsoft Entra ID" or "local"; groups that have a role already are not offered), a role and Add (PUT /api/rooms/:id/groups/:groupId), and the list with a role select and Remove each (GroupsApi). A group is added only when chosen from the list. The dialog is for room owners, so the controls are always there (readOnly shows the list alone); the API enforces it and its messages (403, 404, 400) are shown inline. The dialog of a board in a room (the board page passes roomId) shows Access through the room (RoomAccess) instead: the room's name, how many people it has (only an owner of the room may list them, so otherwise a sentence says so) and its groups with their roles, no controls, and a link "Manage the room" for an owner of it. A viewer gets readonly on the canvas, and the top bar hides Import and Library; an editor sees neither Share nor readonly. A user without a role (or a failing role lookup) is treated like an editor in the interface: the interface is a hint, the backend and the realtime service refuse what a role does not allow (#117, #303).

PDF pages (#726). With frames on the board (or selected, with Selection only) the PDF has one page per frame: each frame is exported alone (exportToSvg with exportingFrame, so only what is inside it, clipped to it), the pages are ordered by framesInOrder (export-options.ts: by the number the names end in when every frame has one, otherwise row by row from the top left), and each page has the frame's name as a title line and as a bookmark in the PDF's outline (svgsToPdf in pdf.ts). placePage lays a page out: fit makes it the size of the content (plus a band for the title, scaled down when a PDF page cannot be that large), A4 and Letter have a margin and the content is scaled up or down to the largest size that fits, centered, the page turned the way the content is shaped unless an orientation is given. The Export menu keeps its options in localStorage (elysion.export-settings, export-settings.ts); a value that is not one of the choices is the default. Images are part of the SVG, so they are in the PDF and the PNG.

The picture of a card (#729). When an editor leaves a board, the shell asks the canvas for exportThumbnail() (a 480 by 300 PNG of the whole board in the light theme on the board's color: exportToCanvas at twice the width, drawn as large as fits and centered into the box, a small board at most twice its size) and stores it with PUT /api/boards/{id}/thumbnail. Leaving is the router's canDeactivate (boardLeaveGuard, Board.prepareToLeave): Angular takes the canvas element out of the page before it destroys the component, so a hook in ngOnDestroy would find a canvas that cannot draw. The guard waits at most a second for the drawing (not for the upload); the tab being hidden (visibilitychange) or closed (pagehide) does the same. ThumbnailUploader makes at most one picture per board per 30 seconds; a viewer, a room that is no stored board, an empty board and a canvas that is not ready make none; a failure is silent (the card keeps its initials). The card (BoardThumbnail) shows the picture when the list says the board has one (hasThumbnail, thumbnailUpdatedAt): fetched with the HTTP client when the card comes into view (an <img src> cannot send the token), shown from an object URL, the version in the query (?v=) so that a new picture is fetched; the object URL is given back when the card goes.

Export of a room (#727). The room's page has Export room (BoardList, RoomPdfExporter in board-list/room-pdf-export.ts). For each board of the room's view, in its order, the exporter puts a hidden <elysion-canvas> (off the screen, 1280 x 800, readonly, images-enabled, with the token provider and the file store of that board) into the page, waits for the element's synced event (the server's state has arrived; 30 s, else the board is skipped) and for whenSettled() (the images are loaded; 20 s), exports it as a PDF with the options of the Export menu (so one page per frame), removes the element, and puts the PDF behind a title page made with pdf-lib (A4: the board's name in Helvetica, characters it cannot show as ?, "Board i of n", and a note for an empty board or one that could not be loaded). All of it is merged into one PDF and downloaded. The progress dialog can be canceled (nothing is downloaded); a room of more than 20 boards asks first ("about N minutes", five seconds a board). The element's new parts: event synced after every sync, and method whenSettled(timeoutMs) (resolves when no image is being fetched or uploaded).

A pre-release. Released under the MIT license.