Skip to content

Unified Shell

One application frame for phone, tablet, desktop, and wide screen — not four. The Unified Shell is the outer layer of every AI-Driven Development Model frontend: it owns navigation, the arrangement of regions, the way commands and conversation are invoked, and the visibility of sync state. An app contributes content to a region — it does not bring a layout of its own.

The frame varies along two axes. The first is the form factor — the available width, and nothing else: targets are the coarse ones everywhere, so a screen keeps one look from the phone to the wide screen and only its regions are arranged differently. The second is where the shell runs — in the browser, or as a Capacitor app on a device with a local store. Only the second one can work offline, so only it has records that have not left the device, a sync state, and conflicts to decide.

Sync is therefore conditional, not permanently green: in the browser build the anchor does not exist at all, and the outbox, the conflict banner and the sync payload go with it. The platform is switched in the bench, not above the embedded frame: it does not annotate the shell, it rebuilds it — safe areas, geometry and the whole sync topic — so it belongs next to the geometry it moves. Embedded in a page the shell is therefore always the device build, and the anchor is there.

Try it

The app below is live: an iPad Air in landscape, scaled to fit, running the same allocation logic described here. Toggle the list, press the navigation row you are already standing on to collapse the column, switch the app in the header and watch the section below it change while the columns stay put — UDINA Go opens on a Home screen that requests nothing but the focus pane — open the Sales group at both widths, work off an inbox item from another app, run a sync, open the right pane and watch the list fall back to a sheet — that is the priority order spending a budget that no longer covers both.

The bar above it names what is being looked at and offers the one thing a reader can want from it: the way into the bench. Everything the bench sets — device presets, requested regions, priority order, hierarchy depth, how many apps the session is entitled to, platform and, in the device build, connectivity, the token overlay, the live theme panel, and a ledger of what the budget actually spent — lives in fullscreen. Platform and connectivity in particular: a control that reshapes the thing you are reading belongs where it can be worked, not in a row above the picture.

Fullscreen mode

Press F to open the shell in fullscreen with the full bench. Press ESC or F to leave it again. Drag the width slider slowly from wide to narrow: regions are given up one at a time, in a fixed order, and the object being worked on never falls below its minimum.

Principle

The shell defines regions, not layouts. A region exists independently of how it is presented; the form factor changes the presentation — column, sheet, navigation level, bottom sheet — never the existence and never the role.

The arrangement follows one semantic rule: left is „where am I", right is „what about this". As soon as one app puts context on the left or navigation on the right, the axis loses its meaning and users relearn where to look in every app.

Navigation is one path answered in three steps, plus a pane that talks about the result:

  1. Where am I — the main navigation picks the core experience. The region is the shell's at every width; what stands in it is scoped by the app — see App scope.
  2. Which one of them — the left pane is the set being worked in.
  3. Which object — the focus pane holds the object and is the visual anchor.
  4. What about this — the right pane says something about that object.

The first column is the name everything is called by, in this document and in the UI; the second is the key an app requests and the budget prices. Both are needed: SAP Joule Work names the same four surfaces the same way, and a shell whose key disagrees with the word its users say will be documented twice and understood once. A core experience is one entry of the main navigation's work sections — what UDINA Spaces calls a work area, and never the app it belongs to. A group in such a section is not a step of its own: it holds core experiences and picks none, so the path stays three steps deep however the entries are folded.

SurfaceKeyQuestion it answersPresentations
Main navigationsidebarWhich app, and which core experience in itcolumn, icon column, offcanvas sheet (stack)
Left paneindexWhich object out of this setcolumn, sheet, navigation level
Focus panedetailThe object itselfcolumn, floating, navigation level
Right panecontextWhat else is known about itcolumn, sheet, bottom sheet
Object tab stripWhich of my open objectsstrip above the panes; absent in stack

The keys are not invented where a name already exists. sidebar is shadcn/ui's own word for this region, and taking it means the shell's geometry tokens are --sidebar-width, --sidebar-width-icon and --sidebar-width-mobile — so a shadcn Sidebar dropped into the frame lands on the frame's measurements instead of its own defaults.

These five are the whole set. The main navigation has three presentations and no phone substitute: at every width it is the same region, differently presented — see Offcanvas.

index, detail and context have no shadcn counterpart — the component library names the region next to the sidebar (SidebarInset) and stops there; its own list/detail examples use unnamed resizable panels. These three are therefore shell-owned vocabulary, on the same footing as the added tokens below. One consequence worth stating: the detail region is where a SidebarInset would otherwise sit, so an app must not nest one inside it.

Schematically — the five surfaces as placeholders, with nothing in them yet:

object tab stripwhich of my open objects · spends no budget · absent in stack
main navigationsidebarwhich core experience am I incolumn · icon column · offcanvas
left paneindexwhich object out of this set
focus panedetailthe object itself · the only guaranteed minimum
right panecontextwhat else is known about it
← where am Iwhat about this →

Every following figure fills in exactly one of these boxes.

Three rules hold across all of them:

What takes the panes is separated from what gives them back. That is what the line above the main navigation's foot divides: the work sections above it, the secondary section below. Anything that is not the work — notifications, sync, the user menu, the palette — is a popover or floats, and must never spend layout budget.

Everything counted uses one indicator: waiting inbox items on the primary-section item, notifications on the notifier, unsent records on the sync anchor. Collapsed it states presence only, as a dot on the top right of its object; once labels have room the same element becomes the count at the right edge of the row. The number never appears where it cannot be read — and because the offcanvas presentation is a wide region rather than a narrow one, a phone shows counts where a collapsed column shows dots.

Navigation degrades, it does not rearrange. Which pane is a column, a sheet or a navigation level is decided by the column budget — never by an app, and never by a media query an app brought with it.

App scope

The UDINA Business Suite is several apps — Trade, Spares, Go, Go Service and Retail Admin — with one frame in front of all of them. The shell is authenticated throughout: there is no logged-out presentation anywhere in it, because behind SAP IAS every employee arrives federated from Entra ID. That is also why the public B2C storefront is not this shell and Retail appears here as its back office — the switcher names the back office, not the shop, or the entry mislabels what it opens.

What the header switches is the app, and an app is not a core experience. A core experience is one entry of the work sections, the same thing UDINA Spaces calls a work area; moving that word up a level would leave the region without a job and rename what Spaces already named. So the switch adds no fourth question to the path — it decides which set of answers the first one has, and how many core experiences an app offers is the app's property, not the shell's.

Entitlement is two-tiered and arrives with the session:

