CoWork · V1

Implementation Plan

A self-hosted, lightweight workspace tool. V1 scope is a dynamic data grid with typed columns, per-workspace versioning, and simple session-based auth. No frameworks. No dependencies beyond what is strictly needed.

What we are building

A personal or small-team workspace tool, self-hosted on any PHP-capable server. Users log in and see a sidebar listing their workspaces. Each workspace is a dynamic table where columns are typed — either free text or a status label with user-defined options and colors. Workspaces are saved explicitly, with a background autosave every ten minutes as a safety net.

V1 scope is intentionally narrow: one workspace type (grid), flat-file storage, no registration UI, no OAuth, no real-time collaboration. Every architectural decision is made to allow these things to be added later without restructuring the codebase.

Scope boundaries

FeatureV1Deferred
Grid workspaceIncluded
Text workspaceV2
Checklist workspaceV2
Email/password authIncluded
User registration UIV2
Authorized users per workspaceStubbed
Full snapshot versioningIncluded
Rollback UIV2
Flat-file storageIncluded
SQLite / Supabase storageInterface ready
Idle session timeoutV2 option
Version controlIncluded
Architecture

Technology Stack

No frontend framework. No build step. Every choice is justified by simplicity, familiarity, and self-hostability.

Backend
PHP (vanilla)
Session management, bcrypt auth, file I/O, and HTMX endpoint handling. No framework. Thin endpoint files only.
Reactivity
Alpine.js
Drives all in-page interactivity: cell editing, dropdown state, column type toggles, unsaved-change tracking, autosave timer.
Server interactions
HTMX
Handles all HTTP requests to PHP endpoints without writing fetch() boilerplate. Load workspace, save, create, delete.
Styling
Tailwind CSS (CDN)
Utility-first styling. No build pipeline — loaded from CDN. Consistent with a Notion-inspired minimal aesthetic.
Storage (V1)
Flat JSON files
One JSON file per workspace. Snapshots written to a per-workspace subdirectory on every save. All behind a PHP interface.
Storage (future)
SQLite / Supabase
The WorkspaceStorage interface means swapping backends requires only writing a new implementation class. Endpoints unchanged.

Why no frontend framework?

The interactivity requirements for V1 — editable cells, dropdowns, column config popovers, an unsaved-changes flag — are well within Alpine's scope. Adding React or Vue would introduce a build step, a node_modules directory, and a component lifecycle to reason about, none of which are warranted here. Alpine keeps reactivity local and declarative without changing the HTML mental model.

Why HTMX alongside Alpine?

Alpine manages UI state. HTMX manages server communication. They have no overlap. HTMX's hx-post and hx-get replace fetch calls for loading and saving workspaces. Alpine handles everything that never needs to touch the server until save time.

Architecture

File Structure

Webroot is /public. All data, config, and source lives outside it. PHP sessions handle auth; no .htaccess trickery needed for security if the server is configured correctly.

/project-root/
public/ ← webroot
index.php ← app shell (requires login)
login.php ← login page
logout.php ← destroys session, redirects
api/ ← HTMX endpoints
workspace.php ← GET load, POST save, POST create, DELETE
auth.php ← POST login handler
assets/
app.css ← custom styles beyond Tailwind
icons/ ← SVG icons (add/delete row/col)

src/ ← PHP classes
WorkspaceStorage.php ← interface
FlatFileStorage.php ← V1 implementation
Auth.php ← session + bcrypt helpers
Workspace.php ← workspace model / helpers

templates/ ← PHP HTML templates
layout.php ← shared shell: sidebar + main panel
login.php ← login form template
workspace-grid.php ← grid rendering
sidebar.php ← workspace list partial

data/ ← outside webroot, or deny-listed
users.json ← user records with bcrypt passwords
workspaces/
ws_001/
current.json
snapshots/
2026-03-01T14-32-00Z_v1.json
2026-03-01T15-10-00Z_v2.json

config.php ← data path, app name, storage driver
data/ must be outside webroot or protected with a server-level deny rule. It contains plaintext user credentials and workspace data. On Apache, a .htaccess with Deny from all is sufficient if moving it outside webroot is not possible.
Architecture

