# Aurora Catalyst — Form Composition Rules

This document is the **authoritative rule set** for composing and auditing detail/form
components in Aurora Catalyst projects. It is self-contained — the skill does not depend on
any external file to operate.

> **Historical note**: these rules were originally synthesized from the Aurora Catalyst CLI
> PRD ("Frontend Detail/Form Component Code Generation"). This document IS the source of
> truth from the skill's perspective. If the original PRD evolves, the update must be
> reflected here.

---

## 1. Detail Modes

A form renders in one of two modes, declared at the module level:

```yaml
front:
  detailMode: 'dialog' | 'view'   # default: 'view'
  outlineIcon: mat_outline:tag
  solidIcon: mat_solid:tag
```

| | Dialog Mode | View Mode |
|---|---|---|
| **Container** | `HlmDialog` overlay on list | `hlmCard` on dedicated route |
| **Available grids** | grid-2, grid-6 | grid-2, grid-6, grid-12 |
| **Max width** | Set by dialog (~640px) | `max-w-lg`, `max-w-3xl`, or full |
| **Tabs support** | **No** (space too limited) | **Yes**, via `hlm-tabs` |
| **Sections (groups)** | Yes | Yes |
| **Shareable URL** | Via query param (`?action=edit&id=uuid`) | Via route (`/tag/edit/:id`) |
| **List context** | Visible behind the dialog | Lost (navigated away) |

**Rule**: `widget.tab` is **ignored** in dialog mode. If a schema has tabs but the
detail mode is `dialog`, that is a PRD violation — flag it.

---

## 2. Module-Level Fields (under `front`)

| Field | Type | Default | Description |
|---|---|---|---|
| `front.detailMode` | `'dialog' \| 'view'` | `'view'` | Render as full page (view) or dialog overlay |
| `front.outlineIcon` | `string` | — | Icon identifier (e.g., `mat_outline:tag`) used in headers |
| `front.solidIcon` | `string` | — | Solid variant of the icon |

---

## 3. Property-Level Fields (under `widget`)

| Field | Type | Default | Description |
|---|---|---|---|
| `widget.tab` | `string` | — | Tab identifier. Fields sharing `tab` render in the same tab panel. **View mode only.** |
| `widget.group` | `string` | — | Section identifier. Fields sharing `group` render under a common section title with visual separator. **Both modes.** |
| `widget.detailSort` | `number` | `Infinity` | Ordering within the group (ascending) |
| `widget.isDetailHidden` | `boolean` | `false` | Hides the field from the detail form |
| `widget.type` | `string` | — | Component selector for relationship fields (`select`, `multiple-select`, `grid-select-element`, etc.) |

Example:

```yaml
aggregateProperties:
  - name: name
    type: varchar
    maxLength: 64
    widget:
      tab: 'personal-data'
      group: 'general'
      detailSort: 1
  - name: password
    type: password
    widget:
      tab: 'security'
      group: 'credentials'
      detailSort: 1
```

---

## 4. Dispatch Logic (2 Levels)

Each field is dispatched to exactly one partial:

```
for property in getFormDetailFieldsProperties(aggregateProperties):
  if property.widget?.type exists:
    -> partials/relationships/{widget.type}-field
  else if property.type is a known field type:
    -> partials/fields/{property.type}-field
```

The `widget.type` acts as the **component selector** — declaring which UI component
handles each relationship. The dispatch is **deterministic**: the same input always produces
the same partial selection.

---

## 5. Field Organization: `tab` and `group`

Two optional organizers:

- **`tab`**: container for a set of fields. Only applies in **view mode**.
- **`group`**: section within a tab (or within the form if no tabs). Renders a section
  title + visual separator. Applies in **both modes**.

### Rendering hierarchy

