Theme slots and view models
theme-kit v0.9. 29 slots: 27 stable, 2 provisional, 0 deprecated.
What the marks mean, and how something is removed, is in
theme-api.md. In short: a stable slot and the fields of its
model do not change before the next major; a provisional slot is named but not yet rendered
by any page, so its model may change in a minor release; a deprecated slot still
works and has a removal scheduled below.
Every slot
| Slot | Kind | Stability | Props |
|---|---|---|---|
Shell |
server |
stable | ShellModel |
Header |
server |
stable | HeaderModel |
UserPanel |
server |
stable | UserPanelModel |
Navigation |
server |
stable | NavigationModel |
Footer |
server |
stable | FooterModel |
Notice |
server |
stable | NoticeModel |
Announcement |
server |
stable | AnnouncementModel |
BoardIndex |
server |
stable | BoardIndexModel |
CategoryBlock |
server |
stable | CategoryBlockModel |
ForumRow |
server |
stable | ForumRowSlotModel |
BoardStats |
server |
stable | BoardStatsModel |
WhoIsOnline |
server |
stable | WhoIsOnlineModel |
LatestThreads |
server |
stable | LatestThreadsModel |
LatestPosts |
server |
stable | LatestPostsModel |
ForumDisplay |
server |
stable | ForumDisplayModel |
ThreadRow |
server |
stable | ThreadRowSlotModel |
SubforumList |
server |
stable | SubforumListModel |
Pagination |
server |
stable | PaginationModel |
ThreadView |
server |
stable | ThreadViewModel |
PostBit |
server |
stable | PostBitSlotModel |
PostActions |
server |
stable | PostActionsSlotModel |
QuickReply |
client |
provisional | QuickReplyModel |
PostForm |
server |
stable | PostFormModel |
EditorToolbar |
client |
provisional | EditorToolbarModel |
MemberProfile |
server |
stable | MemberProfileModel |
SearchForm |
server |
stable | SearchFormModel |
ForumJump |
server |
stable | ForumJumpModel |
RedirectNotice |
server |
stable | RedirectNoticeModel |
ErrorNotice |
server |
stable | ErrorNoticeModel |
Slot reference
Shell
server · stable
The outermost frame: skip link, header, main landmark, footer. Wraps every page including the error pages.
Props: ShellModel
| Field | Type | Notes |
|---|---|---|
boardTitle |
string |
|
viewer |
ViewerModel |
|
children |
ReactNode |
optional |
Header
server · stable
Board title or logo, and the region the user panel sits in.
Props: HeaderModel
| Field | Type | Notes |
|---|---|---|
boardTitle |
string |
|
homeHref |
string |
|
viewer |
ViewerModel |
|
navigation |
readonly LinkModel[] |
|
logo |
LogoModel | undefined |
optional — The board's logo, when it has one. A theme that ignores this renders the board's name and is still correct — which is what makes the field additive rather than breaking. A theme that uses it should keep the name as the link's accessible content when there is no logo, because the header is the only link home on most pages. |
children |
ReactNode |
optional |
UserPanel
server · stable
Greeting and account links, or the sign-in prompt for a guest. Varies by actor, which is why no page wrapping it may be cached globally.
Props: UserPanelModel
| Field | Type | Notes |
|---|---|---|
viewer |
ViewerModel |
|
links |
readonly LinkModel[] |
Sign-in / register, or account links. Resolved by the app. |
unreadNotifications |
number |
0 when there is nothing to show. |
unreadMessages |
number |
|
children |
ReactNode |
optional — Account controls the app supplies — today, the log-out form. Log out cannot be a LinkModel: it is a POST to a Server Action, because a GET that ends a session is fired by every prefetcher and link scanner that touches the page. A Server Action reference is also not plain data and could never cross this contract, so the app renders the form and the theme decides where in the panel it sits. |
Navigation
server · stable
The breadcrumb trail. Board → category → forum → thread.
Props: NavigationModel
| Field | Type | Notes |
|---|---|---|
items |
readonly LinkModel[] |
Footer
server · stable
Board footer: copyright, timezone note, links.
Props: FooterModel
| Field | Type | Notes |
|---|---|---|
boardTitle |
string |
|
links |
readonly LinkModel[] |
|
timezoneLabel |
string |
Which zone TimeModel.labels were formatted in, for the footer note. |
poweredBy |
LinkModel |
optional — What the board runs on, and where to read about it (0.8). A LinkModel and not a hardcoded string in each theme, for the reason every other piece of footer text is one: the app owns the words and the URL, so they are written once and a theme that wants to place the attribution somewhere else in its layout can, without owning a copy of them. Optional, which is what makes it a minor rather than a major: a theme written against 0.7 compiles and runs unchanged, and simply does not render it. The two themes in this repository do. |
Notice
server · stable
A board-wide announcement or a flash message. Server-rendered so a notice is present in the first response, not after hydration.
Props: NoticeModel
| Field | Type | Notes |
|---|---|---|
kind |
'info' | 'success' | 'warning' | 'error' |
|
message |
string |
|
dismissHref |
string | null |
Announcement
server · stable
One announcement: a dated, authored notice shown above the forums. Distinct from Notice, which is a flash message about what the viewer just did — these are for everybody and last until they expire.
Props: AnnouncementModel
| Field | Type | Notes |
|---|---|---|
title |
string |
|
bodyHtml |
string |
Trusted HTML, from @meith/markdown's own renderer — the same contract as a post body, and the reason a theme inserts it rather than escaping it. |
postedBy |
UserRefModel | null |
|
postedAt |
TimeModel |
|
forum |
LinkModel | null |
The forum it belongs to, or null when it is board-wide. |
BoardIndex
server · stable
The index page body: the ordered list of category blocks.
Props: BoardIndexModel
| Field | Type | Notes |
|---|---|---|
markAllReadAction |
string | null |
The "mark all read" target — a form target, not a client handler. |
regions |
{ /** One CategoryBlockper top-level category, already rendered. */ readonly categories: ReactNode readonly stats: ReactNode readonly online: ReactNode /** * The self-refreshing pair: newest threads and newest posts, already * rendered, or absent on a board that cannot answer either question. * * **One region rather than two, and that is the contract rather than a * convenience.** The pair is refreshed by a single round trip while the page * is open, so it arrives as one node; two regions would be two polls of the * same board for the same reason, or one poll that could only update half of * what a theme had placed. A theme places it — the default puts it at the * top of a sidebar — but does not take it apart. * * Optional, so a theme written against an earlier minor compiles and simply * does not show it. Same rule as every other region field here. */ readonly latest?: ReactNode /** * Theindex.footerregion: whatever plugins contributed, already * rendered and ordered by the host. * * Optional, which is what makes this a **minor** addition under the * versioning policy — a theme written against 0.1 keeps compiling and simply * does not render plugin output. Every region field below follows the same rule. */ readonly plugins?: ReactNode /** * Live announcements, already rendered — oneAnnouncement per row, * or absent when there are none. * * Optional for the same reason the plugin region is, and under the same * policy: a theme written against an earlier minor compiles and simply does * not show them. */ readonly announcements?: ReactNode } |
CategoryBlock
server · stable
One top-level category and the forum rows under it.
Props: CategoryBlockModel
| Field | Type | Notes |
|---|---|---|
category |
ForumRowModel |
|
children |
ReactNode |
optional |
ForumRow
server · stable
One forum in a listing: title, description, counters, last post, subforum links.
Props: ForumRowSlotModel
| Field | Type | Notes |
|---|---|---|
forum |
ForumRowModel |
BoardStats
server · stable
Board totals and the newest member.
Props: BoardStatsModel
| Field | Type | Notes |
|---|---|---|
threadCount |
number |
|
postCount |
number |
|
memberCount |
number |
|
newestMember |
UserRefModel | null |
|
computedAt |
TimeModel | null |
When the totals were last rolled up, or null before the first run. Part of the contract rather than a detail the app hides, because a theme that shows the numbers should be able to say how old they are — and "computed ten minutes ago" is the difference between a number that is stale and one that is wrong. |
WhoIsOnline
server · stable
The online list and its record.
Props: WhoIsOnlineModel
| Field | Type | Notes |
|---|---|---|
guestCount |
number |
|
members |
readonly OnlineMemberModel[] |
|
total |
number |
Members plus guests, as this reader is permitted to count them. |
recordCount |
number |
|
recordAt |
TimeModel | null |
|
fullListHref |
string |
The full list, for a theme that shows only a summary here. |
LatestThreads
server · stable
The newest threads on the board, for the index sidebar. Server, not client, even though the panel refreshes itself: the app polls a Server Action that renders this slot again, so the live half is one island around the region rather than a client component per panel.
Props: LatestThreadsModel
| Field | Type | Notes |
|---|---|---|
threads |
readonly LatestThreadModel[] |
|
capturedAt |
TimeModel |
LatestPosts
server · stable
The newest posts on the board, with an excerpt of each. Same server rendering and same refresh path as LatestThreads.
Props: LatestPostsModel
| Field | Type | Notes |
|---|---|---|
posts |
readonly LatestPostModel[] |
|
capturedAt |
TimeModel |
ForumDisplay
server · stable
A forum page body: subforums, thread list, pagination.
Props: ForumDisplayModel
| Field | Type | Notes |
|---|---|---|
forum |
ForumRowModel |
|
newThreadHref |
string | null |
|
markReadAction |
string | null |
|
regions |
{ /** * Controls scoped to this forum — the thread ordering, and the follow * form for a member who may subscribe. Rendered by the route because both * carry a Server Action or a URL contract the theme does not own. * * **A theme renders this under its heading, not above it.** That placement * is the reason the field exists: these were app-rendered strips stacked * *before* ForumDisplay, so the first thing on a forum page was a filter * with nothing yet to say what it filtered. A control belongs after the * thing it acts on has been named. * * Optional, which is what makes it a **minor** addition under the * versioning policy — a theme written against 0.3 keeps compiling. * * Only what acts on the listing *below* it belongs here. Following the * forum is in afterContent, for the reason given there. */ readonly tools?: ReactNode readonly subforums: ReactNode /** One ThreadRowper thread. Empty-state markup is the theme's. */ readonly threads: ReactNode readonly pagination: ReactNode /** * This forum's announcements *and* the board's — an announcement being * board-wide would mean little if it appeared only on the index, which is * the page fewest people arrive on. */ readonly announcements?: ReactNode /** * Controls for somebody who has finished with the page — today, the form * that follows this forum. * * A theme renders it after the listing. "Do you want to hear about this * forum?" is a question you can only answer once you have seen what is in * it, and asked above the threads it is a panel between a reader and the * thing they came for. The ordering tabs stay at the top intools, * because those act on the list underneath them. */ readonly afterContent?: ReactNode } |
ThreadRow
server · stable
One thread in a listing: prefix, title, author, counters, last post.
Props: ThreadRowSlotModel
| Field | Type | Notes |
|---|---|---|
thread |
ThreadRowModel |
|
select |
SelectionModel | null |
The inline-moderation checkbox, or null. |
SubforumList
server · stable
The compact list of child forums shown above a thread list.
Props: SubforumListModel
| Field | Type | Notes |
|---|---|---|
forums |
readonly ForumRowModel[] |
Pagination
server · stable
Page links. Server-rendered and href-based: paging must work with JavaScript disabled, so this can never become an island.
Props: PaginationModel
| Field | Type | Notes |
|---|---|---|
page |
number |
|
pageCount |
number |
|
pages |
readonly { readonly page: number readonly href: string readonly isCurrent: boolean }[] |
|
previousHref |
string | null |
|
nextHref |
string | null |
ThreadView
server · stable
A thread page body: the post list, pagination, reply affordance.
Props: ThreadViewModel
| Field | Type | Notes |
|---|---|---|
thread |
ThreadRowModel |
|
forum |
LinkModel |
|
replyHref |
string | null |
|
markReadAction |
string | null |
A native POST target for the last visible post on this page. |
regions |
{ /** * Controls scoped to this thread — following it, rating it, its poll, and * the moderator's thread tools. Rendered by the route, for the reason * every app-rendered region exists: each one carries a Server Action. * * **A theme renders this under its heading, not above it**, and the same * history is behind this field as behind ForumDisplayModel's. Four of * these strips used to stack before ThreadView, so a thread opened on a * phone began with a follow control, a star rating and a poll, and the * title of the thing being followed, rated and voted on was a screen * further down. * * Only what belongs *before* the posts: the moderator's bar, and the * poll, which is content rather than a control. Rating and following are * in afterContent. * * Optional under the versioning policy: a theme written against 0.3 compiles * and simply does not offer them. */ readonly tools?: ReactNode /** One PostBitper post on this page. */ readonly posts: ReactNode readonly pagination: ReactNode /** * Controls for a reader who has reached the end — rating the thread, and * following it. * * A theme renders it after the posts and **before** the quick reply, which * is the order the two are wanted in: somebody who has just read fifty * posts is deciding what they think and whether to keep hearing about it, * and then whether to answer. Both used to be above the first post, where * they were asking for a verdict on something the reader had not read yet. */ readonly afterContent?: ReactNode /** * The quick-reply island, ornull when the viewer may not reply — in which * case nothing is rendered and no island bytes are shipped. */ readonly quickReply: ReactNode } |
PostBit
server · stable
One post: author block, body, footer. The load-bearing server slot — see this file’s header for what marking it client costs.
Props: PostBitSlotModel
| Field | Type | Notes |
|---|---|---|
post |
PostBitModel |
|
select |
SelectionModel | null |
The inline-moderation checkbox, or null. A theme that ignores it loses only bulk actions. |
regions |
{ /** The PostActionsslot, rendered by the page. */ readonly actions: ReactNode /** Thepostbit.badgesregion, beside the author's name. */ readonly pluginBadges?: ReactNode /** Thepostbit.footer region, below the body. */ readonly pluginFooter?: ReactNode } |
PostActions
server · stable
Per-post controls (quote, edit, report, moderate). Links and forms, not buttons with handlers, so they work without JavaScript.
Props: PostActionsSlotModel
| Field | Type | Notes |
|---|---|---|
actions |
PostActionsModel |
|
postId |
number |
|
children |
ReactNode |
optional — App-rendered controls that belong beside the post's own actions — today, the multi-quote island. It is children for the reason logging out is: the button is a client island holding browser state, and neither a component nor a handler can cross this contract as data. Before this field the page had nowhere to put it but PostBitModel.regions.pluginFooter, so every post on the board carried a second bordered row containing one control — the plugin region used as a parking space, and a visible band of furniture per post as the price. Additive under the versioning policy, and children is already exempt from the plain-data rule. |
QuickReply
client · provisional
The inline reply island at the foot of a thread. Enhances the full reply page; it never becomes the only way to reply.
Props: QuickReplyModel
| Field | Type | Notes |
|---|---|---|
action |
string |
|
threadId |
number |
|
placeholder |
string |
|
submitLabel |
string |
|
fullReplyHref |
string |
Where the no-JS reply form lives, for when the island is not rendered. |
PostForm
server · stable
The composer page: subject, message, prefix, options. A native form posting to a Server Action — the editor toolbar is the island, not this.
Props: PostFormModel
| Field | Type | Notes |
|---|---|---|
mode |
'thread' | 'reply' | 'edit' |
|
heading |
string |
e.g. "Post a new thread in General". |
cancelHref |
string |
Where a cancel link returns to — the forum, or the thread being replied to. |
cancelLabel |
string |
|
errorMessage |
string | null |
|
regions |
{ /** The app-rendered <form>carrying the Server Action and its controls. */ readonly form: ReactNode /** * TheEditorToolbarisland, ornull. A null here must leave a working * plain-textarea form: the island enhances, it never enables. */ readonly toolbar: ReactNode } |
EditorToolbar
client · provisional
Formatting toolbar, preview, attachment picker. Mounted beside the textarea; removing it must leave a working plain-textarea form.
Props: EditorToolbarModel
| Field | Type | Notes |
|---|---|---|
textareaId |
string |
The textarea's id; the island attaches to it rather than owning it. |
buttons |
readonly { readonly tag: string readonly label: string readonly icon: string | null }[] |
|
previewAction |
string | null |
MemberProfile
server · stable
A member’s profile page body: identity, stats, recent activity.
Props: MemberProfileModel
| Field | Type | Notes |
|---|---|---|
user |
UserRefModel |
|
avatarUrl |
string | null |
|
title |
string | null |
|
joinedAt |
TimeModel |
|
lastVisitAt |
TimeModel | null |
|
postCount |
number |
|
signatureHtml |
string | null |
|
fields |
readonly { readonly label: string; readonly value: string }[] |
Custom profile fields, already filtered by visibility. |
actions |
readonly LinkModel[] |
|
regions |
{ /** The profile.panel region. */ readonly plugins?: ReactNode } |
optional |
SearchForm
server · stable
The search form. A GET form with named inputs, so a search is a URL that can be linked and cached.
Props: SearchFormModel
| Field | Type | Notes |
|---|---|---|
action |
string |
Where the form submits. A GET form: a search is a URL. |
fields |
{ readonly query: string readonly forum: string readonly sort: string } |
The names to give the controls, owned by the app. |
query |
string |
|
maxQueryLength |
number |
The server's limit, so the browser can refuse over-long input first. |
forums |
readonly OptionModel[] |
Forums this viewer may search. The first option is "everywhere". |
sorts |
readonly OptionModel[] |
|
hint |
string | null |
Guidance for an empty form: quoting, exclusion. null once submitted. |
errorMessage |
string | null |
ForumJump
server · stable
The jump box at the foot of every page. A GET form with a submit control, never a select that navigates on change — choosing an option is not committing to it, and arrow-keying through one would teleport a keyboard user to the first forum in the list.
Props: ForumJumpModel
| Field | Type | Notes |
|---|---|---|
action |
string |
Where the form submits. GET, because a jump is a navigation. |
field |
string |
The query-parameter name to give the select. The app owns it. |
forums |
readonly ForumJumpOption[] |
Visible forums, in tree order. |
submitLabel |
string |
The label for the submit control. Always rendered. |
label |
string |
Accessible name for the control, e.g. "Jump to forum". |
RedirectNotice
server · stable
The MyBB-style interstitial: "your post was made, continuing in a moment", with a real link for anyone the meta refresh does not carry.
Props: RedirectNoticeModel
| Field | Type | Notes |
|---|---|---|
message |
string |
|
targetHref |
string |
|
delaySeconds |
number |
ErrorNotice
server · stable
The themed body of an error or not-found page. Must not depend on the database: it is what renders when the database is the thing that failed.
Props: ErrorNoticeModel
| Field | Type | Notes |
|---|---|---|
status |
number |
|
title |
string |
|
message |
string |
|
homeHref |
string |
|
requestId |
string | null |
The request id, so a user can quote it in a report. |
Shared models
Referenced by the models above. Same promise: a field of a shared model reached from a stable slot is stable.
ForumJumpOption
| Field | Type | Notes |
|---|---|---|
value |
string |
|
label |
string |
|
depth |
number |
0 for a top-level category. The theme chooses how to show nesting. |
isCategory |
boolean |
A category is a heading, not a destination — rendered disabled. |
isSelected |
boolean |
ForumRowModel
Submitted as the form value. Opaque to the theme. readonly value: string readonly label: string readonly isSelected: boolean } /* ------------------------------------------------------------------ * Listing models ------------------------------------------------------------------
| Field | Type | Notes |
|---|---|---|
id |
number |
|
title |
string |
|
description |
string | null |
|
href |
string |
|
type |
'category' | 'forum' | 'link' |
link rows navigate away and have no counters. |
threadCount |
number |
|
postCount |
number |
|
lastPost |
LastPostModel | null |
|
isUnread |
boolean |
false for a guest, who has no read state. |
subforums |
readonly LinkModel[] |
LastPostModel
The last post in a forum or thread, as a listing shows it.
| Field | Type | Notes |
|---|---|---|
threadTitle |
string |
|
href |
string |
Deep link to the post itself, not the thread's first page. |
author |
UserRefModel |
|
at |
TimeModel |
LatestPostModel
One post in the index's "latest posts" panel.
| Field | Type | Notes |
|---|---|---|
threadTitle |
string |
The thread it is in. A post has no title of its own. |
href |
string |
/thread/12-slug?post=34 — the post, not the top of its thread. |
forum |
LinkModel |
|
author |
UserRefModel |
|
excerpt |
string |
The post as text: flattened out of its Markdown source and cut on a word boundary, the same way a feed entry's summary is. Flattened rather than rendered, because the board's HTML carries quotes, directives and attachment markup whose meaning is lost in two lines — and because a theme dropping raw post HTML into a sidebar is one plugin away from being an injection point. |
postedAt |
TimeModel |
LatestThreadModel
One thread in the index's "latest threads" panel. Every row carries its forum, because these two panels are the only lists on the board that cross it: without the forum, two identically-titled threads in two forums are the same row printed twice.
| Field | Type | Notes |
|---|---|---|
title |
string |
|
href |
string |
|
forum |
LinkModel |
The forum it was started in, resolved — a theme never builds an href. |
author |
UserRefModel |
|
replyCount |
number |
|
startedAt |
TimeModel |
LinkModel
A resolved link. Themes never build hrefs; the app owns URL shape.
| Field | Type | Notes |
|---|---|---|
label |
string |
|
href |
string |
LogoModel
A board's logo, already resolved for this reader's colour scheme. Optional, and absent on most boards: a board with no logo renders its name in text, which is what every board did before this field existed. The app resolves the scheme, not the theme. A theme cannot do it, and the obvious attempt is wrong in the commonest case: dark:hidden matches the .dark class, and a reader who has chosen "system" has no class — their dark mode comes from a media query. They would get the light logo on a black page, which is the exact failure two images exist to prevent. The server knows the answer, so it gives one.
| Field | Type | Notes |
|---|---|---|
src |
string |
The image to render. Already the right one for a forced colour scheme. |
darkSrc |
string | null |
A dark-scheme source, or null. Non-null means "wrap it in a <picture> and put this behind (prefers-color-scheme: dark)" — the reader is on "system" and has two images to choose between. Null covers three different situations a theme does not need to tell apart: one image, or a reader who has forced a scheme, in which case src is already the right one. |
alt |
string |
Never empty — the board's name when the operator has set nothing. |
OnlineMemberModel
One visitor in the online list. location is already resolved against the reader: a forum they may not see arrives as the bare label, never as a title with a link. The theme renders what it is given and cannot leak what it was not.
| Field | Type | Notes |
|---|---|---|
userId |
number | null |
from UserRefModel — null when the account was deleted; username is still shown. |
username |
string |
from UserRefModel |
profileHref |
string | null |
from UserRefModel |
nameClass |
string | null | undefined |
from UserRefModel — optional — A class carrying this member's group colour, or null for most members. A theme should put this on whatever renders the name, wherever a name appears. It is a class rather than a colour because the value has to differ between light and dark, and a style attribute cannot hold two answers — a reader on "system" has no .dark class at all, so the only place both can live is the stylesheet the app emits into <head>. A theme that ignores it renders the name in the ordinary text colour and is still correct, which is what makes the field additive. It will simply not show the board's own hierarchy, which most boards will notice. |
location |
{ readonly label: string; readonly href: string | null } |
Where they are, as this reader may be told. Never null — see label. |
isInvisible |
boolean |
True only for staff, who see hidden members marked rather than absent. |
lastSeen |
TimeModel |
OptionModel
One choice in a <select> or a radio group, with the current one marked. isSelected rather than a separate selected field on the parent: a theme renders options in a loop, and "which of these is current" answered per option is one comparison the theme does not have to write — and cannot write wrongly by comparing a string to a number.
| Field | Type | Notes |
|---|---|---|
value |
string |
Submitted as the form value. Opaque to the theme. |
label |
string |
|
isSelected |
boolean |
PostActionsModel
| Field | Type | Notes |
|---|---|---|
quoteHref |
string | null |
|
editHref |
string | null |
|
restoreHref |
string | null |
Where a soft-deleted post is put back. A separate field rather than a second meaning for editHref, because the two are never both offered: a deleted post cannot be edited, and a visible one has nothing to restore. A theme that renders both gets exactly one. |
reportHref |
string | null |
|
warnHref |
string | null |
Warn this post's author, citing this post. Present for moderators only, and null for a post whose author is the viewer or a deleted account. Separate from moderateHref because a warning is aimed at the person and the post is only the evidence — which is also why the link carries the post id rather than living on the post's own moderation controls. |
moderateHref |
string | null |
Reserved for per-post moderation controls that are not inline. Still null everywhere: per-post moderation is on checkboxes and a bar rather than a per-post link, so nothing fills this yet. It stays in the contract because the moderation control panel is where such a page would live, and removing a public field to add it back next feature is worse than a documented null. |
rateHref |
string | null |
Rate this post's author, for this post. Null on your own post, on a board with reputation off, and for anybody without the permission. It carries the post so the rating is attached to this post rather than to the author generally — which is what makes one rating per post a meaningful rule. |
PostAttachmentModel
One file attached to a post.
| Field | Type | Notes |
|---|---|---|
id |
number |
|
filename |
string |
Sanitised, and always ending in the extension the bytes imply. |
size |
string |
Already formatted — "1.4 MB" — because a theme is not a unit converter. |
isImage |
boolean |
Whether the board is willing to show this inline rather than link it. |
href |
string |
The download. Permission is re-checked on every fetch. |
thumbnailHref |
string | null |
|
width |
number | null |
|
height |
number | null |
PostAuthorModel
The author block beside a post.
| Field | Type | Notes |
|---|---|---|
userId |
number | null |
from UserRefModel — null when the account was deleted; username is still shown. |
username |
string |
from UserRefModel |
profileHref |
string | null |
from UserRefModel |
nameClass |
string | null | undefined |
from UserRefModel — optional — A class carrying this member's group colour, or null for most members. A theme should put this on whatever renders the name, wherever a name appears. It is a class rather than a colour because the value has to differ between light and dark, and a style attribute cannot hold two answers — a reader on "system" has no .dark class at all, so the only place both can live is the stylesheet the app emits into <head>. A theme that ignores it renders the name in the ordinary text colour and is still correct, which is what makes the field additive. It will simply not show the board's own hierarchy, which most boards will notice. |
avatarUrl |
string | null |
|
title |
string | null |
The display group's title, or a custom user title. Was null on every post the board has ever rendered — the field was in the contract from the start and nothing populated it, so every theme's postbit had a place for a member's standing and nothing to put in it. It comes from users.display_group_id, falling back to the primary group. |
badge |
LogoModel | null | undefined |
optional — The board's badge for this member's group, or null. Shaped exactly like LogoModel and for the same reason: the app has already chosen which of the two images this reader gets, so darkSrc is non-null only for a reader on "system", where the server cannot know. |
reputation |
number | null | undefined |
optional — This member's reputation, or null when the board has it switched off. A denormalised counter on users, so it costs the postbit nothing. |
postCount |
number |
|
joinedAt |
TimeModel | null |
|
signatureHtml |
string | null |
Pre-rendered Markdown. Trusted output of the board's own renderer. |
isOnline |
boolean |
|
fields |
readonly { readonly label: string; readonly value: string }[] |
Custom profile fields, for the ones an operator marked for the postbit and this viewer may see. The same {label, value} shape MemberProfileModel.fields uses, and plain text for the same reason: it is rendered as text by the theme, and a field that could carry markup is stored XSS on the board's heaviest page. Empty on a board with no custom fields, which is most of them. |
PostBitModel
| Field | Type | Notes |
|---|---|---|
id |
number |
|
number |
number |
Position within the thread, 1-based. What "#12" in the corner means. |
permalink |
string |
/thread/12-slug#post-3 — anchored by number, so the link says what the corner says. |
author |
PostAuthorModel |
|
bodyHtml |
string |
Pre-rendered Markdown. |
quoteSource |
string |
@deprecated Since theme API 1.4, removed in 2.0. Use post.id. It existed so the client could assemble a quote out of the page. Quoting asks the server for a post by id now, which re-checks who may see it and cannot hand back what a deleted post used to say — so this is a copy of every post's source in the HTML of every thread page, for nobody. Still populated, because a theme could have read it; see DEPRECATIONS. |
postedAt |
TimeModel |
|
editedNote |
string | null |
"Last edited by X on Y", already assembled, or null. |
isFirstPost |
boolean |
|
visibility |
'visible' | 'unapproved' | 'deleted' |
A moderator sees deleted and unapproved posts, marked as such. |
ignored |
{ readonly authorUsername: string /** Same page, this post revealed. A GET: revealing changes nothing. */ readonly revealHref: string } | null |
Set when this viewer ignores the author and has not revealed this post; null otherwise, which is the case on almost every post. The body is withheld server-side when this is set — bodyHtml is empty, the signature and custom fields are gone — rather than hidden with CSS, because "ignored" that ships the text to the browser is a preference rather than a feature. The post keeps its place and its number: filtering it out would give every viewer a different page size and make "#12" mean different posts to different people. A theme renders the placeholder and the link. Both are required — a hidden post with no way to see it is a hole in a conversation. |
attachments |
readonly PostAttachmentModel[] |
The files attached to this post. Empty on almost every post, and empty rather than absent so a theme has one shape to render. Every entry is already downloadable: a pending upload — one whose re-encode has not finished — and a failed one are not in this list, because a link to a file that is not there yet is worse than the file appearing a minute later. thumbnailHref is null for anything that is not an image, and for an image small enough that a thumbnail would be the same picture again. A theme showing an image inline uses thumbnailHref ?? href and gets the right answer in both cases. |
actions |
PostActionsModel |
PrefixModel
A thread prefix; token supplies its styling.
| Field | Type | Notes |
|---|---|---|
label |
string |
|
token |
string | null |
SelectionModel
One inline-moderation checkbox, or null when this viewer has no business selecting rows. Plain data, and it has to be: the form it belongs to carries a Server Action reference, and such references never cross the theme contract. So the app renders the form — below the listing, where a bar of buttons belongs — and the theme renders a checkbox that says which form it belongs to. formId is the whole trick, and it is why this works with scripting off. HTML's form attribute associates a control with a form by id, anywhere in the document, so the checkboxes can live inside table rows, list items or article elements without the listing having to be wrapped in a <form> — which it cannot be, because ForumDisplay already renders a mark-read form and nested forms are not a thing browsers will parse.
| Field | Type | Notes |
|---|---|---|
name |
string |
The field name every checkbox shares. |
value |
string |
This row's value, opaque to the theme. |
formId |
string |
The id of the app-rendered form these checkboxes submit with. |
label |
string |
For a visually-hidden label: "Select 'How do I …' for moderation". |
ThreadRowModel
| Field | Type | Notes |
|---|---|---|
id |
number |
|
title |
string |
|
href |
string |
|
prefix |
PrefixModel | null |
|
author |
UserRefModel |
|
replyCount |
number |
|
viewCount |
number |
|
isSticky |
boolean |
|
isLocked |
boolean |
|
isUnread |
boolean |
|
isMoved |
boolean |
Set when the thread is a move stub; the row renders as a redirect. |
lastPost |
LastPostModel | null |
TimeModel
A timestamp, in both forms a template needs. See this file's header.
| Field | Type | Notes |
|---|---|---|
iso |
string |
ISO-8601 UTC. Goes in <time datetime>; never rendered raw. |
label |
string |
Preformatted in the viewer's timezone, e.g. "Today, 09:14" or "12 Mar 2026". |
UserRefModel
A user as they appear attached to content.
| Field | Type | Notes |
|---|---|---|
userId |
number | null |
null when the account was deleted; username is still shown. |
username |
string |
|
profileHref |
string | null |
|
nameClass |
string | null | undefined |
optional — A class carrying this member's group colour, or null for most members. A theme should put this on whatever renders the name, wherever a name appears. It is a class rather than a colour because the value has to differ between light and dark, and a style attribute cannot hold two answers — a reader on "system" has no .dark class at all, so the only place both can live is the stylesheet the app emits into <head>. A theme that ignores it renders the name in the ordinary text colour and is still correct, which is what makes the field additive. It will simply not show the board's own hierarchy, which most boards will notice. |
ViewerModel
Who is looking. The only actor data a theme is given.
| Field | Type | Notes |
|---|---|---|
isGuest |
boolean |
|
userId |
number | null |
null for a guest. |
username |
string | null |
|
profileHref |
string | null |
|
avatarUrl |
string | null |
|
canAccessAdminCp |
boolean |
Whether to render the admin-panel link. A rendering hint, resolved by the Authorizer already — a theme must never conclude anything about permissions on its own, and themes stay out of authorization entirely. |
canAccessModCp |
boolean |
Whether to render the moderation link. Same shape and same rule as canAccessAdminCp: a rendering hint the Authorizer has already decided. Group-level only, which is a real limitation rather than an oversight: a per-forum appointee's queue exists and is reachable, but answering "does this person moderate anything" for them costs the tree, and the shell renders on every page. The moderation control panel is where that link earns its query. |
Scheduled removals
Nothing is deprecated in v0.9. Nothing can be: this is the first frozen contract, so there is no earlier promise to withdraw.