Storage Layer

Storage is abstracted behind a PHP interface from day one. The V1 implementation is flat JSON files. Swapping to SQLite or Supabase later requires only writing a new class — no changes to endpoints or templates.

The interface

interface WorkspaceStorage {
    public function list(string $userId): array;
    public function load(string $workspaceId): array;
    public function save(string $workspaceId, array $data): void;
    public function create(string $ownerId, string $name): array;
    public function delete(string $workspaceId): void;
}

FlatFileStorage — V1

Each workspace lives in its own subdirectory under data/workspaces/. The canonical state is always current.json. On every save, the previous current.json is copied into snapshots/ with a timestamped filename before being overwritten.

data/workspaces/
  ws_001/
    current.json
    snapshots/
      2026-03-01T10-00-00Z_v1.json
      2026-03-01T14-32-00Z_v2.json

Users file

A single data/users.json holds all user records. In V1, this file is hand-edited by the admin. No registration endpoint exists. The structure is:

[
  {
    "id": "usr_001",
    "email": "alice@example.com",
    "password_hash": "$2y$12$...",
    "display_name": "Alice"
  }
]

config.php

A single config file at the project root sets the data path and the active storage driver. Switching drivers in the future is a one-line change here.

define('DATA_PATH', __DIR__ . '/data');
define('STORAGE_DRIVER', 'flatfile'); // 'sqlite' | 'supabase'
Architecture

Data Model

Every workspace is a single JSON document. The schema is designed to be human-readable and to support future workspace types beyond grid.

Full workspace document

{
  "workspace_id": "ws_001",
  "workspace_name": "Project Tracker",
  "workspace_owner": "usr_001",
  "authorized_users": ["usr_002"],
  "timestamp_created": "2026-03-01T10:00:00Z",
  "timestamp_lastedited": "2026-03-01T14:32:00Z",
  "workspace_type": "grid",
  "version": 3,

  "schema": {
    "columns": [
      {
        "id": "col_1",
        "name": "Task",
        "type": "text"
      },
      {
        "id": "col_2",
        "name": "Status",
        "type": "status",
        "options": [
          { "label": "To Do",       "color": "#e2e8f0" },
          { "label": "In Progress", "color": "#fef08a" },
          { "label": "Done",        "color": "#86efac" }
        ]
      },
      {
        "id": "col_3",
        "name": "Priority",
        "type": "status",
        "options": [
          { "label": "Low",    "color": "#dbeafe" },
          { "label": "High",   "color": "#fee2e2" }
        ]
      }
    ]
  },

  "rows": [
    {
      "id": "row_1",
      "cells": {
        "col_1": "Write documentation",
        "col_2": "In Progress",
        "col_3": "High"
      }
    },
    {
      "id": "row_2",
      "cells": {
        "col_1": "Deploy to production",
        "col_2": "To Do",
        "col_3": "High"
      }
    }
  ]
}

Key decisions

DecisionRationale
schema.columns not columnsWhen text and checklist workspace types are added, they will have different schema shapes. Namespacing under schema keeps the top-level clean.
cells keyed by column idAdding or deleting a column does not shift row data. Cells for deleted columns are orphaned but harmless; easily pruned on save.
status options on columnOptions are defined once per column, not duplicated per cell. A cell stores only the chosen label string.
version integerIncrements on every save. Paired with the timestamped snapshot filename, this gives an unambiguous history.
authorized_users arrayStored in V1, not enforced in V1. Access control logic reads this field when implemented in V2.
Features

Authentication

Simple, stateful, and secure enough for a self-hosted personal tool. Email and bcrypt-hashed password. PHP sessions. No OAuth, no tokens, no registration UI in V1.

Login flow

The user visits /login.php. On POST, api/auth.php reads data/users.json, finds the matching email, and runs password_verify() against the stored bcrypt hash. On success, the user ID and email are written to $_SESSION and the user is redirected to /index.php. On failure, an error message is returned.

