# FES Spec Dual-Shape Support (legacy + new)

**Date written:** 2026-07-22
**Audience:** Anyone modifying the FES inspector dispatcher
(`oscar-fes-inspector/oscar-inspector-dispatcher.js`) or the
FES worker (`fes_worker.py`).

The dispatcher tolerates TWO FES spec shapes. The FES worker should
eventually produce only the new shape (§A) but old FES specs in
production still use the legacy shape (§B). Both must work until
all legacy specs are migrated.

## §A — New shape (canonical, what the worker produces now)

```json
{
  "moduleType": "image",
  "typeKey": "image-default",
  "fieldSchema": {
    "media": { "type": "media", "label": "Image Source" },
    "size_preset": { "type": "select", "options": ["thumbnail", "medium", "large"] }
  },
  "inspector": {
    "Settings": ["media", "size_preset"],
    "Style": ["align", "width", "border_radius"],
    "Advanced": ["css_classes", "custom_css"]
  }
}
```

- `inspector` is a record: `{ tabName: [key, key, ...] }`
- Each key is a string that maps to `fieldSchema`
- `fieldSchema` values are `{ type, label, options, ... }` dicts
- This is what `fes-spec-contract.md` documents

## §B — Legacy shape (older specs, still in production)

```json
{
  "moduleType": "section",
  "typeKey": "section-default",
  "fields": [
    { "key": "html_tag", "type": "select", "label": "HTML Tag", "section": "settings" },
    { "key": "width", "type": "select", "label": "Width", "section": "settings" },
    { "key": "gap", "type": "select", "label": "Gap", "section": "settings" },
    { "key": "background_color", "type": "color", "label": "Background", "section": "style" },
    { "key": "border", "type": "border", "label": "Border", "section": "style" }
  ]
}
```

- `fields` is an array, not a record
- Each field has a `section` property naming its tab
- Section names are lowercased: `"settings"`, `"style"`, `"advanced"`
- `key` is the field identifier (not a separate name/id)
- `type` is the renderer hint
- NO `inspector` block
- NO `fieldSchema` block

## How the dispatcher handles both

```js
// 1. Get the field keys for a tab
function getTabFieldKeys2(tabName) {
  // New shape: inspector[tabName] exists
  if (mod.inspector && Array.isArray(mod.inspector[tabName])) {
    return mod.inspector[tabName];
  }
  // Legacy shape: filter fields by section
  if (Array.isArray(mod.fields)) {
    return mod.fields
      .filter(f => (f.section || '').toLowerCase() === tabName.toLowerCase())
      .map(f => f.key);
  }
  return [];
}

// 2. Build a fieldSchema from legacy fields[] if missing
function buildSchemaFromFields(mod) {
  const out = {};
  if (!Array.isArray(mod.fields)) return out;
  for (const f of mod.fields) {
    out[f.key] = {
      type: f.type || 'text',
      label: f.label || f.key,
      default: f.default,
      options: f.options,
    };
  }
  return out;
}

// 3. In openInspector / openSectionInspector, use both:
const keys = getTabFieldKeys2(tabName);
const fieldSchema = mod.fieldSchema || buildSchemaFromFields(mod);
const tabContent = renderTab(keys, settings, onUpdate, fieldSchema);
```

## How to migrate legacy → new

If you want to convert a legacy spec to new shape:

```js
function migrateLegacyToNew(legacy) {
  const fieldSchema = {};
  const inspector = { Settings: [], Style: [], Advanced: [] };
  for (const f of legacy.fields || []) {
    fieldSchema[f.key] = {
      type: f.type || 'text',
      label: f.label || f.key,
      default: f.default,
      options: f.options,
    };
    const tab = (f.section || 'settings').toLowerCase();
    if (!inspector[tab]) inspector[tab] = [];
    inspector[tab].push(f.key);
  }
  return { ...legacy, fieldSchema, inspector, fields: undefined };
}
```

## When to delete the legacy path

Delete the legacy shape support when:
1. All FES specs in `/app-fragments/fes-modules/*.json` use the new shape
2. The FES worker has a validator that rejects legacy output
3. The migration script has run (or you accept the operator breaking their inspector)

Until then, the dispatcher's dual-shape support is required.
