Summary#
FocusedShell is the arrangement for completing one thing: a checkout, an approval, a data-entry or
configuration step, a guided multi-step flow.
⛔ It is task-oriented, which is not the same as minimal. A later Immersive shell is the experience-oriented
one — a map, a photo editor, a drawing tool, where the content is the point. Build this shell by subtraction and
the two become one shell wearing two names. What defines it is what a task needs: a clear primary action, a
position in a sequence, and a way out. The sparse navigation is a consequence of those, not the design.
[Layout]
[AllowAnonymous]
component FocusedLayout() {
action SaveDraft() { }
render {
FocusedShell(new AppChrome {
Product = "Expensely",
Tagline = "New expense claim",
Home = "/", // the way out
User = new ShellUser { Name = "Olivia Rhye", Initials = "OR" },
Nav = [
new NavItem { Label = "Details", To = "/f", Icon = Icons.File, Exact = true },
new NavItem { Label = "Receipts", To = "/f/receipts", Icon = Icons.Plus, Children = [
new NavItem { Label = "Upload", To = "/f/receipts/upload", Icon = Icons.Plus },
] },
new NavItem { Label = "Review", To = "/f/review", Icon = Icons.CheckCircle },
],
}) {
Outlet(retain: 8);
slot actions { Button("Save draft", onPress: SaveDraft, tone: Tone.Ghost, size: Size.Sm); }
slot aside {
ShellAside("Claim summary") {
Text("Total", fontWeight: FontWeight.Semibold);
Text("EUR 1,240.00", fontSize: FontSize.Heading);
}
}
}
}
}
[Route("/")] [Layout(FocusedLayout)] [Title("Home")] [Render(CSR)] [AllowAnonymous]
component FHome() { render { PageHead("Home"); } }
[Route("/f")] [Layout(FocusedLayout)] [Title("Details")] [Render(CSR)] [AllowAnonymous]
component FDetails() { render { PageHead("Claim details"); Card("Basics") { Text("A form."); } } }
[Route("/f/receipts")] [Layout(FocusedLayout)] [Title("Receipts")] [Render(CSR)] [AllowAnonymous]
component FReceipts() { render { PageHead("Receipts"); } }
[Route("/f/receipts/upload")] [Layout(FocusedLayout)] [Title("Upload")] [Render(CSR)] [AllowAnonymous]
component FUpload() { render { PageHead("Upload a receipt"); } }
[Route("/f/review")] [Layout(FocusedLayout)] [Title("Review")] [Render(CSR)] [AllowAnonymous]
component FReview() { render { PageHead("Review and submit"); } }Signature#
FocusedShell(AppChrome chrome) {
Outlet(retain: 8); // the routed page — a CENTRED column with a readable maximum
slot search { … } // the bar, on a pointer. A reader wants this; a checkout will not fill it
slot actions { … } // the bar — "Save draft" is the wizard-shaped one
slot railFoot { … } // no rail: under the page, inside the same centred column
slot aside { … } // the SUMMARY column — beside the page at 1100+, under it below that
slot primary { … } // THE COMMIT BAR — `ShellTaskBar`. In the task's own column, under the form
}
class FlowStep { // what `AppChrome.Steps(current)` projects `Primary()` into
NavItem Item; int Number; bool Done; bool Active;
}Description#
How does the reader finish?#
slot primary, and it is the feature that makes this shell task-shaped. Fill it with ShellTaskBar, which pins a
commit bar to the foot of the task inside the page's own column, so the button that finishes the form sits
directly under the form.
⛔ THE FILL GOES IN THE LAYOUT, BESIDE THE Outlet — NOT ON THE PAGE. A page renders inside the outlet, so
it cannot fill a slot of the shell hosting it, and writing slot primary { … } in a page is refused:
"a slot primary fill can only appear directly inside a component call". This is the commonest place to reach for
one, because the task's own state lives on the page — so put the fill next to the shell call, and read what it needs
from the layout:
[Layout]
component ClaimLayout() {
action Back() { }
action Next() { }
render {
FocusedShell(chrome) {
Outlet(retain: 8);
slot primary {
ShellTaskBar {
Button("Back", onPress: Back, tone: Tone.Ghost);
Button("Continue", onPress: Next, tone: Tone.Primary); // last, and the only Primary one
}
}
}
}
}⚠ So a commit control whose state is the PAGE's stays on the page. A Submit that is disabled until the form is
complete, or an Approve/Reject pair that depends on which stage a record is at, is knowledge the layout does not
have — put those in the page body (in its PageHead, or at the foot of its own content) and leave slot primary
for a bar the layout can decide on its own. The slot is a position the shell's author fills, not a channel from
the page to its frame.
⚠ A task you cannot see how to finish is not focused, it is undecorated. This is the line between this shell and the experience-oriented one, so if you fork it, keep it.
⚑ The slot exists on all four arrangements, so switching never drops an app's primary action — the record's promise is that you lose the layout and nothing else. It is only this shell that is built around it.
Where does this shell actually go? A ROUTE, not an application#
⛔ No application is a four-step wizard. Reach for FocusedShell for one route group inside a larger app —
create-claim inside an expense tool, checkout inside a storefront, onboarding inside a product. You enter the task,
you finish it, and you come back to the application you came from.
That needs nothing built, because [Layout(…)] is a per-page attribute: the task's pages name the focused
layout, every other page names the app's own, and moving between them is an ordinary navigation.
[Route("/")] [Layout(AppLayout)] component Home() { … } // TabbedShell — the application
[Route("/claim")] [Layout(ClaimLayout)] component Details() { … } // FocusedShell — the task
[Route("/claim/review")][Layout(ClaimLayout)] component Review() { … }Set chrome.Home on the task's layout to the application's home, so the exit leaves the task and lands
somewhere real. A flow whose exit points at its own first step exits nothing.
⚑ Some applications genuinely ARE one guided task — a checkout, a tax filing, a permit application. That reading
is equally correct; there chrome.Home is the flow's own start, which is the honest answer when there is nowhere
else to go back to. demo/shell-arrangements ships both: the claim at /t/claim/* is the task-inside-an-app
reading, and the vehicle registration at /f/* is the standalone one.
⚠ On the route that IS chrome.Home, the exit is not drawn. That follows from the sentence above rather than
qualifying it: a reader standing on Home is not inside anything an exit could end, so the control would be a link
to the page under it — live-looking and inert. Every route inside the task still has it, in the same place. So a
standalone flow like /f/* shows no exit on its own first step and shows one from the second step onwards, which
is what "exits nothing" looks like once the shell acts on it.
Where did the navigation go?#
There is none, and that follows from the model rather than standing on its own: a route out of the task is an
invitation to abandon it. This is the one arrangement that puts no nav landmark on the page, which is worth
asserting in your own tests if you fork it — the way this shell decays is somebody "improving" it with a nav.
It still gets the two things a wandering shell cannot give a task:
- Position in a sequence. The app's own
Navis re-read as a linear flow (see below), so switching arrangement declares nothing new. - A way out, always in the same place — and a real link whenever there is nothing to lose, never one that
resolves to the page you are already on. A modal task with no visible exit is the most complained-about pattern in
checkout design; an exit that looks live and does nothing is the same complaint with an extra click in it. On
chrome.Homeitself there is nothing to leave, so nothing is drawn (above); on a task holding unsaved edits it asks before it goes (below).
And room: the page is a centred column with a readable maximum rather than full-bleed. This is the one place in the kit where centring is right, because there is exactly one thing to read.
Does leaving lose my unsaved work?#
No, and you declare nothing to get that. The shell reads Navigation's Navigation.IsDirty for itself,
so it knows whether this task holds uncommitted edits without your app telling it:
| the task | the Exit is | pressing it |
|---|---|---|
| clean — nothing to lose | a link to chrome.Home, exactly as before | leaves, in one press, with no dialog |
| dirty — uncommitted edits | a button | raises a prompt: Save and exit · Discard and exit · Keep editing |
Those three are Navigation.Save(path), Navigation.Close(path, force: true) and dismissing the prompt — the same
three answers Navigation gives any close prompt, and Save and exit appears only when the page's own
canSave guard says the edits can be saved (Navigation.CanSave). The prompt is the kit's one leave prompt,
LeavePrompt, in a task's words — the sidebar shell's rail asks the same question (LeavePrompt — asking before a page with unsaved edits is left).
⛔ It is a button only while there is something to lose, and the difference matters. A link is what gives
middle-click, open-in-new-tab and a destination a screen reader can announce — so a clean exit keeps all of it. A
link also cannot decline to navigate: the router intercepts a plain left-click in the capture phase and has
already gone by the time any onClick runs. That is not a gap in Link; navigating is a link's whole contract. It
is why a guard is a button, and why an "always confirm" exit — which would make every clean exit cost a pointless
dialog — is the wrong shape rather than the simpler one.
⚑ You can write exactly the same guard yourself, over any control. Nothing here is shell-only: Navigation is
bound into every component's render scope, so a rail item, a "Back to list" button or your own layout can ask the
same question. Navigation #in-actions is the pattern in five lines, and #closing is the full three-answer
prompt.
How does the nav become steps?#
AppChrome.Steps(current) numbers Primary() — the top level, with any Section replaced by its children — and
marks the entry holding the current route as active, everything before it as done. A top-level entry is a step;
its Children are that step's parts, nested under it while it is the active step, never as steps of their own.
⛔ The stepper is a COLUMN beside the task, not a strip above it, and that is a taxonomy decision rather than a layout preference. Horizontal, it had the same silhouette as TabbedShell's tab strip — a top bar, then a full-width row of labelled items, then the page — and those two shells can sit one press apart in the same application. Their models are opposites: tabs are parallel destinations you may choose in any order and which persist for the life of the app; steps are sequential positions inside one task, which you cannot jump ahead in and which vanish when the task ends. A row that looks like tabs invites the reader to click ahead to step four.
Down the side, it is unmistakably a sequence at a glance; it uses the room a bounded task column was otherwise wasting; and it composes into the classic three-column checkout — steps · task · summary.
⚠ Assert it geometrically or not at all. Assert.Visible("Receipts", within: "Progress") is true of a step
wherever it sits, which is why a full suite stayed green while the silhouette was wrong. Assert.Below and
Assert.LeftOf are the only assertions that can see a shape.
⚠ Scope to "Current task", not to "Page". The stepper sits inside the page region, so Page means
[steps | task] and a geometric claim against it cannot tell the two columns apart.
⚠ Done is order, not history. The shell knows the flow's shape and never which steps the reader actually
completed — a step reads as done because it comes before the one holding the current route. An app that tracks
real completion should reorder or trim Nav; nothing in the shell can know it.
⚠ A route the flow does not cover shows no position at all — no counter, no stepper. That is right rather than a gap: a confirmation page is genuinely outside the numbered part, and inventing a position for it would be a lie.
What changes at each width?#
| band | the bar | progress |
|---|---|---|
compact < 768 | mark · "Step 2 of 4" and the step's name · actions · identity · exit | a 4px rule |
cozy 768+ | mark · actions · identity · exit | a stepper column left of the task: every step, ticked when done, with the active step's parts nested under it |
(The exit in both rows is "on a route inside the task" — on chrome.Home itself it is absent at both widths.)
| wide 1100+ | the same | the same, and the aside joins as a third column — steps · task · summary |
⚑ Mobile loses the stepper and nothing else. Four labelled steps do not fit down the side of a 390px screen — there is no side — so the phone gets the count and the name of the step it is on, which is the part that answers "where am I". Every step is still counted, the exit is still there, and the summary still arrives under the page.
⛔ Every piece of chrome must earn its place against "the task matters more than the application". Three were cut on this argument:
| cut | why |
|---|---|
the brand tagline (ShellBrand(tagline: false)) | a workspace subtitle is a fact about the application; the stepper already names the task |
| the progress rule at pointer widths | a second answer to the question the stepper already answers richly. The phone keeps it, because there it is the only answer |
| the horizontal stepper | see above |
Identity stays, and that is a decision rather than an oversight. It is the only route to sign out and to the
appearance control, and a shell whose whole design is to trap the reader in a task is exactly the one where being
unable to reach your own account is worst. It is mark-only, so it costs 28px. The search slot is still placed —
most task apps will not fill it and an unfilled slot draws nothing, but a reader (a long document, a policy you must
accept) genuinely wants search within the task, and silently discarding a slot an app filled would lose content.
Where the summary goes#
aside matters more here than in any other arrangement: a checkout's order total or a wizard's context is the
canonical case, and it is why the page and its aside sit side by side from 1100px up. Below that it stacks under
the page rather than disappearing, because a phone that silently loses a panel has lost content, not layout.
Examples#
// Every other arrangement puts a `nav` landmark named "Main navigation" on the page. This one must not.
Assert.Hidden("Main navigation");
// The active step's PARTS are on screen; the parts of a step you are not on are not.
Ui.Visit("/f/receipts");
Assert.Visible("Upload", within: "Receipts parts");
// The page is a bounded column rather than full-bleed — invisible to any text assertion.
Assert.Narrower("Page", "Page header");Notes#
⚠ The stepper is a region named "Progress", not a second nav. These are not places to go; they are where you
are in one task, and calling them navigation would put a navigation landmark in a shell that deliberately has none.
See also#
- App shells — the shared
AppChromecontract every arrangement reads - TabbedShell — top tabs, and a bottom bar on a phone
- RailShell — a permanent icon rail, when the canvas is the product
- routes and pages —
[Route],[Layout],Outletand retention