Wekan logo

Wekan

Open source kanban board application built with Meteor

Alternative to: trello


About Versions (307)

v11.50

2026-09-05

Binaries in these bundles

Each bundle carries a Node.js, a FerretDB and the MongoDB Database Tools. Which source has a given CPU varies from release to release - nodejs.org builds some architectures, unofficial-builds others, and the wekan/node-patches build the ones neither of them does - and not every source publishes a checksum. This is what went into this release, and which downloads were checked against a published SHA256.

BundleBinaryFromVersionCheckedSHA256
amd64FerretDBwekan/FerretDBv1.67.0verifiedbe7a7c3a7b83dee0…
amd64Node.jsnodejs.orgv24.20.0verified2f2c0da162318f0d…
arm64FerretDBwekan/FerretDBv1.67.0verified8f0b1df0fdff9f26…
arm64Node.jsnodejs.orgv24.20.0verified5f4ddab610c1ab20…
armhfFerretDBwekan/FerretDBv1.67.0verified4e35884b7e826048…
armhfNode.jswekan/node-patchesv24.20.0verifiedb8ed7065d44f0afe…
armv6FerretDBwekan/FerretDBv1.67.0verifiedfc657a2929becd70…
armv6Node.jswekan/node-patchesv24.20.0verifiedd5cefa6f8cc4acb1…
armv7FerretDBwekan/FerretDBv1.67.0verified4e35884b7e826048…
armv7Node.jswekan/node-patchesv24.20.0verifiedc04c81e539347f39…
i386FerretDBwekan/FerretDBv1.67.0verified6e51a7278f850f82…
i386Node.jswekan/node-patchesv24.20.0verifiedbb44927307460dcf…
mac-arm64FerretDBwekan/FerretDBv1.67.0verified9478ab9f907a4106…
mac-arm64Node.jsnodejs.orgv24.20.0verifiedb7bf7707070b950b…
mac-x64FerretDBwekan/FerretDBv1.67.0verified9a052beb1c9d0324…
mac-x64Node.jsnodejs.orgv24.20.0verified26fc30891004603d…
ppc64leFerretDBwekan/FerretDBv1.67.0verified8d96ed0624e0a637…
ppc64leNode.jsnodejs.orgv24.20.0verified341307dcee20d883…
riscv64FerretDBwekan/FerretDBv1.67.0verified791b9294ffb1f48f…
riscv64Node.jsunofficial-builds.nodejs.orgv24.20.0verifieda149c5bf85f98ff1…
s390xFerretDBwekan/FerretDBv1.67.0verified2914f0e00c0d0b5d…
s390xNode.jsnodejs.orgv24.20.0verifiedca381121cb5a8d38…
win-arm64FerretDBwekan/FerretDBv1.67.0verifiedd00ed8f3c5300983…
win-arm64Node.jsnodejs.orgv24.20.0verified31c6799744de8a54…
win64FerretDBwekan/FerretDBv1.67.0verified7e559755fa5b38ce…
win64Node.jsnodejs.orgv24.20.0verified6cac9ffbca8f6a47…

A row saying no checksum published is not a failed check - it is a source that publishes nothing to check against. Those are the ones worth fixing at the source.

v11.50 2026-09-05 WeKan ® release

In short: Undo stops being position-only: it now reads a new universal change history covering every card group, and History is a new view on it, opened from the card, list and swimlane menus. Opening it for real found and fixed a blank table, a row that could not be selected, a panel sized for a menu, and a Restore that handed back the version before the one picked. Copying a list now copies its cards, which an unbound swimlane turned into an empty copy, and Admin Panel / Problems can put back swimlane bindings an older repair cleared. The contribution rules now say which role commits where.

PlatformBinaryFromVersionSHA256
amd64Node.jsnodejs.orgv24.19.014b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647
amd64FerretDBwekan/FerretDBv1.53.0eae1f0a8f73bfc979738bfff7284d40fd1bc55de2cc56514721fc155c3624f7d
arm64Node.jsnodejs.orgv24.19.001443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc
arm64FerretDBwekan/FerretDBv1.53.0bdc50caee3ac28495b42d2130b94a042a9dd6d3a38f732cac02b648f36c891da
mac-arm64Node.jsnodejs.orgv24.19.03f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94
mac-arm64FerretDBwekan/FerretDBv1.53.0cb14ffe93e285903e5a8a9c1821687ddb5b8a979a11c584bf4af534b272c6d3e
mac-x64Node.jsnodejs.orgv24.19.0d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4
mac-x64FerretDBwekan/FerretDBv1.53.0d97dfa9afa60aa05f25384327de82efe7b71d958ed24c1f66618284294a65cd3

This release fixes the following SECURITY ISSUES:

Code scanning alerts #442-#446 - CSS string escaping and regular-expression denial of service.

Escape OOXML font names and parse the language registry in linear time. Thanks to GitHub CodeQL and xet7.

The vendored OOXML viewer wrapped document-provided font-family names in CSS quotes and escaped quote characters, but did not escape existing backslashes first. A backslash immediately before a quote could therefore consume the added escape and alter where the CSS string ended. The serializer now escapes backslashes before quotes, matching the safe serializers already present in the same runtime. A regression test pins their order.

Four test suites parsed languages.js with expressions whose repeated escaped- character alternatives could backtrack inefficiently. They now share a strict line-by-line parser using fixed delimiters and JSON.parse, with positive, malformed-input and one-million-character-name coverage. This closes GitHub CodeQL alerts #442, #443, #444, #445 and #446.

Serving attachments - which files WeKan hands to a browser, and how.

A file WeKan refuses to serve is no longer served by Meteor-Files instead. Thanks to xet7.

A Playwright spec found this, and it found more than it was asking about. stored HTML is forced to a safe download on the original Meteor-Files route expected application/octet-stream and got text/html, status 200 - a stored HTML attachment served inline, which is the stored XSS that models/lib/fileResponseSafety.js exists to prevent.