Session behavior

BehaviorImplementation
Session startsession_start() at the top of every protected page and endpoint
Auth checkEvery protected page checks $_SESSION['user_id'] and redirects to /login.php if absent
No expirySession lasts until the browser closes or the user logs out. No gc_maxlifetime override needed.
Logoutlogout.php calls session_destroy() and redirects to /login.php

Adding users (V1)

Users are hand-written into data/users.json. The password hash is generated with PHP's password_hash($password, PASSWORD_BCRYPT). A tiny CLI helper script can be provided for this: php scripts/add-user.php alice@example.com mypassword.

The Auth.php class wraps session logic and user lookup. When a registration UI is added in V2, it calls the same Auth::createUser() method that the CLI script uses. No endpoint changes needed.
Features

App Layout

Two-panel shell. Sidebar always visible after login. Main panel loads workspaces via HTMX without full page reloads.

Page structure

+------------------+------------------------------------+
|  SIDEBAR         |  MAIN PANEL                        |
|                  |                                    |
|  CoWork          |  [ Workspace Name — editable ]     |
|  ─────────────   |                                    |
|  Project Tracker |  col1 ▾  col2 ▾  col3 ▾  [+col]   |
|  Design Notes    |  ──────────────────────────────    |
|  Sprint Board    |  text   ● Done   ● High   [+row]   |
|                  |  text   ● To Do  ● Low    [+row]   |
|  + Workspace     |                                    |
+------------------+------------------------------------+

Sidebar behaviors

ElementBehavior
Workspace list itemClicking loads the workspace into the main panel via hx-get="/api/workspace.php?id=ws_001" targeting the main panel div
Workspace name (sidebar)Click to edit inline. On blur or Enter, fires a save to update the name. Alpine-driven.
+ Workspace buttonPosts to /api/workspace.php (create action), returns the new workspace HTML and a refreshed sidebar list
Active stateCurrently loaded workspace is highlighted in the sidebar
Log OutLink at the bottom of the sidebar to logout.php

Default workspace naming

When a new workspace is created without a name, the backend counts existing workspaces owned by the user and assigns the next available name: workspace 1, workspace 2, etc. The name is immediately editable in the main panel header.

Features

Workspace Grid

A dynamic table with typed columns. Column and row count can be changed at any time with SVG icon buttons. Two column types: free text and status/label.

Grid rendering

The workspace is rendered as an HTML <table> from the workspace JSON. The Alpine component on the table wrapper holds the full workspace state object. All mutations (add row, delete row, edit cell, etc.) update this state object. Nothing is persisted until save.

Column header

Each <th> contains the column name (editable inline), a type indicator, and a small gear/settings icon that opens a column config popover. The popover lets the user rename the column, change its type, and — if type is status — edit the label options and their colors. A delete icon removes the column (with confirmation).

Column types

TypeCell behaviorColumn config
textContenteditable div, freeform inputName only
statusColored pill showing current label; click opens a dropdown of options defined on the columnName + option list (add/remove/rename options, pick color per option)

Add / delete rows and columns

An SVG + icon at the end of the header row adds a new column (defaults to text type). An SVG + icon at the bottom of the table adds a new row. Each row has a delete icon on the left (visible on hover). Each column header has a delete icon in its config popover. Row and column IDs are generated client-side as row_<timestamp> and col_<timestamp>.

Alpine state shape

Alpine.data('workspace', () => ({
  meta: { id, name, version, ... },
  schema: { columns: [...] },
  rows: [...],
  dirty: false,       // true when unsaved changes exist
  saving: false,      // true during save request
  lastSaved: null,    // timestamp of last successful save

  addRow() { ... },
  deleteRow(id) { ... },
  addColumn() { ... },
  deleteColumn(id) { ... },
  updateCell(rowId, colId, value) { ... },
  markDirty() { this.dirty = true; },
  save() { ... }      // posts to /api/workspace.php
}))
Features

Saving & Versioning

Explicit save with a background autosave safety net every ten minutes. Unsaved-change detection with a leave-page warning. Full snapshots on every save.