TierSourceDecides
AppIAS / Entra ID — one to n per employeewhich entries the header's menu holds
Core experiencethe role inside that appwhich rows the app section holds

With one app there is no switcher. The header then states where you are and is not a control at all: no chevron, no menu, out of the tab order. Not disabled — a dead button invites the click it refuses. And that is the normal case rather than the edge one: Trade and Spares are B2B, Go and Go Service are B2E, and one person rarely holds apps across that line.

The entitlement is fixed for the session, which has two consequences worth writing down. It must be known before the first paint — a header that renders as a brand and then grows a chevron has changed what it is while being read. And a role change takes effect at the next sign-in, not by re-populating the menu under the user's hands.

What a switch touches, and what it must not. Only the app section changes, plus the landing that follows: the shell lands on the new app's first core experience, whatever was open before — a suite entry included. The exception the inbox rule seems to ask for does not survive contact with the column: the shared list is indeed the same in every app, so the scope moved nobody anywhere — but it leaves the app section listing the new app's core experiences with nothing marked in any of them, and the header as the only place the switch happened. A scope you are in with nothing selected in it is not a scope you are standing in. The landing brings its group with it, because „you are here" behind a shut row is a selection nobody can see: the group is disclosed before the navigation, not after. The arrangement of the regions is the frame's — columns must not move when the scope does, because an app contributes content to a region and does not bring a layout. The object tab strip goes with the app, because an open object belongs to one. And the app becomes the first segment of the route while being deliberately not a crumb: a crumb names a place, the app is a scope, and the header states it already.

Picking the app you are already in is nothing at all. The header carries no route home — home is the app's first core experience, which is a navigation row like every other — and the „you are here" mark the menu puts on the current app is what says so.

Five parts, top to bottom: the app switcher, the primary section holding what belongs to the suite, the app section holding the core experiences of the app, the secondary section, and collapse/expand. At collapsed width there is room for one of switcher and user menu above the navigation, and switcher top / user menu bottom is the one arrangement that survives both widths. Every label shares one left edge, so expanding grows the right edge instead of re-flowing the column — the one deliberate exception is a group's sub-list, whose left edge is a step further in. Expanded, icon and title are always shown together.

A collapsed row keeps its name — it only stops showing it. The label goes out of sight and stays in the document: taken out with display: none it would leave the accessible name computation with nothing but the indicator beside it, and the inbox would announce „7 waiting" without ever saying what is waiting. Hovering reveals the same word as a tooltip, and that tooltip is a copy, never the carrier: the collapsed column stands at tablet widths too, where there is no hover to give, and a keyboard focus shows no tooltip in any browser. For the same reason the name is the row's own text rather than an aria-label beside it — one string cannot fall out of step with itself, and it is the only version a browser's translation reaches. An aria-label is used in exactly the three places where the name has to say more than the row does: the sync anchor adds the state and the backlog, the identity adds what its row opens, the switcher adds that it switches. Each of them contains the visible text rather than replacing it.

PartshadcnHoldsBehaviour
App switcherSidebarHeaderthe brand, and the app the section below belongs toopens the header's one menu; never navigates, so the logo neither goes home nor starts a conversation
Primary sectionSidebarContentSidebarGroupwhat belongs to the suite: Spaces and the unified inboxtakes the panes; does not change with the app
App sectiona second SidebarGroupthe core experiences of the active app, grouped where they share a backend systemtakes the panes; exactly one core experience is active at a time
Secondary sectionSidebarFooterassistant, notifications, sync, user menuopens an anchored popover and gives the panes back
Collapse/expandSidebarTriggerthe toggle for the regionchanges the width or the presence of the region, never what is in it