```
Has any field with `tab`?
  YES → <hlm-tabs>
          for each unique tab (in order of first field appearance):
            <hlm-tabs-content>
              Has any field in this tab with `group`?
                YES → for each unique group: <section + title> + <separator>
                      fields in this group, sorted by detailSort
                NO  → fields in this tab, flat, sorted by detailSort
  NO  → Has any field with `group`?
          YES → for each unique group: <section + title> + <separator>
                fields in this group, sorted by detailSort
          NO  → fields flat, sorted by detailSort
```

### Generated HTML — tabs + groups (view mode)

```html
<div hlmTabs tab="personal-data">
  <hlm-tabs-list>
    <button hlmTabsTrigger="personal-data">Personal Data</button>
    <button hlmTabsTrigger="password-change">Password</button>
  </hlm-tabs-list>

  <div hlmTabsContent="personal-data">
    <section class="max-w-lg" hlmCard>
      <div hlmCardHeader>
        <h3 hlmCardTitle>Personal Information</h3>
      </div>
      <div hlmCardContent>
        <form class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5">
          <!-- name, email fields -->
        </form>
      </div>
    </section>
  </div>

  <div hlmTabsContent="password-change">
    <!-- password fields -->
  </div>
</div>
```

### Generated HTML — groups only (dialog mode or view without tabs)

```html
<form class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5">
  <!-- Section: general -->
  <h4 class="col-span-full text-sm font-semibold">General</h4>
  <!-- fields with group: 'general' -->

  <hlm-field-separator class="col-span-full" />

  <!-- Section: config -->
  <h4 class="col-span-full text-sm font-semibold">Config</h4>
  <!-- fields with group: 'config' -->
</form>
```

---

## 6. Grid Tiers (Adaptive)

The grid adapts to the detail mode and the number of visible fields:

| Field count | Dialog mode | View mode |
|---|---|---|
| ≤5 | `compact` (grid-2) | `compact` (grid-2 + `max-w-lg`) |
| 6–10 | `medium` (grid-6) | `medium` (grid-6 + `max-w-3xl`) |
| >10 | `medium` (capped for dialog) | `full` (grid-12, full width) |

### Form wrapper by mode and tier

```html
<!-- VIEW MODE -->
<!-- compact: ≤5 fields -->
<section class="max-w-lg" hlmCard>
  <form class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5">

<!-- medium: 6–10 fields -->
<section class="max-w-3xl" hlmCard>
  <form class="grid grid-cols-2 md:grid-cols-6 gap-x-4 gap-y-5">

<!-- full: >10 fields -->
<section hlmCard>
  <form class="grid grid-cols-6 md:grid-cols-12 gap-x-4 gap-y-5">

<!-- DIALOG MODE -->
<!-- compact: ≤5 fields -->
<form class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5">

<!-- medium: ≥6 fields (max for dialog) -->
<form class="grid grid-cols-2 md:grid-cols-6 gap-x-4 gap-y-5">
```

### Tier decision logic

```typescript
const fields = getFormDetailFieldsProperties(aggregateProperties);
const fieldCount = fields.length;
const isDialog = schema.front?.detailMode === 'dialog';

const gridMode = fieldCount <= 5
  ? 'compact'
  : fieldCount <= 10
    ? 'medium'
    : isDialog ? 'medium' : 'full';  // dialog caps at medium
```

---

## 7. Column Span Calculation

Every field receives a `col-span-*` class. The same **semantic proportion** (third, half,
twoThirds, full) maps to different concrete values per tier — preserving visual proportion
across responsive contexts.

### Proportion → col-span per tier

| Proportion | compact (grid-2) | medium (grid-6) | full (grid-12) |
|---|---|---|---|
| third (1/3) | 1 | 2 | 4 |
| half (1/2) | 1 | 3 | 6 |
| twoThirds (2/3) | 1 | 4 | 8 |
| full (100%) | 2 | 6 | 12 |

### Length → proportion

