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
hyperboleand its dependencies topackage.yamlandstack.yaml. - Database: Full effectful effect system. Create a custom
DBeffect that wraps all SQLite operations, replacing the current rawSQL.Connectionpassing. - 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-cssfor 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 codesrc/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)
LoginFormHyperView with email, password, remember-me fieldsAction = 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)
SignupFormHyperView with display name, email, password, confirm, agree-termsAction = 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
DashboardPageHyperView 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)
ChoreListHyperView — list of all chores for the householdAction ChoreList = DeleteChore ChoreId | StartCreate | StartEdit ChoreId- Each chore: name, schedule badge, schedule description, Edit/Delete buttons
- "New Chore" button inserts a
ChoreFormHyperView inline ChoreFormactions:Submit {fields} | Cancel- On submit, sends
pushEventtoChoreListto refresh - Delete asks for confirmation via inline confirmation state in the HyperView
(no JS
confirm())
Household Page (Sis.Page.Household)
HouseholdPageHyperView — member list, invite managementAction = 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)
ActivityLogHyperView with pagination stateAction = 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
userIdin session - requireAuth: Read
userIdfrom session, redirect to login if absent - Logout: Delete session data
- Password hashing: Keep
Sis/Auth.hsusingcrypton, 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,effectfultopackage.yamlandstack.yaml - Create
app/Main.hswith Hyperbolemain, route definitions, document head - Strip Aeson instances from
Types.hs - Verify a simple page renders
Phase 2: Auth + Login/Signup
- Create
Sis/Database.hswithDBeffect and SQLite handler - Implement Hyperbole session-based auth in
Sis/Auth.hs - Build Login and Signup pages
- Add
requireAuthpattern
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