openbot-screen-layout
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.
git clone --depth 1 https://github.com/CopilotKit/OpenBot /tmp/openbot-screen-layout && cp -r /tmp/openbot-screen-layout/.claude/skills/openbot-screen-layout ~/.claude/skills/openbot-screen-layoutSKILL.md
# 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 shadcn files with their own
upstream.
## 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-2xl`) is the default because configuration is mostly
reading, and a row of label-and-control has no business being wider than the sentence explaining it.
`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.
`PageEmpty` is a sentence, not an illustration with a heading. On a configuration screen "nothing
here yet" is a fact, and the section heading already said what the section is for.
## Procedures
### Procedure 1: Lay out a screen
1. Open with `PageShell`, giving it `title` and `description`. Leave `width` alone.
2. 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 in `action`; `action` is for the
page's one primary verb, on the title's baseline.
3. Group the page's decisions into `PageSection`s, each with a `title` and, where the grouping is not
self-evident, a `description`.
4. Give each section one `PageRows` card. Rows go inside it as `Item size="sm"`, with `<Separator />`
between them and none after the last. `PageRows` is a card with dividers, not a stack of cards —
gaps between rows are the wrong shape.
5. Read `admin/plugins/index.tsx` for the whole pattern end to end, and
`admin/plugins/$key.tsx` or `admin/components/$name.tsx` for a screen with several sections and
mixed row kinds.
### Procedure 2: Compose a row
1. `ItemMedia variant="icon"` holds a leading icon from `@tabler/icons-react`. It is what makes a
card of rows scannable; a row without one reads as a paragraph.
2. `ItemContent` holds `ItemTitle` and, where the row needs a second line, `ItemDescription`.
3. `ItemActions` holds the control, or the value when the row is read-only.
4. `ItemHeader` and `ItemFooter` are `basis-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 in `ItemActions`.
5. `ItemDescription` is `line-clamp-2`. Where the text is the point rather than a hint, pass
`className="line-clamp-none"`.
6. To make the whole row navigate or open something, pass `render`:
`render={<button type="button" onClick={…} />}` or `render={<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.
1. **Binary, immediate** — a `Switch` in `ItemActions`. It takes effect when switched; there is no
save. `ItemDescription` states the consequence in the present tense ("Bots may answer with it." /
"No Bot may use it.").
2. **Anything larger** — a free-text field, a set of grants, more than one input — is a summary in
`ItemDescription` plus `<IconChevronRight className="size-4 text-muted-foreground" />` in
`ItemActions`, and the row opens a dialog. Editing more tha