- Use Tailwind utility classes for layout, spacing, typography, colors, sizing, flexbox, and grid. Avoid custom SCSS when a Tailwind class exists.
- Use PrimeNG components instead of building custom UI (e.g.
p-button,p-card,p-dialog,p-table; text inputs are thepInputTextdirective on a native<input>, not a component). PrimeNG theme tokens handle component styling automatically. - Minimize custom CSS — component
.scssfiles should be the exception, not the default. Most components should need zero or near-zero custom styles.
<!-- ✅ Layout with Tailwind -->
<div class="flex items-center gap-4 p-4">
<span class="font-semibold">Title</span>
<p-button label="Save" />
</div>
<!-- ✅ Responsive grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@for (item of items; track item.id) {
<p-card [header]="item.title" />
}
</div>
<!-- ❌ NEVER: custom CSS for what Tailwind handles -->
<div class="my-custom-flex-container">...</div>A rounded parent does not clip its children. A child with its own background — a card's tinted header band — therefore paints into the rounded corners, and the symptom depends on its radius:
- Child corner is square (e.g. the header's bottom corners when it is the last child): the fill covers the corner arc and erases the border stroke for several pixels. This reads as a card whose border is broken or missing along part of the curve.
- Child radius equals the parent's: the border eats 1px of it, so the child curves more sharply than the space available and leaves a wedge of the page showing through.
<!-- ✅ Preferred: the parent clips the fill to the border's inner curve, all four corners -->
<div class="flex flex-col overflow-hidden rounded-md border border-surface">
<div class="bg-surface-50 px-5 py-4.5">...</div>
</div>
<!-- ✅ When you cannot clip: give the child the border's inner radius explicitly -->
<div class="flex flex-col rounded-md border border-surface">
<div class="rounded-t-[calc(var(--radius-md)-1px)] bg-surface-50 px-5 py-4.5">...</div>
<div class="border-t border-surface px-5 pt-4.5 pb-5">...</div>
</div>overflow-hidden is the better fix — it is state-independent, so a header that is sometimes the
last child (a collapsed card) stays correct. Reach for the explicit inner radius only when the
container holds a PrimeNG overlay component (p-select, p-datepicker, p-autocomplete…): this
app runs with overlayAppendTo: 'self' (PrimeNG's default), those panels render inline, and an
overflow-hidden ancestor clips them. In that case the child must also round every corner it can
be adjacent to — an unrounded corner against the parent's arc is the erased-border case above.
Both cards in dot-ai-config-detail show the two branches, with the reason in a comment.
Custom SCSS is only allowed for:
- PrimeNG component overrides (via
::ng-deepinside:host) - Complex animations or pseudo-element styles Tailwind cannot express
- Third-party library integration overrides
// ✅ PrimeNG override (scoped)
:host {
::ng-deep .p-datatable .p-datatable-header {
border-radius: var(--border-radius);
}
}
// ❌ NEVER: unscoped ::ng-deep
::ng-deep .p-button { color: red; }
// ❌ NEVER: custom SCSS for layout/spacing/colors
.my-container {
display: flex;
gap: 1rem;
padding: 16px;
color: #333;
}@use "variables" as *;
.feature-overlay {
box-shadow: $shadow-m;
border: 1px solid $color-palette-gray-200;
}- Spacing:
$spacing-0through$spacing-9 - Colors:
$color-palette-primary,$color-palette-gray-*, etc. - Shadows:
$shadow-s,$shadow-m,$shadow-l
- Tailwind first — use utility classes for layout, spacing, colors, typography, sizing.
- PrimeNG theme — rely on the theme for component styling; avoid overriding PrimeNG styles unless necessary.
- NEVER hardcode colors, spacing, or shadows in SCSS — use SCSS variables or Tailwind classes.
::ng-deepmust be scoped inside:host— never bare.- BEM naming (
Block__Element--Modifier) only when custom SCSS is truly needed. - Flat SCSS structure — no deeply nested selectors (max 3 levels).
- No
!importantunless justified with a comment.
PrimeNG ships two visually similar but semantically different components. Pick by intent, not appearance.
p-tag— informative, read-only status display. Use it for anything that communicates state the user does not interact with directly: content status badges, locale labels, etc. Tags carry aseverity, so colors come from native severity states (configured once in thetagblock of the theme preset), never from per-template classes.p-chip— interactive or removable elements: filters, removable selections, anything the user can click or dismiss. Chips are mostly neutral gray and do not express severity.
-
Showing a contentlet status? Use the shared
<dot-contentlet-status-badge>component (libs/ui). It takes theDotContentStateand resolves the label, severity, and translation internally — do not hand-roll ap-tagfor contentlet statuses.<dot-contentlet-status-badge [state]="contentlet" />
-
Showing any other status / read-only state? Use
p-tagwith the matchingseverity(see mapping below). Example: the version-history timeline shows per-version states — not a contentletDotContentState— so it uses raw tags: this version is live →success, working copy →warn, experiment variant →info. -
Anything without a status — interactive, removable, or clickable items (filters, selections) — use
p-chip.
| Severity | Status |
|---|---|
success |
Published / live |
danger |
Archived / deleted |
info |
Revision / new |
warn |
Draft |
- Always use
<dot-contentlet-status-badge>for contentlet statuses — never a rawp-tagorp-chip. - Locale/language labels use
p-tag severity="info"— locales are informative, never chips (e.g. the Locale column in Content Drive, the asset card language label). This applies to read-only locale display; interactive locale selectors are designed per area. - Never use
p-chipfor purely informational status — usep-tagwith aseverity. - Never add Tailwind
!importantcolor overrides (bg-green-100!,text-red-700!, etc.) to PrimeNG components. Rely on nativeseverityplus the preset color tokens intheme.config.ts.
The global .form / .field classes in apps/dotcms-ui/src/style.css are the convention.
Use them. They carry label typography, the label-to-control gap, and hint/error colors, so a form
that adopts them needs no local CSS for any of it.
This reverses earlier guidance here, which called them legacy and told you not to extend them.
edit-contentwas the one surface that had never adopted them — its labels rendered larger and lighter than every other admin form, and its hints and errors rendered unstyled, because the editor's root<form>carried no.formclass and every rule below it is scoped to one (#37460). It now uses them like the rest of the product.
edit-content still wraps fields in dot-card-field / dot-card-field-label, but those now
emit the global markup rather than reimplementing it — see
Reaching a direct-child rule from a component.
These rules apply to every field:
-
No
text-*size class on the label of a form control. A control's label inherits the PrimeNG default size/weight — do not addtext-sm,text-base,font-medium, etc. to make it "look right." The only text allowedtext-smis hint/error text under a field, and it pairs with a muted/semantic color (text-gray-500for hints,text-red-500for errors).This rule is about control labels. Section headings, group labels over a set of controls, and card/section descriptions are a different typographic level and may size themselves (
text-sm font-semiboldfor a group label,text-sm text-gray-600for secondary copy). -
Use one gray scale.
text-gray-*andtext-surface-*are separate scales (--color-gray-*maps to--p-gray-*, not to surface).text-gray-*is the app's majority convention — don't mix the two in one screen. -
Do not write the label-to-control gap yourself.
.form .fieldisflex flex-col gap-1; adding your owngap-*to a.fieldoverrides the system for no reason. Outside a.form— a dialog appended tobody, a bundle that does not load the app stylesheet —flex flex-col gap-1is the equivalent to hand-roll. -
A field reserves space for its hint only when it has one. Render the hint conditionally (
@if (field.hint) { <small class="text-sm text-gray-500">...</small> }) — never a permanent empty slot or a fixedmin-h-*"for alignment" when there is no hint to show. -
Required marker. For a form whose required fields are fixed, use the
dotFieldRequireddirective (see below). For a form built from runtime metadata, use a literal*shown@if (field.required)and styledtext-red-500— the directive cannot track a field whose requiredness changes after the first render. -
Prefer PrimeNG components over hand-rolled equivalents:
p-panel/p-accordionfor collapsible sections,p-toggleswitchfor on/off toggles,p-buttonfor actions. For a card container, check what each one actually gives you before hand-rolling:p-panelis a border + radius + asurface.50header band with its own divider, whilep-cardis overridden intheme.config.tsto a plain bordered box (shadow: none,1rempadding) with no header band — they are not interchangeable. -
A custom icon in a
p-buttongoes in an<ng-template #icon>, not as bare content.p-button-icon-only— the square, label-less shape — is derived from theiconinput or an#icontemplate; bare projected content satisfies neither, so an icon-only button silently renders with the padding of a labelled one.
<!-- ✅ Field: the global system supplies typography, gap and hint color -->
<form class="form" [formGroup]="form">
<div class="field">
<label for="name" dotFieldRequired>Name</label>
<input pInputText id="name" formControlName="name" class="w-full" />
@if (hint) {
<small class="p-field-hint">{{ hint }}</small>
}
</div>
</form>
<!-- ❌ NEVER: sized/weighted label, hand-rolled gap, permanently reserved hint slot -->
<div class="flex flex-col gap-2">
<label for="name" class="text-sm font-medium">Name</label>
<input pInputText id="name" formControlName="name" />
<small class="text-sm text-gray-500 min-h-5">{{ hint }}</small>
</div>Two of these class names silently do nothing outside their scope, which is worth knowing before you copy a template that uses them.
apps/dotcms-ui/src/style.css defines:
| Class | What it does | Scope |
|---|---|---|
.form |
w-full, 5-unit rhythm between direct children |
the form element |
.form .field |
column layout, gap-1 between label and control |
one field |
.form .field > label |
text-sm font-medium |
labels, automatically |
.form .p-field-hint |
text-sm text-gray-500 |
a field's hint |
.form .p-field-error |
text-sm text-red-500 |
a field's error |
Three things to know before you copy a template that uses them:
p-field-hintandp-field-erroronly work inside.form. Both rules are descendant-scoped. On an element with no.formancestor the class matches nothing and the text renders at inherited size and color — no error, no warning, just wrong. Outside a.form, write the utilities directly:<small class="text-sm text-gray-500">.- The
p-on those two is ours, not PrimeNG's, as it is onp-label-input-required. PrimeNG 21 claims neither name. Not everyp-class in a template comes from the library. .p-erroris dead — never add it, and replace it when you touch a file that has it. It is defined only inlibs/dotcms-scss/angular/dotcms-theme/utils/_validation.scss, reachable only throughdotcms-theme/theme.scss, and that import is commented out inlibs/dotcms-scss/angular/styles.scss. Nothing else the app builds defines it, so an element carrying it renders as plain inherited text rather than red. There are live usages indot-experimentsanddot-content-types-edittoday whose validation errors do not look like errors.
For a field-level error use <p-message severity="error" variant="simple"> — the simple variant
renders as colored text with no box — or <small class="text-sm text-red-500">. A message that is
not about a field (a dialog reporting a failed action, a page-wide warning) is a full p-message
with a severity, not a field error borrowed for the occasion.
libs/ui/src/lib/dot-field-required/dot-field-required.directive.ts puts the required asterisk on
a label without markup. Four usages:
<!-- 1. Always required -->
<label dotFieldRequired for="name">Name</label>
<!-- 2. Required only if the named control carries Validators.required -->
<label dotFieldRequired checkIsRequiredControl="name" for="name">Name</label>
<!-- 3. Signal forms: follows the field's own required() -->
<label [dotFieldRequired]="field" for="name">Name</label>
<!-- 4. Boolean: for a caller that already knows, from content type metadata rather than a form -->
<label [dotFieldRequired]="isRequired" for="name">Name</label>Mode 4 exists because a component cannot apply a directive to its own host from its template. A
component whose host is the label declares it through hostDirectives and forwards the flag:
hostDirectives: [{ directive: DotFieldRequiredDirective, inputs: ['dotFieldRequired: isRequired'] }]It adds the class p-label-input-required, whose ::after { content: '*' } rule lives at the top
level of apps/dotcms-ui/src/style.css — not scoped to .form, so unlike p-field-hint it
works anywhere.
Modes 1 and 2 are for a form whose required fields are fixed. Do not use mode 2 for a form built from runtime metadata — use mode 3 or 4, which recompute. The reasons are visible in the source:
- It is a one-way latch. The constructor adds the class unconditionally;
checkIsRequiredControlis a plain setter that only ever removes it. Once removed it never comes back, and the setter re-runs only when the bound string changes — so a control that becomes required again keeps no asterisk. - It only recognises
Validators.requiredby reference (hasValidator(Validators.required)). A custom or cross-field validator reads as "not required". - It requires a
FormGroupDirectiveancestor (inject(FormGroupDirective), not optional), so it throws outside a reactive form.
dot-ai-config-detail is the counter-example: its fields come from provider metadata and the
FormGroup is rebuilt on every provider switch, while @for (… track field.name) reuses the DOM
node. The same field name can change requiredness between providers — model is
ProviderField.required for Vertex AI and ProviderField.optionalUnless for Azure — which the
latch above would render stale. That screen predates mode 4 and uses a literal * bound to the
metadata flag; mode 4 now covers the same case through the directive.
.form .field > label is a direct-child selector. A component that renders its own <label>
inside its element makes that label a grandchild of .field, and the rule never matches — the
label keeps the inherited size and weight.
display: contents does not fix this. display governs box generation; selectors match the
DOM tree, which display never changes. It collapses the layout correctly and leaves the
typography untouched, so the gap looks right while the rule still does not apply — a failure that
looks like success. Verify in devtools that the rule appears as matched, not merely that the
spacing looks correct.
The fix is to remove the intermediate element: give the component an attribute selector on the element it is meant to be, so its host is the label.
// ❌ renders <dot-card-field-label><label>…</label></dot-card-field-label>
@Component({ selector: 'dot-card-field-label', template: '<label …><ng-content /></label>' })
// ✅ the host IS the label; nothing sits between it and .field
@Component({
selector: 'label[dotCardFieldLabel]',
template: '<ng-content />',
host: { '[attr.for]': '$variableName()' }
})<label dotCardFieldLabel [variableName]="field.variable">{{ field.name }}</label>This applies to any component that must satisfy a global rule written with >. Wrapper components
whose element no global rule targets — dot-card-field-content, dot-card-field-footer — need no
such treatment.
The required asterisk is ::after content and is deliberately not in the label's accessible
name, so a screen reader never announces "star". That means the control has to carry the
information itself — and before it can, it has to have a name. Three shapes, by widget:
| Widget | Name it with | Required |
|---|---|---|
A labelable control (input, textarea, select, button) |
<label for> + matching id |
aria-required on the control |
A group or collection (div of options, a chips list, a dropzone) |
role + aria-labelledby → the label's id |
only where the role allows it |
| A third-party editor that owns its DOM | the editor's own option | — |
<label for>only associates with labelable elements. A<div role="group">can never be named that way however many roles it carries, and neither can PrimeNG's select, which putsinputIdon a<span role="combobox">. Those takearia-labelledby— PrimeNG exposesariaLabelledByfor exactly this.- Verify with
label.control, not by checking an id exists. The browser's own resolution is the only thing that proves the association; an id that is present but on a non-labelable element looks correct and associates nothing. - ARIA defines
aria-requiredoncombobox,listbox,radiogroup,spinbutton,textboxandtree— not on plaingroup, and there is nocheckboxgrouprole. For a widget that takesgroup, leave it off rather than write invalid ARIA; the asterisk and the required message carry it visually. - Declare the role a widget already behaves as, do not invent one. A button that carries
aria-expandedand opens a listbox-like overlay is a combobox; saying so makesaria-requiredavailable to it. - Never infer the control from "the first focusable descendant". Text Area and WYSIWYG both render an editor-mode dropdown above their real control, so that heuristic marks the dropdown as the mandatory field. A screen reader announcing the wrong control is worse than announcing none. Mark only what the label declares.
- Third-party editors take their own option: Monaco
ariaLabel, TinyMCEiframe_aria_text(the body inside the frame) plusiframe_attrs.title(the frame itself), the block editor a field-derived label in place of its generic one.
- ANGULAR_STANDARDS.md — Component rules, templates
- docs/frontend/README.md — Index of all frontend docs