openbot-screen-layout
GitHub规范 OpenBot 配置页面的默认布局结构,定义 PageShell、PageSection 等组件的使用标准,确保界面一致性,适用于新增或修改路由页面及行级 UI。
Trigger Scenarios
Install
npx skills add CopilotKit/OpenBot --skill openbot-screen-layout -g -y
SKILL.md
Frontmatter
{
"name": "openbot-screen-layout",
"description": "The default layout for every OpenBot configuration screen — PageShell and its prose\/wide widths, PageSection and PageRows, Item row composition, the settings-row pattern where a summary and a chevron open a dialog, and the size and variant vocabulary. This is what a new screen looks like unless an instruction says otherwise. Use when adding or changing a screen under app\/src\/routes, adding a row to an admin or settings page, choosing a Button or Item size, picking between a bordered and a filled row, laying out a dialog, or reviewing a diff that adds max-w-*, a hand-drawn card, or a new spacing scale under app\/src. Don't use for where the data comes from (that is openbot-data-access), for the gallery components under components\/gallery that a Bot draws, for the chat and channel surfaces, or for editing the primitives under components\/ui themselves."
}
OpenBot Screen Layout
When To Use
This skill applies to any change under app/src/routes that puts a configuration screen on the
screen — a new admin page, a new settings section, a new row on an existing page, a detail page
behind a list. Twelve screens render through PageShell today and seven build their rows out of
Item; all of them look the same on purpose.
It does not cover where the data comes from — that is openbot-data-access, which owns queries,
mutations, and the pending/error/empty/rows branching. It does not cover the gallery components
under components/gallery, which a Bot draws inside a conversation rather than a person navigating
to. It does not cover the primitives under components/ui, which are this repository's own design-system
primitives: they began as shadcn output, but there is no upstream to re-pull from, and they are
formatted and linted like the rest of the app.
The Default
A new screen uses this layout. Deviating from it needs a reason given in the request.
This is the point of the skill. The decisions here — the width, the spacing, the heading sizes, the
row anatomy, which control means what — are already made, and they are already made the same way on
every other screen. Somebody who does not work in the frontend should be able to add a page that
looks like it belongs without making a single visual decision, by reaching for PageShell,
PageSection, PageRows and Item and filling them in.
The failure this prevents is the one the PageShell doc comment describes: Admin was once nine pages
that shared no layout — four container widths, four heading sizes, four padding schemes, three of
them drawing their own buttons and inputs. It did not read as a different screen, it read as a
different application, at exactly the moment an administrator was deciding whether to trust it with
credentials.
So: no hand-drawn containers, no new widths, no new spacing scale, no second way to draw a row. If the screen genuinely does not fit — a table, a canvas, an editor beside a live preview — that is a deviation worth stating out loud and worth a comment saying why.
The Shape
Four components make the frame. They compose in this order and nest no other way.
PageShell the page: header (title, description, action, backButton) then children
PageSection a titled group of unrelated decisions, with a deliberately large gap above it
PageRows the grouped card: rounded-lg border bg-card
Item one row, size="sm", divided from its neighbours by <Separator />
PageEmpty what a section says when it has nothing to list
PageShell takes a width: prose (max-w-3xl, 768px) is the default, and every
configuration screen uses it so no screen is wider than another. Sentences inside it are held to
max-w-prose, so the column has room for a label beside its control without stretching the text.
wide (max-w-5xl) exists for one screen — admin/audit.tsx, because an audit log is a table to be
scanned and prose width would wrap every row. It is not a licence for anything else to be wide.
The Rhythm
Spacing comes from the frame, not from the screen. These are the gaps every screen gets by using the components above; a screen that sets its own is drifting.
| Between | Gap | Where it lives |
|---|---|---|
| Page edge (top / sides / bottom) | 48 / 16 / 64px | PageShell column (pt-12 or pt-8 under the bar, px-4, pb-16) |
| Title → description | 8px | PageShell header |
| Title block → first content | 32px | PageShell: [&>header+*]:mt-8 |
| Title → tabs, tabs → content | 24px, then 32px | PageShell: [&>header+nav], [&>header+nav+*] |
| Section → section | 48px | PageSection (mt-12) |
| Section heading → description | 4px | PageSection |
| Heading block → section content | 16px | PageRows / PageEmpty (mt-4) |
| Controls stacked inside a section | 12px | the screen, with gap-3 |
A row in PageRows |
65px with a description, never under 56px | Item (px-4 py-3.5 gap-3), PageRows (min-h-14) |
| Divider between rows | inset 16px from both edges | PageRows, on every horizontal Separator inside it |
The 32px under the title is deliberately tighter than the 48px between sections: the title belongs
to the page it introduces. Those PageShell selectors outrank the first child's own mt-*, so a
section or notice keeps its usual margin everywhere else and loses it only at the top.
A page with one section gives that section no title: the page title already names it, and a second
heading 32px below the first says the same thing twice. Put the description on PageShell instead
(connections.index.tsx). A count or filter that belongs to a search sits on the search's row, not
on a line of its own.
PageEmpty is shadcn's empty state: a bordered box where the rows would be, with an icon for
the kind of thing, a title ("No rules yet", "No matches", "Background research is off"), one
sentence of description saying how the first one arrives, and an action — the same Add button the
page already has — when there is one to offer. Don't repeat "Add one with X" in the description when
the action is right there. A search with no results uses IconSearch and "No matches".
Procedures
Procedure 1: Lay out a screen
- Open with
PageShell, giving ittitleanddescription. Leavewidthalone. - On a detail screen reached from a list, pass
backButton={{ label, linkProps }}— it draws a chevron-left bar above the header. Do not put a Back button inaction;actionis for the page's one primary verb, on the title's baseline. - Group the page's decisions into
PageSections, each with atitleand, where the grouping is not self-evident, adescription. - Give each section one
PageRowscard. Rows go inside it asItem size="sm", with<Separator />between them and none after the last.PageRowsis a card with dividers, not a stack of cards — gaps between rows are the wrong shape. - Read
admin/plugins/index.tsxfor the whole pattern end to end, andadmin/plugins/$key.tsxoradmin/components/$name.tsxfor a screen with several sections and mixed row kinds.
Procedure 2: Compose a row
ItemMedia variant="icon"holds a leading icon from@tabler/icons-react, drawn as a 16px glyph on a 32px muted tile. It is what makes a card of rows scannable; a row without one reads as a paragraph. Pass the bare icon — the variant sizes and colours it — and use plainItemMediaonly for something that is its own picture, such as a Bot's avatar.ItemContentholdsItemTitleand, where the row needs a second line,ItemDescription.ItemActionsholds the control, or the value when the row is read-only.ItemHeaderandItemFooterarebasis-full, so they wrap onto a line of their own inside the row. That is where a set goes — chips, toggles, anything that would otherwise fight the label for horizontal space. A set does not belong inItemActions.ItemDescriptionisline-clamp-2. Where the text is the point rather than a hint, passclassName="line-clamp-none".- To make the whole row navigate or open something, pass
render:render={<button type="button" onClick={…} />}orrender={<Link to=… />}. A real element, so the keyboard reaches it and a link can be opened in a new tab.
The one mistake this makes easy: the element passed to render must have no children.
useRender merges props, and children given there replace the row's own — the media, the content
and the actions all vanish and the row draws empty. The button takes its accessible name from the
title and description inside it.
Procedure 3: Choose what a row does
Every row is one of three kinds. Picking the wrong one is the most common way one of these screens starts to feel wrong.
- Binary, immediate — a
SwitchinItemActions. It takes effect when switched; there is no save.ItemDescriptionstates the consequence in the present tense ("Bots may answer with it." / "No Bot may use it."). - Anything larger — a free-text field, a set of grants, more than one input — is a summary in
ItemDescriptionplus<IconChevronRight className="size-4 text-muted-foreground" />inItemActions, and the row opens a dialog. Editing more than one value in place turns a settings screen back into a form. - Read-only — the value in
ItemActions, no chevron, nothing to click. A chevron on a row that goes nowhere is a promise the row cannot keep.
The summary on a chevron row states the current answer, not the field's name: "All 3 Bots", "2 of 3 Bots", "Nothing — it draws only what the model hands it". Compute it from the query so it updates itself when the dialog changes something.
Procedure 4: Sizes and variants
Item size="sm"for rows inside a card.defaultonly for a row standing on its own.Button size="sm"for a control inside a row or a dialog footer;lgfor a page's own actions;icon-smfor a control floating over artwork.Item variant— pick one for the whole screen.mutedis a fill with no border;outlineis a border with no fill;defaultis neither. Mixing two of them on one screen reads as two different kinds of list, and the reader looks for a distinction that is not there.bg-cardis invisible inside a dialog.--cardand--popoverresolve to the same colour in both themes, so a card-coloured row against a popover is no row at all. Usemuted.
Procedure 5: Add something from a list
- Creating a thing that takes several fields — a credential, a key — is its own screen, not a dialog
over the list. Put it at
<list>_.new.tsx(the_keeps it from nesting inside the list's route), and make the list'sactionaButtonrendering aLinkto it. PageShellwithbackButtonpointing at the list, a title that is the verb ("Add credential"), and a description saying what happens on save.- The fields go in one
PageCardinside a<form>, stacked in a single column, each aFieldwithFieldLabel, an optionalFieldDescription, then the control.PageCard'sfootertakes Cancel (variant="outline", aLinkback) and the submit button; on success, navigate to the list. admin/credentials_.new.tsxis the reference.
Procedure 6: Lay out a dialog
DialogContentdefaults tomax-w-lgandmax-h-[85svh]withp-5. Header, body and footer go in asDialogHeader/DialogBody className="mt-4"/DialogFooter className="mt-4".DialogBodymust stay a direct child ofDialogContent. Wrapping the three sections in one padded div breaks theflex-1 min-h-0chain and the body stops scrolling.DialogBodycarriesflex-1 min-h-0and no overflow of its own, despite a comment saying it scrolls. Any dialog tall enough to need it passesoverflow-y-autoexplicitly; without it the body shrinks and paints its content over the footer.- For artwork reaching the dialog's edges:
className="p-0 gap-0 overflow-hidden"onDialogContent, each section carrying its own padding, andoverflow-hiddenhanding the popup's rounding to the artwork's corners. PassshowCloseButton={false}and supply a close button with its own backing — the built-in one is a ghost button in the foreground colour and disappears over a pale image.
Units
The root is the browser's 16px, so Tailwind's spacing, sizes and widths are their documented values
(p-4 is 16px, max-w-3xl is 768px) and land on its 4px grid. Text is the exception: styles.css
sets every --text-* to Tailwind's default times 15/16, so text-sm is 13.125px and body text is
15px. That is the scale this app has always read at; do not size text in px or rem by hand to match
the docs.
Even so, never hardcode a pixel constant derived from a Tailwind token, and never compute a ratio
from one. Measure the element — offsetWidth, clientWidth, a ResizeObserver — and scale from what
is actually on screen. The root was once 15px, and every constant taken from the docs sat 6.25% off.
Decision Tree
- Adding a screen → Procedure 1, then Procedure 2 for each row.
- Adding a row to an existing screen → Procedure 2, then Procedure 3 to pick its kind.
- The row toggles one thing → Procedure 3, kind 1: a
Switch, no save. - The row edits text, or a set of grants, or more than one value → Procedure 3, kind 2: a summary, a chevron, and a dialog. Then Procedure 6.
- Adding a new thing that takes several fields → Procedure 5: its own screen with a back button.
- The row only states something → Procedure 3, kind 3: a value and no chevron.
- Unsure which
Itemsize,Buttonsize, orvariant→ Procedure 4. - A width, ratio or scale needs a number in pixels → the Units section. Measure, do not assume.
- Choosing a margin or gap between blocks → the Rhythm table. If the frame already gives it, set none.
- The screen genuinely does not fit this shape → say so in the response and comment the reason in the
code. There are two accepted precedents: a table (
admin/audit.tsx,width="wide"), and the Connections tiles (connections.index.tsx), a grid of separatevariant="outline"cards, 56px tall and 12px apart, because apps are picked out by their mark rather than read down as a list. - The question is which states the screen renders while loading or failing → not this skill;
openbot-data-access, Procedure 3.
Red Flags
| Signal | What it means | Do instead |
|---|---|---|
A hand-written rounded-lg border border-border bg-card div wrapping rows |
PageRows re-implemented, and it will drift from the other seven screens |
Use PageRows |
Rows separated by gap-* instead of <Separator /> |
A stack of cards rather than one card of rows | <Separator /> between rows, none after the last |
width="wide" on anything that is not a table |
Prose width is the default for a reason; wide was for the audit log | Leave width alone |
A max-w-*, p-* or text size invented for one screen |
A fifth container width, which is what this layout exists to stop | Reach for the existing component |
Children on an element passed to Item's render |
They replace the row's own children; the row draws empty | Pass a childless element |
bg-card on a row inside a dialog |
--card equals --popover; the row is invisible |
variant="muted" |
Item variant="outline" and variant="muted" on one screen |
Reads as two kinds of list and invites a distinction that is not there | One variant per screen |
| A pixel constant matching a Tailwind rem token (672, 768, 1024) | Breaks the day a width or the root changes | Measure the element |
An mt-* on a screen's first block, or a gap between blocks not in the Rhythm table |
A second spacing scale, which is what made every screen feel slightly off | Let PageShell and PageSection space it |
A one-section page whose section has a title |
The heading repeats the page title | Drop the section title; description on PageShell |
A Switch on a row that needs two or more values |
Half the change happens in place and half in a dialog | A summary, a chevron, and a dialog |
| A chevron on a row that opens nothing | Announces a destination that does not exist | Value in ItemActions, no chevron |
A set of chips or toggles crammed into ItemActions |
It competes with the label for width and wraps badly | ItemFooter, which is basis-full |
A back link in PageShell's action |
action is the page's primary verb, on the title's baseline |
backButton={{ label, linkProps }} |
Error Handling
- A row renders empty — media, title and actions all missing: children were passed on the
element given to
Item'srender. Remove them. - A row's content sits visibly off centre, or a card's corners are clipped by its container: a pixel constant was taken from the Tailwind docs rather than measured. Measure the drawn element instead of the box it was given.
- A dialog's footer has body content painted over it:
DialogBodyis missingoverflow-y-auto. It hasflex-1 min-h-0and no overflow of its own. - A dialog stops scrolling after a refactor:
DialogBodyis no longer a direct child ofDialogContent. Restore it and give each section its own padding instead. - Artwork overflows the box it was placed in: an inline
<svg>with aviewBoxand no width or height resolves to 100% width and a height derived from its own ratio. Give itclassName="h-full w-full";overflow-hiddenon the parent clips at the border rather than the padding, so it is no help here. - A row's summary does not change after its dialog edits something: the summary was captured into state instead of computed from the query. Derive it on every render.
- Two screens that should match do not: one of them drew its own container. Diff the two against
admin/plugins/index.tsxand delete whichever hand-drawn wrapper is notPageRows. - The layout genuinely cannot express the screen: stop and say so rather than bending it silently. A deviation with a stated reason and a comment is fine; an undocumented fifth way to draw a card is what this skill exists to prevent.
Version History
- c396a5f Current 2026-11-02 10:26
-
9c6a237
2026-09-22 00:52
明确界面原语归项目所有,移除shadcn CLI依赖并纳入格式化检查,修复错误列表键值问题。
- 1a7b60a 2026-08-27 09:10


