Problem
The React view layer styles its controls with a single grown stylesheet (tlReactControls.css, about 6000 lines) of literal colors, sizes and durations. There is no defined scale, no mode or density switch, and customer projects have no defined place to override design values.
Solution
A design system as its own package (tl-design-system, Gitea TopLogic/tl-design-system, documentation in its wiki and in docs/skalen.md) that produces four stylesheets and a DTCG token export:
- primitives.css – the raw values (color ramps, size scale, fonts, durations, effects); the place where customer projects intervene.
- tokens.css – semantic roles referencing primitives only, per mode (data-tl-mode) and density (data-tl-density).
- base.css – reset, typography classes .tl-type-*, icon size classes .tl-icon-*, focus ring, reduced motion, print.
- components.css – the components, one per wave, generated from design-system/src/components/**.
Public names are English, kebab-case, prefix tl (--tl-surface-brand, .tl-button--primary, data-tl-mode). States are attributes (disabled, aria-pressed, aria-busy, :focus-visible), never classes. The old stylesheet is cut down component by component in waves (a ratchet test keeps its line count from growing); no aliases of old names. The generated CSS is copied into com.top_logic.layout.react/src/main/webapp/style/tl-design-system/ and loaded as ClientResources.
Wave 0: package, tokens, base classes (merged, !1641)
- The demo application loads primitives.css, tokens.css and base.css as ClientResources.
- UIThemeService sets data-tl-mode and data-tl-density on the html element; the mode follows the selected theme or, for theme "System", the operating system.
Wave 1: button (merged, !1649)
The button is the first component that lives entirely in the package. Decisions (docs/skalen.md, section "Button"): four appearances primary, secondary (default, outlined), ghost (no frame; toolbars, icon buttons), link; one tone danger that recolors the chosen appearance (not with link); no size modifier except sm for icon-only buttons in table rows; states as attributes; no context selectors – the container chooses the appearance.
- components.css is loaded as ClientResource tl-design-system-components after base.css.
- ButtonAppearance gains GHOST; new enum ButtonTone { DEFAULT, DANGER } with ReactButtonControl.setTone(...) and state key tone.
- TLButton, TLToggleButton and TLUploadButton render tl-button with the modifiers --primary|--secondary|--ghost|--link|--danger|--sm, elements __icon/`__label`, states disabled, aria-pressed="true" (replaces the --active class), aria-busy="true" (upload in progress).
- New React context ButtonDefaults: a container tells its buttons which appearance to use when the server state names none – TLToolbar → ghost unless the container the toolbar sits in has chosen otherwise, TLAppBar actions → ghost, TLWindow footer and TLPanel button bar → secondary. The server state always wins over the context.
- Collapsing a labeled button to its icon is not the button's concern: the collapsing toolbar of #29626 decides by the width it is granted (tlToolbar--compact), not a viewport breakpoint and not a context on the button.
- RowCommandColumn renders row commands with ButtonAppearance.GHOST and ButtonSize.SMALL.
- Dark-mode contrast corrections in the token layer: border-control, status-*-text and status-*-border now also meet 3.0 / 4.5 on surface-layer (buttons sit on panels); status-error-surface gets -hover/`-active` steps with derived labels.
- Deleted from tlReactControls.css: the TLButton block, the TLButton enhancements block and the five context blocks (app bar, window toolbar, row command cell, dropdown, app bar collapse). Three transitional rules remain and are marked: the app bar reads --tl-surface-layer/`--tl-text-primary` (wave 4 rewrites the shell), a dropdown item fills its row with the button (wave 3 brings the menu component), the compact toolbar hides tl-button__label (wave 3 brings the toolbar).
Wave 1: pill (CWS/CWS_29617_design_system_welle_1_pill)
The color a value is displayed with is a role of the design system, not a token name and not a color value (docs/skalen.md, section "Pill: Rolle statt Farbe"): neutral, brand, error, warning, success, info, category-1 to category-8. A role reads two tokens – the tinted surface and its line; the text on a pill is text-primary, as on every tinted strip. The eight categories carry no meaning and tell values apart that only have to be distinguishable (project colors, chart series); their hues sit between the meanings, 20° apart from each other and from every meaning.
- ValueColor is that enum; ColorSpec names the role: <color role="warning"/>. ColorTokenOptions, ColorTokenConstraint and the warning about unknown tokens (#29606) leave the annotation; DesignTokenService stays. The token support-info leaves the React theme.
- ReactValueColor.ROLE (colorRole) carries the role's external name to the client, never CSS. TLPill renders tl-pill tl-pill--<role> from pill.css; TLText draws a text with appearance="pill" in the role of its tone (success/`warning`/`error`, accent → brand, else neutral).
- ColorByExpression accepts a classifier or the name of a role (x -> 'category-3'); a Color value gives no color.
- The package brings 32 primitives and 16 roles category-N-subtle/`category-N-border` per mode. Deleted from tlReactControls.css: the .tlPill block with its color-mix rules; --tlPill-color is gone.
Wave 2: disabled form fields (!1740)
An attribute whose dynamic visibility (<dynamic-visibility> with a ModeSelector) computes disabled is shown in the React view layer as an inactive input in edit mode, as in the classic UI: the value stays in its input, greyed out, and cannot be changed. Before, the React layer treated disabled like read-only and showed the value as text only. In view mode a disabled field shows its value like any other field.
- FieldModel has a disabled state (isDisabled(), AbstractFieldModel.setDisabled(...), FieldModelListener.onDisabledChanged(...)). A disabled field is never editable; the flag only decides how the non-editable field is shown: as an inactive input instead of the read-only value. FormFieldAdapter passes on the disabled state of a classic FormField.
- The state contract declares disabled in FieldState (state.proto); ReactFormFieldControl sends it. AttributeFieldControl sets it for the mode disabled while the form is edited.
- Every form control renders a disabled field as its input with disabled (aria-disabled for the dropdown), without clear button, popup, drag or file picker. The read-only display (tl-field-value) is reserved for fields that are neither editable nor disabled (helper showsValueOnly in form/fieldState.ts). The values of a disabled value list are disabled inputs, too. The rich-text and the code editor keep their read-only display.
- A child control that is remounted (e.g. a value list switching between its edit and its display layout) keeps its live state instead of re-applying the state its parent last serialized; before, a value list could show enabled inputs in view mode after a disabled edit.
- ModeSelector.traceDependencies(...) receives the edit mode, like getMode(...). A dynamic visibility with two arguments (obj -> editMode -> ...) now records the attributes it reads, so the form re-evaluates the mode as soon as one of them changes (before, only a one-argument function did; this also applies to the classic form's ModeObserver).
- Demo (tl-demo-react): the flag "Felder gesperrt" on the demo object disables 17 fields of every control kind through a dynamic visibility (Attribute → "Auswahl + Formular"; Demos → Gestaltung for chips, segments, sliders and switch).
Wave 5: satellite stylesheets
Besides tlReactControls.css, the React satellite modules carry stylesheets of their own with foreign palettes (IBM Carbon, Tailwind) as literal hex values and literal fallbacks in var(--token, <literal>): tl-react-wysiwyg.css (about 60), tlScriptEditor.css (about 18), tl-react-chartjs.css (about 12); the engine's form.css, tables.css, core.css carry about 14. They stand outside the package's ratchet today. Wave 5 puts them under it: every value a token of the package, no literal fallback, no foreign palette. (Absorbs #29578.)
Migration
Wave 0 is additive. Wave 1 changes the following for applications that build on the React view layer:
- ButtonSize.LARGE no longer exists (it had no callers in the engine). Configurations or code using it must switch to the default size.
- The CSS classes tlReactButton, tlReactButton__image, tlReactButton__label, tlReactButton--primary|--link|--small|--large|--icon|--iconOnly|--labelOnly|--active no longer exist. Application stylesheets that target them must be rewritten against tl-button and its modifiers, or better, drop the rule: appearance is chosen by the server state (appearance, tone, size) or the container context.
- A button's pressed state is aria-pressed="true", its busy state aria-busy="true"; there is no --active class.
- The app bar with .tlAppBar--primary is no longer painted in the accent color; it uses the design system's layer surface. Applications relying on the accent-colored header need their own stylesheet rule.
- ButtonAppearance.LINK combined with ButtonTone.DANGER is not supported; the tone is ignored for links.
- Custom properties starting with --tl are reserved for the design system. Application or engine code must not define new ones (reading the package's tokens is fine); a ratchet test in the package flags new definitions.
- The annotation <color> names a role: <color role="warning"/>. The attributes value (a literal color) and token (a design token name) no longer exist. The mapping is support-error → error, support-warning → warning, support-success → success, support-info → info, interactive → brand.
- The model stored in the database is migrated automatically (migration Ticket_29617_color_role of tl-element): the tokens above become their role. A <color> with a literal color value or with any other token is removed, because no role corresponds to it; the enumeration literal is then displayed without a color.
- The *.model.xml files of the application are not migrated: a <color> in them with value or token fails to load and must be rewritten by hand, using the mapping above; a literal color becomes the category that tells the value apart best. After that rewrite, the automatic model upgrade at startup brings the roles of literals whose color was removed by the migration back into the database.
- ValueColor.color(...), ValueColor.cssColor(...), ValueColor.themeToken(...) and cssValue() no longer exist; ValueColor is an enum, ValueColor.byExternalName(...) resolves a role by its name. A ValueColorProvider returns a role; a color-by-expression returns a classifier or the name of a role, a Color value gives no color.
- The client state key color of TLText, TLResourceCell and the option descriptors is colorRole and carries a role name; the CSS class tlPill and the custom property --tlPill-color no longer exist. Stylesheets targeting them must be rewritten against tl-pill and its modifiers, or dropped.
- The React theme token support-info no longer exists; the classic theme settings keep theirs.
Wave 2 (disabled form fields) changes:
- ModeSelector.traceDependencies(TLObject, TLStructuredTypePart, Sink, OverlayLookup) has an additional parameter boolean editMode after the attribute. Application classes implementing ModeSelector must add it (and may use it like the editMode of getMode).
- Form field controls send the state key disabled (FieldState.disabled). An adapter of a customer component library should map editable: false with disabled: true to the library's disabled presentation, and editable: false alone to its read-only one (see docs/faq/customer-component-library.md).