docs: HOOK_CONTRACT.md for v0.2.0 (generic Foundry facade)
Adds docs/HOOK_CONTRACT.md — the spec for hooks-lib v0.2.0. The
v0.1.0 implementation (18 hand-written event handlers, the
scripts/systems/ adapter pair, the encounter.js stub) is the wrong
shape. v0.2.0 is a generic Foundry hook facade:
- Subscribes to every Foundry hook in §6 (lifecycle, document CRUD
generic, combat, chat/canvas/UI, dnd5e v2 roll hooks)
- Emits a uniform {ts, hook, args} envelope with no domain
interpretation
- Provides subscribe / subscribeMany / subscribeAll / unsubscribeAll
primitives
- Registers system adapters (separate repos) via a manifest with
semver range matching for system + Foundry versions
- Dispatches sync for pre-* / combat* / applyActiveEffect /
get*Context hooks; async (microtask) for everything else
- Absorbs Foundry rename churn (renderChatInput vs renderChatLog)
and arity shifts (combatRound signature) so consumers stay stable
No code changed. The contract is the gate for v0.2.0 implementation.
Push: Gitea only (this turn). No GitHub mirror.
This commit is contained in:
508
docs/HOOK_CONTRACT.md
Normal file
508
docs/HOOK_CONTRACT.md
Normal file
@@ -0,0 +1,508 @@
|
||||
# HOOK_CONTRACT.md — `hooks-lib` v0.2.0
|
||||
|
||||
**Status:** Proposed spec. Subject to revision until v0.2.0 ships.
|
||||
**Audience:** hooks-lib authors + consumer authors + system adapter authors.
|
||||
**Stability:** v0.2.0 ships as the contract; breaking changes require a major bump.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mission
|
||||
|
||||
`hooks-lib` is a **generic Foundry hook facade**. It subscribes to every
|
||||
Foundry hook that this contract defines, exposes each fire as a
|
||||
uniform envelope, and provides a query API for consumers (modules that
|
||||
want to react to gameplay events).
|
||||
|
||||
**It is not** a domain library. It does not know what a "combat," an
|
||||
"actor," or a "damage roll" means. It does not maintain state. It does
|
||||
not filter for consumers. It does not render anything.
|
||||
|
||||
The library is an **anti-corruption layer** between Foundry (whose
|
||||
hook names and arities change between versions) and consumers (whose
|
||||
business logic should not have to rewrite every Foundry upgrade).
|
||||
|
||||
System-specific knowledge lives in **system adapter repos**
|
||||
(e.g. `hax-hooks-dnd5e`), not in `hooks-lib`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Envelope shape
|
||||
|
||||
Every fire produces exactly one envelope. The shape is fixed across
|
||||
all hooks and all consumers.
|
||||
|
||||
```js
|
||||
{
|
||||
ts: 1719000000000, // epoch ms; set immediately before Hooks.on dispatch returns
|
||||
hook: "updateActor", // the Foundry hook name that fired
|
||||
args: [doc, change, options, userId] // positional args, verbatim from Foundry
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---------|-----------|----------|-------|
|
||||
| `ts` | number | yes | `Date.now()` taken at the moment the wrapper invokes the Foundry handler. NOT when the consumer reads it. |
|
||||
| `hook` | string | yes | The Foundry hook name. Stable across Foundry minor versions (this contract maps semantic names where Foundry renames things; see §8). |
|
||||
| `args` | array | yes | Positional args exactly as Foundry passed them. May be empty. May contain non-serializable refs (Foundry Documents). |
|
||||
|
||||
**What the envelope does NOT contain:**
|
||||
|
||||
- No `kind`. The library doesn't classify events.
|
||||
- No normalized fields (`actorId`, `combatId`, etc). The library doesn't interpret args.
|
||||
- No consumer metadata, no subscription ids, no callbacks.
|
||||
|
||||
Consumers that want `{kind, actorId, delta}` build that themselves
|
||||
from the args. This is intentional: the library is the boundary that
|
||||
absorbs Foundry version churn.
|
||||
|
||||
---
|
||||
|
||||
## 3. Subscriber API
|
||||
|
||||
The library exports four subscribe primitives. All return an
|
||||
unsubscribe function.
|
||||
|
||||
### 3.1 `subscribe(hookName, fn) — single hook, single handler`
|
||||
|
||||
```js
|
||||
const unsub = hooksLib.subscribe("updateActor", (envelope) => {
|
||||
const [doc, change, options, userId] = envelope.args;
|
||||
// consumer logic
|
||||
});
|
||||
unsub(); // remove
|
||||
```
|
||||
|
||||
- `hookName` must be in the **registered hook set** (§6). Calling with
|
||||
an unregistered name throws `TypeError` synchronously. This catches
|
||||
typos at registration time, not at first fire.
|
||||
- `fn` is called with one argument (the envelope).
|
||||
- Multiple `subscribe("updateActor", ...)` calls are allowed and stack.
|
||||
Order is registration order.
|
||||
|
||||
### 3.2 `subscribeMany({hookName: fn, ...}) — batch subscribe`
|
||||
|
||||
```js
|
||||
const unsub = hooksLib.subscribeMany({
|
||||
updateActor: (env) => { ... },
|
||||
updateToken: (env) => { ... },
|
||||
createItem: (env) => { ... },
|
||||
});
|
||||
```
|
||||
|
||||
- Returns a single unsubscribe that removes all handlers.
|
||||
- Equivalent to N `subscribe` calls but atomic: if any name is
|
||||
unregistered, the whole batch throws and **no handlers are registered**.
|
||||
This prevents partial-registration bugs.
|
||||
|
||||
### 3.3 `subscribeAll(fn) — every hook, one handler`
|
||||
|
||||
```js
|
||||
const unsub = hooksLib.subscribeAll((envelope) => {
|
||||
// consumer logic that dispatches on envelope.hook
|
||||
});
|
||||
```
|
||||
|
||||
- For consumers that want to filter at one point (e.g. an audit log).
|
||||
- The `envelope.hook` field is the dispatch key.
|
||||
|
||||
### 3.4 `unsubscribeAll() — purge everything`
|
||||
|
||||
Intended for test teardown and module-disable cleanup. Removes all
|
||||
callbacks registered through any of the three primitives above.
|
||||
|
||||
### 3.5 Error containment
|
||||
|
||||
If a consumer callback throws, the library:
|
||||
1. Catches the error.
|
||||
2. Logs it via `console.error` with the prefix `[hax-hooks-lib]` and
|
||||
the hook name.
|
||||
3. Continues dispatching to subsequent callbacks (registration order).
|
||||
|
||||
This matches `Hooks.on` semantics. A misbehaving consumer cannot
|
||||
break the dispatch chain or other consumers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle
|
||||
|
||||
### 4.1 `init` — registration only
|
||||
|
||||
The library's `init` hook does **only** registration:
|
||||
|
||||
- Declares the registered hook set (§6) by calling `Hooks.on` for
|
||||
each one with the wrapper function.
|
||||
- Does NOT scan for system adapters yet.
|
||||
- Does NOT read `game.system` (it's not safe in init for some hooks).
|
||||
|
||||
### 4.2 `ready` — system adapter evaluation
|
||||
|
||||
The library's `ready` hook:
|
||||
|
||||
1. Reads `game.system.id` and `game.system.version`.
|
||||
2. Iterates the registered system adapters (registered by adapter
|
||||
modules' own `init` hooks — see §5).
|
||||
3. For each adapter whose `system.id` matches and `versions` range
|
||||
covers `game.system.version` AND `foundryVersions` covers
|
||||
`game.version`:
|
||||
- Calls the adapter's factory once.
|
||||
- The factory returns derived-event registrations; the library
|
||||
applies them.
|
||||
4. For each non-matching adapter: log a warning at debug level if the
|
||||
world WOULD have matched if Foundry were a different version (so
|
||||
users can tell why an adapter didn't load).
|
||||
5. Idempotent: calling `ready` twice on the same world produces the
|
||||
same adapter set. Re-registration replaces previous matches.
|
||||
|
||||
### 4.3 `unregisterModule` — cleanup
|
||||
|
||||
When the library's own module is unregistered (Foundry's
|
||||
`unregisterModule` hook with the library's id):
|
||||
|
||||
- Removes all `Hooks.on` registrations the library added.
|
||||
- Calls `unsubscribeAll()` to drop every consumer callback.
|
||||
- Clears the adapter registry.
|
||||
|
||||
Adapter modules that registered with the library should clean up
|
||||
their own `Hooks.on` in their own `unregisterModule`.
|
||||
|
||||
### 4.4 World change
|
||||
|
||||
Foundry's `ready` fires per-client when a world is loaded. The
|
||||
library's `ready` handler is the re-evaluation point. If a user
|
||||
switches worlds with a different `game.system.id`, the new
|
||||
`ready` call re-runs §4.2 with the new system version.
|
||||
|
||||
Adapter modules should make their own `init` re-register their
|
||||
factory with the library. The library deduplicates by `system.id`
|
||||
+ adapter manifest id.
|
||||
|
||||
---
|
||||
|
||||
## 5. System adapter protocol
|
||||
|
||||
A system adapter is a Foundry module that contributes
|
||||
system-specific derived-event APIs to the library. The adapter is
|
||||
itself a module with its own `module.json`, its own `init` hook,
|
||||
its own release cadence.
|
||||
|
||||
### 5.1 Adapter registration
|
||||
|
||||
The adapter module's `init` hook calls:
|
||||
|
||||
```js
|
||||
hooksLib.registerSystemAdapter({
|
||||
id: "hax-hooks-dnd5e", // unique adapter id
|
||||
moduleId: "hax-hooks-dnd5e", // the adapter's own Foundry module id
|
||||
system: {
|
||||
id: "dnd5e", // game.system.id this adapter knows about
|
||||
versions: ">=5.2.0 <5.3.0", // semver range
|
||||
},
|
||||
foundryVersions: ">=13 <15", // semver range, applied to game.version
|
||||
factory: () => {
|
||||
// called once on match. Returns nothing; side-effect is
|
||||
// calling hooksLib.registerDerivedEvent for each derived event.
|
||||
return [
|
||||
{
|
||||
name: "dnd5e.rollAttack",
|
||||
register: (fn) => hooksLib.subscribe("dnd5e.rollAttackV2", (env) => {
|
||||
const [rolls, ctx] = env.args;
|
||||
fn({ actor: ctx?.subject?.actor, item: ctx?.subject?.item, rolls });
|
||||
}),
|
||||
},
|
||||
// ...
|
||||
];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The library stores the manifest. At `ready`, it evaluates the
|
||||
matching conditions (§4.2) and calls `factory()` for matches.
|
||||
|
||||
### 5.2 Adapter non-match
|
||||
|
||||
If the world's `game.system.id` doesn't match the adapter's `system.id`,
|
||||
the adapter is skipped. The library does NOT load the factory.
|
||||
|
||||
If the system's id matches but the version doesn't, the library
|
||||
logs a single warning identifying the adapter and the version it
|
||||
needed vs. the version it found. The adapter does not load.
|
||||
|
||||
### 5.3 Adapter version upgrades
|
||||
|
||||
When the adapter module is updated to support a new dnd5e version,
|
||||
the adapter author bumps the adapter's `system.versions` range.
|
||||
The library re-evaluates on next `ready`.
|
||||
|
||||
The adapter module is **not** versioned against hooks-lib's version
|
||||
in the manifest's `relationships.requires`. The library's runtime
|
||||
adapter API (§5.1) is the contract. If the library's adapter API
|
||||
changes, the library bumps its own major version and adapter authors
|
||||
update.
|
||||
|
||||
### 5.4 Adapter failures
|
||||
|
||||
If an adapter's `factory` throws, the library:
|
||||
- Logs the error with adapter id + stack trace.
|
||||
- Continues loading other adapters.
|
||||
- Marks this adapter as failed for the duration of the session.
|
||||
|
||||
Failed adapters don't crash the library.
|
||||
|
||||
---
|
||||
|
||||
## 6. Registered hook set (v0.2.0)
|
||||
|
||||
The library registers a `Hooks.on` for each of the following Foundry
|
||||
hooks. Each registration uses the wrapper from §2 to produce envelopes.
|
||||
|
||||
**Lifecycle** (always-on):
|
||||
- `init`, `setup`, `ready`
|
||||
- `pauseGame`
|
||||
|
||||
**Document CRUD — generic:**
|
||||
- `createActor`, `updateActor`, `deleteActor`
|
||||
- `preCreateActor`, `preUpdateActor`, `preDeleteActor`
|
||||
- `createToken`, `updateToken`, `deleteToken`
|
||||
- `preCreateToken`, `preUpdateToken`, `preDeleteToken`
|
||||
- `createItem`, `updateItem`, `deleteItem`
|
||||
- `preCreateItem`, `preUpdateItem`, `preDeleteItem`
|
||||
- `createScene`, `updateScene`, `deleteScene`
|
||||
- `createJournalEntry`, `updateJournalEntry`, `deleteJournalEntry`
|
||||
- `createActiveEffect`, `updateActiveEffect`, `deleteActiveEffect`
|
||||
- `preCreateActiveEffect`, `preUpdateActiveEffect`, `preDeleteActiveEffect`
|
||||
- `createCombat`, `updateCombat`, `deleteCombat`
|
||||
- `preCreateCombat`, `preUpdateCombat`, `preDeleteCombat`
|
||||
- `createCombatant`, `updateCombatant`, `deleteCombatant`
|
||||
- `preCreateCombatant`, `preUpdateCombatant`, `preDeleteCombatant`
|
||||
|
||||
**Combat-specific (Foundry core):**
|
||||
- `combatStart`, `combatEnd`, `combatTurn`, `combatRound`
|
||||
- `preUpdateCombat` (for the active→inactive transition detection)
|
||||
|
||||
**Chat & rolls:**
|
||||
- `createChatMessage`
|
||||
- `dnd5e.rollAttackV2`, `dnd5e.rollDamageV2` *(registered so the
|
||||
envelope captures them; system-specific interpretation is the
|
||||
adapter's job, but the envelope shape is the library's)*
|
||||
|
||||
**Canvas / scene / UI:**
|
||||
- `canvasInit`, `canvasReady`, `canvasPan`
|
||||
- `controlToken`, `hoverToken`
|
||||
- `targetToken`
|
||||
- `lightingRefresh`, `sightRefresh`
|
||||
- `collapseSidebar`, `changeSidebarTab`, `getSceneControlButtons`
|
||||
- `collapseSceneNavigation`
|
||||
- `renderChatMessage`, `renderChatInput`, `renderJournalPageSheet`
|
||||
- `initializePointSourceShaders`
|
||||
- `rtcSettingsChanged`
|
||||
|
||||
**Embedded documents:**
|
||||
- For each registered document type's embedded collections, the
|
||||
library also subscribes to `create*`, `update*`, `delete*`,
|
||||
`preCreate*`, `preUpdate*`, `preDelete*` for embedded names.
|
||||
Implementation: a single `for` loop over Foundry's
|
||||
`CONFIG[type].collection.instance` shapes (or equivalent in v14).
|
||||
Exact set TBD during implementation; covered by tests.
|
||||
|
||||
**NOT registered by hooks-lib:**
|
||||
|
||||
- Hooks that fire only server-side with no useful consumer scenario
|
||||
(e.g. `applyActiveEffect` — the dnd5e adapter can register it if
|
||||
it has consumers).
|
||||
- Hooks that are Foundry internal (e.g. `get*Context`).
|
||||
- System-specific hooks beyond dnd5e 5.2.x roll hooks. Each system's
|
||||
adapter registers its own hooks. (Example: a PF2e adapter would
|
||||
register `pf2e.roll*` hooks in its own factory.)
|
||||
|
||||
The full list above is the **v0.2.0 contract**. Adding a hook is a
|
||||
minor version bump. Removing a hook is a major version bump.
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance budget
|
||||
|
||||
The library adds overhead to every registered Foundry hook. The
|
||||
budget is:
|
||||
|
||||
- **Per-fire overhead:** median <0.1ms on a 2020-era x86 (ThinkPad
|
||||
X1 Carbon Gen 8 or equivalent). Measured in `tests/perf.mjs`
|
||||
with a synthetic 10k-fire loop.
|
||||
- **Memory:** no per-fire allocation beyond the envelope object
|
||||
(one object + the args array, which is the same reference Foundry
|
||||
already allocated).
|
||||
- **Synchronous work:** zero. The wrapper is sync, but it does NOT
|
||||
call consumer callbacks inline with Foundry's hook dispatch chain
|
||||
— the wrapper dispatches to a microtask queue (Promise.resolve().then()).
|
||||
This decouples consumer latency from Foundry's hook chain.
|
||||
|
||||
**Why microtask dispatch?** Foundry's hooks can fire in tight loops
|
||||
during combat (hundreds of `updateToken` events per round). If a
|
||||
consumer is slow, synchronous dispatch would block Foundry's hook
|
||||
chain and stall the entire game. Microtask dispatch means each
|
||||
consumer gets called on the next microtask, in registration order,
|
||||
and Foundry's hook returns immediately.
|
||||
|
||||
Trade-off: consumer callbacks see the envelope AFTER Foundry has
|
||||
already moved on. This is fine for `updateActor`/`updateToken` because
|
||||
the args are immutable snapshots. It would NOT be fine for `pre*`
|
||||
hooks where consumers want to cancel the change — for those, the
|
||||
library dispatches SYNCHRONOUSLY, returning the first `false` from
|
||||
the consumer chain. (See §8.)
|
||||
|
||||
---
|
||||
|
||||
## 8. Sync vs. async dispatch
|
||||
|
||||
**Synchronous dispatch** (consumer callbacks fire inline with
|
||||
Foundry's hook):
|
||||
- All `pre*` hooks (so consumers can return `false` to cancel).
|
||||
- All `combat*` hooks (so consumers can react before the next hook
|
||||
in the same tick).
|
||||
- `get*Context` hooks (so consumers can mutate the menu/buttons).
|
||||
- `applyActiveEffect` (so consumers can adjust the change).
|
||||
|
||||
**Async dispatch** (microtask, consumer callbacks fire on next tick):
|
||||
- Everything else.
|
||||
|
||||
The library makes this decision per-hook based on whether synchronous
|
||||
return matters. The dispatch mode is part of the hook's entry in §6.
|
||||
|
||||
---
|
||||
|
||||
## 9. Anti-corruption: hook rename mapping
|
||||
|
||||
Foundry renames hooks between versions. The library absorbs the
|
||||
churn so consumers don't have to. Example mappings (v0.2.0):
|
||||
|
||||
| Foundry v14 hook | Foundry v13 hook | Library `envelope.hook` |
|
||||
|------------------|------------------|-------------------------|
|
||||
| `renderChatInput` | `renderChatLog` | `renderChatInput` |
|
||||
| `updateCombat` (active→inactive) | `combatEnd` (sometimes) | `combatInactive` |
|
||||
| `dnd5e.rollAttackV2` | `dnd5e.rollAttack` (v1) | `dnd5e.rollAttackV2` (only v2 supported in v0.2.0) |
|
||||
|
||||
The library internally subscribes to BOTH the v13 and v14 hook
|
||||
names where applicable, normalizing the envelope `hook` field to the
|
||||
modern name. Consumers see one stable name regardless of Foundry
|
||||
version.
|
||||
|
||||
For each mapped pair, the library's tests assert the normalization.
|
||||
|
||||
---
|
||||
|
||||
## 10. Anti-corruption: arg normalization
|
||||
|
||||
Foundry's hook arities shift between versions. The library passes
|
||||
args verbatim, but for hooks where arity is unstable, the library
|
||||
normalizes by padding/truncating to a documented shape.
|
||||
|
||||
Example (illustrative — exact normalization is implementation-defined):
|
||||
|
||||
```js
|
||||
// Foundry v13: combatRound(combat, updateData, roundNum)
|
||||
// Foundry v14: combatRound(combat, updateData, updateOptions)
|
||||
|
||||
// Library normalizes to:
|
||||
envelope.args = [combat, updateData, roundNum ?? updateOptions?.round ?? 1, updateOptions]
|
||||
```
|
||||
|
||||
This normalization is **the only** consumer-facing change vs raw
|
||||
Foundry. Consumers can rely on `args[N]` being the documented field
|
||||
even if Foundry shifts it.
|
||||
|
||||
The exact normalized shapes are listed in `docs/HOOK_ARG_SHAPES.md`
|
||||
(separate file because it's long). v0.2.0 documents the shape for
|
||||
every hook in §6.
|
||||
|
||||
---
|
||||
|
||||
## 11. Public API surface
|
||||
|
||||
The library exports the following from `scripts/main.js`:
|
||||
|
||||
```js
|
||||
export const VERSION = "0.2.0";
|
||||
export const REGISTERED_HOOKS = [/* array of hook name strings */];
|
||||
|
||||
export function subscribe(hookName, fn) { ... }
|
||||
export function subscribeMany(map) { ... }
|
||||
export function subscribeAll(fn) { ... }
|
||||
export function unsubscribeAll() { ... }
|
||||
|
||||
export function registerSystemAdapter(manifest) { ... }
|
||||
|
||||
export function listRegisteredHooks() { ... }
|
||||
export function listActiveAdapters() { ... }
|
||||
```
|
||||
|
||||
`mod.api` mirrors these so consumers can also access via
|
||||
`game.modules.get("hax-hooks-lib").api.subscribe(...)`.
|
||||
|
||||
---
|
||||
|
||||
## 12. What this contract explicitly does NOT promise
|
||||
|
||||
1. **No domain interpretation.** If you want "HP changed," you
|
||||
subscribe to `preUpdateActor` + `updateActor` + `preUpdateToken`
|
||||
+ `updateToken` and write the diff yourself (or use a system
|
||||
adapter that exposes it).
|
||||
2. **No state.** The library doesn't track "the current combatant"
|
||||
or "what changed since the last hook." Consumers track state.
|
||||
3. **No persistence.** The library doesn't write to settings, scene
|
||||
flags, or anywhere else.
|
||||
4. **No Foundry compatibility shims beyond §9 and §10.** If Foundry
|
||||
adds a new hook, it's a library version bump to surface it.
|
||||
5. **No system-specific hooks beyond dnd5e.** Each system's adapter
|
||||
is responsible for its own hooks.
|
||||
|
||||
---
|
||||
|
||||
## 13. Versioning
|
||||
|
||||
- `hooks-lib` follows semver. The library's adapter API (§5) is the
|
||||
load-bearing surface for adapter authors; breaking it is a major
|
||||
bump.
|
||||
- The hook set in §6 grows additively in minor versions.
|
||||
- The arg-shape normalization in §10 is allowed to add optional
|
||||
fields in minor versions; removing or changing types is major.
|
||||
- Adapter repos version independently. Adapter manifests declare
|
||||
the Foundry + system version ranges they support, not the
|
||||
hooks-lib version.
|
||||
|
||||
---
|
||||
|
||||
## 14. Test plan
|
||||
|
||||
See `tests/PLAN.md`. Summary:
|
||||
|
||||
- **Envelope shape:** every registered hook in §6 produces an
|
||||
envelope with the exact field set from §2, no extras.
|
||||
- **Subscriber API:** all four primitives work; unsubscribe
|
||||
removes callbacks; `subscribeMany` is atomic.
|
||||
- **Error containment:** throwing consumer doesn't break the
|
||||
chain.
|
||||
- **Lifecycle:** hooks registered in init, removed in
|
||||
unregisterModule, re-evaluated in ready.
|
||||
- **System adapter loading:** matching adapter loads; non-matching
|
||||
adapter logs + skips; version-range mismatches log + skip;
|
||||
throwing factory is contained.
|
||||
- **Anti-corruption:** v13 vs v14 hook renames produce the same
|
||||
envelope `hook` value; arg shapes from §10 match documented.
|
||||
- **Performance:** 10k fires complete under the budget in §7.
|
||||
|
||||
---
|
||||
|
||||
## 15. Migration from v0.1.0
|
||||
|
||||
**v0.1.0 is removed.** The 18 hand-written event handlers, the
|
||||
`scripts/systems/` adapters, and the `scripts/encounter.js` stub all
|
||||
go away. The v0.2.0 contract is incompatible with the curated-event
|
||||
catalog shape.
|
||||
|
||||
No migration tooling. The library was 2 days old with no consumers;
|
||||
the only downstream is battle-focus, which still uses its own local
|
||||
copy of the events and is unaffected by hooks-lib's contents.
|
||||
|
||||
If a future consumer needs a curated-event catalog API, that's a
|
||||
separate layer on top of the v0.2.0 facade, not a reversion.
|
||||
Reference in New Issue
Block a user