Save mechanisms

TriggerBehavior
Save buttonVisible and active when dirty === true. Posts workspace JSON to /api/workspace.php. On success, dirty = false, version++, lastSaved updated.
AutosavesetInterval in Alpine every 600,000ms (10 min). Only fires if dirty === true. Silent — no UI confirmation. Same endpoint as manual save.
Leave-page warningwindow.beforeunload event listener. Returns a warning string if dirty === true. Browser shows native "unsaved changes" dialog.

Server-side save process

When api/workspace.php receives a save POST, it does the following in order: validate the session, validate the payload, read the current current.json, copy it to snapshots/<timestamp>_v<n>.json, increment the version in the incoming payload, write the new current.json, return a success response with the new version number and timestamp.

Snapshot naming

snapshots/
  2026-03-01T10-00-00Z_v1.json
  2026-03-01T14-32-00Z_v2.json
  2026-03-01T22-15-44Z_v3.json

The timestamp uses hyphens instead of colons to be filesystem-safe on all platforms. The version suffix is redundant with the timestamp but makes it human-readable at a glance. Rollback UI in V2 reads this directory, sorts by timestamp, and presents the list.

Save button states

StateAppearance
No changesGrayed out, disabled
Unsaved changesActive, accent color
Saving in progressSpinner, disabled
Save successfulBrief "Saved" confirmation, then returns to grayed-out
Execution

Build Order

Each phase produces something that runs. No phase should require the next to be testable.

PHASE 01Project scaffold & config

Create the full directory structure. Write config.php. Write the WorkspaceStorage interface and the stub FlatFileStorage class. Create a placeholder data/users.json with one hand-written user.

PHASE 02Auth — login, session, logout

Build login.php (template + POST handler via api/auth.php). Build Auth.php with session helpers. Build logout.php. Every subsequent page can now be protected. Test with the hand-written user.

PHASE 03App shell & sidebar

Build layout.php with the two-panel shell. Build sidebar.php partial. Wire up + Workspace creation. Workspace list loads from FlatFileStorage::list(). Main panel shows a placeholder. Sidebar navigation works.

PHASE 04Workspace grid — render & edit

Build workspace-grid.php template. Build the Alpine workspace data component. Render columns and rows from JSON. Implement contenteditable text cells. Implement add/delete row and column with SVG icons. Dirty flag tracking.

PHASE 05Status column type

Add column type selector to column config popover. Build the status cell dropdown component in Alpine. Build the option editor (add/remove options, label text, color picker). Wire up cell value selection.

PHASE 06Save, autosave, versioning

Complete FlatFileStorage::save() with snapshot logic. Wire up save button via HTMX. Add setInterval autosave in Alpine. Add beforeunload leave-page warning. Test version increments and snapshot files.

PHASE 07Polish & edge cases

Default workspace naming counter. Inline workspace rename in sidebar and main panel header. Empty state for a workspace with no rows. Column delete confirmation. Handle orphaned cell keys on column delete. Review all session-protection paths.

Execution

V2 Considerations

Decisions made in V1 that are specifically designed to make these features straightforward to add later.

FeatureWhat V1 does to prepare for it
User registrationAuth::createUser() method exists and is used by the CLI script. A registration form calls the same method.
Authorized users per workspaceauthorized_users array is stored in every workspace document. Access control checks in workspace.php are stubbed as comments.
Rollback UISnapshot directory exists and is populated on every save. V2 adds an endpoint that reads the directory and a UI to select and restore a snapshot.
SQLite storageWorkspaceStorage interface means writing SQLiteStorage.php and changing one line in config.php.
Supabase storageSame interface. SupabaseStorage.php hits Supabase's REST API. No other changes.
Text workspace typeworkspace_type field exists in every document. The main panel checks this field to decide which template to render. A workspace-text.php template is added.
Checklist workspace typeSame pattern as text workspace.
Idle timeout / Remember meSession logic is centralized in Auth.php. Adding a last_activity check or a long-lived cookie is isolated to that class.
VersioningRollbacks to prior snapshots.