programmati.ca Open Studio

Examples · Bulletin board

A web bulletin board: 107 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 is saved in this browser; nothing leaves your device.

The state graph

7 screens, 2 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_postsearchadd-postadd-thread

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 ICON="house">
…
<STATE ID="boards" NAV ICON="layout-grid">
…
<STATE ID="board">
…
<STATE ID="thread" WIDTH="820px">
…
<STATE ID="new_post" WIDTH="720px">
…
<STATE ID="search" NAV 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 it, taking the clicked record along. It writes no WIRE at all: it uses the short forms SAVE. OPEN="state" opens an item in that state, and FORM SAVE or UPDATE saves the form to a collection; the engine adds the wires.

  <FORM SAVE="posts" SET="thread={threads.id}; author={user}; time=just now" TO="thread" SUBMIT-LABEL="Post reply">
…
<FORM SAVE="threads" TO="boards" SUBMIT-LABEL="Publish"

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: add-post save posts, add-thread 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.">
…
  <FORM SAVE="posts" SET="thread={threads.id}; author={user}; time=just now" TO="thread" SUBMIT-LABEL="Post reply">
…
      SET="author={user}; board_name={board_label}; time=just now; replies=0; views=1; icon=message-circle; body={opening}">
  <FIELD NAME="board" TYPE="select" SRC="boards" PLACEHOLDER="Choose a board…" VALUE="{boards.id}" REQUIRED />
…
<INPUT ID="query" TYPE="search" PLACEHOLDER="Search thread titles…" LIVE="true" />

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">

Convention over configuration

What the engine infers

Every attribute this spec leaves out has a default inferred from names, data and context. These are the ones this app relies on; writing any of them out would override it.

  • A WIRE add-postThe FORM SAVE="posts" implies this wire
  • A WIRE add-threadThe FORM SAVE="threads" implies this wire
  • STATE NAV → Home, Boards, SearchNAV with no label uses the state ID, humanised
  • COL LABEL → Threads, Posts, Replies, ViewsFrom FIELD
  • COL TYPE → numThe values are numbers
  • FORM ACTION → add-post, add-threadSAVE wires the form to the collection
  • FIELD TYPE → textareaFrom NAME="body"
  • FIELD LABEL → Board, TitleFrom NAME

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 107-line document built from general-purpose components: AVATAR, BADGE, BUTTON, CARD, CARDS, FORM, HERO, INPUT, LIKES, LOGIN, POSTCARD, REPEAT, SECTION, 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 · 107 lines
The same spec with every inferred attribute written out (103 lines)
<APP ID="bbs" NAME="The Bulletin Board" THEME="modern-minimal" LOGO="messages-square">

  <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 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 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" />
      <COL FIELD="posts" />
    </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" />
      <COL FIELD="views" />
    </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">
      <FORM SAVE="posts" SET="thread={threads.id}; author={user}; time=just now" TO="thread" SUBMIT-LABEL="Post reply">
        <FIELD NAME="body" LABEL="" PLACEHOLDER="Write a reply…" REQUIRED />
      </FORM>
    </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" />
    <FORM SAVE="threads" TO="boards" SUBMIT-LABEL="Publish"
          SET="author={user}; board_name={board_label}; time=just now; replies=0; views=1; icon=message-circle; body={opening}">
      <FIELD NAME="board" TYPE="select" SRC="boards" PLACEHOLDER="Choose a board…" VALUE="{boards.id}" REQUIRED />
      <FIELD NAME="title" PLACEHOLDER="What is it about?" REQUIRED />
      <FIELD NAME="opening" TYPE="textarea" LABEL="Opening post" PLACEHOLDER="Say a little more…" />
    </FORM>
  </STATE>

  <STATE ID="search" NAV 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" />
    </TABLE>
  </STATE>

</APP>