# 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](https://github.com/seanhess/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: ```haskell 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: ```haskell 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/` 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: ```haskell 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 `` 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