Files
sis/docs/specs/2026-07-15-hyperbole-port-design.md

11 KiB

Hyperbole Port Design

Date: 2026-07-15 Status: Draft

Motivation

The current Sis architecture uses a Haskell JSON API backend (Orb/WAI) with a TypeScript/Mithril.js SPA frontend. The JavaScript framework has issues with routing and basic functionality. We want to eliminate JavaScript entirely by porting to Hyperbole, a Haskell serverside web framework inspired by HTMX, Elm, and Phoenix LiveView.

After the port, there will be zero application TypeScript/JavaScript in the source code. Only Hyperbole's client runtime (~40KB gzipped) runs in the browser, providing WebSocket connectivity and VirtualDOM patching.

Key Decisions

  • API: Remove the JSON API entirely. All interactivity happens over Hyperbole's WebSocket channel with serverside-rendered HTML.
  • Build system: Keep Stack. Add hyperbole and its dependencies to package.yaml and stack.yaml.
  • Database: Full effectful effect system. Create a custom DB effect that wraps all SQLite operations, replacing the current raw SQL.Connection passing.
  • Auth: Use Hyperbole's built-in session mechanism (cookie-based, stored in memory). Replaces the current custom cookie/SQLite session system.
  • CSS: Keep Neo Brutalism CDN + thin custom CSS. Use Hyperbole's atomic-css for inline styles where needed, but primarily use Neo Brutalism class names.
  • UI: Match the current UI look. No visual redesign. Make UI decisions independently when questions arise, leaning on Neo Brutalism defaults.

Architecture

Before:

Browser ←[HTTP GET]→ WAI static serving (SPA index.html + JS)
Browser ←[fetch JSON]→ WAI /api/* routes
Mithril SPA: routing, state, rendering all in TypeScript

After:

Browser ←[HTTP/WebSocket]→ Warp + Hyperbole
  - Initial page: full HTML rendered serverside
  - Interactions: WebSocket with VirtualDOM diffs
  - All routing, state, rendering in Haskell

Module structure:

sis/
├── app/Main.hs              # Hyperbole app entry, Warp setup, route dispatch
├── src/
│   ├── Sis.hs               # Top-level re-exports
│   ├── Sis/Route.hs         # Route ADT with Route instance
│   ├── Sis/Types.hs         # Core domain types (Aeson instances removed)
│   ├── Sis/Database.hs      # effectful DB effect, SQLite operations
│   ├── Sis/Auth.hs          # Hyperbole session-based auth + password hashing
│   ├── Sis/Page/
│   │   ├── Login.hs         # Login page
│   │   ├── Signup.hs        # Signup page
│   │   ├── Dashboard.hs     # Dashboard with stats + due/completed items
│   │   ├── Chores.hs        # Chore list + create/edit form
│   │   ├── Household.hs     # Members, invites, household management
│   │   └── Activity.hs      # Activity log with pagination
│   ├── Sis/View/
│   │   ├── Layout.hs        # Shell: document head, navbar, page wrapper
│   │   ├── ChoreForm.hs     # Create/edit chore form (HyperView)
│   │   ├── ActivityModal.hs # Record activity form (HyperView)
│   │   └── Field.hs         # Reusable form field helpers
│   └── Sis/Style.hs         # CSS helpers for Neo Brutalism classes
├── frontend/
│   └── static/
│       ├── style.css        # Thin custom CSS (nav, bg, animations)
│       └── manifest.json    # PWA manifest (unchanged)
├── package.yaml             # Updated deps (hyperbole, atomic-css, effectful)
├── stack.yaml               # Updated resolver + extra-deps
└── Dockerfile               # Updated (no npm build step)

Deleted:

  • frontend/src/ — all TypeScript code
  • src/Sis/Server.hs — Orb/WAI routing replaced by Hyperbole pages
  • Dependencies: orb, beeline-routing, shrubbery, json-fleece-aeson, json-fleece-core

Routes

Flat route structure mapping to the current pages:

data AppRoute
  = RouteHome       -- redirects based on auth status
  | RouteLogin
  | RouteSignup
  | RouteDashboard
  | RouteChores
  | RouteHousehold
  | RouteActivity

The Route typeclass generates URLs from constructor names: /login, /dashboard, etc. No dynamic route segments needed — all interactions happen within pages via HyperViews, not separate detail pages.

Router dispatches each route to a page handler:

router RouteLogin     = runPage Sis.Page.Login.page
router RouteSignup    = runPage Sis.Page.Signup.page
router RouteDashboard = runPage Sis.Page.Dashboard.page
router RouteChores    = runPage Sis.Page.Chores.page
router RouteHousehold = runPage Sis.Page.Household.page
router RouteActivity  = runPage Sis.Page.Activity.page

Auth checking happens inside each protected page via requireAuth, which reads user ID from Hyperbole's session. If not authenticated, redirects to login.

Pages

Login Page (Sis.Page.Login)

  • LoginForm HyperView with email, password, remember-me fields
  • Action = SubmitLogin Text Text Bool
  • On success: stores user ID in Hyperbole session, redirects to dashboard
  • On failure: re-renders form with error message
  • Navbar hidden on this page
  • Link to signup via route RouteSignup

Signup Page (Sis.Page.Signup)

  • SignupForm HyperView with display name, email, password, confirm, agree-terms
  • Action = SubmitSignup Text Text Text Text Bool
  • Validates (password length, match, email uniqueness), creates user, creates session
  • Link to login via route RouteLogin

Dashboard (Sis.Page.Dashboard)

  • Single DashboardPage HyperView wrapping all dashboard content (stats tiles, due items, completed items). A single HyperView ensures consistency when state changes across sections.
  • Action = RecordActivity OccurrenceId RecordActivityRequest | Refresh
  • Stat tiles: overdue count (red), due today (yellow), done this week (green)
  • Due items list: each item shows chore name, assignee, status badge, and "Check Off" button
  • Clicking "Check Off" replaces the item row with an inline record-activity form (status dropdown, optional note, notify checkbox, Save/Cancel buttons)
  • Completed items: read-only list of today's activities
  • Link to full activity log

Chores Page (Sis.Page.Chores)

  • ChoreList HyperView — list of all chores for the household
  • Action ChoreList = DeleteChore ChoreId | StartCreate | StartEdit ChoreId
  • Each chore: name, schedule badge, schedule description, Edit/Delete buttons
  • "New Chore" button inserts a ChoreForm HyperView inline
  • ChoreForm actions: Submit {fields} | Cancel
  • On submit, sends pushEvent to ChoreList to refresh
  • Delete asks for confirmation via inline confirmation state in the HyperView (no JS confirm())

Household Page (Sis.Page.Household)

  • HouseholdPage HyperView — member list, invite management
  • Action = CreateInvite | RevokeInvite InviteId | CreateHousehold Text
  • Members list: avatar initials, name, email, role badge
  • If user has no households: show "Create Your Household" form
  • Owner-only actions: create invite link (shows generated code), revoke invites
  • Invite codes displayed as /invite/<code> text

Activity Log (Sis.Page.Activity)

  • ActivityLog HyperView with pagination state
  • Action = GoToPage Int
  • Each entry: status badge, user name, chore name, date, time, optional note
  • Previous/Next pagination buttons with current page indicator
  • 20 entries per page

Domain Types

Sis/Types.hs keeps all current types but removes all Aeson instances (ToJSON, FromJSON, ToJSONKey, FromJSONKey). Types become pure Haskell records and ADTs. New form data types will be added for Hyperbole form handling (simple records with field names matching form inputs).

Database Effect

A custom effectful effect DB replaces raw SQL.Connection passing and direct SQL queries in route handlers:

data DB :: Effect where
  -- Auth
  FindUserByEmail :: Text -> DB m (Maybe User)
  CreateUser :: Text -> Text -> Text -> DB m UserId
  -- Households
  GetUserHouseholds :: UserId -> DB m [Household]
  GetHousehold :: UserId -> HouseholdId -> DB m (Maybe Household)
  CreateHousehold :: UserId -> Text -> DB m Household
  GetMembers :: HouseholdId -> DB m [Membership]
  -- Chores
  GetChores :: HouseholdId -> DB m [Chore]
  CreateChore :: HouseholdId -> CreateChoreRequest -> DB m Chore
  UpdateChore :: ChoreId -> UpdateChoreRequest -> DB m Chore
  DeleteChore :: ChoreId -> DB m ()
  -- Dashboard & Activities
  GetDashboard :: HouseholdId -> Day -> DB m Dashboard
  GenerateOccurrences :: Chore -> DB m ()
  RecordActivity :: OccurrenceId -> UserId -> RecordActivityRequest -> DB m Activity
  GetActivityLog :: HouseholdId -> Int -> Int -> DB m ActivityLogPage
  -- Invites
  CreateInvite :: HouseholdId -> Maybe Text -> DB m Invite
  GetInvites :: HouseholdId -> DB m [Invite]
  RevokeInvite :: InviteId -> DB m ()
  AcceptInvite :: UserId -> Text -> DB m Household
  -- Seed
  Seed :: DB m ()

The handler runDB :: SQL.Connection -> Eff (DB : es) a -> IO (Eff es a) runs all DB operations against SQLite.

Auth

Hyperbole's built-in Session effect stores per-session data keyed by cookie:

  • Login: Validate credentials, store userId in session
  • requireAuth: Read userId from session, redirect to login if absent
  • Logout: Delete session data
  • Password hashing: Keep Sis/Auth.hs using crypton, same as current

CSS & Styling

Neo Brutalism CSS loaded via CDN <link> in the document head. Thin custom CSS (frontend/static/style.css) for:

  • CSS variables (colors: red, yellow, green, orange)
  • Body background (dot pattern)
  • Navbar styling (border, shadow, layout, responsive)
  • Modal overlay animation (fade-in)
  • List item dividers
  • Responsive breakpoints

Hyperbole views use atomic-css combinators for any additional inline styles. Neo Brutalism class names set via attributes where needed.

Implementation Phases

Phase 1: Scaffold

  • Add hyperbole, atomic-css, effectful to package.yaml and stack.yaml
  • Create app/Main.hs with Hyperbole main, route definitions, document head
  • Strip Aeson instances from Types.hs
  • Verify a simple page renders

Phase 2: Auth + Login/Signup

  • Create Sis/Database.hs with DB effect and SQLite handler
  • Implement Hyperbole session-based auth in Sis/Auth.hs
  • Build Login and Signup pages
  • Add requireAuth pattern

Phase 3: Dashboard

  • Build Dashboard page with stats, due items, completed items
  • Implement record-activity workflow as inline form

Phase 4: Chores

  • Build Chore list with create/edit/delete
  • Build ChoreForm as nested HyperView

Phase 5: Household + Activity Log

  • Build Household page with members, invites
  • Build Activity Log page with pagination

Phase 6: Cleanup

  • Delete frontend/src/ (all TypeScript)
  • Remove Orb, beeline, shrubbery, json-fleece deps
  • Update scripts, Dockerfile, README.md, AGENTS.md

Tests

Current Haskell tests in test/Spec.hs test the API via HTTP. After the port:

  • DB effect operations will be testable in isolation with a test SQLite connection
  • Page rendering can be tested by running Hyperbole pages and checking output
  • Playwright tests (per AGENTS.md agent autonomy) verify end-to-end behavior