Upgrade EMR forms to production-ready quality with professional medical document layouts. Use this skill when: the user wants to improve a form's visual layout, recreate a hardcoded form using the Form Builder, fix overlapping/wrapping fields, redesign a form template, or create a new medical form with Georgian-text-friendly layouts. Also trigger when the user says "this form looks bad", "fix the layout", "upgrade the form", "rebuild this form", "make this form look professional", or references form styling/inline groups/field widths.
You are upgrading a MediMind EMR form to production-ready quality. EMR forms are FHIR
Questionnaires rendered by the FormRenderer component. This skill ensures forms look
like clean, professional medical documents — with underline-style inputs, proper section
dividers, and correctly proportioned inline field rows, especially for Georgian text.
Understanding this pipeline is essential — it explains WHY each instruction exists.
Seed Script (FormTemplate)
↓ toQuestionnaire() — packages/app/src/emr/services/fhirHelpers.ts
FHIR Questionnaire (with extensions)
↓ FormRenderer reads extensions
Two CSS paths apply:
↓
├── formStylingToCSS() — Runtime CSS from FORM_STYLING extension ← USE THIS
│ Generates scoped CSS (.form-{formId}) that:
│ 1. Strips modern-form-container decorations (gradients, shadows, pseudo-elements)
│ 2. Applies underline-style inputs (border-bottom only, transparent background)
│ 3. Sets compact label spacing, clean print styles
│ This is what makes forms look like documents instead of web apps.
│
└── FORM_CLASS_MAP + CSS files — Hardcoded per-form CSS ← DO NOT USE FOR NEW FORMS
Only for legacy forms. New forms get their styling from formStyling alone.
The key insight: Without formStyling on the Questionnaire, a form renders inside
modern-form-container which adds gradient bars, radial overlays, and decorative shadows.
The formStyling extension triggers formStylingToCSS() which generates a "document mode
reset" that strips all of that. This is why formStyling is mandatory, not optional.
When fields share an inlineGroup, FormRenderer wraps them in a flex row:
<div class="form-inline-group"> ← display:flex, gap:8px, flex-wrap:wrap
<div class="form-inline-field" style="width:calc(49% - 4px)"> ← field 1
[TextFieldRenderer or NumberDateFieldRenderer]
</div>
<div class="form-inline-field" style="width:calc(49% - 4px)"> ← field 2
[TextFieldRenderer or NumberDateFieldRenderer]
</div>
</div>
The renderer automatically applies calc(${width} - 4px) for percentage widths to
account for the 8px flex gap. So widths like 49% become calc(49% - 4px).
When the user asks to migrate/recreate a hardcoded form using the Form Builder, your job is to:
Never copy field definitions verbatim from form-templates/*.ts into the seed script. Always run through the Migration Checklist (after Step 2). The goal is a BETTER form, not an identical copy.
This is where most visual bugs come from. When showLabel: true is set on a field
inside an inline group, the renderer creates an inline label layout:
┌─── field container (e.g., width: 49%) ───────────┐
│ [Label text (nowrap, flex-shrink:0)] [Input (flex:1)] │
└──────────────────────────────────────────────────┘
The label is white-space: nowrap and flex-shrink: 0, so it takes its full text width.
The input gets whatever space is left. Georgian labels are typically 100-250px wide.
If the container is too narrow, the input becomes a tiny sliver.
This is why you must size fields based on their label length, not just field count.
Ask the user which form to upgrade. Find it by:
packages/app/src/emr/data/form-templates/ for the template filepackages/app/src/emr/scripts/ for existing seed scriptslocalTemplateRegistry.ts for the form IDRead the full template to understand every field.
This step is NOT optional. Even if the original form "works," you MUST check every field against the rules below and fix issues. When migrating a hardcoded form, treat the original layout as a rough draft — your job is to produce a professional medical document, not a pixel-perfect copy.
| Problem | Root Cause | Fix |
|---|---|---|
| Fields stacking vertically | Missing inlineGroup | Add same inlineGroup string to fields that share a row |
| Input is a tiny sliver next to a long label | showLabel: true + narrow width (e.g., 32%) with a long Georgian label | Increase width to 49%+ OR use showLabel: false with a separate display label |
| Fields wrapping to next line | Widths sum too high | Sum should be ≤98% for 2 fields (e.g., 49%+49%), ≤96% for 3 (e.g., 32%+32%+32%) |
| Form has gradient/shadow decorations | Missing formStyling on the template | Add the formStyling object (see Step 3) — this triggers document mode reset |
| Section headers have blue gradient boxes | modern-form-container CSS overrides | The formStyling document mode reset strips these. Ensure formStyling is present |
| Date picker is clipped/overlapping | Date fields need minimum 200px | Use width 49% or wider for date fields in inline groups |
| Number field has ugly spinner arrows | Default NumberInput controls | Change field type to 'text' instead of 'integer' for document forms |
When migrating a hardcoded form, verify EVERY item below. Do NOT proceed to Step 3 until all items pass.
sectionHeader pattern — centered, bold, borderBottom: 2px solid var(--emr-primary), not plain textauto, flex:1, or raw pixel widths for containersmarginBottom: '16px'form-templates/*.tswhiteSpace: 'nowrap' and display: 'inline-block'Create a seed script at packages/app/src/emr/scripts/seed-{form-name}.ts.
import type { MedplumClient } from '@medplum/core';
import type { Extension } from '@medplum/fhirtypes';
import { toQuestionnaire } from '../services/fhirHelpers';
import type { FormTemplate, FieldConfig, FieldStyling, PatientBinding } from '../types/form-builder';
import { QUESTIONNAIRE_EXTENSIONS } from '../constants/fhir-systems';
export const FORM_ID = 'form-xxx-name';
const FORM_URL = 'http://medimind.ge/fhir/Questionnaire/form-xxx-name';
// Helper: create a field with sensible defaults
function field(config: {
id: string;
type: FieldConfig['type'];
label: string;
text?: string;
readOnly?: boolean;
required?: boolean;
patientBinding?: PatientBinding;
inlineGroup?: string;
width?: string;
showLabel?: boolean;
styling?: Partial<FieldStyling>;
options?: FieldConfig['options'];
order?: number;
}): FieldConfig {
const isDisplay = config.type === 'display';
const inGroup = !!config.inlineGroup;
const styling: FieldStyling = {
...(config.width ? { width: config.width } : {}),
...(!isDisplay ? { inputStyle: 'underline' as const } : {}),
// Display fields: prevent CSS truncation of long Georgian text
...(isDisplay ? { whiteSpace: 'normal', lineHeight: '1.6' } : {}),
...(inGroup && !isDisplay ? { showLabel: config.showLabel !== false } : {}),
...config.styling,
};
return {
id: `field-${config.id}`,
linkId: config.id,
type: config.type,
label: config.label,
text: config.text,
readOnly: config.readOnly,
required: config.required,
patientBinding: config.patientBinding,
inlineGroup: config.inlineGroup,
options: config.options,
order: config.order,
...(Object.keys(styling).length > 0 ? { styling } : {}),
};
}
function binding(key: PatientBinding['bindingKey']): PatientBinding {
return { enabled: true, bindingKey: key };
}
The most common layout bug is fields that are too narrow for their Georgian labels. Here's how to size fields correctly:
Rule: Width must accommodate BOTH the label text AND the input.
When showLabel: true (the default in inline groups), the field renders as:
[Label (nowrap)] [Input (flex:1)] inside the width you specify.
| Label Length | Example | Min Width | Safe Width |
|---|---|---|---|
| Short (1-2 words, ~80px) | "ასაკი", "სქესი" | 24% | 32% |
| Medium (2-3 words, ~150px) | "პირადი №", "ტელეფონი" | 32% | 40% |
| Long (3-5 words, ~200px) | "საავადმყოფო ფურცელი №" | 46% | 49% |
| Very long (5+ words, ~250px+) | "განყოფილების ხელმძღვანელი" | 58% | 65% |
Width formulas for inline groups:
2 fields, both with labels: 49% + 49% = 98% ✓
2 fields, one wide label: 58% + 40% = 98% ✓
3 fields, short labels: 32% + 32% + 32% = 96% ✓
3 fields, one long label: 46% + 26% + 26% = 98% ✓
1 wide + 2 narrow: 48% + 24% + 24% = 96% ✓
When labels are too long for the row:
If you need 3 fields but all have long labels, don't force them onto one row. Either:
showLabel: false and add separate display-type label fields aboveDate fields need extra width:
Date pickers have a calendar icon and wider input. Always allocate at least 49%
for a date field with a label in an inline group.
Section headers use type: 'display'. The formStyling document mode reset makes them
render as clean centered text (no gradient boxes):
field({
id: 'section-name',
type: 'display',
label: 'სექციის სახელი',
styling: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 'var(--emr-font-md)',
marginTop: '16px',
marginBottom: '8px',
},
})
Patient-bound fields — auto-populate from patient data, always read-only:
field({
id: 'full-name',
type: 'text',
label: 'სახელი, გვარი',
readOnly: true,
patientBinding: binding('fullName'),
inlineGroup: 'demo-row',
width: '48%',
})
Textareas — always full-width, standalone (NEVER in inline groups):
field({
id: 'diagnosis',
type: 'textarea',
label: 'დიაგნოზი',
styling: { height: '44px', width: '100%', marginBottom: '8px' },
})
Signature fields — always full-width, standalone (NEVER in inline groups):
field({
id: 'doctor-signature',
type: 'signature',
label: 'ხელმოწერა',
styling: { width: '100%' },
})
Integer fields in document forms — use 'text' type to avoid spinner arrows:
field({
id: 'bed-days',
type: 'text', // NOT 'integer' — avoids ugly spinner controls
label: 'საწოლ-დღე',
inlineGroup: 'row-1',
width: '49%',
})
Every form template MUST include formStyling. This triggers the document mode reset
via formStylingToCSS(). Without it, the form renders with gradient decorations.
const formStyling: FormTemplate['formStyling'] = {
container: {
maxWidth: '900px',
padding: '32px 40px',
backgroundColor: 'var(--emr-bg-card)',
boxShadow: 'none',
},
title: {
textAlign: 'center',
fontSize: 'var(--emr-font-xl)',
fontWeight: '700',
color: 'var(--emr-primary)',
marginBottom: '8px',
},
label: {
fontSize: 'var(--emr-font-sm)',
fontWeight: '500',
color: 'var(--emr-text-secondary)',
marginBottom: '2px',
},
sectionHeader: {
fontSize: 'var(--emr-font-md)',
fontWeight: '700',
color: 'var(--emr-primary)',
padding: '8px 0',
borderBottom: '2px solid var(--emr-primary)',
marginBottom: '12px',
textAlign: 'center',
},
};
export const formTemplate: FormTemplate = {
id: FORM_ID,
title: 'ფორმის სახელი',
description: 'ფორმის აღწერა',
status: 'active',
version: '1.0',
fields,
formStyling,
multiRecord: false,
signatureRequirement: 'dual',
categories: ['General'],
encounterPhases: ['admission'],
encounterType: 'inpatient',
};
export async function seedForm(medplum: MedplumClient): Promise<string> {
const questionnaire = toQuestionnaire(formTemplate);
questionnaire.url = FORM_URL;
// Add golden form metadata extensions
const goldenExtensions: Extension[] = [
{ url: QUESTIONNAIRE_EXTENSIONS.FORM_MULTI_RECORD, valueBoolean: false },
{ url: QUESTIONNAIRE_EXTENSIONS.FORM_SIGNATURE_REQUIREMENT, valueString: 'dual' },
{ url: QUESTIONNAIRE_EXTENSIONS.FORM_ENCOUNTER_TYPE, valueString: 'inpatient' },
];
for (const cat of formTemplate.categories ?? []) {
goldenExtensions.push({ url: QUESTIONNAIRE_EXTENSIONS.FORM_CATEGORY, valueString: cat });
}
for (const phase of formTemplate.encounterPhases ?? []) {
goldenExtensions.push({ url: QUESTIONNAIRE_EXTENSIONS.FORM_ENCOUNTER_PHASE, valueString: phase });
}
questionnaire.extension = [...(questionnaire.extension || []), ...goldenExtensions];
// Upsert: search by URL, update if exists, create if not
const existing = await medplum.searchResources('Questionnaire', { url: FORM_URL });
if (existing.length > 0) {
questionnaire.id = existing[0].id;
const result = await medplum.updateResource(questionnaire);
return result.id!;
}
const result = await medplum.createResource(questionnaire);
return result.id!;
}
If the form needs to appear in the patient card sidebar:
activityTabConfig.ts — Add form ID to the appropriate tab's formIds arrayinpatientFormCategories.ts — Add sidebar entry under the right category groupSimpleFormSidebar.tsx — Add same entry (it has its own copy)formComponentRegistry.tsx — Add lazy import + registrationForm{Name}Content.tsx using SharedFormRendererContentDO NOT add entries to FORM_CLASS_MAP or FORM_CSS_LOADERS in FormRenderer.tsx.
The formStyling extension handles styling through runtime CSS injection.
After creating the form, verify the rendering:
# Start server if needed
cd packages/app && npx vite --port 3000
# Navigate and screenshot
npx tsx scripts/playwright/cmd.ts navigate "<patient-card-url>"
npx tsx scripts/playwright/cmd.ts wait 2000
npx tsx scripts/playwright/cmd.ts screenshot "form-check"
# Check for wrapping issues (inline groups where fields wrapped to next line)
npx tsx scripts/playwright/cmd.ts evaluate "
const groups = document.querySelectorAll('[data-inline-group]');
const issues = [];
for (const g of groups) {
const children = Array.from(g.children);
if (children.length < 2) continue;
const last = children[children.length - 1];
const first = children[0];
if (last.getBoundingClientRect().top > first.getBoundingClientRect().top)
issues.push(g.getAttribute('data-inline-group'));
}
JSON.stringify({ total: groups.length, wrapping: issues });
"
Zero wrapping groups = success. If any group wraps, reduce field widths in that row.
datetime fields in inline groups with other labeled fields — they're too wide (label + calendar + clear button = ~430px). Put datetime fields standalone or only pair with SHORT-label fieldsdate fields with labels in inline groups'integer' type in document forms — use 'text' to avoid spinnersheight on textarea fields — use CSS min-height or omit it. Fixed height on the wrapper prevents autosize expansion, causing textareas to overlap elements belowvar(--emr-xxx) CSS variablesmaw (max-width) under 300px for Select fields — Georgian medical text (e.g., "გადაუდებელი განყოფილება (ER)") needs ~250px. Default maw should be 300px when no explicit fieldStyling.width is setwidth: '100%'. The only exception: labelless dates (empty label, showLabel:false) paired with a separate display label in an inline groupdateRowLabel: { width: '300px', flexShrink: '0' }whiteSpace: 'normal' on display fields with long text (legal clauses, paragraph descriptions). FormRenderer CSS applies white-space: nowrap; overflow: hidden; text-overflow: ellipsis to inline field text, which TRUNCATES long Georgian text to just the first few characters. Always add whiteSpace: 'normal' and lineHeight: '1.6' to display fields that contain paragraph-length text| Symptom | Check | Fix |
|---|---|---|
| Form has blue gradient bars and shadows | Is formStyling on the template? | Add the formStyling object — it triggers document mode reset |
| Input is a tiny sliver | Is the field width too narrow for its label? | Increase width or use showLabel: false |
| Fields wrapped to next line | Do widths sum to >98%? | Reduce widths. Use the Playwright wrapping check |
| Section header has gradient background | Missing formStyling document mode reset | Add formStyling to template |
| Labels are hidden in inline group | showLabel not set | The field() helper defaults showLabel to true in groups |
| Date field label always shows | Date fields in inline groups always show labels | This is by design — allocate enough width (49%+) |
| Georgian text wraps character-by-character | Label too long for container | The CSS already prevents this with white-space: nowrap. Increase field width |
| Select text truncated (Georgian cut off) | Is maw too small? Default should be 300px | Increase maw or set fieldStyling.width to accommodate the longest option text |
| Date picker extremely small / no data visible | Date field with Georgian label in narrow inline group (49%) | Make date standalone (100%) or use display label + labelless date in inline group |
| Vertically-stacked inputs not aligned | Labels in sequential inline groups have different widths | Give all labels in the group a shared fixed width constant (e.g., width: '300px') |
fullName, firstName, lastName, dob, age, personalId, gender, phone,
email, address, workplace, bloodGroup, rhFactor, allergies, citizenship,
department, transportationType, admissionDate, dischargeDate,
registrationNumber, treatingPhysician, practitionerName, clinicName
var(--emr-primary) (#1a365d), var(--emr-secondary) (#2b6cb0)var(--emr-bg-card), var(--emr-bg-input), var(--emr-bg-page)var(--emr-text-primary), var(--emr-text-secondary)var(--emr-border-default), var(--emr-border-color)var(--emr-font-xs) through var(--emr-font-3xl)See packages/app/src/emr/scripts/seed-form-300a-v2.ts for a complete, tested example
that demonstrates all patterns: inline groups with labels, section headers, textareas,
date fields, signatures, patient bindings, and formStyling.