```typescript
function lengthToProportion(length: number | undefined): 'third' | 'half' | 'twoThirds' | 'full' {
  if (!length) return 'full';
  if (length <= 30) return 'third';
  if (length <= 60) return 'half';
  if (length <= 90) return 'twoThirds';
  return 'full';
}
```

### Type → proportion

| Type | Proportion |
|---|---|
| `boolean` | third |
| `date`, `timestamp` | third |
| `enum` | third |
| `password` | half |
| `char` | `lengthToProportion(property.length)` |
| `varchar`, `int`, `smallint`, `bigint` | `lengthToProportion(property.maxLength)` |
| `decimal`, `float` | `lengthToProportion(property.decimals?.[0])` |
| `text` | full |
| `id`, `relationship` | half (full if `widget.type` includes "grid") |

### Reference implementation

```typescript
type GridMode = 'compact' | 'medium' | 'full';

const SPAN_TABLE: Record<GridMode, { third: number; half: number; twoThirds: number; full: number }> = {
  compact: { third: 1, half: 1, twoThirds: 1, full: 2 },
  medium:  { third: 2, half: 3, twoThirds: 4, full: 6 },
  full:    { third: 4, half: 6, twoThirds: 8, full: 12 },
};

function calculateFieldColSpan(property: Property, gridMode: GridMode): string {
  const spans = SPAN_TABLE[gridMode];

  switch (property.type) {
    case 'boolean':
    case 'date':
    case 'timestamp':
    case 'enum':
      return 'col-span-' + spans.third;

    case 'password':
      return 'col-span-' + spans.half;

    case 'char':
      return 'col-span-' + spans[lengthToProportion(property.length)];

    case 'varchar':
    case 'int':
    case 'smallint':
    case 'bigint':
      return 'col-span-' + spans[lengthToProportion(property.maxLength)];

    case 'decimal':
    case 'float':
      return 'col-span-' + spans[lengthToProportion(property.decimals?.[0])];

    case 'text':
      return 'col-span-' + spans.full;

    case 'id':
    case 'relationship':
      if (property.widget?.type?.includes('grid')) return 'col-span-' + spans.full;
      return 'col-span-' + spans.half;

    default:
      return 'col-span-' + spans.full;
  }
}
```

**Invariant**: partials never know the grid mode. They receive a pre-calculated `colSpan`
string and apply it as-is to `<hlm-field class="{{ colSpan }}">`.

---

## 8. Field Partial Contract

Every field partial follows the same structure:

```html
<hlm-field class="{{ colSpan }}">
  <label hlmLabel for="{{ fieldId }}">{{ label }}</label>
  <CONTROL formControlName="{{ fieldName }}" ... />
  <hlm-field-error>
    {{ signalForm.get('fieldName')?.errors | validationMessage }}
  </hlm-field-error>
</hlm-field>
```

Rules:

- Each partial is self-contained — no cross-dependencies between partials
- Consistent interface — read one partial, understand them all
- Discoverable — `widget.type: 'select'` maps to `select-field` partial
- Extensible — copy an existing partial as starting point for a new type

---

## 9. Field Type → Spartan Component Mapping

### Basic fields (Phase 1)

| YAML Type | Spartan Control | HTML | Validators |
|---|---|---|---|
| `varchar` | `hlmInput` | `<input hlmInput>` | required?, maxLength?, minLength? |
| `char` | `hlmInput` | `<input hlmInput>` | required?, maxLength (= length) |
| `text` | `hlmTextarea` | `<textarea hlmTextarea>` | required?, maxLength? |
| `int`, `smallint`, `bigint` | `hlmInput` | `<input hlmInput type="number">` | required? |
| `decimal`, `float` | `hlmInput` | `<input hlmInput type="number" step="0.01">` | required? |
| `boolean` | `hlm-switch` | `<hlm-switch>` | — |
| `enum` | `hlm-select` | `<brn-select>` + `<hlm-option>` | required? |
| `enum` (array) | `hlm-select` | `<brn-select multiple>` | — |
| `password` | `hlmInput` | `<input hlmInput type="password">` | required?, maxLength? |
| `date` | `hlm-date-picker` | Spartan date-picker (calendar + popover) | required? |
| `timestamp` | `hlm-date-picker` | Spartan date-picker (calendar + popover) | required? |

