@paramrig/web

A development dependency that exposes a running page’s decisions as controls, and does nothing at all unless a workbench is framing that page.

npm install --save-dev @paramrig/web

1. The manifest

.paramrig/manifest.json is the contract. It says what can be tuned, where the page runs, and which revision of your source it describes.

{
  "version": 1,
  "id": "fieldnotes",
  "revision": "study-1",
  "origin": "http://localhost:3000",
  "pages": [{ "id": "home", "name": "Home", "path": "/" }],
  "groups": [{ "id": "brand", "label": "Brand" }],
  "parameters": [
    { "id": "accent", "kind": "color", "label": "Accent",
      "group": "brand", "defaultValue": "#bc593d" }
  ],
  "bindings": [
    { "paramId": "accent", "scope": "global",
      "kind": "css-variable", "property": "--accent" }
  ]
}
Field Meaning
id Stable. Never change it: the library, the drafts and every batch on disk are keyed by it.
revision The source revision this manifest describes. Change it whenever you change the code the controls describe.
origin The exact origin your development server answers on. Scheme, host and port, nothing else.
pages At least one, at most 100.
parameters At most 500, with unique ids.

parseManifest throws on anything it does not accept, and the message names what is wrong. Call it yourself, so the failure lands in your build.

Twenty-one control kinds exist, because the same controls serve the vector and 3D rigs. On a web page the useful ones are those that map onto a CSS value or onto an adapter you write. Start with number, color, select, switch and text.

2. Bindings

A binding names a paramId, a scope, a kind and a property.

Scope is how far a change reaches, declared by you and never changed by which element someone happens to select:

  • global, the whole application.
  • page, one page, which also declares pageId.
  • element, one instrumented family, which declares target: { id, instance? }. Leaving instance out applies it to every instance of that family, and the review says so before anything is sent.

Kind is where the value goes:

  • css-variable writes a custom property. The first choice for design tokens: one binding moves everything that reads it.
  • style writes one declared property on the target. The original inline value and its priority are preserved and restored.
  • adapter calls a read / apply / restore adapter you registered. For anything CSS cannot express: copy, state, a canvas, a composite value.

unit is appended to numeric CSS values. Without it a number is written bare, which is what line-height and opacity want.

When the workbench pairs, the SDK reads what the page shows instead of trusting the manifest. A computed length comes back in pixels whatever the stylesheet declared, so it is converted into the binding’s unit when the two are commensurable. A percentage cannot be read back, nor can a unitless line-height. There the defaultValue stands, so keep it true.

3. Naming elements

<article data-paramrig-id="story-card"
         data-paramrig-instance="coast"
         data-paramrig-label="Story card"
         data-paramrig-source="src/StoryCard.tsx">
  <h2 data-paramrig-id="story-title">Following the coastline</h2>
</article>

data-paramrig-id is what a binding points at and what survives a refactor. data-paramrig-instance tells repeated components apart, and descendants inherit it. Without a label the identifier is read as a sentence, so story-card becomes Story card, then the accessible name, then the words on screen.

data-paramrig-source is a hint you supply, not an inferred source map. Keep it true or leave it out.

4. Connecting

useEffect(() => {
  if (!import.meta.env.DEV) return
  const connection = connectWeb({
    manifest: parseManifest(manifestFile),
    adapters: {
      headings: {
        read: () => currentHeadingFont,
        apply: value => setHeadingFont(String(value)),
        restore: () => setHeadingFont(initialHeadingFont),
      },
    },
  })
  return () => connection.dispose()
}, [])

Always dispose, on unmount and on hot-module replacement. A second connection without one is handled: it warns and replaces the first, so two overlays never coexist. The warning is telling you the cleanup is missing.

The package declares sideEffects: false, so under Vite the guarded call is dead code once import.meta.env.DEV is false and the import leaves with it. Keep parseManifest and the manifest inside the guard too.

Your development server has to allow the frame:

Content-Security-Policy: frame-ancestors 'self' http://localhost:5174 http://127.0.0.1:5174;

with no conflicting X-Frame-Options. Scope that to the development configuration. Never relax it in production.

What it does when there is no workbench

connectWeb never throws, because a development integration that takes the application off the screen is worse than one that is unavailable.

  • Outside a frame it installs nothing: no listener, no observer, no overlay, nothing in the console.
  • When the origins disagree it writes one console.warn naming both addresses. A development server usually answers to both localhost and 127.0.0.1, and opening the page by the other name should cost a line, not a blank screen.

5. The loop

Someone tunes controls, comments on elements, draws on the page, and approves. That writes one immutable .paramrig/batches/<id>.json, and they hand you an instruction naming it.

The batch carries values, meaning every control and not only the ones that moved, then changes with before, after and the bindings each writes, then tickets with their targets, marks, page, viewport, scroll context and captures. A batch restates every difference from your source, so the newest one alone is the whole picture.

Apply it in the real files. Then set each changed defaultValue in the manifest, so it tells the truth without a page to read, and change revision, because the code the controls describe has changed. Then write one response, atomically: a temporary file, then a rename.

{
  "version": 1,
  "id": "response-001",
  "projectId": "fieldnotes",
  "batchId": "the-approved-batch-id",
  "sourceRevision": "study-1",
  "resultRevision": "study-2",
  "summary": "Applied the approved palette and adjusted the hero.",
  "tickets": [
    { "id": "the-ticket-id", "status": "implemented",
      "message": "Check the hero at mobile width." }
  ]
}

Use needs-info when you cannot act, and say exactly what you need. It reopens the ticket for another round instead of closing it wrongly.

A response is a claim, not an approval. Write the summary and each message as what you did and how to check it. Only the person validates a correction.

What to commit

File Who writes it Commit it?
manifest.json You Yes. It describes your code, so it is source.
batches/<id>.json ParamRig Your call. Immutable, and the record of what was asked.
responses/<id>.json You Your call, and it should match batches/.
draft.json ParamRig No. Rewritten continuously, and not approved instructions.
captures/*.png ParamRig No. Large, and reproducible from the batch.

ParamRig writes that .gitignore when there is none, covering the two “no” rows, and never writes over it. It writes nothing outside .paramrig.

Schemas

JSON Schema (draft 2020-12) for the manifest, the batch and the response ships with the package as @paramrig/web/schemas/<name>.schema.json. A test in the ParamRig repository fails the moment a schema and the runtime guards disagree.