programmati.ca Open Studio

Examples · Bulletin board

A web bulletin board: 117 lines of AppSPEC

A bulletin board with sign-in, boards, threads and replies, starting new threads, likes on every post and live search.

Running live in the sandbox with fixed demo data. Anything you add stays in this browser.

The state graph

7 screens, 7 wires

Screens are nodes and WIRE labels are edges. Screens in blue appear in the navigation; orange edges write a record on the way.

loginhomeboardsboardthreadnew_postsearchboardsboardsboardboardthreadthreadthreadnew_postnew_postreplypublish

How it works

Walking through the spec

01Screens are states

7 screens, each a <STATE>. home, boards, search appear in the navigation because they carry a NAV label. Children lay themselves out by containment: ROW, COL and GRID arrange them, and everything else stacks.

<STATE ID="login" ALIGN="center">
…
<STATE ID="home" NAV="Home" ICON="house">
…
<STATE ID="boards" NAV="Boards" ICON="layout-grid">
…
<STATE ID="board">
…
<STATE ID="thread" WIDTH="820px">
…
<STATE ID="new_post" WIDTH="720px">
…
<STATE ID="search" NAV="Search" ICON="search">

02Screens connect by label

Each <WIRE> names an action and where it leads. A button, card or row that fires the label follows the wire; the clicked record travels with it. This app declares 7 wires.

<WIRE LABEL="login" TO="home" />
<WIRE LABEL="boards" TO="boards" />
<WIRE LABEL="board" TO="board" />
<WIRE LABEL="thread" TO="thread" />
<WIRE LABEL="new_post" TO="new_post" />
<WIRE LABEL="reply" SAVE="posts" SET="thread={threads.id}; author={user}; time=just now" TO="thread" />
<WIRE LABEL="publish" SAVE="threads" SET="author={user}; board_name={board_label}; time=just now; replies=0; views=1; icon=message-circle; body={opening}" TO="boards" />

03Data flows through the key-value pipe

Every input publishes its value into the pipe under its ID, and every screen can read the pipe with {placeholders}. Writes are declared on wires: reply save posts, publish save threads. SET adds key-value pairs to the record being written.

  <SECTION TITLE="{boards.name}" SUBTITLE="{boards.desc}" ACTION="new_post" LABEL="New thread" />
  <TABLE SRC="threads" WHERE="board={boards.id}" SORT="pinned desc, views desc" ACTION="thread" EMPTY="No threads yet. Start one.">
…
    <TEXT STYLE="h1" TEXT="{threads.title}" />
    <TEXT STYLE="muted" TEXT="Started by {threads.author} in {threads.board_name} · {threads.views} views" />
…
  <CARD STYLE="flat" IF="{threads.body}"><TEXT TEXT="{threads.body}" /></CARD>
  <REPEAT SRC="posts" WHERE="thread={threads.id}" SORT="is_op desc, created" GAP="12px" EMPTY="No posts in this thread yet.">
…
    <INPUT ID="body" TYPE="textarea" PLACEHOLDER="Write a reply…" REQUIRED="true" />
…
  <SELECT ID="board" LABEL="Board" SRC="boards" PLACEHOLDER="Choose a board…" VALUE="{boards.id}" REQUIRED="true" />
  <INPUT ID="title" LABEL="Title" PLACEHOLDER="What is it about?" REQUIRED="true" />
…
  <INPUT ID="query" TYPE="search" PLACEHOLDER="Search thread titles…" LIVE="true" />
…
<WIRE LABEL="reply" SAVE="posts" SET="thread={threads.id}; author={user}; time=just now" TO="thread" />
<WIRE LABEL="publish" SAVE="threads" SET="author={user}; board_name={board_label}; time=just now; replies=0; views=1; icon=message-circle; body={opening}" TO="boards" />

04Social components attach themselves

LIKES bind to the record on screen through its GUID (for example posts/…). The spec never says which record: the engine supplies it, so the same tags work unchanged on a video, a product or a story.

<ROW><LIKES /></ROW>

05Components made of components

<DEFINES> builds SECTION, POSTCARD from existing components, with named parameters, and the states use them like built-in tags.

