Wekan
Open source kanban board application built with Meteor
Alternative to: trello
v10.54
2026-08-01v10.54 2026-08-02 WeKan ® release
In short: a large redesign of the pages you are always looking at. The
first header bar now says where you are, carries every control that used to
be scattered around the page, wraps instead of hiding what does not fit, and
holds a bookmarks menu - the star works on any page now, not only on a
board. All Boards gains a Home section for the board that opens after login,
an Archive in its left menu, a Table view, a heading naming the section you are
in, and an address for every section and workspace; the Admin Panel moves
under /admin with an address for every pane. The left menu those two pages
share folds away and is resized by dragging its inner edge, and the
workspaces in it are a real tree: drop one onto another to nest it, to any
depth, and fold a branch away with its caret. Public Boards becomes a
read-only page of its own, a swimlane, a list and a card can each be linked
to directly, and board roles are one capability table with a pane that shows
it. Below that: dependency updates, sixteen bug fixes -
the header bar’s layout and where it starts, a filter that left a spinner
turning, a search that reached past your own boards, a left-menu caret that did
nothing when clicked - and the usual documentation and translation work.
This release adds the following new features:
The left menus - the one menu All Boards and the Admin Panel share.
The left menu folds away, with the caret a list already has. Thanks to xet7.
At the top of both left menus - All Boards and the Admin Panel - there is now a caret that folds the menu away, and the same caret brings it back. It is the control a list has on a board: pointing down while the thing is open and right once it is folded, with the same two words in its tooltip, so there is nothing new to learn.
Folded, the menu is gone - no column, no strip, no narrow band of grey with a glyph in it - and the page beside it takes the whole width. A strip is still a column: it holds width, it keeps the page from starting at the window edge, and a caret alone in it is a target that has to be aimed at.
The way back is the pane title. Folded, the caret moves to the inline start of the heading beside it, and the caret and the title are one target: clicking anywhere on it brings the menu back. Nothing is lost by drawing nothing, because the way back is the largest thing on the page rather than the narrowest. The heading is drawn even for a pane that has no title of its own, so a folded menu can never be a menu you have lost.
One state for both pages: they draw one menu, and a reader who folds it away
on one of them has said what they want on the other. It is a Session value
first, so the fold is instant rather than waiting for a round trip, then
profile.leftMenuCollapsed on the user document so it survives a reload and
follows the reader to their other browser, and a cookie when nobody is signed in
- a public board has this menu too - through the same cookie helpers the public list and swimlane collapse states already use.
Open is the default: a menu that remembered itself collapsed for somebody who has never collapsed one would be a page with no visible way to navigate.
Its width is dragged from its inner edge, the way the right sidebar's is. Thanks to xet7.
The menu’s inner edge - the right one while reading left to right, the left one under a right-to-left language - now carries a grip, and dragging it changes the width. It is the same control the right board sidebar has on its own inner edge: the same strip, the same cursor, and the same sign flip on the drag so the widening direction mirrors under a right-to-left language rather than needing a second rule kept in step.
One number, on <html>. The width is a CSS custom property, and everything
that needs it reads that one: the Admin Panel’s menu, the All Boards one - whose
grid track follows the menu - and the grip itself, which has to sit exactly on
the edge. An inline width on one element could not have done that, because the
menu is a different element in a different template on each page. A breakpoint
that wants a different default width overrides the variable rather than the
menu, or it would beat a dragged width at exactly that one screen size.
The grip is positioned against the row around the menu, not inside it: the menu is its own scroll area, and a handle within it would scroll away with the entries. Being positioned it is neither a flex item nor a grid item, so it adds no column and no gap - which is why the shared menu template can carry it and every Admin Panel pane gets it without naming it.
The width is remembered the same three ways the fold is: a Session value first,
then profile.leftMenuWidth on the user document, then a cookie when nobody is
signed in - the same cookie mechanism the fold uses rather than the localStorage
the right sidebar’s width uses, so one reader’s menu is not remembered in two
different places. It is saved once, when the drag ends; while dragging, the
width is written straight to the property, so the edge follows the pointer
without a database write per pixel. There is nothing to drag on a phone, where
the menu is full width above the content, or while it is folded, where there is
no edge.
Workspaces - the tree of folders for boards in the All Boards left menu.
A workspace nests inside another one, and a caret folds the tree away. Thanks to xet7.
A workspace holds boards and, in the data, other workspaces - but a drag could only ever say one thing about them, and there was no way to fold a branch at all.
Where in a row a workspace is dropped is now what the drop means. The top quarter makes it that row’s previous sibling, the bottom quarter its next sibling, and the middle half makes it that row’s last child - a sub-workspace. The middle is the biggest target on purpose: reordering can also be reached by aiming at the neighbouring row’s far edge, but nesting has only this one. Coming back up is a drop like any other - a child dragged onto a root row’s edge is a root again - because nesting has to be undoable, or a workspace put one level too deep is stuck there.
The placeholder is a slot, not a line. While the pointer is over a row, an empty slot a row high opens above it, below it, or - for “into this one” - indented underneath it, which is exactly where the workspace will appear. A line between two rows is a target that has to be aimed at; a slot is a place to drop into. It opens as a pseudo-element of the row, so the rows below shift down and the row being aimed at stays where the pointer put it.
A workspace may not be dropped into itself or into its own descendant: the subtree would be cut off from the root, taking every workspace under it with it. That is refused while the workspace is still in the air, so the cursor says no rather than the drop landing and quietly doing nothing.
A caret folds a workspace’s own workspaces away, at the start of the row - before the drag handle, so the carets of a tree line up whatever the drag-handles toggle says. It is the control a list has on a board and the one the left menu itself has, down to the two words in its tooltip, and it answers Enter and Space, because a tree that only opens with a mouse is a tree half the readers cannot open. A workspace with nothing under it keeps a spacer of the same width, so a row does not shift sideways the moment it gains its first child. Open is the default, and only the folded ones are stored - fifty workspaces with two folded is two keys - remembered in the same three layers as the rest of this menu: a Session value, the user’s profile, and a cookie for a reader who is not signed in.
The depth is unlimited because nothing counts it: the menu draws itself again for a workspace’s children, each level indenting by one caret’s width with a logical property, so a right-to-left tree indents from the right by itself.
What a drag does to the tree is a pure module with its own tests - which third of a row the pointer is in, and the tree a move produces, guards included - so the rules are proved without a browser, and the page is pinned to calling them rather than working them out a second time. It is written up as Workspaces.
All Boards - its sections, its controls, and what the page opens on.
Every section names itself at the top of its pane. Thanks to xet7.
The right pane began with board tiles and nothing said which list they were. The page is five lists of boards under one name - Starred, Remaining, Home, Templates, Archive, and a workspace - so the pane now opens with a heading naming the one you are looking at.
It is the Admin Panel’s own paneTitle template with the same
.admin-pane-title class, so the two pages have one heading at one size and
colour rather than two written twice that drift apart. Only the space below it
is set here: the Admin Panel’s own gap rule is scoped to its .main-body and
does not reach this page.
It is drawn once, above the view branch, since the board icons and the Table are two ways of showing the same section rather than two sections. Its words are the section’s own title key - the same key the first header bar names the page with, and the same one the highlighted menu row carries - so all three say the same thing. A workspace shows its own name instead, untranslated: a workspace called “starred” is not the Starred section.
Home, a section for the board that opens after login. Thanks to xet7.
One board can be Home: logging in opens it instead of the All Boards page. That has always existed, set from Multi-Selection’s “Set as Home board (opened after login)”, but there was nowhere that said which board it was - the setting was write-only, and the only way to find out what you had chosen was to log out.
Home is now a section of All Boards with a row in the left menu, a home icon and
the word Home, a count beside it like the other lists, and the address
/allboards/home. It sits under Starred and Remaining - the top row stays the
one the page opens on, since after login you are already in the Home board -
and the row is there whether or not a board is at it, because the place to drop
a board onto has to exist before there is anything in it.
Drop a board on the row to make it Home. The row is one more place in a column a board icon can already be dragged onto, so the gesture is the one already in the reader’s hand; the alternative was three clicks through Multi-Selection. A drop replaces rather than toggling: Home holds one board, and a drop that sometimes set and sometimes cleared would depend on state the reader cannot see while dragging.
Home is a mark on a board, not a place boards are kept - like a star. The board stays in Remaining, or in its workspace, and appears in Home as well.
Taking a board off Home is the Android launcher’s gesture. Pick the board up in Home and a Remove bar appears above the tiles - only while the board is actually in the air, because an affordance that shows up when the gesture is possible explains itself, and a trash can sitting permanently under somebody’s boards is a button nobody dares press. Drag the board onto it and it turns red, let go and it asks, and the question says the board itself is not deleted. Every other target refuses the drop while the board is still in the air, so a board cannot leave Home by accident while you are filing it into a workspace.
The server accepts only a board the caller is a member of and that is not archived - a Home board that will not open would send that user to a board that refuses to draw at every login - and clears only the board that is actually theirs. Nothing automatic writes it; in particular Sandstorm’s auto-open still persists nothing.
Documented in Home.
It opens on Starred, or on Remaining when nothing is starred. Thanks to xet7.
Starred was always the section All Boards landed on. On an account that has starred nothing that is an empty page with a full one behind it, which reads as WeKan having lost the boards rather than as a section nobody has filled in yet.
/ now opens Starred when the user has starred boards and Remaining when they
have none, and the left menu puts whichever one that is on top, so the
highlighted row is the first row. Only those two rows move — Templates and the
Archive keep their places.
The rule is one pure function pair in models/lib/allBoardsUrls.js, so the page
and its guard read the same one. The router no longer answers the question: it
runs before the user document has necessarily loaded, so it leaves / open and
the page decides — in an autorun, because on a cold load the user document lands
after the template is created and a single read would say “nothing is starred”
for everybody. It asks the user document’s own starred list rather than the
query that counts the boards, whose answer depends on the subscription and would
draw Remaining and then jump to Starred mid-load. An address that does name a
section still wins, so this can never fight a row the reader has clicked.
The four menu rows became one row drawn once per section, since an order that depends on the user cannot be four copies of the same markup.
Boards in Archive moves to the left menu, and the Workspaces section gets its rules. Thanks to xet7.
Boards in Archive was a button in the first top header bar beside Sort, Search
and Multi-Selection. Those three act on the boards in front of you; Boards in
Archive is a place you go instead, so it is a row of the left menu now, under
Remaining, with a count beside it like the three lists above it — and it opens
as a SECTION of the All Boards page, drawn beside the menu rather than as a
full-width page that replaces it. Selecting something from a menu and then
losing the menu is a menu that throws itself away. Its address is
/allboards/archive, in the same shape as the other four sections.
The count is asked of the server rather than counted from what the page has: All Boards does not subscribe to archived boards at all, and the archive’s own publication is paginated, so counting minimongo would answer 0 on a fresh load.
Boards can be dragged onto that row to archive them, from any of the four lists or from a workspace — the same drag the left menu already accepts for Remaining, and the alternative was three clicks through Multi-Selection. It asks before doing it, because a drop is easy to make by accident.
The Workspaces section has a rule above and below it. The left menu is three kinds of thing in one column — the three board lists, the workspaces tree, and the archive — and without them the tree ran into its neighbours as if it were more of the same list. They are a 2px dark grey line: a first attempt used the same near-white the menu’s own edge uses and was too faint to separate anything.
The whole menu is styled like the Admin Panel’s now — a panel with its own background, border and rounded corners, and a selected row filled with the per-user theme accent and white text — and it reaches the window’s left and bottom edges the way that one does, instead of floating 14px in from an edge it is meant to look attached to. WeKan has one kind of left menu and it should look like one kind of left menu.
The click handler moved with the markup, because a Blaze event map only sees events inside its own template: one left behind in the header buttons would never fire and the row would silently do nothing, which is exactly what happened to this button once before. A guard now checks both halves of that.
Multi-Selection shows that it is on, and the Sort Boards popup gets a title. Thanks to xet7.
The Multi-Selection button in the first header bar looked identical whether or not a selection was running, so the one control that changes what clicking a board does gave no sign it had changed it — and the only way out was to find the row inside the sidebar. It is emphasised while it is on, says so in its label and its tooltip, and has an ✕ beside it to turn it off: the same pair the board’s own Multi-Selection has.
The Sort Boards popup is titled “Sort Boards”, from the key the app already has for that phrase. A title is what gives a pop-over its header, and the header is what carries the close button.
Its four controls stay in the header bar, and the hamburger that only led to them is gone. Thanks to xet7.
Sort, Search, Multi-Selection and Boards in Archive were rows of the right sidebar’s home view, so reaching any of them meant opening a panel over the boards first. They are icons in the first top header bar now, left of the notification bell — exactly where a board’s own controls are — each named by a tooltip.
That home view was the only thing the hamburger opened, so All Boards no longer offers one, nor the divider before it: a menu to reach what is already one click away is a step with nothing in it. The sidebar is still opened, by Search and by Multi-Selection, straight into their own view; and its home view is still what the back arrow of those views leads to. A board keeps its hamburger — what its sidebar holds, members and labels and activities and settings, is not in the bar and has nowhere else to be opened from.
Boards in Archive is drawn in both places now, and a Blaze event map only sees events inside its own template, so each copy has its own handler. A copy with markup and no map is a button that silently does nothing, which is what happened to that exact button once already; a guard now checks both.
One row of controls, in the header bar, and a Table view. Thanks to xet7.
The page carried two rows of controls: the second top header bar, holding only
the title, and a row of its own above the board icons — Multi-Selection with
its archive and duplicate actions, Sort and the search box — styled nothing
like the board header of the Swimlanes view. There is one row now, in the
header bar, with the same .board-header-btn buttons and the same Font Awesome
glyphs a board’s header uses: Starred, Sort, Search, Multi-Selection and the
view menu. The actions ON a selection stay beside the boards they act on, and
appear only while something is selected.
Search is a field, not a button, and it does what the old right-pane search did: it filters as you type, across every one of your boards rather than the section the left menu has selected, and Escape clears it. That is a deliberate difference from the board header, whose Search is a button: on a board, Search opens a whole search view over cards; here it filters the list it sits above, and a filter belongs in the bar it filters.
The view menu names the current view — Lists or Table, never the words “Board
View” — the way the board header says “Swimlanes” or “Lists”. Lists is the
default and is what an account that has never chosen sees. Table is the shared
table page, editable: Edit, Board title and Board description, ten rows a page,
the same boards the Lists view would draw. Edit opens the SAME
boardChangeTitlePopup the Swimlanes view opens rather than a copy of it, which
took one change to make true — its submit read Utils.getCurrentBoard(), and
on All Boards you are not looking at a board, so it now takes the board from
its own data context when it has one and falls back to the current board.
The view choice is remembered per browser, not on the user document: it is a
preference for one page and changes nothing anybody else can see. A board’s
view IS on the profile, because it follows the user between devices; this
deliberately does not. The design is written down in
docs/Design/Page/All-Boards.md.
The first header bar - the strip always on screen: what it says and carries.
Starred: bookmarks to any page, not only to boards. Thanks to xet7.
The star group in the first header bar is a bookmarks menu now: the caret that opens the list, the count of what is in it, and the star that says whether the thing you are looking at is among them.
It held boards only. A board could be starred and reached from the dropdown; every other page - All Boards / Remaining, a workspace, Admin Panel / Settings / Version - could not, so the one control for “keep this where I can get at it” worked on one kind of destination and was simply absent on the rest, even though those pages have had their own addresses since the All Boards and Admin Panel URLs landed.
The star on any other page now stars that page. A bookmark is a relative URL
and the title from the browser tab: relative so it survives the site moving to
another host, and an absolute or protocol-relative URL is refused rather than
cleaned up, because what is stored goes into an href the reader clicks.
The browser tab says where you are - Product name - All Boards / Remaining
- instead of only the product name. Ten open tabs of one WeKan were ten identical tabs, and a bookmark of any of them was named after the whole app rather than after the page. The path is the one the header bar already computes for the page title’s tooltip, published rather than worked out twice.
In All Boards / Starred the bookmarks are tiles beside the starred boards, with the white border the template-container tile carries, the theme’s own colour behind them, and both the title and the URL - the title says where it goes, the address says what it is. Each tile has its own unstar button, since the star in the header bar stars the page you are on.
Dragging a tile past another reorders them, and that is the order of the header dropdown: the two are views of one array. The move matches both ends by URL rather than by index, because the two views are rendered separately and an index from one of them is a guess about the other. The list is capped at 50, oldest dropped, because it is a dropdown.
Documented in Starred.
One star group, and every button whose icon does not say enough is named. Thanks to xet7.
The bar drew two stars in a row — one on the starred-boards dropdown and one for the board you are on — which read as a single control somehow drawn twice. The dropdown has no star now: it is a caret and the COUNT of starred boards, which is what it is about, and the board’s own star sits immediately beside it. The two are wrapped in a rounded outline of their own, shaped like the phone/desktop toggle’s but white where that one is black — the toggle is a white box on the bar, while these sit on the bar’s own colour.
The count is shown even at zero, because it is now the button’s only label; a bare caret says nothing about what it opens.
Clicking Filter or Search while the sidebar is already showing that view closes it. They only ever opened, so a second click did nothing visible and the only way back was the sidebar’s own ✕, somewhere else on screen from the thing you just clicked. Filter has one exception — not while a filter is ON, because the sidebar is then the one place that says what is being hidden from the board, and closing it would leave a board showing a subset of its cards with nothing to say so. Search has no such case: its results are inside the panel.
The starred-boards popup has a title, “Starred Boards”, and so has a header —
and the header is what carries the close button; without a title it rendered
with nothing to shut it but clicking away. The title reuses the key the app
already has for that phrase rather than a starredBoardsPopup-title of its own,
which would be a second copy of one phrase in all 147 language files, English in
every one at first.
Sort Cards, Filter, Search, Show Dependencies, the board’s visibility (Private or Public), its watch level (Watching, Tracking or Muted), both Multi-Selection buttons and the two view menus carry their name beside the icon where the bar has room for it — a tooltip is the one place a name cannot be read without hovering. Below 1100px every one of those labels goes and they are icons again: a label is worth several icons’ width, so on a narrow window keeping them costs more buttons off the first row than the names are worth. All of them together, not some, because half the buttons named and half not reads as a bar half finished — and which half you got would depend on which words happen to be short in your language. All Boards’ four controls — Sort Boards, Search, Multi-Selection and Boards in Archive — are named the same way. Each label uses the same translation key as its own tooltip, so the two cannot say different things. Six view glyphs are six things to learn, a check-box outline says nothing about multi-selection, and a tooltip is the one place a name cannot be read without hovering. The bar wraps to a second row when it runs out of width, which is what makes the word affordable. Sort, Search and Boards in Archive stay icons — those glyphs are well known.
It names the All Boards list you are on, and the workspace inside it. Thanks to xet7.
The bar said “All Boards” on the Starred list, on Templates, on Remaining and
inside every workspace — it named the page and not the list you were looking
at. It shows the path now: All Boards / Starred,
All Boards / Workspaces / Engineering / Backend.
The section’s words are the left menu’s own key, so the title and the row highlighted beside it always say the same thing. A workspace’s words are its NAME rather than the slug in the URL, and are printed as text rather than translated — a workspace name is what a person typed, and a workspace called “starred” is not the Starred section. A trail segment that names nothing stops the walk, so a stale link titles the part of the path that is still real instead of nothing at all.
The Admin Panel’s Admin Panel / Settings / Version and this are one list of
segments rather than a helper each: the two pages do not have the same number
of them, and a workspace has as many as its tree is deep.
It says where you are: Admin Panel / Settings / Version. Thanks to xet7.
The bar named the panel and stopped there. The Admin Panel is four pages and each page is a stack of panes, so “Admin Panel” named the building and not the room - and all four of its routes answered the same three words, whichever one was open.
The title now names the same three things the address does. The page’s words are the tab’s own and the pane’s words are the menu row’s own, so the title, the tab that is marked active and the row that opened the pane cannot say different things about one place.
The button of the page you are ON also keeps the hover background, permanently and a shade darker. The tabs had marked themselves active all along, but the only rule that drew it was scoped to the second header bar - which those tabs no longer live in - so the state had been computed and invisible since they moved.
It starts where the left menus start. Thanks to xet7.
The All Boards and Admin Panel left menus indent a row by 4px of row margin plus 18px of link padding, so a row’s icon lands 22px from the window edge. The first header bar sat flush against the edge, so its home icon started 10px in and every button under it was a little to the left of the menu below.
The bar now carries a 12px inline gutter: 12 + 4 + 6 = 22, the same x as the menu rows. It is on the BAR rather than on its first item because the bar wraps
- padding applies to every row it wraps onto, while a margin on the first item would indent the first row and leave the second starting at the edge. The number is a variable so it has one home, and the phone overrides use it too rather than putting the bar back against the edge at the widths where the alignment matters most.
The Admin Panel - where the settings live, and how they are addressed.
It moves under /admin, and its address names the pane you are on. Thanks to xet7.
Every left-menu entry of the Admin Panel had a URL, but the four pages sat at
the TOP level - /settings, /people, /attachments - as if they were pages
of the app rather than of the Admin Panel, and /attachments is also the path
the file server serves attachments from, so the panel and the files were
claiming one address. They are under /admin now:
/admin/settings/version, /admin/people/login, /admin/problems/database,
/admin/attachments/backup.
The DEFAULT pane is named too. It used to be left implicit in a bare page URL -
/settings rather than /settings/version - so the address of “Settings” and
the address of “Settings showing Version” were one string. The address is meant
to say where you are, and the first pane is somewhere too. The bare
/admin/settings still resolves; it redirects to /admin/settings/version
rather than being a second name for it.
Every path the panel used to answer on redirects, and a bookmarked
/settings/global-webhooks keeps its pane rather than landing on the top of
the panel. The redirects are built from the same map the URLs are, so a page
cannot be given an address without also being given its redirect.
Board views - the board itself: its swimlanes, lists and cards.
A swimlane, a list and a card can each be linked, and the link lands on the thing it names. Thanks to xet7.
A card has had an address since there has been a card route. A swimlane and a
list had none, so “the Backlog list of this board” could only be sent as “open
this board and scroll down” — and List.absoluteUrl() answered with the URL of
whichever CARD the cache returned first for that list, so the one place that did
offer a list link showed a card’s address, and an empty box for an empty list.
Both have their own now — /b/<board>/<slug>/swimlane/<id> and
/b/<board>/<slug>/list/<id>. Five segments against the card route’s four,
which is what keeps the three apart: a card URL cannot match these and these
cannot match a card.
Following one brings the thing into view. The route cannot scroll — it runs before the board has rendered, and on a board that is already open it runs without re-creating anything — so it names what to reveal and the board body reveals it once the element exists, waiting for it rather than assuming it, because a large board renders in more than one pass. It gives up after a few seconds instead of spinning: a link to a list that was archived names an element that is never going to exist, and the board is still the right place to have landed. The revealed swimlane or list gets a brief outline, because a scroll that lands mid-board gives no sign of which of the things now on screen the link was about.
All three are copied the same way, from the first row of the hamburger menu, with the link icon and the name beside it. The card carried this as an icon in its title header named only by a tooltip — the one place a name cannot be read without hovering — and that button is gone, along with its handler and the “Copied” tooltip only it used.
The copy row sits above every permission check in those menus: copying an address is reading, not editing, and somebody who may only read the board can still tell a colleague which list they mean.
Public Boards - the page a visitor sees without an account.
Its own read-only table page, not All Boards with a different query. Thanks to xet7.
/public rendered the All Boards page with its query swapped for { permission: 'public' }, which brought the whole of All Boards with it: the Starred /
Templates / Remaining menu counting the user’s own boards beside a grid that
was not, the workspaces tree, the org and team filters, Multi-Selection with its
archive and duplicate actions, the sort popup, board dragging, and an “Add
board” tile that made a private board from a page about public ones.
Multi-Selection offered to archive boards the visitor has no rights to at all.
It is only the table now: board title and board description, ten rows a page, paged and counted on the server. No left menu, no create, no selection, no drag, and nothing on the page changes anything — a row’s only action is to open its board. The rows carry their board’s colour and background image, so a board is recognised here the way it is on All Boards.
A page costs six fields for ten boards, not ten board documents: the two
columns, the slug the link needs, and the two the row is coloured with.
members is deliberately absent — it is the largest field on a busy board and
this page shows no avatars. The selector is built on the server and takes
nothing from the client: public, not archived, a real board rather than a
template container, and not an internal ^Subtasks^ board.
Not carried over from the board tile: member avatars, the per-list card counts and the spent-time clock. What a visitor needs of a board they do not belong to is what it is called and what it is for; the rest is the inside of a board they have not opened, and each costs a query the page would otherwise not make — the clock answers by looking for cards, which this page does not publish, so it would read false for every board on every instance.
The design is docs/Design/Page/Public.md, which describes only what is different about this page and links back to the shared Table page design for everything else.
Two things it got wrong on the way, both reported by xet7: the page drew its
own “Public boards” heading
under a header bar that already said “Public”, and a row’s text was white on
white — the row was given a
board-colour class, but boardColors.css styles .board-list .board-color-X a
and nothing on this page matches that selector. The seventeen colour rules name
the public row too now, and a board with no colour set falls back to a readable
default instead of to the page’s own background.
Board roles - what a member of a board may do.
One capability table, three roles fixed, and a Roles Status pane that shows it. Thanks to xet7.
What each role may do was spelled out THREE times — in the server allow helpers,
in the client’s canModify* helpers, and in prose in the docs — and the three
had drifted apart. Every place they disagreed was a role that did not do what
its name says. models/lib/boardRoleCapabilities.js is that table now, in code,
and everything reads it: the allow rules, the UI helpers, the new Admin Panel
pane and the documentation.
“Comment only, assigned” had full write access. Nothing outside the card publications read its flag and it was not in the write rule, so the role could create and edit cards, lists and checklists — it was “Normal, assigned only” under another name. It is comment-only now, like the role it is named after.
“No comments” could not write anything. The write rule excluded it, so the role blocked editing as well as commenting — a second read-only role under a name that says otherwise, and one the UI still offered the edit buttons for. It blocks commenting only now.
The write rule did not exempt board admins. Every other helper ignores a
flag on an admin; that one read the raw flags, so an admin who also carried
isNoComments silently lost write access. Not reachable from the Web UI, which
writes all eight flags at once, but reachable over the REST API.
The UI helpers were part of the same drift — canModifyCard() did not exclude
isNoComments while the server did, and canModifyBoard() excluded neither
isNoComments nor isWorker — so each disagreement was a button offered to
somebody whose write the server then refused.
A fourth gap was found and is NOT fixed: a Worker cannot move a card, which is the one thing the role is for. Moving a card is a card update, so it goes through the write rule, which excludes Worker. The fix means letting a role write some fields of a card and not others, and validating that a member change only ever adds the caller — a field-level policy on the path every card update takes, which wants deciding on purpose. It is recorded under “Known gaps” with what it needs.
Roles Status, at Admin Panel / People / Roles below the Save button: a read-only table of what each role may do. It is the shared table page, with no markup of its own, no interactive rows and nothing editable, because a role’s capabilities are a property of the code and not a setting. Every string is a translation key, the Yes/No of each cell included. The “Invite to board” column reads the pane’s working copy, so the table follows the checkboxes above the Save button as they are ticked, before saving.
and reorganises the following in the user interface:
All Boards - where its controls live, and what the page shows around them.
The actions on a selection move into the header bar with everything else. Thanks to xet7.
The header bar took the controls of the page, but the actions ON a selection —
Move Board to Archive, Duplicate Board, and the “Selected:” star and home —
were left where they were, in a strip above the board icons, on the reasoning
that they are about those boards rather than about the page. That still left
two places to look for a button. They are in the header bar now, to the right
of the controls that are always there, as .board-header-btn like every
control beside them, and .boards-path-header holds the section title and
nothing else.
They appear only while something is selected: four buttons that would do nothing are worse than no buttons. Archive and duplicate carry their names; star and home stay icon-only under the “Selected:” label that already named them, because their names are sentences — “Set as Home board (opened after login)” — that belong in a tooltip, and spelling them out pushes the bar onto a second row. The label stays a label, with neither the button class nor any button behaviour.
Two things had to move with the buttons, because Blaze binds both to a single
template: their four click handlers, which an event map on boardList could no
longer see, and hasBoardsSelected, which decides whether they are drawn. The
stylesheet lost .path-right, .selected-action and .selected-actions,
which now select nothing, and the phone media query lost the four rules that
flattened a controls row this page no longer has.
No Starred button in the bar, Multi-Selection after the view menu, a narrower search. Thanks to xet7.
Starred was the first button in the header bar, and Starred is a section:
the left menu already lists it beside Templates and Remaining, counts it, and
highlights it when it is the one shown. A second way to reach one section, one
click away from the first, is a control whose only job is to be kept in step
with the menu — so it is gone, and with it the header bar’s own
js-select-menu handler and isSelectedMenu helper, which had no other caller
there. The left menu is part of boardList and has always had its own.
Multi-Selection moved to the right of the Lists/Table menu. The bar now reads left to right as what is shown — Sort, Search, Lists/Table — and then what is selected: Multi-Selection, followed by the archive, duplicate, star and home actions that appear with it.
The search box is 150px wide, half of the 300px it was drawn at when it had a
card of its own. Getting there turned up that it had no styling at all: its
rules said .boards-path-header .board-search, the bar it used to live in, so
from the moment the controls moved to the header bar they matched nothing and
the box rendered at the browser’s default input size. They are
.all-boards-controls … now — and because a white box on a themed bar cannot
inherit that bar’s light-on-dark colour without putting white text in a white
box, the input, the magnifier and the ✕ each set their own.
The Selected star is a toggle now, and every button in the bar names itself. Thanks to xet7.
The “Selected: ★” button only ever added stars: it walked the selection and starred whatever was not starred yet. Once every selected board was starred it did nothing at all, so there was no way to undo from here what it had just done, and its tooltip read “Star the selected boards” whatever state the selection was in. It is a toggle now — none starred stars them all, all starred unstars them all, and a mixed selection stars the rest, leaving the starred alone. The mixed case deliberately does not flip each board independently: one click that starred some boards and un-starred others is not something a button may do. Only the boards that must change are called, because the server method flips one board and calling it for an already-starred one would un-star it.
The tooltip says which way the button goes right now, set-selected-starred or
the new set-selected-unstarred, and it reads the SAME function the click does
— two copies of “which way does this button go” would eventually be two
answers, and the tooltip would lie about what the click does. The rule itself
is models/lib/selectedStars.js: pure, so it is unit-tested rather than
reasoned about.
All four buttons on a selection are icon-only now, and they follow the “Selected:” label in the order star, home, archive, duplicate — the two that only mark a board first, the two that change what boards exist last. Archive and duplicate carried their names, which are sentences and pushed the bar onto a second row.
Checking that each button says what it is turned up one that did not: the ✕
that turns Multi-Selection off said “Clear filter”, which is what the other ✕
in this bar does, in the search box. It is multi-selection-off. A guard now
requires every .board-header-btn in the bar to carry a title, and every
title to come out of a translation key rather than being literal English.
Both new keys are in all 147 language files as English placeholders, which is what the translation policy does with a string that is untranslated everywhere, so no language silently loses a tooltip.
Search and Multi-Selection are the board header's own controls, opening a right sidebar. Thanks to xet7.
All Boards had a search field in its header bar, and a Multi-Selection
button whose actions were four icons beside it. A board has a search button
and a Multi-Selection button, and both open the right sidebar. Two pages, the
same two control names in the same place, behaving differently — and one of them
not the way the rest of WeKan does. They are the board’s now, and they are the
same markup: headerBarControls.jade holds one headerSearchButton and one
headerMultiSelectionButton, included by the board header of the Swimlanes view
and by the All Boards bar. The two copies had already drifted — the ✕ that turns
Multi-Selection off said “Clear filter” on the board header, which is what a
different control does, and says multi-selection-off in both now.
Only the markup is shared. What a click does is not — a board searches and
selects cards, All Boards searches and selects boards — and it does not need to
be: a Blaze event map catches events from the templates rendered inside it, so
each bar’s own map sees the clicks on its own copy. isActive is passed in,
because the two pages keep different selection objects.
All Boards has a right sidebar of its own. Not the board one: that is built
around a board’s members, labels, activities and settings, and this page has no
board. It borrows the shell — the same .board-sidebar.sidebar classes, the
same ✕, the same title-and-back-arrow above a view — so the two look and behave
alike, and it has three views. home, what the hamburger opens, is the page’s
menu: Search, Multi-Selection, and Boards in Archive, which had a handler in the
header bar and no way to reach it. search is the field that was in the bar,
still writing the page’s own search term so the boards behind it narrow as you
type. multiselection is where the actions on a selection went — star, home,
archive, duplicate and a way out — each with its name beside it instead of being
a crowded icon.
The bar also gained the divider and the hamburger, in their own flex item and last in the source exactly as the board header has them, which is what keeps the hamburger in the top right on a phone while the other buttons wrap.
Two things this turned up. A .jade file is not picked up by being on disk — it
has to be imported from client/features/, and the shared controls threw “No
such template: headerSearchButton” on render until they were; a guard now
requires every .jade under client/components to be imported and every
+template it includes to exist, and it found a dangling +subtaskDeleteDialog
that has never had a template (unreachable, so it has never thrown, and it is
recorded with that reason rather than hidden). And deriving a template name from
a view name gave allBoardsMultiselectionSidebar for
allBoardsMultiSelectionSidebar — one letter, renders nothing, no error worth
the name — so the names are an explicit map the guard can check.
The designs are Search and Multi-Selection, one per shared control, each covering both pages.
The white bar above the board icons is gone. Thanks to xet7.
Once every control had moved to the header bar, what was left above ”+ Add Board” was a white strip carrying one thing: a Font Awesome icon for whichever left-menu section is selected. Three things already say which section that is — the left menu highlights it, the header bar names the page, and the Starred control is emphasised while Starred is on — so the strip said nothing and cost a bar’s worth of height on every screen. The board icons start at the top of the right column now.
It took more with it than the markup. currentMenuPath, sixty-four lines that
resolved a workspace path to an icon and a name, had no other caller. Every
.boards-path-header rule went — the seventeen that styled the bar and its
contents, and the phone media query that had already been reduced to holding
the title. So did the pulse keyframes, whose only user was the
multi-selection hint that lived in the bar. The pager’s flex: 0 0 auto was
sharing a selector list with the bar, and is kept on its own: it is still the
fixed-height thing above the scrolling list.
A URL for every left-menu entry here too, workspaces included. Thanks to xet7.
/allboards/starred, /allboards/templates, /allboards/remaining,
/allboards/workspaces, and a workspace as deep as its tree goes:
/allboards/workspaces/engineering/front-end/design-system. The page was three
addresses and the workspaces tree had none at all — which workspace you had open
was a ReactiveVar, so it could not be linked or bookmarked either.
A workspace is addressed by the slugs of its names, not by its id: the id is
a random string and a URL should say where you are. The slugs come from
getSlug (limax), the same function that gives a board its slug, so a workspace
and a board turn a name into a URL the same way — including the scripts where a
naive slugifier returns an empty string. When a name slugifies to nothing anyway
— an emoji-only name — the node’s id stands in, because a workspace with no
address could not be opened from a link.
The route resolves the section and splits the path; the PAGE resolves the
workspace, in an autorun, once its tree has loaded. The router cannot: the tree
is on the user document, which it has no way to read before the page has it —
and a one-shot read would always run before the tree arrived and never select
anything. / stays the home and still shows Starred; /templates and
/remaining redirect to the new form.
One thing this had to fix rather than add: the page filters boards by membership only on the All Boards routes, by route name, and a route missing from that list falls through to the public-boards branch — it would have shown public boards instead of your own.
The Admin Panel - its own addresses.
A URL for every left-menu entry. Thanks to xet7.
The panel was four addresses — /setting, /people, /admin-reports,
/attachments — each opening whichever pane its page happened to open first.
Which pane you were looking at was ReactiveVar state and nothing else, so a pane
could not be linked to a colleague, bookmarked, opened in a second tab or
reached with the back button, and /setting always landed on Version even if
you had just been in Global Webhooks.
Every entry has its own now: /settings/visibility,
/settings/global-webhooks, /people/roles, /admin-reports/cpu,
/attachments/s3. Lowercase, words separated by -, and the default pane
keeps the bare page URL — /settings, not /settings/version — so there is one
address for “the Settings page” rather than two that show the same thing. The
Settings path is plural: /setting was the odd one out beside /people,
/attachments and /admin-reports, and it still resolves, as a redirect.
The slug is not derived from the pane id. The ids are internal and read like
it — tableVisibilityMode-setting, layout-setting, report-cpu — while a URL
is something a person types and pastes into a chat, and a name derived from
another name is wrong the moment the two spellings differ. So it is an explicit
map, and the guard checks it against the real menus in BOTH directions: every
slug names a pane the page has, and every menu entry has a slug. Neither failure
shows up until somebody clicks that row. A slug that is not one falls back to
the page’s default rather than rendering an empty panel, because a URL is typed.
/information and /translation redirected to /setting and handed their pane
over in a Session value the page consumed once. They redirect to the pane’s
own address now.
The first header bar - what it stopped carrying.
The 100% zoom control is gone. Thanks to xet7.
It scaled the board with a CSS transform, it did not work, and there is no plan to fix it — WeKan already has a font-size setting, which is what the control was reached for anyway. A control that does nothing is worse than no control.
What went with it: the pill and its number, the helper and three handlers, the
zoom utilities and their call sites, the profile.zoomLevel schema field and
its setter and server method, one translation string, and 57 stylesheet rules
spread over four files — which is how much of the stylesheet a broken feature
had accumulated. The card zoom is a different feature and is untouched.
Member Settings - the per-user panes.
Change Password's button is themed, like the Save button beside it. Thanks to xet7.
Change Password draws the useraccounts form, and its submit button carries that package’s own classes rather than any of ours - so it fell back to the plain grey button, while the Save button one entry above it in the same menu was painted with the theme accent. Two buttons, one menu, two looks.
It is named in the same rules as every other primary button rather than given a copy of them, so the accent and the hover and active states keep one home, and it is scoped to a popup: the login page styles that form its own way and is not what this is about.
Change Color shows as many swatch columns as the width allows. Thanks to xet7.
The swatch list is shared with the board-background picker, where it is a float-based two-column grid. Two columns is right for background thumbnails; for Select Color it meant Flat, Clear, Dark and Special each ran down the popup in a narrow pair, and most of them were below the fold however wide the browser was.
Auto-filling columns instead — the same answer the Change Language popup already uses. It takes as many columns as fit and collapses to one on a narrow window, so no media query is needed and a phone is unaffected. Both Change Color popups, Member Settings and Board Settings, get more width on desktop to spend on columns; below 800px every popup is a full-screen sheet and is left alone. The width is mirrored in the popup positioning code, which clamps a popup into the viewport by its width — computed for the default it placed a wide popup opened near the right edge with a third of itself off the screen.
Change Settings: the button is Save, and it has a theme. Thanks to xet7.
It said “Apply” while every other settings form in WeKan says Save, and it was
pure black. Both came from one line: the submit carried no primary class, so
it fell to the base button rule — whose fallback is black — instead of the
primary rule beside it, which is the themed one. The Change Language form
directly above it in the same popup is the shape it now matches.
and updates the following dependencies:
- aldeed:collection2 4.2.0 → 4.2.1 — cleans and validates every write against a collection’s SimpleSchema, so it is on the path of every insert and update WeKan makes. Update. Thanks to xet7.
- Meteor 3.5 → 3.5.1-beta.0 — the framework WeKan is built on, and with it the build system, the MongoDB driver and the accounts packages that ship as part of the release. Update. Thanks to Meteor developers and xet7.
- meteor-node-stubs fork — the Node core-module shims the browser bundle is
built against, forked into
npm-packages/meteor-node-stubs. Update. Thanks to xet7. - @meteorjs/rspack 2.0.1 → 2.1.0-beta.0 — the rspack bundler Meteor builds
the client with, the counterpart of the 3.5.1-beta.0 release above. It moved
to rimraf 6, whose glob 13 no longer ships a command line of its own, so
twenty-seven packages leave the lockfile with it (975 → 948) without anything
being dropped from WeKan itself.
archiverandunzipperalso move into alphabetical order inpackage.json, which changes nothing that is installed. Update. Thanks to developers of dependencies and xet7.
and fixes the following bugs:
The first header bar - how it lays itself out, and what sits under it.
The Notifications panel starts below the bar, so the avatar is not over it. Thanks to xet7.
The panel is fixed at 48px from the top - a guess at the height of one header bar. The bar wraps to a second and a third row, and the user avatar is the item that wraps last: on a window where it did, the panel covered the row the avatar was on, and the avatar - which is inside a bar that paints above it - landed straight on the panel’s own header, beside the ✕ that closes it.
It starts at the height the header MEASURES itself to be now, kept current by a
ResizeObserver because a bar re-wrapping is not a window resize. The panel’s
own fixed header - the row the ✕ is in - follows the same number, and so does
the height it may take: the 100vh - 28px - 36px it had was the same guess
written as a subtraction in two pieces. That measurement is what everything else
laid out against the viewport already uses, so this is the panel joining them
rather than a new mechanism.
The All Boards house starts at the same X at every window width. Thanks to xet7.
The house at the start of the bar sat further in on a wide window than on a narrow one, and on none of them on the line the left menu’s rows below it start on.
Its inset is a SUM, and two of the terms were added at some widths only. The
header carried side padding of its own - 8px below 800px, 16px between 768 and
1024, none between 1024 and 1920, 8px above 1920: four widths, four insets. And
.allBoards is the SAME element as the house’s own .home-icon, so its side
padding lands between that icon’s margin and the link - and it was 15px on a
desktop against 6px on a phone.
Neither adds anything sideways now, and the link’s own start padding is the same 6px in every rule, phone rules included: a bigger tap target on a small screen grows at the END, not at the inset. So the house is at 12px of the bar’s own gutter plus 4px of the icon’s margin plus 6px of the link’s padding = 22px at every width - the same 22px the left menu’s rows are indented by, so the two are one line down the page.
The existing guard added up the three terms it knew about, got 22, and passed while the house still moved. The new one pins the other side of it: no rule, at any width, may add a side inset of its own.
It fills each row before starting the next one. Thanks to xet7.
The bar wraps when its buttons do not fit, but everything after the drag-handles toggle was wrapped in one group — and a nested flex box is a single item to the bar, so the whole group moved to the second row together. The first row ended halfway across with nothing in the rest of it while the second row was crowded.
The group generates no box now, so its buttons are items of the bar itself and wrap one at a time: the second row takes only what did not fit on the first. The push that keeps them at the end of the bar moves to the group’s first child, and on a row that wrapped there is no free space for it to absorb, so those items pack from the start — which is what fills the row rather than stranding it.
It wraps to a second row instead of hiding the buttons that do not fit. Thanks to xet7.
The bar was one row with overflow: hidden, so a button that did not fit was
not drawn at all - and a button that is not drawn gives no sign that it exists.
It wraps now, and every height on it is a minimum, including the four phone
rules that pinned 40px or 48px: a fixed height cannot hold two rows, so it
would have cut off exactly what the wrap was for. A phone is where the buttons
run out of room first, which makes it the last place that should hide them.
Everything after the drag-handles toggle hugs the end of the bar from one
margin-inline-start: auto, a LOGICAL property, so a right-to-left language
mirrors it by itself rather than needing a second rule kept in step.
Both view menus lost their visible labels - the board’s said “Swimlanes” and All Boards’ said “Lists” - and name their view in a tooltip instead, where the other buttons of that bar already keep theirs. A divider after the notification bell separates what belongs to the page from what belongs to you.
The right sidebar starts below it again, on every page that has no second bar. Thanks to xet7.
--wekan-header-height is what anything laid out against the viewport starts
below, and it measured #header - the SECOND header bar - from when every page
had one. Most pages have none now, their title being in the first bar and their
controls in a sidebar, so on those the variable was 0 and the All Boards
sidebar covered the bar above it.
It measures both bars, as the bottom of the lowest one rather than a sum of heights, so any margin between them counts and a bar that is absent contributes nothing without needing a special case. Each bar is watched by its own ResizeObserver, because the first one wrapping to a second row is a resize of that element and of nothing else.
Sidebar buttons are no longer drawn under the close button either: the ✕ is positioned absolutely, so it contributes no height and the row holding it collapsed to its padding.
All Boards - the overview and its search.
The selected workspace shows its board count again. Thanks to xet7.
The count was there, at the right of the ⋯ menu where it belongs, and it was invisible.
The theme accent fills the icon and the name of the selected row, not the whole row: the menu button and the count sit after it, on the panel’s own light grey. A rule gave the count a light pill with white text “for contrast against the filled row” - but the count is not on the filled row, so it was white on light grey. The selected workspace was the one row whose board count could not be read, and it is the row you have just asked which boards are in.
The rule is gone rather than re-tinted, because there is no accent behind the count to contrast with: it keeps the same grey pill every other row has. The count and the menu button also hold their size now, so a long workspace name ellipses itself instead of squeezing them off the row.
A workspace row obeys the drag-handles toggle, and drags without one. Thanks to xet7.
The workspace rows in the left menu drew a ✥ handle whatever the Show desktop drag handles toggle in the first header bar said - and the handle was decoration: the whole ROW was draggable, so the drag started anywhere on it. A row that is draggable as a whole starts a drag on the way to a click, and a workspace row is a row you click, because that is how a workspace is opened.
The handle follows the toggle now, through the same helper the board tiles beside it use, so the two cannot drift apart. With handles on, the handle is drawn and is the only place a workspace drag may start. With handles off there is no handle and the workspace’s icon and name is what reorders it.
draggable lives on the handle or on that icon-and-name anchor, never on the
row - the row also holds the ⋯ menu and the board count, and a drag started on
those is a drag of something else - and the dragstart handler stays on the row
because the event bubbles up from whichever child started it, so the reordering
itself is untouched. Clicking the anchor still opens the workspace: a click and
a drag are two gestures on one element, as they are on a board tile with handles
off.
Two things make that drag real rather than declared, and both are easy to leave
out. The anchor is opted out of the page-level dragscroll, which would
otherwise take the mousedown so the drag never begins - which is exactly what
“reordering does not work” looks like. And it carries user-select: none while
it is the drag source, because the name is TEXT: a press-and-move over
selectable text starts a selection, and the browser owns the gesture from
there. Everything that changes with the toggle follows the draggable attribute
itself rather than a second class, so there is one answer to “is this the drag
source right now” instead of two that can disagree.
Its view menu opens a titled popup, like the board's own. Thanks to xet7.
The Lists/Table dropdown in the first header bar opened a bare list of two links, while the board’s Swimlanes/Lists dropdown - which asks the same question about the same kind of page - opens with Board View above it and a close ✕ beside it. A popup with no title renders no header at all, so the two read as two different kinds of control.
It is titled now, with the BOARD’s own translation. The convention is
<popupName>-title, which here would mean a second key saying the same two
words - and a new key starts as English in all 142 language files, so most
languages would have shown English for a phrase they have translated for years.
Archive opens the section of this page, not the full-width page it replaced. Thanks to xet7.
Member Settings / Archive went to /archive, and Boards in Archive is a
section of All Boards now: a row in its left menu, drawn beside it like
Starred and Remaining. That page is the thing the section replaced. Landing on
it meant the same list of boards with no menu beside it, no way across to
another section without going back first, and the menu row that says Archive was
not the row you had arrived at. A menu entry should land you on the same Archive
the menu itself offers.
All four entry points are the same line in a different menu - the board menu,
the member menu, the board sidebar and the All Boards sidebar - so all four go
to the section now, through the URL helper rather than a path spelled out in
four places that can drift apart. The member menu also closes itself behind the
click, like every other entry in it that navigates. /archive is still a route
and still renders, so a bookmark from before does not break.
It no longer throws No such function: isAllBoardsView as it renders. Thanks to xet7.
The All Boards page chooses between the board icons and the Table view with
{{#if isAllBoardsView 'table'}}, and that helper was registered on
boardListHeaderBar and on allBoardsViewPopup but not on boardList — the
template that actually asks. A Blaze helper belongs to the template it is
registered on, so the page threw as soon as the router rendered it, right after
login, and All Boards did not come up at all.
Nothing noticed, because the guard read the jade and the JavaScript as two
separate files: it checked that the controls are in the header bar and that the
Table branch calls +tablePage, never that the template asking a question has
the helper that answers it. It now collects every helper this file registers,
and for each template in the jade every helper it uses that this file defines
must be registered on THAT template. Only helpers the file itself defines are
checked — a name it registers nowhere is a model helper on the data context,
like colorClass on a board, and a guard cannot tell one of those from a typo.
The other four templates in the file were clean.
Search All Boards searches all of your boards, not Public Boards anymore. Thanks to xet7.
The board scope listed the ways a user reaches a board — member, organization,
team, e-mail domain — and { permission: 'public' }. That last one is the odd
one out: it is not a relationship to the user at all, it is “anybody may open
this”.
That belongs in the boards list, where a public board is meant to be discoverable. In a search it meant every public board on the instance was searched: on a public server a common word answered with strangers’ cards, and following a hit dropped the user into a board they have no part in. Someone who wants to look inside a public board can still open it and search there.
The option defaults to including public boards, so every other caller — the
boards list, the lists and comments lookups — is unchanged. The search names its
scope once and passes it to all four board lookups plus the board: filter’s
name resolution: one missed lookup and that branch still reaches the whole
instance, with nothing looking wrong.
The left menus - the one menu All Boards and the Admin Panel share.
The caret that folds the menu away does something when clicked. Thanks to xet7.
The caret at the top of the left menu - on All Boards and in the Admin Panel - rendered, pointed down, and did nothing at all when clicked.
Its template draws the caret; the click that folds the menu, and the helper that
says whether it is folded, live in a .js file beside it that nothing imported.
package.json sets meteor.mainModule, so the client is not eagerly
loaded: a file nobody imports is not in the bundle at all. The click handler was
never registered, so the caret was a dead control - and an unregistered Blaze
helper is undefined, so the menu never took the collapsed class either, which
is why even the caret itself never turned to point right.
One import fixes it. The new guard is what stops it happening a third time - it
had already happened to the Admin Panel reports’ stylesheet: a test walks the
import graph from the client’s entry point and pins that every file under
client/components which REGISTERS something with Blaze - a template’s events,
helpers or lifecycle, a global helper, a BlazeComponent - is reachable from
it, and that every stylesheet and template beside them is too. A file that only
exports helpers is left alone: whoever uses it pulls it in.
Board views - filtering a board, and who sees which cards.
A filter no longer leaves a spinner over an empty list, and a half-arrived card is not drawn blank. Thanks to xet7.
Two problems from one report with screenshots, both in how a list decides what to draw at its bottom edge.
“Once a filter is applied for a member, the 3 dots continue to animate.” The screenshot shows a list with no cards under the filter and the load-more spinner still turning under it — and the scroll handler raising the window limit by ten every idle callback for as long as it stayed on screen.
The spinner asked “does this list hold more cards than the window I asked for?”
and answered it from a total built somewhere else than the cards being drawn.
The cards come from the filtered selector; the total came either from the list’s
own card count or, in lazy card-loading mode, from a count document published
per list/swimlane — whose id was listId::swimlaneId and did not include
the filter. So changing the filter re-subscribed with a new selector under the
SAME document id, two publications wrote one document, and Meteor’s merge box
serves whichever subscription it prefers — which during the changeover is the
older, pre-filter one. The list drew its empty filtered window while its count
still described the unfiltered list.
The count document now carries a short stable key for its selector, so each filter counts into its own document and a count can never describe another filter’s cards. And the spinner no longer depends on that being right: there is nothing more to fetch unless the window came back FULL. If we asked for twenty cards and got three, those three are all there are, whatever any count says — which holds in both card-loading modes and for a stale count in either direction.
“Random blank cards are appearing on the board.” White minicards with the handle icon and nothing else — no title, no members, no labels — mixed in among the real ones.
A minicard renders blank when its document is in minimongo without its fields:
getTitle() returns null for an undefined title, and every badge is
conditional on a field that is not there either. That is not a card with an
empty title — the schema declares title as optional with defaultValue: '',
so a card that went through it has the key, '' at worst. A document without
the key at all is a partial replication, and several publications ship cards
with a projection (openCardData publishes { _id: 1 } as the parent of its
children cursors, the search publications ship their own field lists), while
minimongo merges what every live publication says about an id.
Which of them produced these particular stubs is not settled — it needs the running board to catch — so this is a guard where the card is drawn rather than a fix at the source: a document that does not carry the field the minicard is built around is not drawn. It cannot hide a real card, and when the full document arrives the card appears, which is what the blank box was standing in for anyway.
Clicking outside the filter panel closes it. Thanks to xet7.
“If I use any filter, the modal that appears on screen sometimes doesn’t disappear. Ideally, this should close the moment I click anything outside the modal.”
It is the board sidebar showing its filter view, and nothing dismissed it but
the sidebar’s own toggle or Escape. The reason is one argument: the document
click handler runs clickExecute(evt.target, 'multiselection'), and
sidebarView sits below multiselection in the escape hierarchy, so the loop
returns before ever reaching it. A click could not close the sidebar by design.
The handler lives in the filter template rather than raising that limit, because raising it would make every sidebar view close on any outside click — Archive, Settings and Card Settings are panels people work beside on purpose, and only the filter reads as a thing you open, use and are done with. Escape is untouched: it still returns the sidebar to its default view.
Three clicks deliberately do not close it. Inside the panel, obviously. A pop-over the panel opened — the label, member and due-date pickers render outside the sidebar, so without this, choosing a value in one would close the panel behind it. And the header button that opens the filter, which would otherwise toggle it shut in the same gesture that opened it. The handler is bound on the next tick so the opening click cannot reach the handler it just created, and it is removed by name when the panel goes, so it can never outlive it and close the sidebar under some later view.
The sidebar is hidden, not reset, so reopening it comes back to the filter you were using.
An assigned-only member sees only their own cards on a big board too. Thanks to xet7.
Three board-member flags mean the same thing — isReadAssignedOnly,
isNormalAssignedOnly, isCommentAssignedOnly — the member may only see the
cards they are assigned to. The board publication has always narrowed its card
cursor for such a member. boardCardsWindow, which is what ships the cards in
LAZY card-loading mode, did not.
So whether the restriction applied at all depended on the board’s card-loading
mode. The same member saw only their own cards on a small board and every card
in the window on a big one (or on any board with CARDS_LOADING=lazy) — and
with the cards went their comments, attachments, checklists and checklist items,
because the window’s children hang off the same selector. The restriction is
part of the window scope now, and of its count: an unrestricted count still told
the member how many cards the list really holds, and offered to scroll in cards
that would never arrive.
Two things this had to get right. The field projection: publish-composite hands
each child the document as the parent cursor published it, and that cursor
projected to { _id: 1 } — so board.members was undefined in every child and
the restriction would have been dead code. The parent publishes members now,
which also makes it reactive, and the board publication already ships them to
the same client.
And the merge. The board scope is spread into the client’s selector at the top
level because FerretDB v1 (SQLite) does not push a top-level $and down to its
index — the wrapped form full-scanned the whole cards table on every poll and
cards never loaded on a big board. But a top-level spread can only be used when
the two selectors do not both speak for the same key, and the board Filter has
an assignee filter, so that collision is reachable from the UI: in the direction
the publication spreads them, the client’s value would have won and the
restriction would have been silently dropped. The guard covered
boardId/archived only; it is mergeCardScope now, which merges when the
keys are disjoint and falls back to $and — where both hold — when they are
not. An assigned-only member filtering for someone else gets nothing rather than
everything, and an unrestricted member keeps the fast path.
Public Boards - what it lists.
The internal Subtasks boards are no longer listed. Thanks to xet7.
WeKan creates boards of its own to hold machinery — a subtasks board, for one.
Nobody chose to make one and nobody means to open one, so no list of boards
shows them; they are recognised by their title being wrapped in carets,
^Subtasks^.
The selector for that was typed out at each list — five copies of the same regular expression — and the sixth place forgot it: /public built its own query and listed every public subtasks board on the instance beside the real ones. It is one shared helper now, used by every list including /public. A function rather than a shared constant, because Mongo selectors get merged and mutated by their callers.
and improves the following developer tooling:
build.sh installs Node on macOS with nvm, and installs the newest 24.x. Thanks to xet7.
“Install WeKan dependencies” ran brew install node@24, which gives whatever
24.x Homebrew currently has bottled rather than what nodejs.org has released,
and which is keg-only — so the branch also wrote PATH, LDFLAGS and
CPPFLAGS for it into ~/.zshrc by hand. It is nvm install 24 now: the
major alone, so it resolves to the newest 24.x every time it runs and never
needs bumping, and nvm puts it on PATH itself. npm comes with the Node it
installs, so brew install npm is gone too.
The nvm installer is fetched from a pinned release tag rather than master,
because the line pipes a downloaded script into a shell, and nvm.sh is
sourced before the first nvm call — nvm is a shell function, not a binary, so
without that every call is “command not found” even straight after a successful
install. An NVM_DIR the caller has already set is honoured, and a failed
install is reported instead of run into.
Two things the old branch did are now actively undone. npm config set prefix '~/.npm' cannot coexist with nvm: it overrides the per-version prefix, global
installs land outside the Node they were installed for, and nvm refuses to
switch versions while it is set — so the branch clears it, which a machine that
ran the Homebrew path needs. And that prefix directory was created with a
quoted tilde, mkdir "~/.npm", which makes a directory literally named ~ in
whatever directory build.sh was run from.
Linux is untouched and still installs Node with n, which the guard pins so
the split stays deliberate.
The notifications spec looks for the header bar that exists. Thanks to xet7.
One browser test waited for header, #header to be visible on the page of the
user who had just been mentioned. The first header bar was rebuilt this release
and there is no <header> element and no #header id any more - the bar is
#header-quick-access, which two other specs already address it by. The
locator matched nothing, so the test asserted that a non-existent element was
visible, and failed in all three browsers.
The guard is what changed, not the app: the bar was deliberately rebuilt. It names the bar that is there now, and says so in the test for the next reader. It also asserts the BELL inside that bar, which is what the test is about - the count beside it arrives asynchronously, so asserting the count would be timing rather than behaviour, but a notification the user cannot see the bell for is not a notification.
and documents the following:
What each board role may and may not do, as one table, read from the code. Thanks to xet7.
There was no comparison of the roles anywhere. Members.md listed three of them — Admin, Normal, Comment only — in one line each, and there are nine: board admin, normal, no comments, comment only, worker, read only, and an assigned-only variant of normal, comment-only and read-only. The API page shows how to set each flag without saying what any of them does.
Board
roles
is the table: for every role, which cards it sees, whether it may comment,
create or edit cards, move cards, edit lists and swimlanes, and change the
board’s settings and members. It says where each column comes from, because the
answer is only two helpers in server/lib/utils.js plus isBoardAdmin() and,
for visibility, the assigned-only scope in the card publications. It is what the
SERVER allows, because the server is the authority and the UI can only hide
buttons.
Reading the code to write it turned up three roles that do not do what their name says. They are recorded as gaps rather than fixed, because each needs a decision about which side is wrong. “Comment only, assigned” has full write access — nothing outside the card publications ever reads that flag, so the role is in practice “Normal, but only sees my cards”, which another role already means. “No comments” cannot write anything — the write helper excludes it, so the role blocks editing as well as commenting, while the schema calls it “not allowed to make comments” and the UI offers the edit affordances anyway. The write helper does not exempt board admins — every other helper ignores a flag on an admin; that one reads the raw flags, which the REST API can set individually. A fourth section lists the buttons the UI offers that the server then refuses.
A test keeps the page honest rather than trusting it — a permissions table that quietly goes stale is worse than none, because it is what an admin decides who to trust with. It parses the table and checks that every role the code can return has a row naming a flag it really reads, that the “create / edit” and “comment” columns match the flag lists in the two server helpers, that “which cards they see” matches the assigned-only scope, and that each gap it marks is still real and still explained — so fixing one has to update the page with it.
and improves the translations:
The Roles Status strings are translated into 111 languages. Thanks to xet7.
The eleven strings the Roles Status table is built from were new, so they were
untranslated everywhere — on Transifex and in git — and every language showed
them in English. They are filled directly, per language, from that language’s
own existing translations and its usual kanban vocabulary, with
fill-translations.mjs --apply, which writes only into keys that are still
English placeholders. A filled string can never overwrite a human translation
and is never pushed to Transifex, so it cannot masquerade as one there. The
thirty-one languages that have no translator at all keep the English source, as
they did before.
The left menu's drag-to-resize tooltip is translated into every language. Thanks to xet7.
The grip on the left menu’s inner edge got its tooltip this release, so its string was English in all 142 languages. It is translated directly - no external translation service, API or key - from each language’s OWN existing strings: that language’s “Drag to resize sidebar” sentence gives the pattern and the verb, and its own words for “left” and “menu” replace the sidebar, so the two controls read as the pair they are.
Applied with fill-translations.mjs, which writes only into keys that are still
English placeholders, so a human translation cannot be overwritten by it, and
filled strings are never pushed to Transifex and so cannot masquerade as human
ones there.
- Newest translations from Transifex. Thanks to translators and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.