### Relationship fields (Phase 2+)

| `widget.type` | Spartan Control | Phase |
|---|---|---|
| `select` | `<brn-select>` with observable data source | 2 |
| `multiple-select` | `<brn-select multiple>` | 2 |
| `async-search-select` | Composable `useAsyncSearch` + select | 3 |
| `async-search-multiple-select` | Composable `useAsyncSearch` + multi-select | 3 |
| `grid-select-element` | Composable `useGridSelect` + dialog | 3 |
| `grid-select-multiple-elements` | Composable `useGridSelect` + pivot table | 3 |
| `grid-elements-manager` | Composable `useGridManager` + inline CRUD | 3 |

---

## 10. pickModel Defaults

Initial FormGroup values by property type:

| YAML Type | Default |
|---|---|
| `varchar`, `char`, `text`, `password` | `''` |
| `int`, `smallint`, `bigint`, `decimal`, `float` | `null` |
| `boolean` | `false` |
| `enum` | `null` |
| `enum` (array) | `[]` |
| `date`, `timestamp` | `null` |
| `id` | `''` |

---

## 11. Validator Generation

From property metadata:

```typescript
const validators = [];
if (!property.nullable) validators.push('Validators.required');
if (property.maxLength) validators.push(`Validators.maxLength(${property.maxLength})`);
if (property.minLength) validators.push(`Validators.minLength(${property.minLength})`);
if (property.length) {
  // char type: fixed length
  validators.push(`Validators.minLength(${property.length})`);
  validators.push(`Validators.maxLength(${property.length})`);
}
```

---

## 12. Exclusions (never rendered in forms)

Filtered by `getFormGroupFieldsProperties` + `isAllowProperty`:

- **Always excluded**: `createdAt`, `updatedAt`, `deletedAt` (audit timestamps)
- **Always excluded**: `rowId` (auto-increment internal)
- **Always excluded**: `meta` (jsonb metadata, manual only)
- **Hidden in form**: `id` — included in FormGroup for submit, **NOT rendered**
- **Excluded by flag**: `widget.isDetailHidden: true`
- **Excluded by type**: non-enum `array`, `jsonb` (manual only in Phase 1)
- **Excluded by relationship**: `one-to-many` (rendered elsewhere, not in form)

---

## 13. Ordering

Fields are rendered in the order returned by `getFormDetailFieldsProperties()`:

1. Filter out hidden/excluded properties
2. Sort by `widget.detailSort` ascending
3. Properties without `detailSort` appear last (sorted by `Infinity`)

Within the form, render order is:

1. Group by `tab` (if any) — tabs render in order of first field appearance
2. Within each tab, group by `group` (if any) — sections render in order of first field
   appearance
3. Within each group, sort by `detailSort`

---

## 14. Reference Target Output

### View mode, compact, no tabs, no groups

```typescript
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule, RouterLink, NgIcon,
    HlmButtonImports, HlmCardImports, HlmFieldImports,
    HlmInputImports, HlmLabelImports, HlmSpinnerImports,
  ],
  providers: [provideIcons({ lucideTag })],
  template: `
    <div class="space-y-3 px-6 pt-2 pb-6">
      <section class="max-w-lg" hlmCard>
        <div hlmCardHeader>
          <h3 hlmCardTitle>Tag Details</h3>
          <p hlmCardDescription>Tag configuration</p>
        </div>
        <div hlmCardContent>
          <form class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5"
                [formGroup]="signalForm" (ngSubmit)="onSubmit()">
            <!-- field partials -->
          </form>
        </div>
      </section>
    </div>
  `,
})
export default class TagDetailComponent { /* ... */ }
```

