Skip to content
Meith
Documents
Themes

Theme slots and view models

docs/theme-slots.mdgenerated from the code

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

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.

server · stable

The breadcrumb trail. Board → category → forum → thread.

Props: NavigationModel

Field Type Notes
items readonly LinkModel[]

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 UserRefModelnull 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 UserRefModelnull 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.