That policy module is correct. It was never reached. Meteor-Files calls the storage strategy’s interceptDownload, and a FALSE return means not handled, serve it yourself - so Meteor-Files served the file from its stored path, with its stored Content-Type, and none of WeKan’s headers.

What makes that a bypass rather than a harmless fallback is why the strategy declines: getReadStream() returns nothing when the file cannot be resolved INSIDE the storage root, the containment check of GHSA-4mxf-m8pq-xc9p. So false means this is not a file WeKan may serve, and handing that same file to a server with no containment check of its own answers the refusal with the file.

All three strategies answer 404 and claim the request now, and so does the abstract base, whose body was EMPTY - returning undefined, which Meteor-Files reads exactly as it reads false, so a strategy that forgot to override it failed open too. A file that is genuinely absent gets the same 404 it would have got anyway.

It is deliberately not logged to Admin Panel / Problems: the same path is taken by an attachment deleted while a link to it survived, which is ordinary use, and interceptDownload cannot tell the two apart.

and adds the following new features:

Undo - what pressing Ctrl+Z can actually put back.

Dragging a card is recorded, so undoing it does something. Thanks to xet7.

Ctrl+Z after dragging a card did nothing, and never had. #6478 found that every trackChange call site guarded on typeof UserPositionHistory !== 'undefined' against a bare identifier no file imported — it is an ES-module default export, not a global, so the guard was always false and nothing was recorded. The fix was applied to the list path and not to the card path, which kept the dead guard. List moves became undoable, card moves did not, and docs/Features/Login/Undo/Undo.md said card moves were “already present (now actually runs)” the whole time.

The import has to be lazy and inside the call: models/userPositionHistory.js imports models/cards.js, so a top-level import would be a cycle and could leave the binding undefined depending on evaluation order — most likely why that file was skipped rather than fixed. The guard is gone from the list path too; it was harmless there, but it is the shape that turns recording off when the block is copied somewhere without the import, which is how the card path stayed dead.

tests/undoRecordsWhatItClaims.test.cjs matters more than the fix, because undo fails silently — nothing throws when a change is not recorded, the user just presses Ctrl+Z and nothing happens. It finds the recording sites rather than listing them and fails when one cannot reach the collection, when one reintroduces the assumed-global guard, when recording is not wrapped so it can never fail the move it records, and when a type is recorded that undo() cannot handle. It also pins the reverse gap — swimlane, checklist and checklistItem have full undo() cases that nothing records — so that stays a known follow-up rather than a surprise.

Undo.md now says plainly what is recorded (list moves, list soft-delete and restore, card moves) and what is not: a description, a checklist title, labels, members and dates are written straight to the document with no previous state kept, so there is nothing to restore them from.

The universal change history is built, and Ctrl+Z reads it instead of positions alone. Thanks to xet7.

Phase 1 of the design in docs/Features/Reports/History/History.md, plus the write half of phase 2.

The store. models/changeHistory.js is the append-only collection: one row per change, carrying every container id it sits inside — boardId, swimlaneId, listId, cardId — so a container scope is a plain equality rather than a join the database cannot do. That is what lets a swimlane’s history include its lists’ and cards’ rows.

It imports no other model, deliberately. Its predecessor imports Cards, Lists, Swimlanes and Checklists so its undo() can write to them, and that is exactly why models/cards.js could not import it, guarded on an assumed global instead, and recorded nothing for years. A collection that entities must import cannot import entities. Applying a change back to a document therefore lives in server/models/changeHistory.js, which nothing imports and which may import anything.

The rules are pure. models/lib/changeHistoryQuery.js holds the scope-to-selector translation, the search and the selection normalisation, with no Meteor and no database — because that is where this feature is either right or quietly wrong. A scope resolving to the wrong id column shows one board’s history under another board’s menu; a selection that does not normalise its input restores the wrong rows. Both are silent. Among the tests: an unknown or half-given scope is REFUSED rather than widened into a selector that matches everything.

Undo is now the whole history. changeHistory.undoLast/redoLast replace the position-only methods, with the keyboard bindings and the tested pickUndo/pickRedo rule unchanged. One rule covers every change type instead of a case per action: undo applies previousContent, redo applies newContent. A restore goes through the same setters an ordinary edit uses, so validation, hooks and Activities still run, and is itself recorded twice — once attributed to whoever made the change being restored, once to whoever pressed Restore.

Recorded so far: card description edits (the previous text is read BEFORE the write, since afterwards the old value is gone), card moves, list moves, and list soft-delete/restore, which is what makes undoing a deleted list work. changeHistory is in the snap’s MERGE_COLLECTIONS, so a row written on the database copy that is not served is not stranded there.

Not built: the History popup, table, contributor avatars, Restore button and menu items, and recording for the remaining card groups. The methods those screens need exist and are tested; nothing calls them yet. Both design documents now say which half is which — the last time one of them claimed more than the code did, the gap survived for months.

Every card group is recorded, and History is one table opened from every menu. Thanks to xet7.

Phases 3, 5 and 6 of the design, and the viewer of phase 2.

Recording everything, from one place. §5 suggests “a thin, central choke point … avoids sprinkling calls everywhere”, and server/models/changeHistoryHooks.js is that: an after.update hook per collection that diffs the changed fields, plus insert/remove for the sub-entities. Cards, lists, swimlanes, checklists, checklist items and comments are covered across every group the design names.

The advantage over editing twenty setters is not brevity, it is coverage: the REST API, the CSV and Trello importers and the rules engine all write through the collection and none call the client setters, so a per-setter rollout would have recorded a description edited in the interface and silently missed the same edit made over the API.

What a hook must not do is record too much, so models/lib/changeHistoryGroups.js is a table rather than a rule. modifiedAt and dateLastActivity change on nearly every write and would bury the changes a person actually made; and the four fields of a move only mean anything together — reported separately, one drag becomes four rows and undo puts back a quarter of it. Moves and the list soft delete therefore record themselves, as one change each.

