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);
}
}
}
}
}
[Page("/")] [Layout(FocusedLayout)] [Title("Home")] [Render(CSR)] [AllowAnonymous]
component FHome() { render { PageHead("Home"); } }
[Page("/f")] [Layout(FocusedLayout)] [Title("Details")] [Render(CSR)] [AllowAnonymous]
component FDetails() { render { PageHead("Claim details"); Card("Basics") { Text("A form."); } } }
[Page("/f/receipts")] [Layout(FocusedLayout)] [Title("Receipts")] [Render(CSR)] [AllowAnonymous]
component FReceipts() { render { PageHead("Receipts"); } }
[Page("/f/receipts/upload")] [Layout(FocusedLayout)] [Title("Upload")] [Render(CSR)] [AllowAnonymous]
component FUpload() { render { PageHead("Upload a receipt"); } }
[Page("/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.
slot primary {
ShellTaskBar {
Button("Back", onPress: Back, tone: Tone.Ghost);
Button("Continue", onPress: Next, tone: Tone.Primary); // last, and the only Primary one
}
}⚠ 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.
[Page("/")] [Layout(AppLayout)] component Home() { … } // TabbedShell — the application
[Page("/claim")] [Layout(ClaimLayout)] component Details() { … } // FocusedShell — the task
[Page("/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.
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 always a real link to
chrome.Home. A modal task with no visible exit is the most complained-about pattern in checkout design.
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.
⚠ The exit does not yet protect unsaved work. chrome.Home is a route, so leaving is a plain link and a
half-finished task is simply abandoned. Making it confirm needs something the contract cannot express today — a
shell cannot ask whether a named slot was filled, so a slot exit with a shell-drawn fallback is impossible, and an
AppChrome.OnExit would put a field on the shared record that three of four arrangements have no use for. Until
that call is made, a "Save draft" in slot actions is the pattern that works.
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 |
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 —
[Page],[Layout],Outletand retention