### Dialog mode, medium, with groups

```typescript
@Component({
  selector: 'au-restaurant-detail-dialog',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    HlmDialogImports, HlmFieldImports, HlmInputImports, HlmLabelImports,
    HlmButtonImports, HlmSpinnerImports, HlmFieldSeparatorImports,
  ],
  template: `
    <hlm-dialog-header>
      <h2 hlmDialogTitle>{{ mode() === 'new' ? 'Create' : 'Edit' }} Restaurant</h2>
      <p hlmDialogDescription>Restaurant configuration</p>
    </hlm-dialog-header>

    <form class="grid grid-cols-2 md:grid-cols-6 gap-x-4 gap-y-5"
          [formGroup]="signalForm" (ngSubmit)="onSubmit()">
      <!-- group: general -->
      <h4 class="col-span-full text-sm font-semibold">General</h4>
      <!-- name, owner, city, address, phone, email fields -->

      <hlm-field-separator class="col-span-full" />

      <!-- group: service-config -->
      <h4 class="col-span-full text-sm font-semibold">Service Config</h4>
      <!-- mealStart, mealEnd, dinnerStart, dinnerEnd fields -->
    </form>

    <hlm-dialog-footer>
      <button hlmBtn variant="outline" (click)="close()">Cancel</button>
      <button hlmBtn (click)="onSubmit()">Save</button>
    </hlm-dialog-footer>
  `,
})
export class RestaurantDetailDialog { /* ... */ }
```

### View mode with tabs, compact

```typescript
template: `
  <div hlmTabs tab="personal-data">
    <hlm-tabs-list>
      <button hlmTabsTrigger="personal-data">Personal Data</button>
      <button hlmTabsTrigger="password-change">Password</button>
    </hlm-tabs-list>

    <div hlmTabsContent="personal-data">
      <section class="max-w-lg" hlmCard>
        <div hlmCardHeader>
          <h3 hlmCardTitle>Personal Info</h3>
          <p hlmCardDescription>Your personal information</p>
        </div>
        <div hlmCardContent>
          <form class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5" ...>
            <!-- name, email, phone, department -->
          </form>
        </div>
      </section>
    </div>

    <div hlmTabsContent="password-change">
      <section class="max-w-lg" hlmCard>
        <!-- password fields -->
      </section>
    </div>
  </div>
`,
```

---

## 15. Prototype-Specific Adaptations

This skill runs inside `aurora-catalyst-prototype`. When composing or auditing here, all
rules above apply **with these substitutions**:

- **No Transloco** → use direct string labels (e.g., `Name`, not `{{ t('iam.tag.Name') }}`)
- **No codegen types** → use local `.types.ts` interfaces
- **No GraphQL composables** → use Signal-based mock services
- **Standalone components**, **OnPush**, **inline templates**, **tab indentation**

What migrates from prototype to catalyst:
- Layout and composition (grid tiers, col-span, tabs, groups)
- FormGroup structure (validators, pickModel)
- Component selection (which Spartan controls for which types)
- Visual hierarchy decisions

What does NOT migrate:
- Mock services (replaced by Aurora codegen)
- Direct string labels (replaced by Transloco)

---

## 16. Success Criteria (for composition)

A composed form is correct when:

1. Every visible field uses the correct partial per dispatch
2. Every field's `col-span-*` matches `calculateFieldColSpan(type, length, gridMode)`
3. The grid-cols class matches the tier for the visible field count
4. The FormGroup has the correct validators and pickModel defaults per type
5. Excluded properties are NOT rendered
6. `id` is in FormGroup but NOT rendered
7. Ordering follows: tab → group → detailSort
8. The partial contract (`hlm-field` → `hlmLabel` → control → `hlm-field-error`) is intact
9. Switching between dialog/view mode requires only changing `front.detailMode` — no
   template structural changes needed
10. Tabs are present ONLY in view mode
