feat(docs): add a Diátaxis documentation section to the public site

A new public /docs area covering the whole platform, organized by Diátaxis:
tutorials (learning), how-to guides (tasks), reference (facts), and explanation
(the why). 29 pages, each grounded in the actual routes, actions, schema, and
UI — verified against the code and corrected for accuracy (e.g. admin-only vs
admin+board gating, where the permissions matrix actually lives, how Stripe
reconciliation matches).

Implementation:
- lib/docs/nav.ts is the single source of truth (categories + pages), driving
  the sidebar, static params, and prev/next.
- Content authored as content/docs/<category>/<slug>.md, read at build time
  (the routes are statically generated), rendered by <DocsMarkdown> — reuses the
  existing react-markdown/GFM stack, no new dependencies.
- (docs) route group with its own theme (docs.css) matching the public site's
  dark / monospace / lime aesthetic; sidebar with active-state, breadcrumb,
  prev/next, and a Diátaxis home. Public — signed-in users can read it too.
- "Docs" link added to the landing nav.

typecheck 0 · lint 0 errors · build ok (all /docs pages prerendered)
This commit is contained in:
Moheeb Zara
2026-08-05 00:42:42 -07:00
parent fc6adaf16e
commit 16229ae523
38 changed files with 2928 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
Build a custom form or a signable waiver, choose who can submit it, share it, and read the responses. This recipe covers the whole lifecycle from the form builder at [/forms](/forms) through published results.
This is an admin-side task. You need the `forms.manage` permission, which is granted to the `admin` and `board` roles by default. If the Forms screen is not available to you, ask an admin to grant it.
## Create the form
1. Go to [/forms](/forms) and choose **New form**. You land in the builder at [/forms/new](/forms/new).
2. Set the **Type**:
- **Form** — a plain form (surveys, interest lists, equipment requests).
- **Waiver** — a form that requires the signer to agree to legal/consent text before submitting.
3. Enter a **Title**. As you type, a **Link slug** is generated for you; edit it if you want. The slug is lowercase letters, digits, and hyphens (1–80 characters) and must be unique within your space. It becomes the public address `/f/<space>/<slug>`. The slug and the Type cannot change after creation, so pick them deliberately.
4. Optionally add a **Description** shown above the fields.
5. For a **Waiver**, fill in **Waiver / consent text**. This is the exact text the signer must agree to, and it is snapshotted into every signature (see [Immutable submissions](#immutable-submissions)).
## Add fields
In the **Fields** section, add one field per answer you want to collect. The available field types are:
| Builder button | Type | Collects |
| --- | --- | --- |
| Text | `short_text` | A single line (up to 2000 chars) |
| Paragraph | `long_text` | Multi-line text (up to 20000 chars) |
| Email | `email` | A validated email address |
| Number | `number` | A numeric value |
| Date | `date` | A calendar date |
| Checkbox | `checkbox` | A yes/no box |
| Dropdown | `select` | One choice from a list |
| Choice | `radio` | One choice from a list |
For each field:
- Edit the label in place.
- For **Dropdown** and **Choice**, add or remove options with **Add option**.
- Toggle **Required** to force an answer. A required checkbox must be checked to submit.
- Reorder with the up/down arrows, or remove a field with the trash icon.
The **Live preview** panel on the right renders the form exactly as a submitter sees it, including the "I agree to the waiver above" checkbox for waivers.
When you are done, choose **Create form**. The form is created as a **draft** and you land on its edit page.
## Set who can submit
The **Who can submit** setting controls visibility:
| Option | Stored value | Who can submit | Public link |
| --- | --- | --- | --- |
| Members only | `members` | Signed-in members of your space | No — filled inside the app |
| Anyone signed in | `public_auth` | Any signed-in user | Yes |
| Anyone (no account) | `public_anon` | Anyone, no account needed | Yes |
You can change visibility any time from the builder. Members-only forms are never served on the public page, so their fields stay private to your space.
## Publish and share
A draft is not visible to anyone. On the form's edit page, choose **Publish** to make it live and start accepting responses.
- For a public form (`public_auth` or `public_anon`), the builder shows the public URL with a **Copy link** button. The list at [/forms](/forms) also has a copy-link action for published public forms. Share that link however you like.
- For a **Members only** form, there is no public link. Members submit it from inside the app.
Use **Close** to stop accepting responses (existing responses are kept), or **Back to draft** to hide it again. **Re-publish** a closed form to reopen it.
## Read the results
Open **Results** from [/forms](/forms) or go to `/forms/<id>/results`. You get a table of every response, newest first, with the submitter (member, email, or Anonymous) and each field answer. Use **Export CSV** to download all responses. You can delete a single response, or delete the whole form.
Deleting a form is permanent and cascades to every response, including signed waivers. Export first if you need a record.
## Immutable submissions
Every submission is an append-only snapshot. At submit time the platform stores a copy of the field schema, the waiver text, and the form version alongside the answers. Later edits to the form never alter past submissions, so a signed waiver stays valid against exactly what that signer saw. Submissions cannot be edited by anyone — only deleted.
Editing a published waiver's legal text or fields bumps its version. Existing signatures remain valid against their own snapshot; only new signers see the new version.
## Related
- [Forms](/forms) — manage your forms and waivers
- [Members](/members) — see a member's linked form submissions
- [Invite links](/docs/how-to/invite-links)
@@ -0,0 +1,81 @@
Onboarding is the short, ordered flow a new member walks through the first time they open the app. You edit it under the **Onboarding** section of [/customize](/customize), in the **New member onboarding** card. This page shows you how to add, reorder, require, disable, and delete steps.
## Who can edit onboarding
Editing onboarding is role-gated:
| Action | Required role |
| --- | --- |
| Add a step (Custom or Form) | admin |
| Edit title, body, config, order, toggles | admin |
| Delete a step | admin (built-in steps can never be deleted) |
Board members can open [/customize](/customize) and see the onboarding steps, but the panel is read-only for them: the **+ Custom step** / **+ Form step** buttons are hidden and every field, toggle, and Delete control is disabled. Members who are neither admin nor board are redirected away from /customize entirely.
## Understand the built-in steps
Every space is seeded with four built-in steps when it is created. They are marked **built-in** and cannot be deleted — only disabled or reordered.
| Step | Type | Seeded as | Notes |
| --- | --- | --- | --- |
| Welcome to *space* | `welcome` | enabled, optional | Info screen, markdown body, never blocking |
| Code of Conduct | `code_of_conduct` | enabled, required | Body plus a required acknowledgement checkbox |
| Complete your profile | `profile` | enabled, optional | Prompts the member for display name, handle, bio, skills |
| Set up your dues | `payment` | enabled, optional | Dues nudge with an optional payment link button |
## The step types
There are six step types. The four above are built-in; the two you can add yourself are `content` and `form`.
| Type | Body editable | Extra config | Deletable |
| --- | --- | --- | --- |
| `welcome` | yes | — | no (built-in) |
| `code_of_conduct` | yes | `require_ack`, `ack_label` | no (built-in) |
| `profile` | no | — | no (built-in) |
| `payment` | yes | `payment_url` (link button) | no (built-in) |
| `content` | yes | — | yes |
| `form` | yes | `form_id` (published form) | yes |
Body fields accept markdown and a safe subset of HTML.
## Add a custom step
1. Go to [/customize](/customize) and open the **Onboarding** section.
2. Click **+ Custom step**. A new `content` step is appended at the bottom, titled "New step".
3. Edit the **title** field and the body textarea inline. Changes save when the field loses focus.
## Add a form or waiver step
Use this to make members sign a waiver or fill out a form as part of joining.
1. First create and **publish** the form under [/forms](/forms) (Forms & waivers). Only published forms can be linked.
2. In the **Onboarding** section, click **+ Form step**.
3. In the new step, use the **Select a form…** dropdown to pick the published form. Waivers are labelled `(waiver)`. The choice is stored as `form_id` on the step.
If there are no published forms yet, the step shows a prompt to create one first instead of a dropdown.
## Reorder steps
Each step row starts with a small number field — its `sort_order`. Type a new number and click away; steps re-sort by ascending order. Lower numbers run first. New steps are added with an order one higher than the current maximum.
## Toggle required and enabled
Each step has two checkboxes:
- **Enabled** — unchecked steps are hidden from the flow entirely.
- **Required** — the member cannot finish onboarding until every *enabled and required* step is completed.
Edge cases worth knowing:
- A member can only **skip** onboarding when no enabled step is required. If any enabled required step exists, skip is refused.
- A required `form` step is satisfied once a submission for the linked form exists for that member. If the linked form is missing or unpublished, the step is treated as non-blocking so a misconfiguration cannot lock members out.
## Delete a step
Only custom (`content`) and `form` steps show a **Delete** button, and only for admins. Built-in steps have no delete control — disable them instead. Deleting asks for confirmation and is permanent.
## Related
- [Onboard members](/docs/tutorials/onboard-members)
- [Invite links](/docs/how-to/invite-links)
- [Import members](/docs/how-to/import-members)
+59
View File
@@ -0,0 +1,59 @@
A door connection ties your space to a physical door controller so the platform can push member cards, actuate the door, and record every action. This recipe walks you from an empty screen to a working connection with cards assigned and live events flowing into the access log.
You need the `door.manage` permission (held by `admin` and `board` by default) to create and configure connections. Operating the door and assigning cards additionally needs `door.operate`.
## Before you start
Store the controller's shared password in your encrypted Secrets vault under [/ops](/ops) first. The password is never stored on the connection itself — the connection only references a vault entry by title, and the server decrypts it at call time. If your controller has no password, you can skip this.
## Add the connection
1. Open [/door/manage](/door/manage) (the "Door access" screen) and click **New connection**.
2. Enter a **Name** (for example, `Front door`).
3. Pick the **adapter**:
- **HeatSync / 23b Open Source Access Control (native)** — the verified Arduino query-string firmware. Verb encoding is built in; you configure nothing else.
- **Other / generic HTTP controller** — any controller you can drive over HTTP, using per-verb templates (see below).
4. Choose the vault password from the second dropdown, or leave **No password (auth: none)**.
5. Set the **Base URL** (for example, `http://192.168.1.50/`) and the **Pinned host** (for example, `192.168.1.50`).
6. Click **Create connection**.
The app is cloud-hosted, so the target can be a publicly reachable controller or proxy, or a VPN-reachable device on your LAN. The server only ever calls the exact pinned host, never follows redirects, caps time and response size, and blocks cloud-metadata and link-local addresses regardless of what you pin.
### Generic controller verbs
For a generic connection, fill the request templates the controller expects. Each is appended to the base URL and sent as a `GET`. Placeholders are URL-encoded and substituted:
| Placeholder | Meaning |
| --- | --- |
| `{slot}` | the member's integer card slot |
| `{tag}` | the card UID / tag value |
| `{perm}` | permission level (default 1) |
| `{door}` | door identifier, for open/lock verbs |
| `{pw}` | the shared password from your vault (server-side only) |
Leave any verb blank if the controller does not support it. Example: `?m{slot}&p001&t{tag}&e={pw}`.
## Verify it
Click **Test** on the connection. This runs the read-only `status` verb only — it never opens the door — and writes one redacted row to the access log. A success toast shows the HTTP status.
## Assign member cards
With `door.operate`, click **Cards** in the Operate row. You get every active card in the space. Click **Grant** to write a card to the controller; the platform allocates the lowest free slot (HeatSync uses slots `0`–`200`) and shows it. Click **Revoke** to remove it. Slots are per connection, so the same card can hold different slots on different controllers, and re-granting is idempotent.
The Operate row also gives you **Open** (momentary), **Unlock** (hold open), and **Lock**.
## Read real events back in (optional)
To pull actual entry and denied events into the log, click **Inbound**:
1. Choose a **webhook secret** from your vault (distinct from the door password) and click **Turn inbound on**.
2. For a generic controller, POST events to the shown webhook URL, `/api/door/inbound/<connection-id>`, with header `Authorization: Bearer <your webhook secret>` and a JSON body like `{ "events": [{ "id": "<stable-id>", "card_uid": "<hex>", "result": "granted" }] }`. The `id` dedupes retries.
3. For a HeatSync connection, a once-a-minute poll reads the controller's log automatically once inbound is on — no webhook wiring needed.
Ingested events resolve each presented card to a member and appear in the **Access log** at the bottom of the page. That log is append-only and immutable: only the server writes it, secrets are redacted first, and rows survive even if you later delete the connection.
## Related
- Enable member self-entry per connection with the **Self-entry on** toggle (elevated physical-security risk; off by default).
- Define custom HTTP actions at [/door/buttons](/door/buttons) ("API buttons"), which members invoke from [/doors](/doors).
+57
View File
@@ -0,0 +1,57 @@
Dues that arrive off-platform — PayPal, Venmo, Zeffy, or cash handed to the treasurer — land in the [/payments](/payments) ledger as transactions, where you match each one to the member who paid. This recipe covers getting those transactions in and reconciling them by hand.
If you want dues that reconcile themselves, set up recurring Stripe billing instead — see [Dues end to end](/docs/tutorials/dues-end-to-end). This page is for everything that is *not* an automatic Stripe subscription.
## Roles you need
| Task | Where | Required role |
| --- | --- | --- |
| Connect a platform, add pay-here links | [/settings](/settings) | `admin` |
| Log cash, import CSV, sync, link a payment | [/payments](/payments), [/import](/import) | `admin`, `board`, `treasurer` |
## Add a pay-here link (optional)
Give members a button to pay you directly. In [/settings](/settings) open the **Dues** tab, find **Other ways to pay dues**, and add a link per platform:
1. Paste an `https://` payment URL for **PayPal**, **Zeffy**, or **Venmo**.
2. Add optional instructions, e.g. "put your member name in the note."
3. Leave the toggle on **shown** and click **Save**.
Members see active links on their [/me](/me) page. These links do not record anything — money paid through them still has to be reconciled in [/payments](/payments) later.
## Connect PayPal to sync transactions
To pull PayPal transactions automatically, connect the API credentials once. In [/settings](/settings), open the **Integrations** tab, click **Connect** on **PayPal**, and enter your Client ID, Client Secret, and mode (`sandbox` or `live`). Zeffy and Venmo have their own credential fields on the same tab.
Once PayPal shows **LIVE** on the [/payments](/payments) summary card, a **Sync Now** button appears. Click it to import incoming transactions as **unlinked** payments. The sync deduplicates on the PayPal transaction id, so re-syncing will not create duplicates.
## Import transactions from a CSV
For any platform, upload a spreadsheet export at [/import](/import):
1. Choose **payments** as the import type.
2. Drop your CSV file. Columns auto-map by header name.
3. Confirm the mapping. Required fields: **Amount**, **Sender / From Name**, **Date**. Optional: **Platform** (`venmo`/`paypal`/`cash`) and **Note / Memo**.
4. Click **Preview Import**, then **Import**.
Rows with a bad amount, date, or platform are skipped and counted in the results — they are not silently dropped. Imported rows land **unlinked**.
## Log a cash payment
For cash handed over in person, click **Log Cash** on [/payments](/payments). Enter the amount, a note (e.g. "John Smith — March dues"), an optional date, and optionally pick the member from **Link to Member** to link it immediately.
## Reconcile: match payments to members
New transactions arrive **unlinked**; the header shows an `N unlinked` count. To clear them:
1. Use the **Platform** and **Status** filters to narrow to **Unlinked**.
2. For each row, read the **From / Note** column. Synced PayPal rows carry the payer's email there, which you can compare against the member emails shown in the picker.
3. Click **+ Link member**, then choose the member. The picker lists each member's display name and email.
Linking sets the payment to **linked** and advances that member's dues. Reconciliation is *advance-only*: linking a payment moves the member's last-paid date forward and marks them `current` only if it is their most recent payment. A backdated payment older than what is already on file will not push a member's status backward.
### Notes
- Linking is one payment to one member; there is no bulk or automatic email match. Work down the unlinked list by hand.
- Only `current` and `late` members appear in the picker.
- The ledger shows the 100 most recent transactions across `paypal`, `zeffy`, `venmo`, and `cash`.
+83
View File
@@ -0,0 +1,83 @@
The [/customize](/customize) screen is where you bend the platform's built-in vocabulary to fit your space. From one place you can rename and recolor the five built-in roles, tune the per-space permission matrix, add display-only custom roles, define membership tiers, and set up shop areas and area leads.
Operational settings (space identity, integrations, webhooks) live under [Settings](/docs/how-to/space-settings), not here.
## Before you start
You need the **board** or **admin** role to open [/customize](/customize) at all. Most panels let board members read the current configuration, but the changes that alter authorization — editing the permission matrix, creating roles, changing tiers, assigning area leads — are **admin-only**. When you are a board member, those controls render as view-only.
Open [/customize](/customize) and use the left-hand nav to move between panels: Roles, Permissions, Membership tiers, Areas, Area leads, Invite codes, and Onboarding. This page covers the first five.
## Rename and recolor the built-in roles
The five roles — `admin`, `board`, `treasurer`, `member`, `associate` — are fixed and drive every permission check. You cannot add or remove them, but you can change how they are displayed everywhere in the app.
1. Go to the **Roles** panel.
2. Under **Built-in roles**, each row shows the fixed role code, an editable display name, a badge color, and an optional short description.
3. Edit the name, pick a color, or type a description. Each field saves when it loses focus (on blur); you do not submit a form.
The new label appears anywhere the role is shown. The underlying role code never changes, so permissions and RLS are unaffected.
## Add a custom role
Custom roles are extra labels for org structure — committees, mentors, working groups. They are **display-only and do not grant any permissions** on their own. (They can, however, appear as subjects in the permission matrix; see below.)
1. In the **Roles** panel, under **Custom roles**, choose **+ Custom role** (admin only).
2. Enter a `slug` (lowercase, kebab/underscore), a display name, a color, and an optional description.
3. Choose **Create**. Existing rows are edited in place on blur, and **Delete** removes a role permanently.
## Edit the permission matrix
The **Permissions** panel is a grid: capabilities down the side, role subjects across the top. Checking a box grants that permission to that role for this space.
- **`admin` is never listed** — it implicitly holds every permission and cannot be locked out.
- Subjects are the built-in roles (except admin) plus any custom roles you created.
- Toggles save immediately per subject; a "…" indicator shows while saving, and a failed save reverts.
Permissions are **additive**. A grant only widens what a role can do through the surfaces that consult permissions; it never overrides the database tenant isolation (RLS) that keeps spaces separate. The available capabilities include, among others:
| Code | What it allows |
| --- | --- |
| `members.manage` | Manage members |
| `payments.manage` | Manage payments |
| `governance.manage` | Manage proposals, incidents, policies |
| `ops.kb.write` | Write knowledge base |
| `ops.secrets.read` | Reveal secrets |
| `classes.instruct` | Run classes: attendance, completion |
| `door.operate` | Open/lock doors, push/revoke cards |
| `customize.manage` | Customize roles, tiers, areas, invites, onboarding |
The full catalog is grouped by area (Ops, People, Finance, Governance, Community, Certifications, Classes, Equipment, Access, Admin) in the panel itself.
## Define membership tiers
Every space is seeded with three built-in tiers: **Plus**, **Basic**, and **Associate**. Tiers set the plans a member can be placed on (see [members](/members)).
1. Go to the **Membership tiers** panel.
2. Choose **+ New tier**, then set a `slug`, display name, price in USD, and a billing cadence: `monthly`, `quarterly`, `annual`, `one_time`, or `custom`.
3. Choose **Create tier**. For any tier, edit the price inline (saves on blur).
Built-in tiers are marked **built-in** and can be **archived** (hidden) but not deleted. Custom tiers can be deleted outright.
## Set up areas and area leads
Areas tag tasks, projects, and [knowledge base](/docs/reference/knowledge-base) entries by shop space. New spaces come seeded with areas like `woodshop`, `electronics`, `laser`, and `3d-printing`.
To manage areas:
1. Open the **Areas** panel and choose **+ New area**.
2. Enter a `code`, a display name, and an optional emoji icon.
3. Reorder with the sort-order field, **Archive** to hide an area from pickers without losing history, or **Delete** to remove it.
To manage area leads:
1. Open the **Area leads** panel. Each row is an **area-lead role**; unfilled roles show as **Vacant**.
2. Choose **+ Area-lead role**, give it an area code and a role name (e.g. `woodshop` / "Woodshop Lead").
3. Use the per-row dropdown to assign a current member, or set it back to **Vacant** to unassign.
The assigned member effectively holds that area-lead role, so any Ops item whose access list includes it becomes available to them. You can also assign leads from the member directory.
## Related
- [Invite codes](/docs/how-to/invite-links) and [Onboarding](/docs/how-to/configure-onboarding) — the remaining Customize panels.
- [Members](/members) — where roles and tiers are applied to people.
@@ -0,0 +1,64 @@
Register your tools and machines, let members book time on them without double-booking, and optionally require a certification before a tool can be reserved. This recipe covers the manager side (the registry) and the member side (browsing and reserving).
## Before you start
Managing the registry requires the `equipment.manage` permission. By default the **board** role holds it; a space can grant it to other roles under [/customize](/customize). Reserving a tool needs no special permission — any member in good standing (status `current`, `unverified`, or `late`) can book from the catalog.
If you plan to gate a tool behind a certification, create that certification first at [/certifications](/certifications) so it appears in the requirement dropdown.
## Register a piece of equipment
1. Go to [/equipment/manage](/equipment/manage) and choose **New equipment**.
2. Give it a **Name** (required, up to 200 characters, e.g. `Laser Cutter #1`).
3. Optionally fill in **Description**, **Location**, and **Asset tag**.
4. Set the operational **status**. New items default to **Available**.
5. Optionally pick a required certification (see below), then choose **Add equipment**.
The item appears immediately in the manager list and, once it is available, in the member-facing catalog at [/equipment](/equipment).
### Equipment status
Status controls whether a tool can be booked at all. Only `available` equipment accepts reservations.
| Status | Label | Reservable? |
| --- | --- | --- |
| `available` | Available | Yes |
| `maintenance` | Under maintenance | No |
| `retired` | Retired | No |
To take a machine offline temporarily, edit it and switch the status to **Under maintenance**; switch it back to **Available** when repairs are done. Use **Retired** for equipment that is permanently out of service.
## Gate a tool behind a certification
To require that a member hold a certification before they can reserve or use a tool:
1. Edit the equipment (or set this while creating it) on [/equipment/manage](/equipment/manage).
2. In the certification dropdown, choose **Requires: `<certification>`** instead of **No required certification**.
3. Save.
The certification must belong to your space or the save is rejected. Once set, members who do not hold an active certification see a **Requires `<name>`** badge on [/equipment](/equipment) and the **Reserve** button is disabled for them. Members who hold it see a **Certified: `<name>`** badge and can book normally.
A revoked or expired certification does not count as active, so it will not satisfy the gate. An `equipment.manage` holder can override the certification gate — including booking on another member's behalf — but the override never bypasses an operational status block (a `maintenance` or `retired` tool still cannot be reserved).
## Reserve a tool (member view)
1. From [/equipment](/equipment), find the tool and choose **Reserve**.
2. Pick a **Starts** and **Ends** time. The end must be after the start, and the start cannot be in the past.
3. Optionally add **Notes**, then choose **Confirm reservation**.
Members can review their own bookings on [/me](/me). A member can cancel their own reservation; an `equipment.manage` holder can cancel anyone's.
## How overlaps are prevented
Two reservations for the same tool cannot overlap. Time windows are half-open, so a booking that ends exactly when the next one begins is allowed (they touch but do not conflict). Only active (`reserved`) reservations block a slot — `cancelled` and `completed` ones never do.
This is enforced two ways: the action checks for conflicts before inserting, and a database exclusion constraint (`equipment_reservations_no_overlap`) is the final arbiter, so two people booking the same slot at the same instant cannot both succeed. The losing request sees `That time overlaps an existing reservation.`
## Edit, archive, or delete
On [/equipment/manage](/equipment/manage) each item offers:
- **Reservations** — view the booking list for that tool.
- **Edit** — change any field, including status and the required certification.
- **Archive** / **Restore** — archived items are hidden from the member catalog but keep their history. Use this instead of deleting once a tool has bookings.
- **Delete** — permanently removes the item. This is blocked if the tool has any reservations; archive it instead.
+71
View File
@@ -0,0 +1,71 @@
Incidents are how your space records code-of-conduct complaints, safety reports, and conflicts, then moves each one from a report to a decision. Any member can file; admins and board members triage. This recipe walks the whole path, including the anonymous option and the appeal-to-a-proposal exit.
## Before you start
- **File a report:** any member of the space.
- **Change status, record a disposition, scope update visibility, appeal:** `admin` or `board` role.
- A reporter always sees their own incident and its non-board updates, even without a privileged role.
## File a report
1. Go to [/incidents](/incidents) and click **File a report** (or open [/incidents/new](/incidents/new) directly).
2. Enter a **Title** (short summary, up to 200 characters) and describe **What happened** in the body — when, where, who was present, what was said or done.
3. Set **Category** (free text, e.g. `general`, `safety`, `harassment`, `theft`) and **Severity**.
4. Optionally check any **Subjects** — members the report concerns. Subjects are stored on the record but are not notified and do not gain read access by default.
5. Optionally check **File anonymously** (see below).
6. Click **File report**. A named report opens at its detail page; the incident starts in status `received`.
Severity is one of:
| Value | |
|---|---|
| `low` | |
| `medium` | default |
| `high` | |
| `critical` | |
## File anonymously
Checking **File anonymously** omits your name from the record and the activity log. In exchange you get a one-time **tracking token** shown on screen.
- The token is the only way to look the report up later — it is never emailed or displayed again. Save it immediately.
- Check status any time at [/track](/track) by entering the token. That view shows the report, its status timeline, a disposition once one exists, and updates that are not board-only.
- This is not cryptographic anonymity: an admin with direct database access can still see the row exists.
## Triage and move the incident
On the incident detail page (`/incidents/[id]`), admins and board members get an **Update status** control. Statuses:
| Status | Meaning | Timestamp stamped |
|---|---|---|
| `received` | Filed, not yet acknowledged | — |
| `under_review` | Acknowledged, being handled | `acknowledged_at` |
| `decided` | A disposition has been reached | `decided_at` |
| `appealed` | Reporter has appealed the decision | — |
| `closed` | Resolved and archived | `closed_at` |
When you select `decided` or `closed`, a **Disposition / reasoning** box appears. What you write there is visible to the reporter and is surfaced on the public tracking view. Deciding also records you in the incident's decision-makers.
## Post updates with scoped visibility
Use **Post an update** to add information, ask a question, or share a decision. Admins and board members choose a visibility for each note; reporters post at `all_parties`.
| Visibility | Who can read it |
|---|---|
| `reporter_only` | Admin/board and the reporter |
| `all_parties` | Admin/board and the reporter |
| `board_only` | Admin/board only — hidden from the reporter and the tracking view |
Each incident also has a discussion thread at the bottom of the page for members who can see the record.
## Appeal a decision
Once an incident is `decided`, the reporter sees a **Request membership appeal** form. Filing an appeal:
1. Creates a **draft** proposal of type `membership_vote` with a simple-majority threshold, linked back to the incident.
2. Flips the incident to `appealed`.
3. The action itself requires `admin` or `board` access.
The proposal opens as a draft so the language can be refined before voting — you still open it for voting yourself. From then on it behaves like any other proposal; see [Run a proposal](/docs/how-to/run-a-proposal). A link to the appeal proposal appears on the incident under **Appeal**.
Incidents are never deleted — the platform has no delete path, so the record is retained for the space's history.
+63
View File
@@ -0,0 +1,63 @@
Bring an existing roster into your space in one pass. The [/import](/import) screen (titled **Import & Sync**) reads a CSV, lets you map its columns to member fields, previews the result, and upserts everyone into [/members](/members) with a tier and a status.
## Before you start
- **Role required:** the Import & Sync screen opens for **admin**, **board**, and **treasurer**. The member import itself is gated tighter — the server action accepts only **admin** or **board**. A treasurer who runs a member import gets `Admin access required`.
- **File format:** a `.csv` (or `.txt`) file. There is no paste box — you upload or drag-and-drop a file. Quoted fields and embedded commas are handled.
- **Row limit:** 5000 members per import.
- One row per member; the first row must be a header row.
## Columns
Only two columns are required. Header names are auto-detected on upload, so sensible names map themselves; you can override any mapping in the next step.
| App field | Required | Notes |
| --- | --- | --- |
| Display Name | Yes | Non-empty, max 100 chars |
| Email | Yes | Must be a valid email; lowercased on import; the dedup key |
| Phone | No | Max 20 chars |
| Tier / Membership Type | No | Normalized to `plus`, `basic`, or `associate` (see below); defaults to `basic` |
| Join Date | No | Any parseable date; defaults to the import time if blank |
| Last Paid Date | No | Any parseable date |
| Card Access | No | `yes`/`true`/`1`/`y` becomes true; anything else false |
The mapping dropdown also offers **Handle / Username**, but that column is not written to the member record — leave it on **Skip this column** or don't rely on it.
Tier values are matched loosely and collapsed to the three real tiers:
| Your value | Stored tier |
| --- | --- |
| `plus`, `premium`, `full` | `plus` |
| `basic`, `member`, `standard` | `basic` |
| `associate`, `visitor`, `guest` | `associate` |
| anything else | `basic` |
## Steps
1. Go to [/import](/import). Leave the **Import type** toggle on **members**.
2. Drop your CSV onto the upload area, or click to browse. The wizard advances to **Map Columns** and shows the row count.
3. Check each column's mapping. Your header sits on the left with a sample value; pick the matching app field on the right, or **Skip this column**. Required fields are marked with `*`. If **Display Name** or **Email** is unmapped, a warning lists them and **Preview Import** stays disabled.
4. Click **Preview Import**. Review the first 5 rows as they will land, then click **Import _N_ rows**.
5. On the results screen, read how many imported and how many failed. Up to 10 per-row errors are listed. Click **Import Another File** to start over.
## What each imported member gets
Every valid row is upserted into `space_members` with these fixed defaults:
| Field | Value |
| --- | --- |
| `role` | `member` |
| `status` | `current` |
| `approved` | `true` |
| `user_id` | left empty (an offline member with no login) |
Imported members start as **current** and unlinked to any login account. They link a login later by signing up with the same email or being invited.
## Edge cases
- **Re-importing updates, not duplicates.** Rows upsert on `(space_id, email)`. Importing an email that already exists in your space overwrites that member's mapped fields — a safe way to bulk-update. Rows with no matching email are inserted new.
- **Bad rows are skipped, not fatal.** Each row is validated individually (invalid email, missing name, unknown tier, or unparseable date). Skipped rows are counted and reported; the rest still import.
- **A row missing name or email** is dropped before the server call and reported as `Row N: missing name or email` (N is the spreadsheet row).
- **Card access is granted in the record only.** Importing `has_card_access` marks the flag; it does not program a physical door card or card reader.
Importing payments instead? The same [/import](/import) screen has a **payments** mode with its own required columns (amount, sender, date).
+51
View File
@@ -0,0 +1,51 @@
Invite codes let new people join your space through a shareable code or one-click link. Each code can grant a specific role, cap how many times it is used, and expire on a date you choose — so you can hand out a permanent public code and a single-use admin link from the same screen.
## Before you start
- You need the **admin** role. In the UI, creating, editing, disabling, and deleting invites is admin-only. A **board** member can open the **Invite codes** panel — it is visible to admin and board — but sees only the read-only **Copy code** and **Copy link** buttons; the **+ New invite**, **Disable/Enable**, and **Delete** controls are hidden. (The underlying `createInvite` server action also permits board, but the panel does not expose it.)
- As the admin, you can grant any role with an invite, including **admin**.
- Invite codes live in the **Invite codes** panel on the [`/customize`](/customize) screen.
## Create an invite code
1. Go to [`/customize`](/customize) and find the **Invite codes** panel.
2. Click **+ New invite**.
3. Fill in the fields (all optional except the granted role, which defaults to **member**):
| Field | What it does |
| --- | --- |
| Code | The code people redeem. Leave blank to auto-generate an 8-character code. Typed codes are uppercased and limited to `A–Z`, `0–9`, and `-`, 4–32 characters, and must be unique across all spaces. |
| Label | A private note to remember what the code is for (for example, "Open house 2026"). |
| Expiry | A date and time after which the code stops working. Leave blank for no expiry. |
| Max uses | How many times the code may be redeemed. Leave blank for unlimited. Must be a positive number. |
| Grants | The role a member receives when they join with this code. The dropdown only lists roles you are allowed to assign. |
| Single use | Sets max uses to 1 and auto-disables the code after one join. |
4. Click **Create invite**. The new code appears at the top of the list with a `grants <role>` badge.
## Share the code or link
Each row has two buttons:
- **Copy code** — copies the raw code (for example, `K7P2R9QT`) to paste into a message.
- **Copy link** — copies a one-click join link in the form:
```
https://your-space.example/join/<space-slug>?code=<CODE>
```
The link lands new people on a space-scoped join page that carries the code into account creation. Anyone who redeems it becomes a member at the role the invite grants.
## Revoke or remove a code
These controls are admin-only.
- **Disable** turns the code off without deleting it. Redemptions stop immediately; you can **Enable** it again later. This is the quickest way to revoke a leaked or expired code.
- **Delete** removes the code permanently after a confirmation prompt.
A code also stops working on its own once it is disabled, past its expiry, or at its use cap. The list flags these with `disabled`, `expired`, and `at cap` markers, and each row shows its usage count (for example, `uses 3 / 10`).
## Notes and edge cases
- Disabling and deleting affect only future joins. Members who already joined keep their role; change it from [`/members`](/members).
- If your space requires approval, new joiners still land as unverified pending an admin's review. See [`/docs/reference/roles`](/docs/reference/roles) for what each granted role can do.
+64
View File
@@ -0,0 +1,64 @@
Policies are your space's versioned governance documents: bylaws, code of conduct, station rules. Each policy has a stable slug and a full version history, and only one version at a time is active. This recipe walks you through drafting a policy, publishing it, and later superseding it with a new version.
You need the `admin` or `board` role. Every member can read policies at [/policies](/policies), but the `New policy` button and the management controls only appear for admins and board.
## Draft a new policy
1. Go to [/policies](/policies) and click `New policy` (top right). This opens [/policies/new](/policies/new).
2. Fill in the fields:
| Field | Required | Notes |
| --- | --- | --- |
| Slug | Yes | Lowercase letters, numbers, and hyphens only; max 80 characters (e.g. `bylaws`, `code-of-conduct`). This is the stable identifier that carries across every version. |
| Section reference | No | An optional citation label such as `Article III §2`, shown next to the version. |
| Title | Yes | Human-readable name, max 200 characters. |
| Plain-language summary | No | A short, readable gloss of the formal text. Rendered as Markdown. |
| Formal text | Yes in practice | The authoritative body. Rendered as Markdown. |
3. Click `Save as draft v1`. The policy is created at `version` 1 with status `draft` and you land on its detail page at `/policies/<slug>`.
A draft is not yet in force. It has no `effective_at` date and does not count as the space's governing text until you activate it.
The slug must be unique within your space. If you try to create a second policy with a slug that already exists, you get an error telling you to supersede the existing one instead — that is how new versions are made.
## Publish (activate) the policy
1. Open the policy at `/policies/<slug>`.
2. In the `Manage` panel, click `Activate v1`.
Activating does two things atomically:
- Sets the policy's status to `active` and stamps `effective_at` with the current time.
- Marks any previously `active` version of the same slug as `superseded`, so exactly one version is ever active.
The detail page's primary view always shows the active version (falling back to the latest version if none is active), and the status badge on [/policies](/policies) reflects it.
## Supersede with a new version
When the text needs to change, you do not edit the active version in place — you publish a new version and swap it in.
1. Open the active policy and click `Supersede with new version` in the `Manage` panel.
2. The formal text and plain-language fields open pre-filled with the current version's content. Edit them.
3. Click `Create draft v<n+1>`. This inserts a new row that keeps the same slug, increments `version`, records `prior_version_id` pointing at the version you superseded, and starts at status `draft`.
4. Review the new draft, then click `Activate v<n+1>`. Activation flips the new version to `active` and marks the prior active version as `superseded` in the same step.
The full lineage stays visible in the `Version history` section of the detail page, newest first, with each version's status and creation date. Nothing is ever deleted.
## Other status transitions
The `Manage` panel also exposes the remaining statuses. Policy status is one of:
| Status | Meaning |
| --- | --- |
| `draft` | Written but not in force; no effective date. |
| `active` | The current governing version. At most one per slug. |
| `deprecated` | Retired without a replacement (e.g. a rule you no longer enforce). Use the `Deprecate` button on an active policy. |
| `superseded` | Replaced by a newer version. Set automatically when a newer version is activated. |
You can also set `draft`, `deprecated`, or `superseded` directly with the `Set <status>` buttons, but the automatic transitions during activation handle the common case.
## Notes
- Every action here requires the `admin` or `board` role; the underlying create, supersede, and status actions reject anyone else.
- A version can record the proposal that adopted it; when set, the version history links straight to that proposal. See [Run a proposal](/docs/how-to/run-a-proposal) for putting a bylaw change to a vote before you activate it.
- Both body fields render Markdown, so headings, lists, and emphasis display formatted on the detail page.
+60
View File
@@ -0,0 +1,60 @@
Proposals give your space an auditable, async way to decide things: a member drafts a question, opens it for voting, members record a position, and the app computes quorum and pass/fail for you. This recipe walks the full lifecycle from draft to decided.
## Before you start
- Any current member can create a proposal and open it for voting. A member whose status is still `unverified` (pending approval) cannot create, open, or vote.
- Only an `admin` or `board` member can mark an open proposal **decided**. A proposer can **withdraw** their own proposal only while it is still a **draft**; an `admin` or `board` member can withdraw a draft **or** an open proposal. The Withdraw button also appears to the proposer on an open proposal, but clicking it there is a silent no-op — row-level security blocks the change and no error is shown.
- Quorum and the voting window are not entered per proposal. They are computed from your space's governance defaults at the moment voting opens. You choose only the pass **threshold**.
## Create the proposal
1. Go to [/proposals](/proposals) and click **New proposal** (this opens [/proposals/new](/proposals/new)).
2. Fill in **Title** and **Body**. The body accepts Markdown and is rendered on the detail page.
3. Pick a **Type**. This is a label describing the kind of decision:
| Type | Use for |
|------|---------|
| `advisory_poll` | Non-binding sense of the room (the default) |
| `board_action` | A specific action for the board to take |
| `membership_vote` | A decision put to the membership |
| `bylaw_change` | Amending a policy or bylaw |
| `budget` | Spending or budget approval |
| `recall` | A vote of confidence / recall |
4. Pick a **Threshold to pass**. This sets how the yes/no split is judged when the proposal is decided:
| Threshold | Passes when |
|-----------|-------------|
| `simple_majority` | Yes votes exceed no votes |
| `two_thirds` | Yes is at least two-thirds of yes+no |
| `three_fourths` | Yes is at least three-fourths of yes+no |
| `unanimous` | No "no" votes and at least one "yes" |
5. To go straight to voting, tick **Open voting immediately**. Its label notes that quorum and the voting window come from space defaults. Leave it unchecked to save a draft you can open later.
6. Click **Save proposal**. You land on the proposal's detail page.
## Open voting
If you saved a draft, open it when you are ready. On the proposal page under **Manage**, click **Open for voting**. At that moment the app:
- counts your active members and computes `quorum_required` from the space's default quorum percent (with a floor), and
- sets the voting window using the default window length (shipped default is 216 hours, or 9 days) unless you supplied a close time.
Once open, the proposal moves to the **Open for voting** section on [/proposals](/proposals) and shows its close time.
## Let members vote
While voting is open, every member sees a **Cast your vote** panel with four positions: `yes`, `no`, `abstain`, `recused`. Choosing `recused` requires a recusal reason. A public **Comment** is optional. Members can change their vote until the window closes; votes are never deleted. All votes and voter names are visible to the space for transparency.
The **Tally** section updates live: yes / no / abstain / recused counts, plus a quorum bar showing votes counted against `quorum_required` and whether quorum is **met**. Quorum counts yes, no, and abstain; recused votes do not count toward quorum.
## Record the outcome
When the discussion has run its course, an `admin` or `board` member clicks **Mark decided** under **Manage**. This freezes the tally, stamps the decision time, and sets the outcome. The proposal page then shows **PASSED** or **DID NOT PASS**, and it moves to the **Archive** section on [/proposals](/proposals).
A proposal passes only if quorum was met **and** the threshold you chose is satisfied on the yes/no split (abstentions do not count toward the threshold). If quorum was never met, the result is **DID NOT PASS** regardless of the vote split.
## Related
- [Governance model](/docs/explanation/governance-model) — why proposals, incidents, and policies are built together.
- [Governance reference](/docs/reference/governance) — every enum, field, and status.