The middle column is not decoration. The shell's parts are shadcn's parts in the same order — SidebarHeader for branding, SidebarContent for the navigation, SidebarFooter for the user menu, plus the trigger — so this table is what lets an app author read one structure instead of two. The one piece of shadcn's sidebar the shell does not take is the SidebarRail, the unlabelled second target for the trigger: the navigation itself carries that act instead, and an empty surface that changes the region's width is a click into nothing that has to be learnt. The one structural addition is that SidebarContent holds two SidebarGroups rather than one, and that split is the app scope made visible. The pieces inside a section follow the same rule: a navigation entry is a SidebarMenuButton (its collapsed hover title is that component's tooltip — the visible copy of the label, not the name — and „you are here" is its isActive), a count on one is a SidebarMenuBadge, and a row's overflow is a SidebarMenuAction. A group is the row plus what belongs to it — SidebarMenuItem around a SidebarMenuButton and the SidebarMenuSub it discloses, whose entries are SidebarMenuSubButtons. Only one thing is added to that vocabulary, and it is the collapsed answer: shadcn has no piece for a sub-list that cannot be shown in place, so the shell borrows the surface its own row menus already use.

The switcher is a mark and two lines. The mark is the suite's signet, and the app rides it as a badge at the bottom right — the level the lower line sits on, so the mark and the word that names the app are one statement at one height. That the top right is spoken for anyway settles it: there, an object in this region means „something is waiting", which is not what this says. It is laid over the signet's bottom-right corner and changes nothing about the signet, which keeps the size and the place it has everywhere else. No ring around it: one wide enough to read would clear more space than this mark has ink in that corner, and the badge would stand in a hole it punched itself. It stands at both widths, because it is the stand-in for a per-app signet — logo and app in one mark, which the offline builds need as their launcher icon anyway — and a placeholder belongs where the thing it replaces will stand. A customer build may bring no logo of its own, and then the badge is the only thing that names the app at 64px, where the lines are gone. Those lines are the wordmark and, under it, the app in chrome type; both truncate and never wrap, because a header whose height depended on the length of a name would move the whole primary section down. A further line — a sales org, an environment — is optional and earns its place only where it can actually differ: a slot that says the same thing forever is not read. Where a user acts for several business partners, that choice belongs to the app and its backend, and the shell already has the place for it — the account row in the user menu, with the count it leads to.

The app section is the one part of the region that scrolls, and that is what the region's two hairlines are for. How many core experiences an app holds is the app's business — one to a dozen, and with every group open a tall one runs past the region's foot — so the section shrinks under its own content and moves inside itself. The primary section stays put above it: an inbox that can be scrolled out of the column is a backlog nobody is reminded of. The two lines are written on the neighbours of the scroller rather than on the scroller itself — the primary section closes with one, the secondary section opens with one — because a row that slides out of sight needs a stated edge to slide behind, while a box that draws its own edges says „I am a box" instead of „this is where the column holds still". Nothing else inside the region takes a rule, and the air that used to divide the two work sections is gone: the line is the break. The app section also carries no SidebarGroupLabel, although shadcn offers one and its own admin templates use it: the only label it could carry is the app name, which the header states one row above.

A group is disclosure, not a destination. Where several experiences belong together, they stand in a SidebarMenuSub under a row that names what they have in common — in Trade, Sales holds Quotes, Orders, Deliveries and Returns. That row never navigates: it would need a landing page of its own and there is nothing to land on, so it carries aria-expanded and no „you are here". Its chevron sits where a count would, so a navigation row keeps one right edge for whatever it says about itself. The rows under it are set in by one step and are otherwise rows like every other — same glyph, same type, and „you are here" across the full width of the column, because the active state must not have a second, narrower version of itself one level down. The indent carries the membership on its own; a rule beside the sub-list would be a second surface in a column that has one, and everything the row draws would have to work around it. A group holding the daily work arrives open: which groups start open is a property of the navigation, not something the reader has to set up first.

What „together" means is whichever axis still carries information in that app. Where an app reaches into several backend systems, that axis is the system the objects come from. Where it reaches into one, it is the part of the process they serve: Trade sells out of S/4HANA through a single CAP proxy, so a group per system would be one group holding the whole app and naming nothing — its groups are Sales, Billing and Master data instead, which is the order-to-cash chain plus the records both halves of it stand on. Grouping is therefore the app's decision and not a shape the shell prescribes, and two consequences follow from picking the axis rather than inheriting it: an entry that is not a list of one part of the process stays outside every groupTrade's dashboard is the app's overview, and filing it under a third of the app would be a claim about it that is not true — and where one group really is a system after all, it says so.

Collapsed, the sub-list moves beside the column — it is not given up, and the region is not widened for it. At icon width the group opens the same rows in a popover anchored to its own row, in exactly the place the collapsed row's hover title would have taken, and the popover names the group first: that title is what a glyph cannot say, and it is owed before the destinations under it. Widening the region instead would spend the work area's width on a question an overlay answers for free, and move every other row while doing it. The chevron goes — there is no room for it next to a 22px glyph — so the group is a glyph like any other and borrows its active child's mark, being then the only row in the column that can carry it. The mark stays put while the popover stands open: the popover is transient, and a state switching off with it would flicker on every glance into a group.

Which section an entry belongs to is decided by one question: does its number survive an app switch? The unified inbox's does. It is one worklist over everything the shell knows, its rows name the app that raised each item, and it is therefore the same list in every app — so it stands above the scope, in the primary section. It follows that opening an item from another app does not re-scope the navigation: you are in the inbox, not in that app, and only the object travels. The same test puts the whole secondary section in the suite — notifications are suite-wide, and the outbox belongs to the device, because „are my changes through" is asked of the device and never of one app.

That the inbox is counted like the notifier and still stands here rather than beside it is the other division at work: the two sections are separated by behaviour rather than by subject matter, and the inbox owns a left pane and a focus pane.

The distinction is worth the adjacency it gives up. A notification is a signal — transient, dismissible, gone once read. An inbox item is stock — it leaves the list by being decided. Side by side with a counter each they read as one thing said twice; apart, each says what it is.

Secondary itemOpensWhy here
Assistantthe command paletteinvokes, holds nothing
Notificationspopover, grouped by daysignals, never the work
Syncpopover with both directionsdevice build only — conditional, therefore above the user menu
User menuthe account cardidentity only; always present

The sync anchor sits above the user menu because it can be absent: the avatar must not move between the browser and the device build. The user menu is identity — name in the type of every other navigation label; mail, role, employment, the account switch with the count it leads to, settings, about and sign-out are what its popover is for, with sign-out set apart at its foot as the one destructive act.

All three popovers are anchored to their item, open to the right and are mutually exclusive — two would occupy the same strip of screen. A collapsed group's sub-list joins that rule: it is the one popover of the work sections, it stands in the same strip, and it is as much „not the work" as the other three — so opening it closes them, and opening any of them closes it. The switcher's menu is the fifth in that strip and keeps the rule with them. It is the only one that opens downwards while the region is a column, because it is anchored at the top edge and there is nothing above it to open into; collapsed it moves beside the column, where a group's sub-list stands, for the same reason that put it there — the anchor has to stay visible under it. It is also the only one that does not exist offcanvas instead of moving into the sheet: the sheet has room for the sub-list in place, and a popover there would answer a question the region no longer asks. Escape and a click outside close them, and focus lands on the first control inside, not on the container. Offcanvas the region is nearly the screen's width, so there is no right to open to: the popover lands inside the sheet, above the item it belongs to and in the sheet's width. The anchor stays visible under it, which is what put the popover to the side in a column in the first place.

Collapse/expand is the last item of the secondary section and draws a frame with its leading column, not a hamburger: what it collapses is a region. The label carries the direction, so the glyph stays put.

The second way to collapse the region is the row you already stand on, which is VS Code's rule for its activity bar. A row that is not „you are here" only navigates — collapsing the column in the same gesture would take away the list the reader just asked for, and one press would have done two things. Once the row is the current one there is no second destination left in it, and that is what it spends on the region: every further press toggles between labels and icons, and the row that is „you are here" is where the reader's hand already is. The empty area above the secondary section is deliberately not that target — shadcn offers it as the SidebarRail, but a surface that holds nothing and still changes the region's width is a click into nothing, which cannot be seen and has to be learnt. At icon width the same press comes from the group's popover, where the active entry stands in place of a row the column no longer shows. Programmatic navigation is exempt: an app switch that lands on the view already open must not collapse the region as a side effect of arriving.

The same glyph appears a second time in stack mode — in the chrome row of whichever pane leads, as the only way to ask for a region that is off screen. It is the same act on the same region, so it must not arrive with a mark of its own: there is deliberately no hamburger anywhere in the shell.

UDINATrade
Spaces
Inbox7
Sales
Catalogue
Assistant
Notifications3
Sync2
User menu
Collapse
SalesQuotesOrders
app switcher — the signet with the app as a badge on it, and the app the section below belongs to; never a route homeprimary section — what belongs to the suite: Spaces and the unified inbox, whose count survives an app switchapp section — the core experiences of the active app; the region's one scroller, bounded by a line at each end, and it carries no label the header already statessecondary section — assistant, notifications, sync (device build only), user menu; gives the panes backcollapse/expand · collapsed the lines go and the badge on the logo is what still names the app, counts become dots, a group carries its child's mark and opens its sub-list beside the column

Left pane

The left pane belongs to navigation, not to content — an architectural decision: as a mode of the content area, every app would build its own collapse and stack behaviour.

Row density is a property of the view, and there are exactly two row contracts. Single-text rows have no dividers — a border appears only on hover and on the selected row, so a long reference list stays quiet. Rows carrying mark, title, subtitle and state are cards with a border each. Switch between Orders and Accounts above to see both.

The card row's mark carries whatever identifies the row fastest — initials for a name, a type glyph where there is none to abbreviate. State marks are shared across views: three silhouettes rather than three hues — filled disc, red diamond, hollow ring — filled with each view's own vocabulary.

The quick filter is per view and is not automatically the state column. In Accounts it is. In the Inbox it is not: an item leaves that list by being decided, so a tab called Open would count everything. The two cuts that earn a tab are the questions actually asked — can I act on this now (Open, dropping what is parked on someone else) and is this mine or the group's (Mine), with All behind them as the way back to the undivided list. A tab therefore carries a predicate, not a field value, and the criterion is stated on the object itself — the detail names who the task is assigned to, or Mine would be unverifiable. The inbox opens on Open: starting on everything hands the triage back to the reader.

Selection is a white surface inset on all sides and nothing else — white means „this is the thing you are working on", and a coloured bar in front of it would say the same thing twice. It is tracked by identity, never by row position: search, sort and a shrinking worklist all reorder the rendered list.

A row has exactly one primary action — opening the object. The rest sits behind an overflow button revealed on hover, and on keyboard focus, otherwise it would be unreachable without a pointer. It occupies no slot: it floats over the right end of the row and fades the content out underneath, so the status stays at the right edge in every row. Which direction its menu opens is measured against the list, not derived from the row index.

An empty list says which kind of empty it is: no match for the query, nothing in the filter, or nothing left to work off.

card rows
Open · 7Mine · 3
···
mark · title · subtitle · state — the white row is the selection, overflow floats on hover
single-text rows
no dividers, no quick-filter row — a border only on hover and on the selection
empty
no match for the query · nothing in the filter · nothing left to work off

Focus pane

The focus pane is the visual anchor and the only region with a guaranteed minimum. The budget is handed out after that minimum is reserved, so requesting further regions can never shrink the surface being worked on.

A view may request nothing but this pane. UDINA Go's Home and Route have no set to choose from and no object of their own, so they request neither the left pane nor an object header: the screen itself is the content. It takes the pane's full width at its own height — these are screens, not backdrops, so nothing about one may be cropped to fill a box, and what is left under a short one is the pane's surface.

The frame does not step aside for it. The chrome row keeps its height and its ink, and the picture begins below it: that row is the only way back to the navigation in stack mode, so it is neither swallowed by a screen nor restyled for one. What it gives up is a surface — the picture's own backdrop fills whatever the screen does not cover and runs behind the row, so the row reads as the distance to the picture instead of a band of a second colour with an edge against it. That backdrop travels with the picture rather than with the frame: a screen an app brings is content, and it brings the surface it was designed on — a colour, or a picture of its own stretched over the region and anchored at its top edge, which is what a screen needs whose panels were cut out of a photograph that has to keep going past them. The row's ink follows from that: light glyphs where it passes over a photograph, its own where it passes over a light screen, stated with the picture rather than sampled from it. Where a picture carries a rule in its own top rows — a screenshot taken with the header's border still in frame — it is cropped by that much, stated per picture and never as a blanket rule; what the cut exposes is the picture's own backdrop, so the seam cannot be seen, and the next screen that has content up there is not quietly clipped for it. Its leading slot carries the offcanvas trigger instead of the back button, because with no list beneath it this pane is the root of the flow, and an arrow there would pop to where it already stands.

Where the object is a decision rather than a record — an inbox item — the pane takes a foot of its own, outside the scroll container: an act must not be scrollable out of reach. The order inverts accordingly: what is being asked first, the facts needed to answer it second. Acts are right-aligned with the primary leading them, because a decision is one object made of several acts. Only the deciding acts remove the item from the left pane; forwarding or commenting hands it on, and the row has to stay for that to be visible. When the row does go, the selection moves on within the filter the user is looking at.

/
object header — the only display-size type in the shell
scroll container · what is being asked, then the facts
foot — outside the scrollForwardRejectApprove
minimum 440 — reserved before the budget is handed outhands on · decides, and the row leaves the left pane

Right pane

The right pane is not an AI panel. It is a slot with an interchangeable payload — conversation is one payload alongside object details and the sync log. Model it as a chat panel and a second right-hand surface gets built within a year, after which two panes compete for the same side of the screen.

The payloads are tabs, not a segmented button: they switch what the pane shows, which is navigation within the pane. Other surfaces hand off to it instead of duplicating — Sync details switches the payload rather than growing the popover into a second log.

right panecontext
ConversationObject detailsSync log
payload — interchangeable
tabs switch the payload — navigation inside the pane, not a segmented button

Object tab strip

Which of my open objects: a strip above the panes, optional and off by default. Not a region: it spends no budget, and it is absent in stack mode, where the navigation stack carries the same information.

An active tab is a brand tint plus an accent edge, not the focus pane surface — with the left pane open as a column „same white as below" would be white over muted, and a tab's position comes from tab order, never from what sits beneath it. Tint alone is the current view; tint plus edge is the current document.

current objectoptional · off by default
focus pane — white; the active tab is tint plus edge, never this white

Offcanvas

Below 600 the main navigation is neither narrowed nor replaced: it leaves the flow and slides in from the left, over the work area. In the chrome row of whichever pane leads there is one icon for it, and nothing else of the region is on screen. This is shadcn's collapsible="offcanvas", which on a phone renders the very same Sidebar inside a Sheet — the region keeps its header, its primary section, its app section, its secondary section, its foot and its four surface tokens. Only its presentation changes. The app switcher travels with it, and because the sheet has the width of a column and a half, its menu lands inside the sheet, dropping from the trigger as it does in a column.

An overlay spends no width, which makes this the one region the budget cannot outbid. So the shell's own rule finally holds everywhere: navigation degrades, it does not rearrange.

Inbox
closed
UDINATrade
Spaces
Inbox7
Sales
Quotes
Orders
Assistant
Sync3
A. Claude
open
one icon in the chrome row — the same frame glyph the region's own trigger carries--sidebar-width-mobile · 288the scrim dismisses it, and so does picking a destinationthe whole region arrives: the switcher, both work sections, counts, sync anchor, user menu

Why the whole region, and not a phone-only tab bar

Bottom tabs can carry the primary section and nothing else, so the notifier, the sync anchor and the user menu end up behind a More label — for a status anchor whose whole justification is being readable without interaction, that is a contradiction. They also speak a design language of their own: small labels, a top edge marker, a form found nowhere else in the frame or in shadcn. And they make the phone the only width at which navigation is a different component rather than the same region differently presented.

Offcanvas gives up one thing in exchange: the primary section is two taps away instead of one, and the trigger sits in the top-left corner rather than under the thumb. That is the accepted cost — and it is the cost shadcn, its admin templates and Base UI all pay, none of which ship a bottom tab bar at all.

One level deeper the back button owns that slot, so the sheet is reachable from the root of the stack only. That is not a gap: at the root the row's left edge answers „where else can I go", one level in it answers „how do I get back", and a navigation stack cannot have both in one place. Tapping the current core experience in the sheet pops the stack back to its list.

The assistant keeps its place in the secondary section and only changes surface: a floating palette needs room around it and a keyboard to be worth opening, so on a phone the item opens the conversation payload in the bottom sheet instead.

In the stack flow the home indicator is paid by whatever the flow ends with: the list at the root, the body or the decision foot one level in — --safe-bottom belongs to no element of its own. --safe-top goes to the chrome row of whichever pane leads, which grows by the inset instead of being pushed down by it — a phone draws its head under the clock rather than leaving a stripe above it.

Both insets are a runtime condition on top of the layout one, and the criterion is the absence of browser chrome — not the presence of a notch. That distinction decides three cases:

RuntimeInsetsWhy
Browser tab0the URL bar already owns the strip; there is nothing left to reserve, and env(safe-area-inset-*) reports 0 without viewport-fit=cover anyway
Installed as a PWApaidthe chrome is gone — the same situation as Capacitor, from the same code
Capacitor WebViewpaidthe platform reserves nothing: it hands the insets over as env() values, and consuming them is the frame's job. On Android 15 and up, edge-to-edge is enforced, so there is no opting out

The middle row is why „device build only" would be the wrong rule to write down: a shell that reserves nothing in a PWA is a shell whose first line sits under the clock the moment someone adds it to their home screen. The platform never does this for you — in an Ionic app the Ionic components do it, and here the shell is that layer.

Below 600 without browser chrome the insets are paid, and the prototype draws what they are for — clock and home indicator, above every region and every sheet. Reserved space that shows nothing does not read as reserved; it reads as a mistake in the frame. The prototype hard-codes 47 and 34 because a simulated phone inside a desktop page would read 0 from the real env() values; a shell in a real runtime reads those four instead.

Chrome row

Every region carries its chrome row on the same vertical level. In the focus pane it holds the left-pane toggle, the breadcrumb, a spacer, and the right-pane toggle; title and subtitle sit below it in the object header, because a display-size title does not fit the chrome height — and only without a title can the row align across all regions. In stack mode the row's leading slot belongs to the navigation stack: the offcanvas trigger at the root, the back button one level in.

Everything in a chrome row is frame, not content, and reads at one size: region title, wordmark and breadcrumb come from a single chrome-label token, so none can drift into looking like a heading. The only display-size type in the shell is the object title.

In the left pane the row holds the title and the same three icon-only tools in every view: search, sort, filter. Below it, a view whose rows carry a state adds quick-filter tabs, each with the count it would actually show — taken from the current search result, not from the whole set. A view without row states has no such row.

How many tabs are shown is measured, not fixed — translated labels are longer, so the row gives up one tab at a time to an overflow menu until it fits, and hides rather than scrolls what is left. The trigger then carries the label of the active filter: the current state of a list must never be invisible. Search is a toggle whose field opens on a row of its own — inside the chrome row it would cost the shared icon level — and closing it drops the query. It is the only one of the three that is drawn as pressed while it is on, in every index view, because the field it opened is the only thing that would otherwise say so and it stands below the fold of a scrolled list. Sort flips too but is not a toggle: its glyph already carries the direction, and a pressed square behind a changing icon states the same fact twice in two vocabularies. The rule is general — a control paints its active square when it holds a state nothing else shows, never when its own icon is the state.

The breadcrumb is derived from the same source as the route: two renderings of one truth. A crumb names a place, not a title, so the inbox trail carries the task number — and the app, which is the route's first segment, is not a crumb at all: it is a scope, and the switcher states it. Truncation is measured, because the available width depends on whether the left-pane trigger is visible: full path, elided middle, current level alone; in stack mode only the parent survives, as the return label next to the back button.

left pane
//
right pane
one chrome level across all regions — icons only, one label token, no title
search — its own row when toggled on
Open · 7Mine · 3···
quick-filter tabs — measured, overflow carries the active label
object header — below the chrome row, because a display-size title does not fit it
ConversationDetails
payload tabs — second level, like the quick filters

Column budget

Regions are not independent booleans — four booleans times three form factors is 48 combinations, and nobody tests all of them. The available width is handed out by priority instead.

Priority, high to low: focus pane > right pane > left pane > main-navigation labels

The shell reserves the base need — collapsed main navigation plus focus pane minimum — then walks the requested regions in order. A region becomes a column if what is left covers its price; otherwise it degrades to a sheet, and the budget locks.

PositionKeyTokenPrice
Main navigation, collapsed (base need)sidebar--sidebar-width-icon64
Focus pane minimum (base need)detail--region-detail-min440
Right panecontext--region-context360
Left paneindex--region-index320
Main-navigation labels (280 − 64)sidebar--sidebar-width216

The main navigation has a third measurement that is not in this table because it has no price: --sidebar-width-mobile, 288, the width of the offcanvas sheet below 600. An overlay is not competing with anything, which is why it may be wider than the column it replaces and why the budget never sees it.

280 is a deliberate 24px over shadcn

shadcn's --sidebar-width default is 16rem (256) and its --sidebar-width-icon is 3rem (48). The shell keeps 64 — which is what AppShell.tsx already overrides --sidebar-width-icon to — and 280 rather than 256.

Not because 256 would be too narrow: it would not, the longest label clears it with room to spare in German too. The reason is the one number this price actually moves. Because the labels are last in priority, no threshold below them depends on what they cost — 1184, 864 and 600 are identical at either width. Only the all-columns threshold shifts, and 280 puts it on 1400 where 256 would put it on 1376. A published breakpoint is read far more often than it is computed, so the rounder one wins a choice that is otherwise free.

Both values are stated on shadcn's own two token names, so the deviation is visible at the place a theme would override it, rather than being a second geometry with a vocabulary of its own.

The lock is the important part

Without it a low-priority region wins, because the higher-priority one gave up first and therefore consumed no budget: the main navigation got its labels in exactly the width range where the left and right pane had already degraded to sheets. That is the classic greedy-allocation trap — for the user, a layout that behaves non-monotonically while the window is resized.

The thresholds follow entirely from those prices:

ViewportResult
≥ 1400all four as columns
1184 – 1399main navigation stays icons-only
864 – 1183left pane degrades to a sheet
600 – 863left and right pane as sheets
< 600stack mode

Main-navigation labels being last means they can be refused outright. The toggle says so, instead of leaving a button that appears to do nothing.

Sheet state

A region's presentation and whether it is open are two facts, and the shell keeps them in two fields — index / context carry column, sheet, hidden or stack, and indexOpen / contextOpen carry the openness. That is shadcn's own split between collapsible and open, and it is not a formality: the same sheet presentation arises from two causes, which must not feel the same.

The main navigation is on the same split, with shadcn's own two values: collapsible is icon in columns and offcanvas in stack, while one sidebar state — expanded or collapsed — carries the openness in both. Collapsing means a narrower column in the first case and no column at all in the second, and that is the whole difference. Its openness is the one piece of state the shell keeps twice: labels-in-a-column and sheet-on-a-phone are different requests, and crossing 600 with labels switched on must not throw a sheet over the work area. shadcn splits open from openMobile for exactly that reason.

CausePresentationOpen
Degradation through a width changesheetclosed, trigger in the chrome row
The user switching the region onsheetopen

Fusing the two into one value — an „overlay open" state distinct from an „overlay closed" one — would make every reader decode a string to answer either question, and the two causes of one presentation would stop being separable at all.

A scrim appears only when a sheet is genuinely open, and clicking it closes the sheet instead of switching the region off — otherwise opening the right pane in the middle width range turns into a modal that makes the list unusable. The chrome toggle keeps one meaning at every width: show or hide the list. Column or sheet is the budget's business.

Presentation modes

ModeConditionBehaviour
columns≥ 600, business appregions as columns, per the budget
floating≥ 600, drawing surfaceleft and right pane float, consume no budget, never modal
stack< 600panes become navigation levels, main navigation → offcanvas sheet, right pane → bottom sheet

floating exists because a drawing surface is not a column problem: neighbouring columns clip the surface, floating palettes do not. It is part of the contract; the prototype implements columns and stack.

floating and offcanvas are not the same axis

shadcn's collapsible="offcanvas" means a region that slides fully out of view — which is exactly what the main navigation does in stack mode. floating means panes that float over the surface and are never modal, shadcn's variant="floating". Two words one letter apart, meaning opposite things, in a document an app author reads next to the component docs.

So the two live on different axes: columns / floating / stack is how the shell arranges the panes, icon / offcanvas is how the main navigation collapses — and one is never a value of the other.

stack is not a CSS problem but a navigation stack with push and pop — in the shell, or it gets rebuilt in every app and differently each time. Safe-area insets belong to it: status bar above, home indicator below, as tokens fed from env(safe-area-inset-*). Layout and runtime are separate conditions — a browser tab at the same width pays neither, because its own chrome is already in the way. See Offcanvas.

Platform and form factor

Two facts, two questions.

Where the shell runs decides whether sync exists: a Capacitor build has a local store, works offline and therefore has an outbox and conflicts. A browser build has none of it, and the whole topic — connectivity included — drops out of the main navigation rather than standing there permanently green.

The runtime decides a second thing, and by a different test: whether the safe areas are paid. Sync asks „is there a local store", safe areas ask „is there browser chrome in the way" — and an installed PWA answers those two differently. Do not collapse them into one isNative flag.

How much room there is decides the presentation — and only the presentation. The budget spends the available width on regions; it does not change how anything inside them is sized.

Target size is not on that axis at all. The shell has one target size, the coarse one — 48 (Android guidance, not 44), at every width, on every platform, mouse or finger. There is no fine variant and no pointer detection, because both would buy the wrong thing:

  • A frame whose controls shrink under a mouse is two applications that resemble each other. The same screen, learned on a tablet, has a different rhythm on the desktop — and the two have to be judged, screenshotted and reviewed separately.
  • pointer: coarse is the one media query that is regularly wrong in the cases that matter: a touchscreen laptop, a tablet with a keyboard case, a pen, a desktop browser being driven from a phone. Being wrong here means an unhittable control, and hedging it with an override just moves the guess into a setting.

The cost of one size is white space on the desktop, and that is the cheaper mistake: an interface with air in it reads as calm, an interface with 32-pixel targets on a touchscreen reads as broken. Where the extra room genuinely hurts, the answer is a denser layout — fewer regions, tighter rows — not smaller targets.

The target size is the area that can be hit, not the area that is painted: an icon button paints 30 and grows only its hit area to 48, so the chrome rows stay visually compact at a size a finger can still land on. One square for all of them, the chrome rows and the composer alike, so the shell has a single icon-button size rather than one per region. The hover and pressed fills are drawn on exactly that square and never larger: a chip that grows under the pointer makes the row breathe as it is crossed, and in the composer it pushes the tools apart for as long as the pointer is there.

Online, offline, sync

Sync exists only in the device build, and there the anchor is permanently visible, because a mobile user must be able to answer „are my changes through" without interacting with anything. It sits in the secondary section above the user menu and carries two channels on one item: the glyph is the state, the indicator is the backlog.

The states are online, syncing, offline and conflict, and they answer two questions: online / offline is connectivity, „can the device reach the backend"; syncing / conflict is sync, „what happened to my changes". One anchor states both, because the user asks them together.

The split runs deeper than a label: connectivity is a condition, the sync states are outcomes. Only connectivity is ever set — in the bench as much as in the product. syncing is what a running transfer is; conflict is what a rejected one produced. Asserting either would claim a result no run delivered. Losing the connection mid-run is accordingly not a conflict: the run stops, the records stay in the outbox, and the state falls back to what the connection says.

Transmitting is an act of the user, never of the network. An online device may sit on a full outbox as long as the user leaves it there, so the outbox indicator is independent of the sync state and both directions live in the anchor's popover: Download receives, Upload sends. A device that flushes silently as soon as a connection appears answers the field user's one question behind their back.

The popover states the current state and what is pending, the two facts behind it — last run, outbox — then the two acts, then in words why they are unavailable when they are: a disabled pair without explanation reads as broken. While a run is in flight the pressed button keeps its colour and turns into the running glyph while the other dims — which direction is running has to stay readable after the press, not only during it.

Never state anything in colour alone

Every state carries a silhouette as well as a hue: signal arcs for online, transfer arrows while a run is in flight, a struck-through arc for offline, a warning triangle for a conflict — the same triangle the notification card and the inbox row use for that event. Row states in the left pane follow the same rule with filled disc, hollow ring and diamond. Colour is the fastest signal, never the only one.

Only the glyph is tinted, never the label — the anchor is a navigation item first. online stays in the navigation's own ink, and the running state pulses, because the brand palette holds no fourth status colour. Where reduced motion is requested the pulse stops and the glyph carries the meaning alone.

The notifier opens its list as a popover, never a region: notifications are never the work. The count is the length of that list — one number, one source, so the dot cannot contradict the list behind it. Grouping is by day, and every card names its source: a signal pointing at the inbox is not a second copy of the inbox item.

Errors need plain language, which a status glyph cannot carry. For that there is a transient banner inside the focus pane, above the scroll container — only in the error or conflict case, identical on all form factors, costing no space otherwise. It must never sit above the main navigation, and it stays out of the inbox item, where the conflict is already the thing being decided.

Invocation layer

Two surfaces, one component, one store: a floating command palette that only invokes, and the conversation payload in the right pane that holds the thread.

Answers appear only in the right pane. The palette starts things; it does not answer, or a second history surface comes into being and the left/right separation breaks through the back door.

The palette is anchored at the bottom, centred over the left and focus pane; main navigation and right pane are excluded. It needs a trigger inside the shellK opens and closes it where there is a keyboard, and on a touch device that shortcut does not exist — which is Assistant in the secondary section, at every width: the offcanvas presentation brings that section along. Only the surface it opens differs. A floating palette needs room around it and a keyboard to be worth opening, so on a phone the same item opens the conversation payload directly, where the thread is the bottom sheet.

IntentBehaviour
terminalexecute, confirm with a toast, the right pane stays closed
conversationalopen the right pane, append the turn to the shared store

Free text without a command is always conversational. A toast is a confirmation, not an answer surface — and a confirmation and a rejection cannot share a glyph: a tick next to „records rejected" contradicts its own sentence.

The composer follows the omnibox pattern on two rows. The message line is the field and, at its end, the mic — dictation is an alternative to typing, so it sits on the line it stands in for and nothing else joins it there. The foot row is everything the message is given: +, /, @, the chip run, and Send.

Both surfaces are the same control at the same size. The field is one line until the text needs more, grows to --composer-max, then scrolls; the mic keeps the top-right corner while it grows, because a control that travelled down with the last line would read as part of the text rather than as the thing that dictates it. Docked in the conversation pane the composer grows upward — it is the last child of the column, so the thread yields the space — which means a thread read to its last message has to be re-pinned as the field grows, or the answer scrolls silently out of view.

The foot row reads left to right as widening scope. + opens a menu of the kinds of context there are — upload a file, add the open object — because a plus that acted on its own would have to guess which kind was meant. / picks a command, @ a person. All three raise the same popover surface from the composer's top edge, and only one is ever open.

Context is assembled and removable — the chips show exactly what the assistant is being given, rather than inferring it silently from the screen. They sit in the foot row rather than in a labelled strip above the field: the chips name themselves, so a header over them only adds a line between the question and the place it is typed.

@ opens a people list the same way / opens the command list: both filter as the token is typed, both are picked by click or by Enter on the first hit. A picked person leaves the text as a chip — the mention is context handed over, not a word in the sentence, so the @token that summoned the list goes with it. The chip run takes the width between the tools and Send and scrolls inside it; a chip elides at its own maximum rather than widening the row, so the composer can never push its Send target out of the panel.

Two implementation traps worth recording

The command popover has to re-filter on every keystroke, not only when it opens — otherwise Enter picks the wrong command. And the composer text has to be controlled state: a render path that skips the focused field to avoid overwriting it also prevents a programmatically inserted command from arriving, and store and DOM drift apart.

The assistant stays behind the CAP AI layer and reaches business logic only through approved MCP tools — the invocation layer is a surface, not an authority.

Design tokens

Layout belongs in tokens, not just colour. Region widths, the detail minimum, chrome heights, target size and safe-area insets are tokens, because the budget arithmetic and the rendered geometry have to use the same numbers — a width that exists twice will disagree with itself. The bench's Tokens overlay names them on the regions they size.

The surface hierarchy is part of the contract, not decoration:

RoleSurfaceReason
Focus panewhitethe visual anchor; content is the document
Chrome — main navigation, left pane, right pane, tab stripmutedthe anchor is readable because its neighbours are not white; one surface keeps the frame quiet on every form factor
Selected object in the left panewhite fillthe selected row and the focus pane it leads to are the same colour, and nothing else may claim it
Active item in navigation — primary and app section, the current app in the switcher's menu, open-object tabmuted brand tintnavigation says „you are here", not „this is your object" — and the scope is a „where", so the switcher takes this tint and not the grey of a toggle
An icon toggle while it is on — search, index, context panel, the omni's + / @grey filla switch is not a place. The brand tint belongs to navigation alone, or „the search field is open" and „this is the view you are in" arrive in the same colour from two different rows
Grouping inside the focus panemuted filloutlined cards on white read like a sheet of paper
Grouping inside the right panewhite fillthe same logic inverted

Because the main navigation and the left pane share one muted surface they need a hairline where they meet — --sidebar-border, and only while the left pane is beside it. The tab strip needs one a weight stronger, because it holds muted against muted whatever sits below. Against the white focus pane the change of surface is the separation and a line would be noise.

The table states an order, not three hues. „White" is „the brightest surface in the frame", and after dark the same order is mirrored rather than replaced: the seam behind the regions is the deepest value, the chrome carrying navigation and the left pane a step above it, and the focus pane is still the brightest thing on screen. That last relation is the one the light theme asserts and a dark one must not drop — everything about where the work is rests on it. The brand walks the other way for the same reason: on a light surface it reads as the darkest thing in the frame, on a dark one it has to be the lightest, which is what keeps „the active-item tint takes the brand as its ink" true at both ends.

Token contract

Colour is not named twice. The shell speaks the shadcn/ui token vocabulary — the same one the components inside its regions use — so a theme written for the component library themes the frame unchanged, and there is no second palette to keep in step. Every surface arrives as a surface/foreground pair, structure as --border / --input / --ring, geometry as --radius, from which four corner steps derive.

The obligation runs in one direction: every name shadcn defines is stated here, including the ones the shell only derives. A token the shell leaves out is a token a theme can set and the frame will silently ignore.

GroupTokens
Surfaces--background / --foreground, then --card, --popover, --muted — each with its -foreground
Actions--primary, --secondary, --accent, --destructive — each with its -foreground
Structure--border, --input, --ring, --radius (+ -sm / -md / -lg / -xl)
Sidebarall eight: --sidebar, --sidebar-foreground, --sidebar-primary, --sidebar-primary-foreground, --sidebar-accent, --sidebar-accent-foreground, --sidebar-border, --sidebar-ring
Charts--chart-1--chart-5

Note --background's partner is plain --foreground, not --background-foreground; the suffix is dropped on the base surface only. --radius's steps use shadcn's own offsets (−4 / −2 / 0 / +4) rather than a set of the shell's, or a component dropped into a region rounds differently from the region around it.

The charts row is in the contract although the shell draws no chart: a view inside the focus pane does, and a chart palette invented per app is one more version of the brand. --sidebar-border earns its place for a reason the surface hierarchy above already states — the hairline where the main navigation meets the left pane is a region seam, so it belongs to the sidebar block rather than to the global --border.

--accent is a surface in this vocabulary — the hover and active tint — not the interactive hue. Only what shadcn has no name for is added, and nothing more:

AddedWhy the standard set has no name for it
--accent-strongshadcn names the tint and the focus ring, not the mark between them: active indicators and text actions. --ring follows it rather than stating a second blue.
--toggle / --toggle-foregroundshadcn's Toggle takes --accent for its on state, but here the accent tint is already navigation's „you are here". A switch needs a fill that says on without saying place — one step below --border, the lightest grey that still reads on --card and on --muted, the two surfaces a toggle appears on.
--border-strongthe line one weight above a seam — the tab strip holds muted against muted, where a region head's hairline is invisible
--muted-markthe quiet mark tier: scroll thumbs, hollow state marks, the edge a floating region needs. --input follows it.
--sidebar-muted-foregroundthe sidebar block has a -foreground and an -accent-foreground but no muted ink tier, and the main navigation needs one
--status-online, -syncing, -offline, -conflictsync has no vocabulary in a component library — and a status colour defined locally is one version of the truth per app
--shell-scheme, --shadow-ink, --scrim-alpha, --reject-markwhat a scheme owes the shell besides colour, and none of it is a swatch: which scheme is in force, so the browser draws the widgets it owns — scrollbars first — in the shell's light and not the page's; the ink a shadow is spent in, stated once as an rgb triple because how far a thing floats is that thing's business and only the alpha belongs to each site; how much of it the scrim takes, because „everything behind this is out of reach" is a statement about light and a dark surface has less of it left to remove; and the one mark that reads against --primary rather than against a surface — the rejection glyph on a toast that keeps its brand fill and changes only its icon

Brand and typography come from the UDINA brand guide: the blue scale, secondary surface and divider forming the muted level, dark blue as the ink. The fullscreen bench edits the whole set live, next to the shell rather than over it — a colour is judged against the frame it lands in — and hands it back as a CSS block: the way a session becomes the next default theme. A token that only derives from another is named with its source and stays out of that block unless it was actually overridden, because a derivation belongs in the stylesheet and not in a theme. The scheme values above are the exception that closes it: they have no swatch to offer and are exported all the same, because a dark theme pasted without them is a dark palette with a light theme's shadows.

A scheme is a theme, not a second stylesheet. UDINA Dark is a base theme like the light one and goes through the same machinery — the panel edits it, the export writes it, overrides layer on top of it — rather than arriving as a block of overrides beside the palette. Which base is in force follows the page: the site's own light/dark toggle is its statement about the light the shell is being read in, so the mock answers to it instead of standing in the page as a lit window. Overrides are therefore kept per base: a value taken over for the light theme is a decision about the light theme, and carried into the dark one it would be a light value on a dark surface — and it is the page's toggle, not the designer, that switched it. No value in the dark set is a second brand: every blue is a step of the same site palette, walked the other way.

Integration guide

Everything about arrangement belongs to the shell, everything about meaning to the app.

What an app gets for free — budget allocation and degradation, sheet state including scrim and triggers, the navigation stack, the offcanvas navigation with its trigger, the app switcher including the entitlement it reads and the one-app case, the object tab strip, chrome row and object header, the breadcrumb including measured truncation, platform and form factor detection, target size and safe-area tokens, the status anchor with its states and acts, the notifier, the user menu, the banner region, the palette plus right pane with their shared store, and both colour schemes with the token set that carries them.

What an app must implement:

ResponsibilityScope
Identity in the suiteits name and its mark — what the switcher shows, and what an inbox row names as the source of an item
App section contentthe core experiences it offers, their grouping, and which of them a role may see
Region requestwhich regions a view requests — not how they are presented
Left pane content (index)the list, the density choice, and the quick filters as predicates with their default
Focus pane content (detail)object header data and content; for a decision, which acts close the item and which hand it on
Right pane payload (context)which payloads are offered, and with what data
Route and trailone source from which both breadcrumb and route are derived
Commandsregistration with an intent class
Sync statethe current state, the outbox count, and the plain-language text for banner cases

What an app must not do — set its own breakpoints for region arrangement, size its targets below the frame's minimum or vary them by pointer, model the left pane as a content mode, hard-wire the right pane as a chat panel, render answers in the palette, define status colours locally, bring a dark palette of its own instead of the scheme the frame is in, assert a sync state it has not observed, transmit without the user asking, bring its own SidebarProvider or SidebarInset (the shell is both), state a shadcn token the frame already owns, claim a row in the primary section (that section belongs to the suite), treat the switcher as a route home, or re-scope the navigation because an object from another app was opened.

Relationship to the model

LayerOwns
Unified ShellRegions, budget, navigation chrome, the app scope, invocation layer, sync state
UDINA SpacesWhich work areas and intents exist, and what the shell navigates to — one level below the app the switcher picks
React & shadcn/uiThe components inside each region
CAPDomain, services, annotations, authorization, AI and MCP

The Unified Shell, in one line

One frame from phone to wide screen and from browser to offline device: named regions, priced allocation, defined degradation — so every screen size and every runtime is the same application, resolved differently.