One table, every scope. §7a: “there is ONE implementation, parametrised by scope”. client/components/history/historyTable is that one — contributor pane, search, pagination, row selection, Restore and RTL — and the card, list and swimlane menus each open it with a different scope. Adding History to a menu is a menu item and a two-line handler, exactly as the design promises. tests/historyOneTemplate.test.cjs walks every .jade under client/ and fails if a second History table is ever defined, because six copies of a table drift: one gets RTL and the others do not, one gets the search fixed and the others keep the bug.

Two things the interface needed that were nearly wrong. The scope has to travel as dataContextIfCurrentDataIsUndefined, because Popup.open’s second argument is options — a bare object there is ignored, and the popup would open on the menu’s own data context and show the wrong history. And a popup without a title key renders with no header and so no close button; this one reuses the existing history key rather than adding another.

Four new words — Removed, Edited, Moved, Restored — because the Action column is the one a reader must understand. Added the app already had, and every other label reuses the word the card view already uses for that section, so a group reads as Description or Labels in the language the card beside it speaks. That kept this to four keys across 197 locales instead of twenty-six.

Not verified live: there was no Meteor runtime available for this work, so none of it has been opened in a browser. The design asks for each phase to be verified live before the next; both documents now say plainly that this has not happened, and the interface in particular should be treated as unproven.

The History panel - the table every menu opens, and what a running server said about it.

The design document says what was actually opened, and what still has not been. Thanks to xet7.

History.md still said none of this had been exercised in a running WeKan, which stopped being true the moment it was. It now records what was done in a browser rather than what was likely: the card, list and swimlane menus each opening the table with their own scope reaching the template, search narrowing it and reporting no results for a term nothing matched, a contributor’s avatar filtering to that person, and 32 rows paging 1 / 2 with the remainder in sequence.

Nothing new was found in that pass, and that is recorded as plainly as the four faults the first one found - because “it shares a template with something that works” is the reasoning those four faults survived.

History works when it is opened, which is four faults later than it looked. Thanks to xet7.

The entry above says this was written with no Meteor runtime available and should be treated as unproven. It has now been opened, and the pass found four things, three of them fatal to the feature. None was visible in the source, and every one of them looks completely ordinary in a diff.

