The home surface — apps launch, spaces list

The user home has exactly two regions and one rule between them.

Region What it lists Where it comes from
Apps What you launch — a plugin, a course, a hub, Threads the viewer's own {owner}/_App/* records, one partition-local query
Content The spaces you can reach, plus your own home items namespace: … nodeType:Spacenamespace:{owner} … is:content

Nothing belongs to both regions. An app that also lists as content says the same thing twice, in two visual languages, and the reader has to work out that they are the same thing.

The root level of the content list is an ALLOW-list

UserActivityLayoutAreas's root leg is nodeType:Space — nothing else. It used to be a deny-list (-nodeType:Store/Plugin -nodeType:Store/Catalog -nodeType:User) and the difference is not cosmetic: a deny-list is only ever as complete as the last person who remembered to extend it, and the home is precisely the surface that cannot know what someone will install next.

Measured on memex.meshweaver.cloud, 2026-08-30: the home listed Posts under a "Posts Hubs" heading and Event under "Event Hubs". Both are partition roots of perfectly ordinary NodeTypes (SocialMedia/PostsHub, Marketing/EventHub) that nobody had thought to add to the list. With an allow-list a new root NodeType has to opt in, and the failure mode of forgetting is "my thing is not on the home page" rather than "everyone's home page grew a section".

A hub is not an exception to the rule — it is the rule. A publishing hub is something you launch, so it gets an app tile ({viewer}/_App/{id} with content.plugin naming the hub) and stays off the content list like every other app.

The own-partition leg is NOT type-filtered — the NODE opts out instead

Your home partition contains no Spaces at all, so filtering that leg by type would empty the list of everything you ever authored. What keeps app chrome out of it is a property of the node: ExcludeFromContext: ["content"], which is:content honours at query time.

An install lands a workspace root at {viewer}/{packageId}AppleMaps — my workspace, ThinkInStreams — my workspace. That node is the landing page of an app whose tile is on the band directly above it. Localizer.BuildHomeRoot stamps the opt-out; Localizer.ReconcileHomeRoot heals one an install touches again; AppTileRefresh.HomeRootHeals reaches the roots no install will ever touch again, on the store render per viewer and mesh-wide under RefreshAppTiles. One viewer's home listed forty of them before that landed.

🚨 A root is a root because the viewer's own INSTALL MANIFEST says the package landed there — the recorded installedPath, else the standard target. Never because of how the node is named. A node you created yourself is untouched however closely it resembles the pattern.

A tile's name and icon are COPIES, so they converge

The Apps grid paints from query rows alone — MeshNode.Name and MeshNode.Icon, no per-tile hub activation and no content read. That is what makes it fast, and it means the tile's display identity is a machine-stamped copy of its package's, stored on the record.

A copy that no longer matches its source is exactly what a migration exists to fix, so AppTileRefresh converges both fields to the package's, with one explicit escape hatch each:

field converges to the viewer's own is kept when
Icon Localizer.HomeRootIcon(package.Icon) content.customIcon == true
Name package.Name content.customName == true

The escape hatch is always declared, never inferred. "It looks hand-edited" is not a signal a migration can act on.

Two things this must not do, both learned the hard way:

The accretion, and why the fallback strips

CourseAppTile reads its base back from tileBaseName and falls back to the record's current name only when that key is missing. That fallback is right exactly once — the first stamp. If the key is ever lost (a writer replaces the content object without round-tripping it), the next stamp reads an already-stamped name as its base and appends to it, and every stamp after that appends again. On memex one tile had reached:

AdvancedBusinessRules · 1/17 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14 · 1/14

CourseAppTile.Unstamped therefore strips whole trailing · n/m groups in that fallback: a lost base must cost at most the tail it can no longer explain, never a longer name. It strips only whole groups, so no ordinary name is touched; a package legitimately named Fractions · 1/2 loses its tail only in the window before a store render, because AppTileRefresh.Renamed writes the base from the package's own name and is authoritative over the heuristic.

The Apps band is a FILTER over one query — categories are a dimension, not a place

The band has had three shapes in three days, and the two it discarded say what the rule is.

Shape Why it went
Every group's tiles at once, section under section With a dozen groups it is a wall of icons and no overview.
An overview of FOLDER tiles you click into (2026-09-14) A launcher that makes you navigate before you can launch. Every app became two clicks, and the overview was a place to get lost in.
One row of chrome over one grid (2026-09-15) The grid always shows apps. The chrome says WHICH apps and HOW MUCH of each.

The chrome is one row, beside the title: a filter combobox, a filter box, and a view switch.

Filter Shows
Recent The apps this viewer actually opened, most recent first — the ONE slice whose order is the access log's rather than the arrangement's.
Favorites The apps they marked (User.PinnedPaths — the same pin the rest of the product uses, so a favorite app is a pinned node).
All Everything, grouped by category with its headings — the sectioned grid, which is also the only place a tile can be dragged BETWEEN categories.
one entry per category That category's apps, heading dropped (the combobox already names it).
a declared scope A different QUERY the control declared (Spaces), switched through SelectScope like any other scope.

Each entry carries its count, so an empty slice says so before it is picked, and every empty state names the way out of it ("Nothing opened yet — pick All to see every app").

