Development
cd web && npm ci && npm run build && cd .. # embedded assetsgo test -p 1 ./... # unit testsTEST_DATABASE_URL=postgres://... go test -p 1 ./... # + integration testscd web && npm run dev # Vite dev server, proxies /api and /ws to :8080-p 1 matters: the integration tests share one database and migrate it, so
running packages in parallel makes them fight over the schema.
Layout
Section titled “Layout”| Path | What lives there |
|---|---|
cmd/parley |
main — config, boot, graceful shutdown |
internal/api |
HTTP router, identity, spaces, sessions, passcodes |
internal/auth |
OpenID Connect relying party |
internal/poker, internal/standup |
the session kinds |
internal/session |
the registry the kinds plug into, plus CSV |
internal/hub |
WebSocket fan-out and presence |
internal/store |
Postgres queries |
internal/db/migrations |
numbered SQL, applied at boot |
web |
Vite + React frontend, embedded into the binary |
plugins |
Ceremony plugins that must not change the host (plugins/retrospective) |
sdk |
@parley/plugin-sdk, @parley/plugin-ui, and the frozen ABI |
site |
this documentation site |
Session kinds are self-contained packages. Adding one touches three places and nothing else — no router surgery, no constraint change, no frontend ternaries.
Adding a session kind
Section titled “Adding a session kind”-
Write the package and register it. Give it a
Kind()constructor returning asession.Kind— itsName, aStatefunction returning only client-safe data (it’s broadcast to every participant, so nothing one participant shouldn’t see may be included),NewConfig, an optionalCSV, and itsActionsdispatch table. Add it to the slice ininternal/api/router.go:for _, k := range []session.Kind{poker.Kind(), standup.Kind(), retro.Kind()} {Actions are resolved against the session’s own kind, so two kinds may name an action the same thing. The shared dispatcher already rejects any write on an ended session with a 409, so an action does not need to check that itself.
Stateis handed the server’s*pgxpool.Pooldirectly:func buildState(ctx context.Context, pool *pgxpool.Pool, sess store.Session) (any, error)That concrete pool is a deliberate choice, not an oversight. A narrower
SessionReaderinterface was considered so registry dispatch could be tested without Postgres, and rejected: every state builder issues its own arbitrary SQL — poker joinsstoriestovotesand aggregates the voter ids, standup readssessionsandstandup_entries— so the interface would have to exposeQueryandQueryRow, which still hand youpgx.Rowsandpgx.Row. It renames the dependency rather than removing it, while making a convincing fake mean reimplementingpgx.Rows.BuildEnvelopeneeds the pool for the roster regardless, so narrowingStatealone would not have bought a single database-free test. Write state-builder tests against a real database;internal/sessionpins this signature so changing it is a deliberate revisit of the trade-off rather than a drive-by refactor. -
Seed the kind row. Migrations are append-only, so add a new numbered file in
internal/db/migrations/rather than editing one that shipped:-- 0011_retro_kind.sqlinsert into session_kinds (kind, provider, display)values ('retro', 'core', 'Retrospective');Skip this step and the kind still registers, but creating a session of it fails the foreign key and the API answers 400 with “that session kind is missing its session_kinds row”.
sessions.kindis a foreign keyon delete restrict, so a kind that has sessions can never be deleted. Withdraw one by settingretired_atinstead: new sessions are refused, the create dialog stops offering it, and existing ones keep working. -
Add the frontend entry. Append a
KindDeftoKINDSinweb/src/lib/kinds.ts—idmatching thekindcolumn,label, theRoomcomponent, andfieldsif the create dialog should offer config options. Give the kind a glyph inKindChiptoo, or its chip stays text-only. The glyph tests pick it up automatically and will hold it to the same legibility floor as the existing ones.
Ceremonies delivered as plugins
Section titled “Ceremonies delivered as plugins”A plugin can provide a whole ceremony rather than a panel, and the point of that
is that it works against the extension points as they are. scripts/check-core-untouched.sh
holds the line: a pull request that touches plugins/ may not also change
internal/, cmd/ or web/src. It runs as the core untouched by plugins CI
leg on every pull request.
Core work on its own is unaffected — the rule only fires on the combination. If a ceremony plugin genuinely needs the host to change, that is a finding about the extension points: land the core change as its own pull request first, then rebase the plugin onto it. Folding it in quietly is exactly what the check exists to prevent, because a plugin system that keeps needing core commits is a plugin system that does not work yet.
The kinds a plugin provides are declared on the uploaded package as kinds
(kind, display, actions). The install route persists them on first install
and on upgrade; a name or verb the host will not accept is 400 and writes nothing.
An offered plugin kind is not a KindDef in web/src/lib/kinds.ts. The
envelope names the install (plugin.name, plugin.version, plugin.grants) and
SessionPage frames it in the full-room slot with the same sandboxed iframe and
MessageChannel a nested poker panel uses. Nested panels stay h-64 inside
Poker; a plugin-owned ceremony fills the room chrome. Toolbar, nav and
export-menu slots are the same iframe in host chrome: a plugin that does not
declare a slot is not drawn there. kindUnavailable is still
asked first: a disabled plugin is a switched-off ceremony, not an unknown kind.
Screenshots
Section titled “Screenshots”The marketing and README screenshots live in site/src/assets/ and docs/, and
site/src/assets/screenshots.json records, per asset, the commit it was last
shot against (shot_at) and the web/src files whose UI it depicts
(depicts). Nothing else ties the two together, which is how the shipped set
once ended up several releases behind: four features shipped, every pull request
was green, and none had any reason to open the assets directory.
scripts/check-screenshot-freshness.sh reads that manifest and counts, per
asset, the commits since its stamp that touched what it depicts. CI runs it on
any pull request touching web/src and writes the result to the job summary. It
is advisory and cannot fail a build — a screenshot lagging the UI is not a
reason to block a code change, and a count of one commit is usually noise. A
count that keeps climbing is not.
The one thing it does fail on is a manifest it cannot read: an unparseable file
or a missing .assets array exits nonzero rather than reporting an all-clear,
because a report that says the set is fresh has to mean it looked. An asset with
an empty depicts is called out for the same reason — nothing could ever mark
it stale — and so is a depicts path that no longer exists at HEAD, which
would otherwise read as fresh forever the moment somebody renamed or deleted the
component.
depicts entries are matched literally, not as globs: web/src/pages/*.tsx is
reported as a path that does not exist rather than quietly standing in for a
whole directory, so what the report prints is what it matched. List the files.
When you re-shoot, update shot_at for the assets you replaced to the commit
the new frames land in, and add a depicts entry for any new surface a shot
now covers. A new screenshot belongs in the manifest in the same pull request
that adds it.
Contributing
Section titled “Contributing”Use Discussions for support
and designs that are not yet settled. Issues track accepted, actionable work.
The repository’s CONTRIBUTING.md, GOVERNANCE.md, SUPPORT.md, and
CODE_OF_CONDUCT.md contain the full community policy.
A few things that make review quick:
- Open an issue before a large change, so nobody builds the wrong thing twice.
go test -p 1 ./...andnpm run lintpass.- A behaviour change comes with a test that fails without it.
- Migrations are additive and numbered; never edit one that has shipped.
- Every commit includes a Developer Certificate of Origin sign-off. Use
git commit -s; CI checks each pull-request commit.