The table was blank. {{#each row in rows}} binds row as a NAME and leaves the data context alone - unlike {{#each rows}}, which replaces it - so {{_id}}, {{contentSummary}} and the rest resolved against the OUTER context. Nothing errors: the table drew one row of four empty cells and a checkbox with no id. The contributor pane had the same bug. Every field now goes through its loop variable.

The row could not be selected. WeKan hides every bare checkbox app-wide - forms.css: [type="checkbox"] { display: none } - and draws .materialCheckBox divs instead, so the real one here rendered 0x0. The row was visible, could never be ticked, and Restore stayed disabled with nothing that could enable it.

The panel was 380px wide. That is a popup’s default, and it left 129px for the contributor pane and 201px for a four-column table: the search box was squeezed to 32px and one row had to be scrolled sideways to be read. History is the same shape as the export panels - two panes, opened from a menu at the edge of the screen - so it joins them in popup.css and in the full-width list in client/lib/popupOffset.js, which is the other half that decides where a panel is put. The rows also scroll in a wrapper now instead of in the table itself: display: block on a <table> stops the cells sharing column widths, so the header and the rows under it drift apart.

And a restore was recorded twice - the one fault that is a correct decision with a consequence. History.md §8.2 says a restore re-applies content through the SAME setters an ordinary edit uses, so validation, hooks and Activities all still run. The field-diffing after.update hook is one of those hooks, so it saw the restore’s own write and recorded it, leaving an Edited row nobody made above the Restored row describing the same write. Recording, and only recording, is switched off for the duration of the applier - as an AsyncLocalStorage scope rather than a module-level flag, because the server handles several requests at once and a shared boolean set during one user’s restore would have silently swallowed another user’s edit landing in the same window.

Verified rather than reasoned about, in a browser against a running server: a card renamed through the UI recorded one row with the right group and both values; the card, list and swimlane menus each opened the table with their own scope reaching the template; Restore put the title back and left exactly two rows; search narrowed the table and said no results for a term nothing matched; a contributor’s avatar filtered to that person; and with 32 rows the footer read 1 / 2, the second page held the remaining seven in sequence, and the next arrow disabled itself there.

The History panel leaves the same gap below it as above it. Thanks to xet7.

The panel was pinned 10px below the top of the window and then stopped wherever its contents ended, so a two-row table sat in the top eighth of the screen with the rest of it blank. It now ends 10px above the bottom of the window: measured at 1280x720, 10px on all four sides, a 700px panel, the same in RTL.

Three separate rules were needed, and each was found by measuring rather than by reading the file:

  • A height. popupOffset.js already sent a max-height of the right size, and a maximum alone lets a short table stay short.
  • No margin. The base .pop-over adds margin-top: 6px as the little gap between a menu and the button that opened it. These panels are not hung off a button - they are pinned to the viewport’s own 10px gutter - so the margin was added to a position that was already final. Every full-width panel sat 16px from the top while its own comment said 10, and on History, which now states a height, it pushed 6px past the bottom as well: 16px above against 4px below. The export panels have the same contract and lose it too.
  • Fixed positioning. Every other popup is absolute in DOCUMENT coordinates, which is right for a menu that should travel with its button and wrong for a box sized from the viewport. Opened on a page scrolled 53px down and then scrolled back, the panel stayed 63px below the window with 43px of itself past the bottom. It is positioned from the viewport now, the way the Admin edit popups already are.

The height then had to reach the table or the blank space would only have moved indoors, so the wrappers between the shell and the template pass it down, the rows take what the controls leave, and they scroll inside the panel instead of being capped at 55vh. With 40 rows the panel stays 700px and the table scrolls within it.

And it fills the screen at phone widths, so the scrollbar is at the foot there too. Thanks to xet7.

Above 801px the panel already ended the same 10px from the bottom that it starts from the top. Below that, where every popup becomes a full-screen sheet, it did not: the horizontal scrollbar sat halfway up the panel with blank space beneath it, because nothing carried the sheet’s height down to the rows.

Measured at 375x812, three things were wrong, and each of them is invisible in a diff:

  • The chain was desktop-only. The rules passing the height from the shell to the template were written inside @media (min-width: 801px) - backwards, since above that width the panel is given a height directly and below it the sheet is the only thing that needs the help.
  • The percentages had nothing to resolve against. .content-container stopped at 588px, capped by max-height: calc(70vh + 20px), whose usual override does not reach a sheet; .content collapsed to 10px, its height: calc(100% - 20px) resolving to the padding alone. The chain is flex now, which needs no parent height.
  • Two caps ate the edges. max-height: 90vh left a tenth of the screen empty under the sheet, and the * reset being content-box put the 1px border outside the stated height - 10px above against 8px below.

Verified in a browser at 375x812, 768x1024, 886x711 and 1280x720: under 801px the sheet fills the screen, above it the gaps are 10px on all four sides, and in every one the scrollbar is at the foot of the panel.

A scrollbar with nothing to scroll, and a band of empty space above the search box. Thanks to xet7.

Both faults were inside the panel, which is why measuring its frame found neither - the gaps around it were right the whole time.

The empty band. .content-container holds the popup STACK, one entry per open menu, with the ones you are not looking at collapsed by .content.no-height { height: 0 }. Making the container a flex column turned those into flex items and the growth rule reached all of them: flex: 1 1 auto says grow, and height: 0 is only the basis it grows from. The card menu History was opened over stayed in the layout and took 336px of a 925px window.

The scrollbar. width: 100% on the table was wrong at both ends, in opposite directions. Wide: WeKan’s global table, td, th rule gives the table a border-inline-start: 1px - the vertical lines between columns - and the * reset being content-box added it outside the 100%. Columns summing to 993.047 in 993.047 of space, border box 994.047: one pixel of nothing, drawing a bar across the foot of the panel at every width the table fitted. Narrow: the table stayed at 100% while its columns needed 436px, so they spilled out of it and the scroll box - which measures the table, not its spill - offered a 1px bar to reach 171px of content. It is width: max-content with min-width: 100% now, so the table grows to what the columns want and fills the panel when they want less.

The 10px gutter stays desktop-only: below 801px, and in mobile mode at any width, a popup is a full-screen sheet flush to all four edges. The height chain is deliberately not in a media query, for the opposite reason - the sheet still has to pass its height down, or the scrollbar ends up halfway up the panel again. Measured in desktop mode at 886x711: 10px on all four sides, no horizontal scrollbar, 65px above the search box - the header and nothing else. At 375x812: edge to edge, with 169px of real horizontal scrolling.

Restore puts back the version you picked, not the one before it. Thanks to xet7.

Reported as “when I try to restore card description from card history, it restores wrong history, that I did not select” - and the selection was never wrong. The server restored exactly the row whose checkbox was ticked. It applied the wrong half of it.

A row carries two contents, before and after. The table shows the AFTER - History.md §7: the content column holds “the new text” - and Restore applied the BEFORE, because it reused the undo path. So choosing the row that displayed the description you wanted handed you the description from the row above it, and choosing rows one after another walked backwards through the history instead of moving through it.

Restore has a direction of its own now, and the rule is one sentence: the row a reader picks and the value they get are the same thing. Undo is deliberately the other way round - Ctrl+Z reverses your own last change - and is unchanged. The two agree only when the row you pick happens to be the last one, which is why this survived the first live pass: the title restored there WAS the most recent change, so both directions gave the same answer.

The rows a restore appends were wrong in the same way - they repeated the restored row’s own before and after, which describes a different change. The live value is read before the write now and recorded as what was displaced.

Verified against a running server with three description versions, FIRST, SECOND, THIRD, and THIRD current: picking the row showing FIRST set the description to FIRST and logged THIRD → FIRST, then picking SECOND set it to SECOND and logged FIRST → SECOND. Before this, picking FIRST emptied the description.

and fixes the following bugs:

API usage report - recording calls after their responses finish.

API usage counts now pass EventLog schema validation. Thanks to xet7.

The API middleware accumulated calls correctly, but every timed flush failed with api is not allowed by the schema in eventlog updateAsync. Its summary identity writes api and apiUserId, and the shared fold writes the normalized ipv4 or ipv6 address, while the attached EventLog schema declared none of those fields. Collection2 rejected the upsert selector at api, so the report could never receive a row. All four fields are now declared. Regression coverage checks every API/fold field against the schema so another report column cannot be wired end to end yet rejected only when its first live event arrives.

Minicards - what dragging the card title does.

The minicard title drags the card or drag-scrolls the board. Thanks to xet7.

Direct title editing on the minicard intercepted the largest natural drag surface. It is now commented out. With drag handles disabled, dragging the title moves the minicard along with the rest of the card. With drag handles enabled, only the handle moves the card, while dragging the title pans the board. Card sorting continues to work in both modes: a browser regression drags the title with handles disabled and the visible handle with them enabled, then checks the persisted card order. Focused negative coverage also ensures the title does not become an inline-editor trigger again.

The minicard drag handle no longer has a grey background. Thanks to xet7.

On touch devices the enlarged drag target looked like a separate grey button. Its background is now transparent, leaving only the drag icon visible, while the target remains directly below the minicard menu and keeps its full finger-sized area. The focused regression test pins the transparent background, the menu-and-handle ordering, the shared trailing edge and the absence of the old grey or tinted background.

The minicard drag icon lines up below its menu. Thanks to xet7.

The touch target was in the correct trailing-edge column, but Font Awesome’s four-way arrow has uneven side bearings and made the visible icon look too far toward the edge. The glyph now moves inward independently while its 44-pixel touch target stays in place. The handle also explicitly suppresses borders and shadows and gives its transparent background priority, preventing a mobile rule from drawing a grey block below the icon. The focused regression test covers the logical, RTL-safe alignment and every background layer.

Mobile Mode uses one aligned minicard control column in every browser. Thanks to xet7.

The enlarged, aligned menu-and-handle column was restricted to coarse pointers. A desktop browser switched with Toggle between Desktop and Mobile Mode has a mouse pointer, so it kept the compact desktop drag handle and placed its center farther toward the minicard edge than the menu center. Mobile Mode now owns the layout regardless of pointer type: both controls have the same trailing inset and width, which gives them exactly the same horizontal center on phone and desktop browsers and mirrors the column in RTL. Desktop Mode retains its compact handle. The regression test calculates and compares the two centers and rejects any pointer-type media query that could split the explicit mode again.

iPhone Safari no longer paints the minicard drag target grey. Thanks to xet7.

iPhone Safari still drew a grey rectangle over the full drag target in Mobile Mode even though its CSS background was transparent; Desktop Mode’s compact handle did not expose the problem. The Mobile Mode target now inherits the minicard’s actual background, so it blends into white, coloured and hovered cards, and explicitly disables WebKit’s tap highlight. Its pseudo-elements, border and shadow are also pinned to paint nothing. The icon, aligned control column and 44-pixel touch target are unchanged. Regression coverage keeps these Safari-specific paint guards scoped to Mobile Mode.

Mobile card and swimlane drag handles now keep their desktop alignment. Thanks to xet7.

The Mobile Mode minicard arrow was shifted six pixels away from the shared menu-and-handle control center. Mobile font scaling enlarged that error, leaving the arrow visibly to the side of the menu bars even though their touch targets had equal width. The shift is gone, so both glyph centers have the same x coordinate in every browser and direction. Swimlanes also no longer substitute a larger, separately positioned handle on touch devices; one compact logical-position handle is shared by Desktop and Mobile Modes on every device. A live Chromium regression compares the actual glyph centers and verifies the swimlane handle’s x coordinate is unchanged when switching modes. Focused positive and negative source tests reject either device-specific positioning variant.

Swimlanes - which swimlane a list belongs to, and what travels with it.

Opening a Mobile Mode list no longer empties the other swimlanes. Thanks to xet7.

Mobile Mode stored one globally selected list, and every swimlane guarded its compact list rows with unless currentList. Opening List 1 at Swimlane 2 therefore expanded that list correctly but hid every list in Swimlanes 1 and 3. The guard now asks whether the selected list belongs to this swimlane: its own swimlane renders the expanded list while every other swimlane retains its compact rows. Board-wide lists still expand in every swimlane by design. A live Chromium regression against the port-3000 application seeds two swimlanes, opens the second one’s list and verifies the first one’s list remains visible; focused positive and negative source tests pin the template scope as well.

Opening a Mobile Mode list no longer resizes lists in other swimlanes. Thanks to xet7.

The compact list rows in every swimlane consulted the board’s global selected-list state when choosing their header controls. Opening List 1 at Swimlane 2 therefore made the still-visible rows in Swimlanes 1 and 3 switch to the expanded header shape, changing their height even though neither swimlane had been selected. A list header now switches shape only when its own ID is selected, so every other swimlane keeps the same controls and row heights. The live Chromium regression records every compact row height before opening the second swimlane’s list and verifies the dimensions are unchanged afterward; focused positive and negative source coverage pins the ID scope.

Opening a Mobile Mode list no longer enlarges the top header. Thanks to xet7.

Selecting List 1 at Swimlane 2 inserted the names of every board list into the quick-access header. On a narrow screen that list navigation consumed another row, made the blue top bar taller and moved the board content down. Mobile Mode already presents every list as a selectable row inside its swimlane, so the duplicate header list has been removed. The live Chromium regression verifies that the selected list’s name is absent from the top bar and that the bar has exactly the same height before and after the list opens; focused negative coverage prevents the conditional list from returning to the header template.

Copying a list copies its cards, into a new list. Thanks to xet7.

List.copy(boardId, swimlaneId) carried both of the faults the List.move fix in v11.49 removes, and the copy was the more visible of the two: it produced an empty list.

const oldSwimlaneId = this.swimlaneId || null;
...
const cards = await ReactiveCache.getCards({
  swimlaneId: oldSwimlaneId, listId: oldId, archived: false });

A list that is not bound to a swimlane - an empty or missing swimlaneId, which is what every list on a board predating per-swimlane lists still has, and what #6515 left behind on boards opened before it - turns that into swimlaneId: null, so the selector asks for cards that have NO swimlane. The cards of such a list carry the real swimlaneIds of the swimlanes they are in, so it matched nothing and the copy came out with no cards at all. Even for a bound list the filter could only ever remove cards that are in the list being copied. A list is the unit of a copy, so every card in it travels, exactly as in List.move.

The second fault is the #6670 shape exactly. copy() searched the target board for a list with this title to reuse, without first asking whether the target board IS this list’s own board - and on a same-board copy that search finds THIS LIST. _id became the original, so the “copy” wrote the cards back into the source list, doubling them, and returned the source list’s id, which POST /api/boards/:boardId/lists/:listId/copy then repositioned: the user asked to copy a list and got the original moved with twice the cards. Reusing a same-titled list is only meaningful across boards, so a same-board copy is now always a new list, the way Swimlane.copy already creates one.

Fixing the first fault raises a question that could not come up while the copy was empty: where the cards land. The REST endpoint’s own default is a copy on the same board with no toSwimlaneId, and pinning every card to “no swimlane” would dump the cards of three swimlanes into none - so when no swimlane is asked for and the copy stays on the same board, each card keeps the swimlane it is in and the duplicate looks like the original. Across boards it cannot: the source card’s swimlaneId belongs to the OTHER board and would arrive orphaned, so those cards take the copy’s own swimlane.

The decision lives in models/lib/listCopyPlan.js, the twin of models/lib/listMovePlan.js, where it is unit-tested without a database; models/lists.js applies it. tests/listCopySwimlane.test.cjs pins the same-board copy and the copy of a board-wide list, and its negative tests require that the card selector never scopes by swimlane, that a same-board copy is never a merge even when a same-titled list exists, that a cross-board copy never keeps a swimlaneId from the source board, and that models/lists.js compares the boards before it looks a list up by title.

tests/listMoveSwimlane.test.cjs is corrected while its sibling is written: two of its source scans took their offsets from the un-stripped file and sliced the comment-stripped one, which worked by accident and slid off List.move as soon as anything above it changed. Both offsets and the slice now come from the same string, and every assertion is kept.

Admin Panel / Problems can put back the swimlane a list lost. Thanks to TawsTm and xet7.

Boards opened under the versions before #6515 had every per-swimlane list un-bound automatically: the board data-repair treated any list with a swimlaneId as #6484 corruption and cleared it, and a per-swimlane list is indistinguishable from a corrupted board-wide one at the data level. #6515 stopped it, but nothing put the bindings back, so those lists still render under every swimlane and deleting one from a swimlane deletes the only list document there is.

The old value turns out to be recoverable rather than guessable. The clearing went through Lists.direct.updateAsync, which bypasses collection hooks, so it only ever touched the list document - while the binding each list was CREATED with is recorded in a different collection:

// models/lists.js - Lists.after.insert -> trackOriginalPosition()
originalSwimlaneId: this.swimlaneId || null,
if (!existingHistory) { PositionHistory.insertAsync(document); }

That insert is insert-ONLY, written once at creation and never overwritten, so it survived untouched for every list created since list position tracking landed in October 2025.

Admin Panel / Problems / Summary now detects this the way it detects broken cards - Lists missing their swimlane N, with a Restore button beside it - and restoring puts each list back in the swimlane its own record names. Every rule in it is a reason to SKIP, because here doing nothing is better than doing something wrong: a list that already has a swimlaneId is never touched, so the repair is idempotent and cannot undo a binding an admin has set by hand since; a list with no record, or one recorded as board-wide, stays board-wide; and a swimlane that has since been deleted is not resurrected, nor is one on another board accepted, because either would hide the list in every swimlane rather than show it in one.

Nothing is inferred from the cards, and a test pins that the planner cannot grow a use for them. Inference is the obvious idea and it is wrong: on a board whose second swimlane is new every card is still in the first one, so it would bind every list to swimlane 1 and hide them from the others - which is #6484 again, the bug the clearing existed to fix.

Detection is read-only and swallows its own errors, since the Problems page polls it every thirty seconds and a detection that throws would take the other problems on that page with it. The repair writes swimlaneId and nothing else, through .direct, one update per swimlane rather than one per list.

and states the rules a contribution is judged by:

Contributing - who commits where, and who gets named for the work.

The instruction files say where the WeKan checkout is on each operating system. Thanks to xet7.

“The WeKan repo” meant “wherever you happen to be”, and an agent given a task in the wrong directory had nothing to check itself against. It is a fixed place per operating system now - ~/repos/wekan on Linux, ~/Documents/repos/wekan on macOS, Downloads\repos\wekan on Windows - along with the companion repositories under .tools/, each on its own default branch, which is not always called main.

This commit also said that commits go directly on main, full stop, which is true of the maintainer and wrong for everybody else. The entry below is the correction, in this same release: a contributor works on a branch in their own fork and opens a pull request. The rule as it stands is the two-role table, not this half of it.

The contribution rules say which role commits on which branch, and where the checkout is. Thanks to xet7.

Two things an agent had to infer, and could infer wrongly. WHICH BRANCH: the maintainer commits directly on main - never a feature branch, never a pull request - and a contributor works on a branch in their own fork and opens a pull request, never committing to main. Both halves are given their reason, because a rule with a reason survives a tool that offers something else: releases/release-all.sh cuts a release from whatever is on main, so work parked on a branch misses it; and nobody but the maintainer commits to main in wekan/wekan, so a change from anyone else arrives as a pull request, which is also the only place it can be discussed first.

WHERE: the checkout is at a fixed place per operating system - ~/repos/wekan on Linux, ~/Documents/repos/wekan on macOS, Downloads\repos\wekan on Windows - so “the WeKan repo” no longer means “wherever you happen to be”.

The never-push boundary is unchanged and covers both roles: an AI makes the local commit and stops, whether that commit is on main or on a contributor’s branch. Pushing it, opening the pull request and every release step are the human’s.

An AI is credited when it is the contributor, and never when it only helped one. Thanks to xet7.

Never attribute a commit to an AI was one rule where there are two, and it gave the wrong answer to half the cases it met. The test is whether a person is behind the change:

  • A human’s AI is invisible. The maintainer or a contributor using an assistant is the author; the tool appears nowhere - no Co-Authored-By: trailer, no Generated with, no model name in the commit, the pull-request body or the CHANGELOG.
  • An AI that raised the pull request itself is the contributor, and is named. GitHub CodeQL filing a fix for something it found, Copilot Autofix, Dependabot: nobody wrote those, so crediting a human would be false and crediting nobody would leave the change unattributed.

So the same word - Copilot - is forbidden in one commit and required in another, and the files now say which is which. The second half is not new practice: the dependency sections have always closed with Thanks to dependabot. and the Hall of Fame has always named GitHub CodeQL as a reporter. Those were the rule being followed without being written down. CODE_OF_CONDUCT.md carries the same rule in its own plain register, since it is where a contributor looks first.

The maintainer's AI is credited on the sponsors page, and only there. Thanks to xet7.

Saying a human’s AI is invisible read as “never acknowledged anywhere”, and a rule that omits its own exception invites somebody to add the credit back where it was removed from. It is acknowledged, once, at wekan.fi/sponsors, under “AI donated by. All code and PRs verified by xet7”, where Claude, Codex and GitHub Copilot are listed alongside the people and companies that donate hosting, servers, grants and testing.

That is the whole of the credit, and it is where it is because attributing it per commit put the same fact on thousands of lines and drowned out the humans the entries exist to name. Acknowledging it anywhere else is not extra politeness; it undoes that.

and has the following developer-tooling fixes:

The build tree - which directory a build writes to, and what is not source.

build.sh raises the open-file limit, so mongod does not abort mid test run. Thanks to xet7.

A full Playwright run died thirteen minutes in, and every spec after it reported MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:3001 - which reads like the test database was never started. It was. It started, said what was wrong with it in the same breath, and was ignored: Soft rlimits for open file descriptors too low, currentValue 256, recommendedMinimum 64000. Thirteen minutes later it ran out of them and WiredTiger panicked.

macOS starts a shell with a soft limit of 256, so every run of the browser suites was a race between finishing and running out of descriptors, with the cause landing 10,000 lines away in a different log. ensure_open_files runs on every invocation beside the inotify check. It needs no root - the hard limit is normally unlimited - and it sets the SOFT limit only, because plain ulimit -n sets both and lowering a hard limit is irreversible.

Three permanently red test suites now pass, two of them macOS faults. Thanks to xet7.

Each was a different fault rather than a stale assertion.

database-autopick restores MongoDB’s data-file mtimes with stat -c %Y and touch -d @EPOCH, which are GNU. On macOS the stat fails, every file is skipped by the || continue beside it, and the restore returns having done nothing - silently, and only there. It tries the BSD spelling now.

provenance-table.sh opened with shopt -s nullglob globstar, and globstar arrived in bash 4 while macOS ships bash 3.2 as /bin/bash: the shopt failed and set -e took the script with it. The default file list is a single find now. Then the empty case failed too, because bash 3.2 calls an empty array an unbound variable under set -u where 4.4 does not.

fill-translations.mjs --list was TRUNCATED whenever stdout was a pipe: it prints 128 KB with console.log and then calls process.exit, which cuts off whatever has not drained. A pipe delivered 65,510 bytes - valid-looking JSON stopping mid-key, with status 0. Redirecting to a file worked, because that is synchronous, so this only bit anything reading the output programmatically - including the translation workflow itself.

The last two red suites: a missing utility, and a count of another repository. Thanks to xet7.

bundle-smoke-boot.sh runs the bundle under timeout, which is GNU coreutils and is not on macOS. It died with timeout: command not found and exit 127 - which the script’s own checks then read as the bundle exited without reaching its database, reporting a startup failure for a missing utility. It uses timeout, or gtimeout, or a shell fallback now.

The Hall of Fame comparison demanded at least 50 *bleed directories in the wekan.fi checkout as a sanity check. That number counts what was published when the line was written, so a checkout two months old has fewer and the suite went red over the state of another repository. A stale checkout cannot cause a false failure there - it only makes the check smaller - so what it asks now is whether the directory is the Hall of Fame at all.

Attachments are readable again when the storage root is a symlink. Thanks to xet7.

FileStoreStrategyFilesystem builds its candidate paths by joining names onto the storage root AS WRITTEN, then checked each one against that root with every symlink RESOLVED. Those are the same string only when nothing in the path is a symlink. On macOS os.tmpdir() is /var/folders/..., a symlink to /private/var/folders/..., so every candidate failed containment before it was looked at. A deployment whose data directory is a symlink has the same fault on Linux.

The security property is untouched, because it was never the lexical check that carried it: what a caller must not do is reach a file outside the root, and that is decided by resolving the candidate and requiring the result to be inside the resolved root.

build.sh raises the open-file limit, so mongod does not abort mid test run. Thanks to xet7.

A full Playwright run died thirteen minutes in, and every spec after it reported MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:3001 - which reads like the test database was never started. It was. It started, said what was wrong with it in the same breath, and was ignored:

"Soft rlimits for open file descriptors too low"
currentValue: 256, recommendedMinimum: 64000

Thirteen minutes later it ran out of them - Too many open files accepting connections, then opendir on its own journal, then WT_PANIC: WiredTiger library panic and Abort trap: 6. macOS starts a shell with a soft limit of 256, build.sh never raised it, and so every run of the browser suites was a race between finishing and running out of descriptors, with the cause landing 10,000 lines away in a different log in a form that points at the wrong thing.

ensure_open_files runs on every invocation beside the inotify check, for the same reason: a limit that is too low breaks a later step with an error that does not name it. Unlike that one it needs no root - the hard limit is normally unlimited, so the process raises its own soft limit and mongod, the bundle server and the browsers inherit it. It sets the SOFT limit explicitly, because plain ulimit -n sets both and lowering a hard limit is irreversible; and it clamps to the hard limit and steps down from there, because asking for more fails outright rather than clamping.

Commit links in this file are repointed when a rebase makes them stale. Thanks to xet7.

A rebase rewrites hashes, and every <summary> here carries one in its href. The links that had gone stale were repointed, which is the same repair build.sh’s pull and push both run - a stale link is not a local annoyance, it is a 404 for everyone who opens the release notes.

The History table formats dates with the helper WeKan actually has. Thanks to xet7.

The new table opened with import moment from 'moment', and that one line broke the client build outright:

ERROR in ./client/components/history/historyTable.js
  x Module not found: Can't resolve 'moment'

moment was removed from WeKan and replaced with native Date helpers - imports/i18n/moment.js says so in its first line - so it is not a dependency and nothing resolves it. The import is now formatDateTime from /imports/lib/dateUtils, which is what the rest of the app already uses.

What is worth keeping from this is how it was found. The line is the most ordinary-looking one in the file, and no amount of rereading the diff would have shown it, because what was wrong was somewhere else entirely: the dependency list. tests/importsResolve.test.cjs now resolves every bare import in client/, models/, imports/ and server/ against the real node_modules, which is the same question the bundler asks and takes under a second instead of a two-minute build. It also pins that moment stays gone, and that the set of packages imported without being declared in package.json - body-parser and mime-types, both of which predate this - cannot grow.

Every ignore file agrees on what is not source, and the two build directories are explained. Thanks to xet7.

There are two build directories one character apart, and nothing said which was which. _build/ is rspack’s handoff: it compiles the app into _build/main-prod/ and Meteor then reads server-meteor.js and client-meteor.js from there as the application’s main modules, so ignoring it does not tidy anything - it fails the build with Could not find mainModule for 'os' architecture. .build/ is the opposite, the finished bundle meteor build .build --directory writes. Both are now described where somebody meets them: the headers of build.sh and build.bat, Directory-Structure.md, Build-from-source.md, Meteor-bundle.md, Build-and-Create-Pull-Request.md, and this file’s two AI instruction files.

The ignore files had drifted apart from each other in the meantime, so each was brought to the same answer: _build reached .dockerignore, .eslintignore and .prettierignore, which had never heard of it and were linting and shipping a bundled copy of the app. .meteorignore gained the trees that are not application source at all - docs/, meta/, old-CHANGELOG/, openapi/, the packaging directories, releases/, scripts/, tools/, .github/ and the editor and tooling directories - after checking, rather than assuming, that nothing under client/, server/, models/, imports/, config/ or packages/ imports from any of them. Meteor takes one file watcher per directory it does not ignore, from a per-user limit shared with every editor on the machine, and that limit is what .tools/ was added for in the first place.

Ignoring .build does not silence Meteor's warning about it, and no longer says it does. Thanks to xet7.

The comment added beside /.build/ claimed the entry was this warning answered:

WARNING: The output directory is under your source tree.
         Your generated files may get interpreted as source code!

The very next build printed it again. The warning is a path comparison on the output argument, made before any file is written - tools/cli/commands.js asks whether pathRelative(appDir, outputPath) starts with .. and prints it when it does not. No ignore file is consulted and none can reach it; silencing it would mean building outside the tree, which build.sh, build.bat and the release workflows would all have to agree on.

The entry is still worth having, for the second half of the same sentence: without it the next meteor run walks a whole bundled copy of the app. That is what the comment says now. tests/meteorignoreScanScope.test.cjs pins both directions - _build must not be ignored, .build must be - and fails if the false claim comes back.

and improves release automation:

Variant repositories - one authoritative source and two compatibility names.

Update Ondra and Gantt repositories as a required release job. Thanks to xet7.

The compatibility snaps published successfully while their GitHub repositories stayed stale because repository synchronization was an optional step inside a continue-on-error architecture matrix. A missing or insufficient repository token could skip or fail that step without failing the release.

A reusable workflow now updates both repositories from the release tag as its own required job. Its shared preparation script copies only committed WeKan source and preserves each variant’s Snap, npm, GHCR, Quay and Docker Hub package identities. Fixture tests cover both variants, reject dirty targets and prove that ignored local files do not enter the synchronized repositories. Snap publication remains independent of repository synchronization.

Keep Dependabot updates in WeKan instead of its release mirrors. Thanks to xet7.

Synchronizing the complete source tree also copied WeKan’s Dependabot configuration into wekan-ondra and wekan-gantt-gpl. Both mirrors then opened duplicate dependency PRs against snapshots that later synchronization replaces. The preparation step now removes both supported Dependabot configuration filenames. Dependency changes remain reviewed and tested once in wekan/wekan and reach each compatibility repository through the normal sync. Positive and negative fixture coverage pins the exclusion for both variants.

Test suite - full runs inspect source rather than generated copies.

Keep source scans out of builds and exercise minicard links through the UI. Thanks to xet7.

The complete test run found two scanners walking generated .build-* bundles: one mistook bundled history calls for source without imports, while the security map had not yet associated nine published vulnerability names with their existing regression suites. Build variants are now excluded and the suites name the disclosures they cover.

The minicard markdown-link browser test also called the module-scoped ReactiveCache identifier as if it were a browser global, failing in Chromium and Firefox before testing the link. It now creates the markdown title through the real card editor, closes the card and verifies that the minicard link opens without restoring inline title editing.

Keep consistency checks aligned with release helpers and security names. Thanks to xet7.

The complete test rerun found four consistency failures rather than application failures. The new variant preparation helper now has its documented workflow-only menu exemption, and the variant design explicitly retains historical Docker tags without claiming that new variant images are published. Upcoming entries are grouped by their actual areas.

The security inventory previously used substring matching, so naming CookieTokenBleed made it falsely conclude that the unrelated TokenBleed gap had acquired coverage. Vulnerability names now require non-alphanumeric boundaries. The four suites that failed in the complete run pass together.

and improves documentation:

Outgoing email documentation - current configuration and readable Markdown.

Remove visible Liquid tags from email troubleshooting. Thanks to xet7.

The email troubleshooting document wrapped its entire contents in Liquid raw tags to protect one literal template placeholder. GitHub’s normal Markdown view displayed those tags as document text. The example now uses a fixed escaped regular-expression literal, which still replaces every exact placeholder without presenting Liquid syntax to a Pages build. Tests pin the rendering boundary and positive and negative replacement behavior.

Email troubleshooting starts with the Admin Panel provider choices. Thanks to xet7.

The email troubleshooting page still said email could only be configured with MAIL_URL, and the Admin Panel page described its live provider fields as commented out. Both now lead with Admin Panel / People / E-mail and the Enable below email settings opt-in. They explain that administrators can choose custom SMTP or the built-in Gmail, Outlook 365, Proton, SendGrid, Mailgun, Postmark, Resend and AWS SES options, while leaving the switch disabled continues to use MAIL_URL and MAIL_FROM. The general webserver settings page points to the same two choices.

and improves the translations:

Bosnian - a language file that was almost entirely English.

2175 untranslated strings become one. Thanks to xet7.

Croatian and Serbian are complete and Bosnian is the same Štokavian standard, so Croatian is the base with the documented Bosnian forms applied - sedmica for tjedan, nivo for razina, server for poslužitelj, tok for tijek, hiljada for tisuća, historija for povijest, and tačka/tačno for točka/točno. 29 strings needed one; the rest are identical in both standards.

Two substitutions were REVERTED after reading the output, which is the part worth keeping: poveznica → link produced “iz ove linkove” and “iz bilo koje linkove na kartu”, because poveznice is both nominative plural and genitive singular and one rule cannot be both. Poveznica is good Bosnian, so it stays - a correct word left alone beats a more idiomatic one put in the wrong case. The same blindness left “Najviša nivo”, a feminine adjective on a masculine noun.

The last ten were translated directly. The one that remains is Server, spelled that way in Bosnian too - the tool counts a translation identical to its source as missing, which is its limit rather than a gap.

Thanks to above GitHub users for their contributions and translators for their translations.