Three views, one surface: Icons (the phone grid, and the only rearrangeable one), Cards (icon, name, the package's own description) and Rows (the same on one full-width line each). The filter and the view are remembered per launcher in the browser — localStorage, keyed by the band's query, so two users on one browser never share a memory — and read on first render BEFORE the result source starts, so the viewer lands on their slice rather than on the default and then a jump.

🚨 Every control here slices rows the band's ONE query already returned. The filter box is a filter, not a search: it matches every typed word against the row's name and description and never becomes a query. The only thing that re-queries is picking a declared scope. That is the two-stage rule holding (see apps-load-two-stage): one anchored, contentless query paints the grid, and the access log, the marks and the click targets all arrive as second passes that re-project in place.

Drag-and-drop is alive only where the sections ARE the arrangement — All, one category, or a declared scope. Under Recent or Favorites a drop would write a position the view does not paint, so the grid is not rearrangeable there; the tile's right-click menu moves it instead.

The ⋯ menu is the category's second hand — and the discoverable one

A tile's menu offers: open it, mark it, file it under any category the viewer's apps carry, a new category, or no category. Every one of those writes through the SAME arrangement path a drop uses (TileArrangement.Movegroup, order, customGroup), so the two cannot drift apart. It works in every view, which is what makes the categories usable where dragging is not — cards, rows, a phone.

🚨 It opens from a visible ⋯ button as well as from right-click, and the button is the one that matters. A right-click menu is invisible: nobody discovers it, a touch screen has no right-click at all, and a keyboard cannot reach it — so the only way to re-file an app would have been a gesture most viewers never try. The ⋯ appears on hover or focus on a pointer device and is always visible where there is no hover, which is the Microsoft 365 apps page's affordance for the same job (Roland, 2026-09-15: "get inspiration from apps screen in m365"). The same page is why a card lifts on hover, clamps its description to two lines, and carries its category as a chip — under Recent or Favorites the sections have no headings, so the chip is the only thing that says where an app lives.

A tile's GROUP is a stamped copy too — so it converges like the name and icon

The Store stamps the package's category as the tile's starting group. Until 2026-09-14 the heal filled only a MISSING group, so re-categorizing a package reached future installs and nobody who already had the tile. The group now follows the same rule as the other stamped copies:

field converges to the viewer's own is kept when
Icon Localizer.HomeRootIcon(package.Icon) content.customIcon == true
Name package.Name content.customName == true
Description package.Description never — see below
Group package.Category content.customGroup == true; or the group is "" (a deliberate ungrouping); or the group differs from content.stampedGroup; or — with no stamp — the group names no category any package declares

Two markers, because the viewer's choice must be recognisable BOTH ways round:

Description has no escape hatch, deliberately: no surface anywhere lets a viewer write a tile description, so every value it can hold is a machine-stamped copy of the package's and converging it can undo nobody's choice. It exists because the card and row views paint it off the query ROW — the grid never reads content — so a record without one advertises the app as "Ask the agent to create a description".

A record that already matches is not written (the mesh-wide RefreshAppTiles sweep must report zero for a converged mesh).

Every package declares a CATEGORY and a DESCRIPTION — and the taxonomy is one file

Both are dimensions of a surface, not decoration, and both are now PR gates (validate-repos.pycheck_package_categories):

The taxonomy is ONE authored list: Store/Catalog/Source/StoreCategories.cs. The store reads it for a section's accent and order; validate-repos.py parses the SAME file (one entry per line, which is why the table's shape is load-bearing and pinned by its --self-test) and refuses a package whose category is not in it — or is in it with different capitalisation, since the literal string is the key. A satellite repo carries no Store, so there the check degrades to "declare SOME category" and says so.

🚨 A category name is data that is already written down elsewhere — in every package's index.json and in every viewer's installed-app records. Renaming one in the taxonomy renames neither: it makes the old name unknown to the gate and strands tiles in a group no category explains. A rename is the list, every package that declares the old name, and a heal for the records — all three, or none.

The store page and the home's filter order categories DIFFERENTLY, on purpose. The storefront sells courses first (StoreCatalogLayoutAreas.CategoryRank puts Education at 0, then the taxonomy's order); the filter lists them by everyday use (StoreCategories.Rank alone — nobody launching an app wants courses first). Two purposes, two orders, one taxonomy.

The categories, 2026-09-15

Roland asked for the home to open on what you do, not on the technology: Administration for the things you set up once (LLM settings, providers, hosting, notification channels, payments, electronic-signature setup) and Daily work for what you open every morning (mail, calendar, chat, the approvals waiting on you). Both landed 2026-09-14; the taxonomy now carries every name the fleet uses, satellites included:

Category Packages here
Daily work Mail, Teams, Google, ICloud, Approvals
Administration MyAi, Providers, Hosting, Hosting.Instance, Notifications, Observability, Stripe, Signature
Operations BuildServer, Governance
Communication WhatsApp, iMessage
(and) AI · Productivity · Developer Tools · Maps & Places · Smart Home · Games · Showcase · Platform
(satellites) Education · Insurance · Manufacturing · Social Media · Marketing

Where each rule lives

Rule Code
Root level of the content list is Spaces core MeshWeaver.Graph/UserActivityLayoutAreas.RootTypeFilter
A workspace root is not content Store/Installer/Source/Localizer.{BuildHomeRoot,ReconcileHomeRoot,WithoutContentContext}
…and the roots already out there heal Store/Installer/Source/AppTileRefresh.HomeRootHeals
A tile's name/icon/description converge to its package Store/Installer/Source/AppTileRefresh.{Renamed,Described,Refreshed}
A tile's GROUP converges to its package's category unless the viewer chose it Store/Installer/Source/Localizer.WithGroup · MeshSearchView.WithArrangement (the customGroup flag)
The category taxonomy — the one authored list Store/Catalog/Source/StoreCategories.cs · gated by scripts/validate-repos.pycheck_package_categories
The launcher's filters, views, memory and tile menu src/MeshWeaver.Blazor.Views/Components/Launcher.cs · MeshSearchView.Sortable.cs
A learner's progress rides on the same name Edu/Module/Source/CourseAppTile
The mesh-wide migration Store/MaintenanceRefreshAppTiles
Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.