<DEFINES>
  <!-- A section header: title, subtitle and an optional action -->
  <SECTION TITLE="" SUBTITLE="" ACTION="" LABEL="">
    <ROW JUSTIFY="between">
      <COL GAP="2px">
        <TEXT STYLE="h2" TEXT="{title}" />
        <TEXT STYLE="muted" TEXT="{subtitle}" />
      </COL>
      <BUTTON ACTION="{action}" LABEL="{label}" ICON="pencil" IF="{label}" />
    </ROW>
  </SECTION>

  <!-- One post in a thread -->
  <POSTCARD>
    <CARD STYLE="flat" GAP="10px">
      <ROW GAP="10px">
        <AVATAR NAME="{author}" TEXT="{avatar}" />
        <COL GAP="0">
          <ROW GAP="8px"><TEXT STYLE="h3" TEXT="{author}" /><BADGE TEXT="OP" IF="{is_op}" /></ROW>
          <TEXT STYLE="small" TEXT="{time|default:just now}" />
        </COL>
      </ROW>
      <TEXT TEXT="{body}" />
      <ROW><LIKES /></ROW>
    </CARD>
  </POSTCARD>
</DEFINES>

06One attribute sets the look

THEME="modern-minimal" sets every colour, radius and typeface. Change the word and the whole app restyles; all 31 themes work with every component.

<APP ID="bbs" NAME="The Bulletin Board" THEME="modern-minimal" LOGO="messages-square">

What this would typically take

The pieces a conventional stack needs

A typical implementation of the same app assembles these parts, each with its own code, libraries and configuration. The size depends on the team and the tools, so we do not quote a number for it.

Here the same app is one 117-line document built from general-purpose components: AVATAR, BADGE, BUTTON, CARD, CARDS, HERO, INPUT, LIKES, LOGIN, POSTCARD, REPEAT, SECTION, SELECT, TABLE, TEXT.

AuthenticationA sign-in screen and a session that other screens can read.
RoutingRoutes for home, boards, a board, a thread, new thread and search.
Data APIEndpoints to list boards and threads, read a thread and post replies and threads.
FormsValidation, submission and clearing of the reply and new-thread forms.
SearchLive filtering of thread titles.
Reusable viewsPost and section components shared across screens.
Build toolingA bundler, a dev server and a build configuration.
The full specbbs.appspec · 117 lines
<APP ID="bbs" NAME="The Bulletin Board" THEME="modern-minimal" LOGO="messages-square">

  <DATA SRC="api/" />

  <DEFINES>
    <!-- A section header: title, subtitle and an optional action -->
    <SECTION TITLE="" SUBTITLE="" ACTION="" LABEL="">
      <ROW JUSTIFY="between">
        <COL GAP="2px">
          <TEXT STYLE="h2" TEXT="{title}" />
          <TEXT STYLE="muted" TEXT="{subtitle}" />
        </COL>
        <BUTTON ACTION="{action}" LABEL="{label}" ICON="pencil" IF="{label}" />
      </ROW>
    </SECTION>

    <!-- One post in a thread -->
    <POSTCARD>
      <CARD STYLE="flat" GAP="10px">
        <ROW GAP="10px">
          <AVATAR NAME="{author}" TEXT="{avatar}" />
          <COL GAP="0">
            <ROW GAP="8px"><TEXT STYLE="h3" TEXT="{author}" /><BADGE TEXT="OP" IF="{is_op}" /></ROW>
            <TEXT STYLE="small" TEXT="{time|default:just now}" />
          </COL>
        </ROW>
        <TEXT TEXT="{body}" />
        <ROW><LIKES /></ROW>
      </CARD>
    </POSTCARD>
  </DEFINES>

  <STATE ID="login" ALIGN="center">
    <LOGIN TITLE="The Bulletin Board" SUBTITLE="Sign in to post and reply" />
  </STATE>

  <STATE ID="home" NAV="Home" ICON="house">
    <HERO EYEBROW="Welcome back, {user}" TITLE="What people are talking about" SUBTITLE="Eight boards, fresh threads and a friendly crowd." />
    <ROW JUSTIFY="between">
      <TEXT STYLE="h3" TEXT="Hot threads" />
      <BUTTON STYLE="ghost" SIZE="sm" LABEL="All boards" ACTION="boards" />
    </ROW>
    <CARDS SRC="threads" SORT="views desc" LIMIT="6" COLS="3" ICON="icon" TITLE="title" SUBTITLE="{author} · {board_name}" META="{replies} repl{replies|plural:y/ies} · {views|num} views" ACTION="thread" />
    <TEXT STYLE="h3" TEXT="Boards" />
    <CARDS SRC="boards" COLS="4" ICON="icon" TITLE="name" SUBTITLE="desc" META="{threads} thread{threads|plural} · {posts} post{posts|plural}" ACTION="board" />
  </STATE>

  <STATE ID="boards" NAV="Boards" ICON="layout-grid">
    <SECTION TITLE="Browse boards" SUBTITLE="Every board and its latest activity" ACTION="new_post" LABEL="New thread" />
    <TABLE SRC="boards" ACTION="board">
      <COL FIELD="icon" LABEL="" TYPE="icon" WIDTH="40px" />
      <COL FIELD="name" LABEL="Board" />
      <COL FIELD="desc" LABEL="About" />
      <COL FIELD="threads" LABEL="Threads" TYPE="num" />
      <COL FIELD="posts" LABEL="Posts" TYPE="num" />
    </TABLE>
  </STATE>

  <STATE ID="board">
    <BUTTON STYLE="ghost" SIZE="sm" ICON="arrow-left" LABEL="Boards" ACTION="boards" />
    <SECTION TITLE="{boards.name}" SUBTITLE="{boards.desc}" ACTION="new_post" LABEL="New thread" />
    <TABLE SRC="threads" WHERE="board={boards.id}" SORT="pinned desc, views desc" ACTION="thread" EMPTY="No threads yet. Start one.">
      <COL FIELD="icon" LABEL="" TYPE="icon" WIDTH="40px" />
      <COL FIELD="title" LABEL="Thread" />
      <COL FIELD="author" LABEL="Started by" />
      <COL FIELD="replies" LABEL="Replies" TYPE="num" />
      <COL FIELD="views" LABEL="Views" TYPE="num" />
    </TABLE>
  </STATE>

  <STATE ID="thread" WIDTH="820px">
    <BUTTON STYLE="ghost" SIZE="sm" ICON="arrow-left" LABEL="Back" ACTION="back" />
    <COL GAP="4px">
      <TEXT STYLE="h1" TEXT="{threads.title}" />
      <TEXT STYLE="muted" TEXT="Started by {threads.author} in {threads.board_name} · {threads.views} views" />
    </COL>
    <CARD STYLE="flat" IF="{threads.body}"><TEXT TEXT="{threads.body}" /></CARD>
    <REPEAT SRC="posts" WHERE="thread={threads.id}" SORT="is_op desc, created" GAP="12px" EMPTY="No posts in this thread yet.">
      <POSTCARD />
    </REPEAT>
    <CARD TITLE="Reply" GAP="12px">
      <INPUT ID="body" TYPE="textarea" PLACEHOLDER="Write a reply…" REQUIRED="true" />
      <ROW JUSTIFY="end"><BUTTON LABEL="Post reply" ICON="send" ACTION="reply" /></ROW>
    </CARD>
  </STATE>

  <STATE ID="new_post" WIDTH="720px">
    <BUTTON STYLE="ghost" SIZE="sm" ICON="arrow-left" LABEL="Cancel" ACTION="back" />
    <TEXT STYLE="h1" TEXT="Start a thread" />
    <SELECT ID="board" LABEL="Board" SRC="boards" PLACEHOLDER="Choose a board…" VALUE="{boards.id}" REQUIRED="true" />
    <INPUT ID="title" LABEL="Title" PLACEHOLDER="What is it about?" REQUIRED="true" />
    <INPUT ID="opening" TYPE="textarea" LABEL="Opening post" PLACEHOLDER="Say a little more…" />
    <ROW JUSTIFY="end">
      <BUTTON STYLE="secondary" LABEL="Cancel" ACTION="back" />
      <BUTTON LABEL="Publish" ICON="send" ACTION="publish" />
    </ROW>
  </STATE>

  <STATE ID="search" NAV="Search" ICON="search">
    <TEXT STYLE="h1" TEXT="Search" />
    <INPUT ID="query" TYPE="search" PLACEHOLDER="Search thread titles…" LIVE="true" />
    <TABLE SRC="threads" WHERE="title LIKE {query}" ACTION="thread" EMPTY="No threads match.">
      <COL FIELD="title" LABEL="Thread" />
      <COL FIELD="board_name" LABEL="Board" />
      <COL FIELD="replies" LABEL="Replies" TYPE="num" />
    </TABLE>
  </STATE>

  <WIRE LABEL="login" TO="home" />
  <WIRE LABEL="boards" TO="boards" />
  <WIRE LABEL="board" TO="board" />
  <WIRE LABEL="thread" TO="thread" />
  <WIRE LABEL="new_post" TO="new_post" />
  <WIRE LABEL="reply" SAVE="posts" SET="thread={threads.id}; author={user}; time=just now" TO="thread" />
  <WIRE LABEL="publish" SAVE="threads" SET="author={user}; board_name={board_label}; time=just now; replies=0; views=1; icon=message-circle; body={opening}" TO="boards" />

</APP>