diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index adfa3c8dd..9ec8e7f11 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -24,6 +24,7 @@ to the symptom table below, so the next similar issue costs fewer reads. | Compile error: `undefined: microflows.XxxOperation` | Missing SDK struct | `sdk/microflows/microflows_actions.go` | Add struct + `func (XxxOperation) isListOperation() {}` marker | | `TypeCacheUnknownTypeException` in Studio Pro | Wrong `$type` storage name in BSON write | `sdk/mpr/writer_microflow.go` | Check the storage name table in CLAUDE.md; verify against `reference/mendixmodellib/reflection-data/` | | CE0066 "Entity access is out of date" | MemberAccess added to wrong entity | `sdk/mpr/writer_domainmodel.go` | MemberAccess must only be on the FROM entity (`ParentPointer`), not the TO entity — see CLAUDE.md association semantics | +| `grant view on page` / `grant execute on microflow\|nanoflow` / `grant access on odata\|published rest service` to a role from **another module** passes `mxcli check`/`exec` but fails the Mendix build with **CE0148 "reselect roles"** — the own-module role works | Document access (page/microflow/nanoflow/service `AllowedModuleRoles`) may only reference the document's **own** module roles; Studio Pro's picker only offers those. The grant path wrote `role.Module + "." + role.Name` verbatim with no same-module check (only `validateModuleRole` = role-exists-in-its-module), so a cross-module reference reached the model. The MOVE path already guarded this (`remapDocumentAccessRoles`) — GRANT didn't | `mdl/executor/cmd_security_defaults.go` (`checkDocumentAccessRolesSameModule`) + the 5 grant handlers in `mdl/executor/cmd_security_write.go` | Pre-check each grant: reject when any `role.Module != docModule` with an actionable message (name the doc's module + suggest the own-module role). Reject (don't silently remap) — a GRANT is explicit, so a wrong role/doc shouldn't be substituted. Wired into page/microflow/nanoflow/OData/published-REST grants. Repro `mdl-examples/bug-tests/ce0148-cross-module-grant.mdl` | | CE0463 "widget definition changed" | Object property structure doesn't match Type PropertyTypes | `sdk/widgets/templates/` | Re-extract template from Studio Pro; see `sdk/widgets/templates/README.md` | | Parser returns `nil` for a known BSON type | Unhandled `default` in a `parseXxx()` switch | `sdk/mpr/parser_microflow.go` or `parser_page.go` | Find the switch by grepping for `default: return nil`; add the missing case | | MDL check gives "unexpected token" on valid-looking syntax | Grammar missing rule or token | `mdl/grammar/MDLParser.g4` + `MDLLexer.g4` | Add rule/token, run `make grammar` | @@ -46,6 +47,7 @@ to the symptom table below, so the next similar issue costs fewer reads. | Studio Pro renders **every activity/decision as a 1-px sliver** (caption wraps one letter per line) after a modelsdk round-trip; `mx check` reports **NO** error (it ignores box size, so the corruption is silent) | `flowObjectFromGen` carried each object's `Position` but not its `Size`, so the round-trip rewrote every node with size `0;0` | `mdl/backend/modelsdk/microflow.go` (`sizeFromGen`, `splitFlowObjects`) | Add `sizeFromGen` (mirrors `pointFromGen`) and apply it at the `splitFlowObjects` call site — covers nested loop bodies via recursion; add `GetSize`/`SetSize` to `BaseMicroflowObject`. Go round-trip test (not MDL, same reason as A1). Issue #723 A2 | | `mx check` **CE0117** "Error in expression" when creating a rule-based decision (`if Module.SomeRule(...)`) via MDL on the **modelsdk** engine; the decision's subtype is demoted Rule→Expression on every round-trip | modelsdk `*Backend` never implemented `IsRule` → fell back to the embedded `unimplemented` stub (which errors); the builder's `isRule, err := backend.IsRule(...); if err != nil || !isRule` guard treated the error as "not a rule" and emitted an invalid `ExpressionSplitCondition` | `mdl/backend/modelsdk/microflow.go` (`IsRule`) | Implement `IsRule` on `*Backend` (list `Microflows$Rule` units, match qualified name via `moduleNameFor`), mirroring the legacy reader; it shadows the generated stub. With the existing builder mock-test this guards the full chain modelsdk.IsRule→RuleSplitCondition. Issue #723 A4 | | `create enumeration … ("Value" = 'Caption')` fails with cryptic `mismatched input '=' expecting ')'`; user blames the *quotes* | Not a quoting bug — enum value names quote fine (`enumValueName` accepts `QUOTED_IDENTIFIER`). The `=` is invalid: MDL enum values are `Value 'Caption'` (or `Value caption 'Caption'`), no equals sign | `mdl/visitor/visitor.go` (`enhanceErrorMessage`/`looksLikeEnumEquals`) | Add an error hint keyed on `mismatched input '=' expecting ')'` (specific to enum value lists — attribute-default `=` gives a different message) pointing at the `=`. Clarify in `check-syntax.md` that captions use `Name 'Caption'`, never `=` | +| A genuine parse error on a short lowercase MDL keyword/identifier (`mismatched input 'on'`, `'in'`, `'as'`, `'to'`, `'by'`, …) is misdiagnosed with the "unescaped apostrophe" hint, sending the user to look for a quote that isn't there | `looksLikeUnescapedApostrophe` matched **any** 1–4 char lowercase token as a contraction leftover, so real short keywords tripped it | `mdl/visitor/visitor.go` (`looksLikeUnescapedApostrophe`, `contractionSuffixes`) | Match only the fixed contraction-suffix set (`s`/`t`/`d`/`m`/`re`/`ve`/`ll` — the leftovers from it's/don't/he'd/I'm/you're/we've/you'll), not arbitrary short lowercase words. Real apostrophe errors still hint; `on`/`in`/`as`/`to`/`by` no longer do. Findings #4 | | Docs/skills document MDL that doesn't parse (drift): `ALTER ENTITY X ADD (a, b)` / `DROP (a)` / `MODIFY (a)` / `RENAME a TO b`, `ALTER ENUMERATION … REMOVE VALUE`, `CREATE CONSTANT … TYPE X;` (no default), `DELETE_BEHAVIOR ` | Docs were written against a SQL-DDL mental model, never `mxcli check`-validated. Correct forms: `ADD ATTRIBUTE a: type` (one action/statement), `DROP ATTRIBUTE a`, `MODIFY ATTRIBUTE a: type`, `RENAME ATTRIBUTE a TO b`, `DROP INDEX `; `DROP VALUE`; enum `ADD VALUE X CAPTION 'y'` (CAPTION keyword required for ALTER, unlike CREATE); a constant `DEFAULT` is mandatory; delete-behavior ∈ {DELETE_AND_REFERENCES, DELETE_BUT_KEEP_REFERENCES, DELETE_IF_NO_REFERENCES, CASCADE, PREVENT} | `scripts/check-skill-mdl.sh` + `make check-skill-mdl` (CI) | The guard extracts DDL statements from `.claude/skills/mendix/` **and** `docs-site/src/` and runs `mxcli check`; keeps this class of drift from recurring. Run it after editing any MDL example | | `DESCRIBE microflow` on the **modelsdk** engine renders `download file $X …;` as `-- Empty action` | `actionFromGen` lacked a `DownloadFileAction` case | `mdl/backend/modelsdk/microflow_read_actions.go` (`actionFromGen`) | Add the case reading `FileDocumentVariableName()`/`ShowFileInBrowser()`, defaulting an empty `ErrorHandlingType` to Rollback. Note: the storage key is `ShowFileInBrowser` (legacy's `parseDownloadFileAction` reads the wrong `ShowInBrowser` key — a latent legacy bug; the gen reads it correctly) | | `DESCRIBE microflow` on the **modelsdk** engine renders a legacy SOAP `call web service …` as `-- Empty action` | `actionFromGen` lacked a `WebServiceCallAction` case | `mdl/backend/modelsdk/microflow_read_actions.go` (`actionFromGen`, `webServiceActionRequiresRawBSON`) | Add the case: read the structured fields (ImportedService / OperationName / NewResultHandling / RequestHandling) and, when the action carries any field the structured form can't represent, set `RawBSON = a.Raw()` so the renderer emits `call web service raw ''`. Mirror legacy's supported-key set exactly; `canonicalRawBSON` makes both engines' base64 byte-identical | @@ -138,6 +140,12 @@ to the symptom table below, so the next similar issue costs fewer reads. | A **DataView bound to a to-one referenced object over an association** ("data from context", e.g. an inner DataView showing the referenced Employee's `Name`/`Email` inside an `Expense` DataView) can't be authored/round-tripped: `dataview dvEmp (datasource: $currentObject/Module.Assoc)` was rejected (MDL-WIDGET08), a bare nested `dataview { textbox (attribute: Name) }` bound children to the **parent** entity (CE1613), and `describe` dropped the source → describe/exec **silently strips** the association DataView (round-trip corruption) | A DataView over an association is **not** a `Forms$AssociationSource` (that's list-widgets-only; CE6705 on a DataView). It is a `Forms$DataViewSource` whose `EntityRef` is a `DomainModels$IndirectEntityRef` navigating the association (a DataViewSource *is* an `EntityPathSource`, so its EntityRef may be indirect — `generated/metamodel/types.go:558`). mxcli generated the wrong source type, then (Bug 5 v1) wrongly rejected the whole case | `mdl/backend/modelsdk/widget_write.go` (`dataViewContextAssociationSourceToGen`, wired into `dataViewSourceToGen`'s `*pages.AssociationSource` case) + `mdl/executor/cmd_pages_builder_v3_widgets.go` (removed the `buildDataViewV3` association guard) + `mdl/executor/validate_widgets.go` (removed MDL-WIDGET08) + `mdl/executor/cmd_pages_describe_parse.go` (`Forms$DataViewSource` reads `EntityRef.Steps` via `associationSourcePath`) | **modelsdk engine only.** For a DataView association datasource, emit `Forms$DataViewSource` with `EntityRef = IndirectEntityRef{EntityRefStep{Association, DestinationEntity}}` (+ optional page-param `SourceVariable`; `$currentObject` → none) — mirrors `associationSourceToGen` but wraps the IndirectEntityRef in a DataViewSource. Children then bind to the destination entity. DESCRIBE reconstructs `$currentObject/Module.Assoc`. Removes the earlier MDL-WIDGET08 over-rejection. **When two widget kinds share a navigation but MxBuild accepts it on only one, the distinction is the source `$Type` wrapper, not the EntityRef inside it** — check `generated/metamodel/types.go` for the widget's allowed `DataSource` subtypes. mxbuild-validated (`mxcli docker check --no-update-widgets` = 0 errors) + full describe→re-exec round-trip. Tests: `mdl/backend/modelsdk/widget_assoc_source_test.go` (`TestDataViewAssociationSource_Serialized`); bug-test `mdl-examples/bug-tests/dataview-context-association-source.mdl`. Bug 5 (reclassified) | | **DataGrid2** (`datagrid`) columns fail MxBuild **CE0463** "widget definition changed" on the **default (modelsdk)** engine — reported for custom-content columns, but actually **every** column (attribute too). Legacy is fine | The column WidgetObject's `Properties` were serialized in **alphabetical** order (`alignment, allowEventPropagation, attribute, …`) instead of the template's `PropertyTypes` order (`showContentAs, attribute, content, dynamicText, …`). Studio Pro hashes the object structure against the type and flags CE0463 on any order mismatch. Cause: the object-list item builder (`mdl/backend/widgetobj/builder.go:274`) orders by `NestedKeyOrder`, falling back to alphabetical when empty; the **modelsdk** registry loader never captured it (`types.PropertyTypeIDEntry` had no such field), while the MPR/legacy loader did → legacy correct, modelsdk broken. Top-level widget props were unaffected (ordered by a different path), which is why only the nested column object-list broke | `mdl/types/widget_property_type.go` (`PropertyTypeIDEntry.NestedKeyOrder`) + `modelsdk/widgets/loader.go` (thread `nestedKeyOrder` through `jsonValueToBSONWithNestedObjectType`→`extractNestedObjectType`→`extractNestedPropertyTypes`, append in template array order) + `mdl/backend/modelsdk/widget_pluggable_write.go` (`convertPropTypeIDs` copies `NestedKeyOrder`) | Mirror the `sdk/widgets` loader's existing `nestedKeyOrder` capture in the parallel `modelsdk/widgets` loader (dedup on first occurrence, append in PropertyTypes array order), add the field to `types.PropertyTypeIDEntry`, and copy it in the modelsdk `convertPropTypeIDs`. **Diagnose column-order CE0463 by mapping each column WidgetProperty's `TypePointer` → the type's `WidgetPropertyType.PropertyKey` and comparing the sequence to the template's PropertyTypes order.** Verified: modelsdk column order now equals the template (and legacy) order for both attribute and custom-content columns, and **`mxcli docker check --no-update-widgets` (raw output, no widget normalization) = 0 errors** for the report's exact pattern (attribute column + custom-content dynamictext-over-association + custom-content actionbutton with `show_page`). The custom-content child-widget subtree needed no separate fix — ordering was the whole cause. Tests: `modelsdk/widgets/nested_key_order_test.go`; bug-test `mdl-examples/bug-tests/datagrid2-custom-content-column.mdl`. **To validate CE-class MxBuild errors locally: `mxcli setup mxbuild -p app.mpr --force` then `mxcli docker check -p app.mpr --no-update-widgets`** (the `--no-update-widgets` is essential — the default runs `mx update-widgets` which auto-normalizes pluggable widgets and masks a real CE0463) | | Pluggable-widget datasource `sort by desc` (DataGrid2, Gallery; quoted or unquoted attr) round-trips as `asc` — `describe page` shows the direction flipped, runtime renders oldest-first, no error (silent wrong-order). Reproduces on the **default (modelsdk)** engine | A Pages/`Forms$GridSortItem` stores its direction under the BSON key **`SortDirection`** (authoritative: the reflection-generated codec type `modelsdk/gen/pages` GridSortItem writes/reads `SortDirection`). The default engine wrote it correctly, but the DESCRIBE readers looked up the wrong key `SortOrder` (that key is only correct for `Microflows$SortItem` / `DocumentTemplates$GridSortItem`) → always fell back to `asc`. The legacy `sdk/mpr` writer *also* emitted the wrong `SortOrder` key, so under `--engine legacy` Studio Pro ignored it and reverted to ascending at runtime too | `mdl/executor/cmd_pages_describe_pluggable.go` (`gridSortDirection` helper + 3 call sites) & `cmd_pages_describe_parse.go` (1 call site); writer `sdk/mpr/writer_widgets.go` (`SerializeCustomWidgetDataSource`) | Add `gridSortDirection(sortItem)` reading `SortDirection` with a `SortOrder` fallback (keeps pre-fix files readable); route all four grid-sort readers through it. Fix the legacy writer to emit `SortDirection`. When a sort/direction field seems misnamed, check the element's gen type in `modelsdk/gen/*/types.go` — different metamodel types genuinely use different keys (`SortDirection` for Forms/Pages grids, `SortOrder` for microflow/document-template sorts). Tests: `mdl/executor/cmd_pages_describe_sortdir_test.go`; bug-test `mdl-examples/bug-tests/bug8-datagrid-gallery-sort-desc.mdl`. Bug 8 | +| A **compound** (nested) design property authored inline on a widget — `designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large', 'margin-top': 'Medium']]`, e.g. the Atlas `Spacing` group, and any block copied in via `use building block` — is silently dropped on write: `check`, `exec`, **and `mx check`** all pass, but the nested styling is simply gone (only the flat toggle/option props survive). The **legacy** builder write path. Sibling of #668, which fixed the describe **read** side but not this CREATE write path | `serializeDesignProperties` switched on `p.ValueType` with cases for `toggle`/`option`/`custom` and a `default: continue` that dropped `compound` entirely. A compound property's value is itself a set of sub-properties stored in a `Forms$CompoundDesignPropertyValue`, whose `Properties` list has the SAME marker-prefixed `Forms$DesignPropertyValue` shape as the outer array — but nothing serialized it, so the whole entry (key included) vanished | `sdk/mpr/writer_widgets.go` (`serializeDesignProperties`) | Add a `case "compound"` emitting `{$ID, $Type: Forms$CompoundDesignPropertyValue, Properties: serializeDesignProperties(p.Compound)}` — recurse into the sub-entries, which the marker-prefixed array handling already covers. The AST (`DesignPropertyEntryV3.Nested`), visitor (`buildDesignPropertyEntryV3`), and builder (`astDesignPropToValue` → ValueType `"compound"`, `Compound`) all already produced the nested model; only the serializer's terminal switch dropped it. Verified: `mxcli docker check` = 0 errors on 11.12.1 (authoritative) + write→describe round-trip preserves `Spacing` on **both** engines. Test `TestSerializeDesignProperties_Compound` (`sdk/mpr/writer_widgets_test.go`); bug-test `mdl-examples/bug-tests/compound-designproperties.mdl`. **Diagnosis pattern**: when a value passes every check *including `mx check`* yet the construct is absent, suspect a terminal `default: continue`/`default:` drop in a type-switched serializer — the value never reaches BSON, so nothing downstream can complain | +| A `use fragment` / `use building block` (or a content `slot`) nested **inside a container/layout/dataview** — not at the page-body top level — fails `exec` with `unsupported widget type: USE_FRAGMENT` (or `USE_BUILDING_BLOCK`). The same ref at the top level of the page body works. So a reusable card/panel can't be placed inside a layout column — the natural usage | `expandFragments` (the sentinel-expansion pass) only ran on the **top-level** widget list (the two call sites in `execCreatePage`/`execCreateSnippet`); the layout/container builders build children via `buildWidgetV3` directly, which has no case for the `USE_FRAGMENT`/`USE_BUILDING_BLOCK`/`SLOT` sentinel types → falls to `default` "unsupported widget type" | `mdl/executor/cmd_pages_builder_v3.go` (`expandFragments`) | Make `expandFragments` **recurse into each widget's children** after expanding the top sentinel: `for _, e := range expanded { if len(e.Children) > 0 { e.Children, _ = pb.expandFragments(e.Children) }; result = append(result, e) }`. The tree is fully expanded to concrete widgets *before* `buildWidgetV3` runs, so no builder needs a sentinel case. Expansion is idempotent on concrete widgets, so the extra traversal of an already-expanded slot payload is harmless. Verified: nested cards inside a layoutgrid column exec + `mx check` = 0 on 11.12.1. Test `TestExpandFragments_NestedInsideContainer`; bug-test `mdl-examples/bug-tests/nested-fragment-expansion.mdl`. **Diagnosis pattern**: "works at top level, `unsupported widget type` when nested" = an AST pre-pass (expansion/normalization) that only walks the root list; make it recurse into `.Children` | +| A single `alter entity` with **comma-separated** `add attribute` clauses fails to parse — e.g. `add attribute A: integer default 9, add attribute B: integer default 9` → `no viable alternative at input '9'`. Looks like "only the first `add` can carry a `default`" but the real cause is the comma | `alterStatement`'s `ALTER ENTITY qualifiedName alterEntityAction+` had **no separator** — commas between actions weren't allowed at all; the error surfaced on the second clause's `default` value token, which misdirects. Sudoku findings #5 | `mdl/grammar/MDLParser.g4` (`alterStatement`, ALTER ENTITY alt) | Change `alterEntityAction+` → `alterEntityAction (COMMA? alterEntityAction)*` (optional comma, mirroring `entityOptions`); `make grammar`. Newline-separated actions still parse. Bug-test `mdl-examples/bug-tests/f5-alter-entity-comma.mdl`. **Diagnosis pattern**: a parse error on the *value* inside the *second* item of a list usually means the list rule lacks a separator, not that the value form is wrong | +| An `autonumber` attribute with **no seed** passes `mxcli check` but fails the build with **CE7247 "Value cannot be empty"** (`alter entity … add attribute X: autonumber` or in a `create`). Docs showed seedless `autonumber` | `ValidateEntity` had no autonumber-seed rule; the writer emits no `AttributeValue` when `!attr.HasDefault`, so Studio Pro has no start value. Sudoku findings #6 | `mdl/executor/cmd_enumerations.go` (`ValidateEntity`) | Add **MDL023** (error): `attr.Type.Kind == ast.TypeAutoNumber && !attr.HasDefault` → "autonumber requires a seed (`default N`)". Also fixed the skill docs (`mdl-entities.md`, `generate-domain-model.md`) to show `autonumber default 1`. Test `TestValidateEntityAutonumberNeedsSeed`; negative bug-test `f6-autonumber-seed.fail.mdl` | +| An AutoX audit pseudo-type declared under a non-matching name — `StartedAt: autocreateddate` — silently becomes the fixed system member `CreatedDate` (declared name discarded), and binding that member in a widget then fails the build with **CE1613 "attribute … no longer exists"** (it's a system member, not a bindable attribute) | The write path discards the identifier for AutoX types and `ValidateEntity` skipped them with no name check, so the rename + unbindable-member trap was silent. Sudoku findings #7 | `mdl/executor/cmd_enumerations.go` (`ValidateEntity`, `autoMemberNames`) | Add **MDL022** (warning): when an AutoX attr's name (case-insensitive) ≠ its canonical member (`owner`/`ChangedBy`/`CreatedDate`/`ChangedDate`), warn that the name is discarded and the member isn't widget-bindable — use a plain attribute you set yourself if a widget must show it. Test `TestValidateEntityAutoMemberRename` | +| `mxcli run` warm-loop ergonomics from Sudoku findings: (a #17) a **relative** `-p` fails with MxBuild's raw "should be an absolute path" + a Windows JSON sample; (b #15/#23) a **build failure in the watch loop** (incl. a SCSS compile error like `Expected expression. _x.scss 180:35`) printed only the generic `build failed: `, swallowing the real detail | (a) `cmd_run.go` passed `-p` straight through without `filepath.Abs`; (b) the watch path at `runlocal.go` printed only `build.Message` while `build.Raw` (the full serve `/build` body, which the cold-build path already prints) held the compiler/model detail | `cmd/mxcli/cmd_run.go` + `cmd/mxcli/docker/runlocal.go` (watch build-failed branch) | (a) `projectPath, _ = filepath.Abs(projectPath)` after reading the flag; (b) also print `strings.TrimSpace(string(build.Raw))` (indented) when it differs from `build.Message`, matching the cold path. **Diagnosis pattern**: when a dev-loop error is unhelpfully generic, check whether a `Raw`/full-body field is already captured and just not printed on that code path | | A **DataGrid2 column bound to an associated attribute** (`column c (attribute: Order_Customer/Name)`) passes `mxcli check` but fails MxBuild **CE1613** "The selected attribute 'Module.Entity.Order_Customer/Name' no longer exists." — an own-entity `attribute: Name` works. (Feature gap: no way to show an associated attribute in a column.) | The grammar `attributePathV3` already accepts a bare `Assoc/Attr` path (module-qualified `M.Assoc/Attr` does not — bare only), but the reader flattened it (`resolveAttributePath` just prefixes the entity, leaving the `/` embedded → `Module.Entity.Assoc/Attr`) and both column serializers hardcoded `EntityRef: nil`. So the column stored a flat, unresolvable attribute path with no association step | `mdl/executor/cmd_pages_builder_v3.go` (`resolveAssociationAttributePath`, extracted from `resolveTemplateAssociationPath`) + `mdl/executor/widget_engine.go` (full-page column `attribute` case) + `cmd_pages_builder_v3_widgets.go` (`buildColumnSpecFromAST`, ALTER) + `mdl/backend/mutation.go` (`ObjectListItemProperty`/`DataGridColumnSpec` gain `AttributeRefSteps`) + `mdl/backend/widgetobj/builder.go` (`setAttributeRefField`+`attributeEntityRefBSON`) + `datagrid_column.go` (`buildColumnAttributeProperty`) + `mdl/executor/cmd_pages_describe_pluggable.go` (`columnAttributeFromRef`) | Reuse the DynamicText contentparam machinery: resolve the `/`-path to a final attribute QN + `[]pages.AttributeRefStep` (hop → destination entity via `associationEndpoints`), carry the steps on the column spec, and emit `AttributeRef.EntityRef = IndirectEntityRef` of `EntityRefStep{Association, DestinationEntity}` (raw-BSON `attributeEntityRefBSON`, mirroring the codec-form `attributeRefWithStepsToGen`). DESCRIBE reconstructs the **short** `Assoc/Attr` (short association names — `attributePathV3` rejects module-qualified associations). mxbuild-validated (`mxcli docker check --no-update-widgets` = 0 errors) + describe round-trip. Tests: `mdl/backend/widgetobj/widget_builder_attribute_ref_test.go`, `mdl/executor/cmd_pages_describe_column_assoc_test.go`; bug-test `mdl-examples/bug-tests/datagrid2-associated-attribute-column.mdl`. Bug 7 | | A DataGrid2 column's `DynamicCellClass: ''` (per-cell dynamic CSS class) parses, passes `check` + `mxbuild`, but is **silently dropped** — `describe` shows no `DynamicCellClass`, the `columnClass` slot is written as an **empty** expression, runtime cell is unstyled. Both engines | The DataGrid `columns` object-list mapping (`itemPropertyAliases`) had aliases for `header←Caption`, `dynamicText←Content`, `width←ColumnWidth` but **none** for `columnClass`. `buildObjectListItem` looks up the schema key + its MDL aliases in the AST property bag (case-insensitive via `lookupProperty`), so `DynamicCellClass` never matched → the property fell through to the template's empty default. Cached `.mxcli/widgets/datagrid.def.json` files also had to regenerate to carry the new alias | `mdl/executor/widget_defs.go` (`itemPropertyAliases`, `columns.columnClass`) + `mdl/executor/widget_engine.go` (`WidgetDefGeneratorVersion` bump) | Add `"columnClass": {"DynamicCellClass"}` to the datagrid `columns` aliases (schema property is `type: "expression"` → `operationForType` → written via the expression branch). **Bump `WidgetDefGeneratorVersion`** (5→6) so existing projects' cached def.json auto-regenerate via `RefreshStaleWidgetDefinitions` and pick up the alias — a code-only alias add is invisible until the stamped def is refreshed. DESCRIBE already reads `columnClass`→`DynamicCellClass` (`cmd_pages_describe_pluggable.go`). Test: `TestObjectListItemAliases`; bug-test `mdl-examples/bug-tests/bug10-dynamic-css-classes.mdl`. Bug 10a | | A standard widget's generic property written in **lowercase** (e.g. `dynamicclasses:` instead of `DynamicClasses:`) is silently dropped on write, though the canonical-case form persists. Documented lowercase examples (`create-page.md`) don't work | Generic (non-tokenized) widget properties are stored in `WidgetV3.Properties` under the **user's original casing** (`visitWidgetProperty` generic branch: `Properties[id.GetText()]`). The builder reads appearance props via `GetStringProp("DynamicClasses")` — an exact-case map lookup — so a lowercased key never matched. MDL property names are documented case-insensitive (mirrors `lookupProperty` in the widget engine, used by the pluggable path) | `mdl/ast/ast_page_v3.go` (`GetStringProp`) | Make `GetStringProp` case-insensitive: exact-match fast path, then a lowercased scan (mirrors `lookupProperty`). Fixes any generic-stored standard-widget property read via `GetStringProp`/`GetDynamicClasses`, not just DynamicClasses. Test: `mdl/ast/ast_page_v3_getprop_test.go`; same bug-test as 10a. Bug 10b | @@ -158,6 +166,17 @@ to the symptom table below, so the next similar issue costs fewer reads. | No way to change an existing **enumeration value's caption** in place — `alter enumeration` had only `ADD`/`RENAME`/`DROP VALUE` + `SET COMMENT`; `RENAME VALUE X TO Y` changes the *name*, not the caption. The only route was drop + recreate, which fails while the enum is referenced by an attribute | Missing grammar action + AST op + executor case — a full-stack gap, not a code bug | `mdl/grammar/domains/MDLDomainModel.g4` (`alterEnumerationAction`) → `mdl/ast/ast_enumeration.go` (`AlterEnumOp`) → `mdl/visitor/visitor_enumeration.go` (`ExitAlterEnumerationAction`) → `mdl/executor/cmd_enumerations.go` (`execAlterEnumeration`) | Add `MODIFY VALUE IDENTIFIER CAPTION STRING_LITERAL` (reuses existing `MODIFY`/`CAPTION` tokens, so no lexer change); add `AlterEnumModifyCaption` op reusing the `Caption` field; executor finds the value by name and replaces only the `en_US` translation (preserves the value's ID + other locales). Re-captions in place, so it works while referenced (mxbuild 0 errors) — no drop needed. Value names in `alter` must be plain identifiers (a reserved-word-named value like `Created` can't be targeted — pre-existing, shared by ADD/RENAME/DROP). Tests: `TestAlterEnumeration_ModifyValueCaption` (visitor) + `TestAlterEnumeration_ModifyValueCaption_Mock` (executor); example in `01-domain-model-examples.mdl` | | A page with a native **LISTVIEW** over a `database from` (XPath) datasource passes `mx check` but **crashes the browser client at runtime** and redirects to login: `TypeError: Cannot read properties of undefined (reading 'length')` at `processResult` → `retrieveByXPath`. Pluggable Gallery/DataGrid2 database sources are fine | The serialized `Forms$ListViewXPathSource` omitted the arrays the client reads `.length` of. **Codec (default):** `Forms$ListViewSearch` emitted without its `SearchRefs` list — the encoder drops an empty, never-`Set` PartList unless the `$Type` is in `RegisterTypeDefaults` (GridSortBar.SortItems was registered; ListViewSearch was not). **Legacy:** wrote a bogus `Forms$ListViewSort` + a `Paths` key (renamed `SearchRefs` in 7.11.0) and no `Forms$GridSortBar`. NOT a `SortItems` marker issue — an empty `[2]` compiles fine when search is off; the crash is the absent `SearchRefs`. Diagnose by building a Deploy target and reading the compiled `deployment/web/pages/.js` (the client model) + `mxcli bson dump --type page` on the source | `mdl/backend/modelsdk/widget_write.go` (`init` RegisterTypeDefaults) + `sdk/mpr/writer_widgets_display.go` (`serializeListViewDataSource`, `emptyListViewXPathSource`) | Codec: `RegisterTypeDefaults("Forms$ListViewSearch", {MandatoryLists: []string{"SearchRefs"}})` (emits empty marker-3 list). Legacy: emit `Forms$GridSortBar`/`SortItems` (mirror `SerializeCustomWidgetDataSource`) + `Forms$ListViewSearch`/`SearchRefs` + `ForceFullObjects`; drop the bogus `Sort`/`Paths`. Tests: `TestListViewSourceToGen_SearchRefsEmitted` (codec, encode-level), `TestSerializeListViewDataSource_Database` + `TestEmptyListViewXPathSource_Shape` (legacy). Repro `mdl-examples/bug-tests/listview-database-source-searchrefs.mdl` | | Editing `themesource/**/main.scss` (or `theme/web/main.scss`) while `mxcli run --local` serves on `:8080` keeps showing **old styles** — reads exactly like a stale compiled-CSS cache. `rm -rf theme-cache/ .mendix-cache/ deployment/` "fixes" it only because a restart came with it | THREE distinct causes, none a CSS cache: (1) **no `--watch` = no watcher at all** — `mxbuild --serve` only rebuilds on a `/build` request (startup, or a watch tick), so a save changes nothing; (2) **stale process silently adopted** — a leftover serve/runtime on the ports answers the startup readiness probes (`waitReady`/`waitAdminReady` only check "port answers"), so a new run attaches to the OLD process and its own child is torn down by `defer`; a backgrounded `run --local` whose wrapping shell exited non-zero dies while its serve+runtime keep serving; (3) the theme source was **watched by nothing** — the `--watch` signal was model-only (`.mpr`+`mprcontents/`). The incremental theme step itself is FINE: one `/build` after an scss **content** edit does rewrite `theme-cache/web/theme.compiled.css` (verified), so there is no cache to clear | `cmd/mxcli/docker/runlocal.go` — `checkTargetPortsFree` (guard), `themeSourceMTime`/`sourceMTime` (watch signal), `watchAndApply` (generation log) | (2) Refuse to boot when `:8080/:8090/:6543` already answer, with an actionable message (never auto-kill — user's call). (3) Add `theme/`+`themesource/` scss/css/js to the `--watch` mtime-poll signal (`sourceMTime` = max(model, theme)); poll-based so it's container-safe (unlike the rollup chokidar/inotify web-client watcher). Log a build-generation counter (`build #2`) so "did it take?" is answerable. Docs: `docs-site/src/tools/run-local.md` + skill `run-local.md` — "SCSS needs a rebuild (`--watch` or clean restart), never a cache-clear; kill the old serve/runtime first". Tests `TestThemeSourceMTime_WatchesThemeAndThemesource`, `TestCheckTargetPortsFree` | +| Re-running a domain script that is 90% already-applied applies **none** of the remaining 10%: `alter entity … add attribute X` errors `attribute 'X' already exists` and, because `exec` halts on the first error, everything after it is skipped. No idempotent add/drop and no continue-past-errors | Two gaps: (1) `ADD ATTRIBUTE` / `DROP ATTRIBUTE` had no `IF NOT EXISTS` / `IF EXISTS` guard, so a re-apply was a hard error; (2) `ExecuteProgram` returns on the first statement error | grammar `mdl/grammar/domains/MDLDomainModel.g4` (`ifNotExists`/`ifExists`, `alterEntityAction`) + AST `mdl/ast/ast_entity.go` (`IfNotExists`/`IfExists`) + visitor `mdl/visitor/visitor_entity.go` (`ExitAlterEntityAction`) + executor `mdl/executor/cmd_entities.go` (add/drop guards) + `mdl/executor/executor.go` (`ExecuteProgramContinueOnError`) + `cmd/mxcli/cmd_exec.go` (`--continue-on-error`) | Add `IF NOT EXISTS`/`IF EXISTS` to the grammar (regen), carry the flag on the AST, and in the executor turn the already-exists / not-found error into a skip-with-notice when the guard is set. Separately add `mxcli exec --continue-on-error`: attempts every statement, prints each failure as `statement N: …`, exits non-zero if any failed (never masks a real error; `exit`/`quit` still stop the run). Bug-test `mdl-examples/bug-tests/f10-idempotent-alter-entity.mdl`. Findings #10 | +| `alter entity M.E add attribute X: autonumber;` (no seed) or `... add attribute Created: AutoCreatedDate;` (renamed AutoX) passes `mxcli check` "Check passed!" but fails the build (CE7247) / silently discards the name — while the SAME attribute in `create entity` is correctly flagged (MDL023 / MDL022) | The per-attribute checks (MDL021/022/023) only ran on `CreateEntityStmt`; the ALTER ENTITY ADD ATTRIBUTE path had no validation at all, so an attribute added later escaped every rule | `mdl/executor/cmd_enumerations.go` (`ValidateAlterEntity`, `validateEntityAttribute`) wired from `cmd/mxcli/cmd_check.go` + `cmd/mxcli/lsp_diagnostics.go` | Extract the CREATE loop body into `validateEntityAttribute(attr, persistent, entityName)`; add `ValidateAlterEntity(stmt)` that runs it on `AlterEntityAddAttribute`. The entity kind isn't known from ALTER, so the persistent-only MDL020 is skipped; the kind-independent MDL021/022/023 all run. Bug-test `mdl-examples/bug-tests/f6-autonumber-seed-alter.fail.mdl`. Findings #6 (alter path) | +| `create or modify entity M.E ( )` on an entity that already has more attributes **silently drops** every attribute not re-listed (36→2 attrs seen in practice), then widgets/microflows still bound to them fail the build with CE1613 — and the "already exists" error that leads users here recommends the destructive `create or modify` for a partial edit | `create or modify` rebuilds the entity from the statement alone and REPLACEs the stored one, so any omitted attribute is deleted with no warning; the `NewAlreadyExistsMsg` hint pointed at `create or modify` without distinguishing "replace whole" from "add one" | `mdl/executor/cmd_entities.go` (`droppedEntityMembers`, the warn block before `UpdateEntity`, and the `execCreateEntity` already-exists message) | Warn-only (non-blocking, the user asked to modify): before `UpdateEntity`, diff existing vs replacement members (`droppedEntityMembers` — named attrs case-insensitive + the four audit flags) and print what's dropped + point at `alter entity … add attribute` for incremental edits. Fix the already-exists message to recommend `alter entity` for a member change and reserve `create or modify` for a full replace. Bug-test `mdl-examples/bug-tests/f24-create-or-modify-dataloss.mdl`. Findings #24 | +| A microflow expression calls a function that doesn't exist (e.g. `randomInt(9)` — in some docs but not a Mendix built-in): parses, passes `mxcli check`, then fails the build with CE0117 "Error(s) in expression". Also: a Decimal-returning function (`random()`, `secondsBetween`, the duration `*Between` family) assigned to an Integer/Long variable fails CE0117, but `mxcli check` only caught bare `div` | (1) The func checker only validated arity for *known* functions; an unknown call name was ignored. (2) `checkNumericAssignment` used `SourceIsArithmeticDecimal`, which fires only on arithmetic roots, so a Decimal *function* result slipped through. Also the `*Between` duration funcs were mistyped Integer in `funcTable` | `mdl/exprcheck/unknown_funcs.go` (`UnknownFunctionCalls`, `SourceRejectedForIntegerTarget`, `nearestFunc`) + `mdl/exprcheck/func_checker.go` (`funcTable` between-date return kinds) + `mdl/executor/validate_microflow.go` (`checkExprFunctions` → MDL044; `checkNumericAssignment` now uses `SourceRejectedForIntegerTarget`) | Walk each expression for `CallExpr` names not in `funcTable` (which lists *every* built-in — a bare `name(...)` is always a built-in call) → **MDL044** with a Levenshtein/prefix "did you mean" hint. Extend the Decimal-into-Integer check to Decimal-returning non-rounding functions → **MDL041**. Correct `secondsBetween`/`minutesBetween`/`hoursBetween`/`daysBetween`/`weeksBetween` to Decimal (calendar variants stay Integer, millisecondsBetween stays Long). When adding a new Mendix built-in, add it to `funcTable` or MDL044 will false-positive. Bug-tests `f1-unknown-expression-function.fail.mdl`, `f2-decimal-func-into-integer.fail.mdl`. Findings #1, #2 | +| `index name on (cols)` inside `create entity` (or `alter entity add index name on (cols)`) fails with `extraneous input 'on' expecting '('` — the SQL-like form docs/users expect. The bare `index name (cols)` worked | `indexDefinition` had no `ON` token: `IDENTIFIER? LPAREN indexAttributeList RPAREN` | `mdl/grammar/domains/MDLDomainModel.g4` (`indexDefinition`) | Make `ON` optional: `IDENTIFIER? ON? LPAREN indexAttributeList RPAREN`, regen grammar. `buildIndex` reads columns from `IndexAttributeList` only, so ON can't be mistaken for a column and no visitor change is needed. Covers both CREATE (entityOption) and ALTER ADD INDEX (shared rule). Bug-test `mdl-examples/bug-tests/f4-entity-index-on.mdl`. Findings #4 | +| `retrieve … where [Seq = $Game/MoveSeq + 1]` fails with a bare `mismatched input '+' expecting ']'` — no hint that Mendix XPath can't compute values (this is a Mendix limitation, not an mxcli bug) | Mendix XPath constraints take a literal/token/variable/path on the value side, never an arithmetic expression; the parse error named the token but not the cause | `mdl/visitor/visitor.go` (`enhanceErrorMessage`, `looksLikeXPathArithmetic`/`xpathArithmeticRe`) | Do NOT add grammar support (mxbuild would still reject the XPath). Add an error hint keyed on `mismatched input '<+|*|div|mod>' expecting ']'` (`expecting ']'` only occurs inside a `[…]` constraint) explaining the limitation and the workaround: compute into a variable first, then compare. Also documented in `xpath-constraints.md`. Bug-test `mdl-examples/bug-tests/f8-xpath-arithmetic.fail.mdl`. Findings #8 | +| Design properties are written free-form: a `ColorPicker`/`ToggleButtonGroup` value serializes as a plain option (wrong `$Type` for Studio Pro's Appearance tab), and a typo'd key/value (they're case-sensitive) passes `mxcli check`. Also `show design properties ` reports "No design properties found for widget type container" for a valid widget | Root bug: `resolveDesignPropsKey` upper-cased the MDL keyword but the lookup map is **lowercase-keyed**, so `container`→`DivContainer` never resolved — leaving `resolveDesignPropertyValueType` dead code and the theme registry unused on the write/validate paths | `mdl/executor/theme_reader.go` (`resolveDesignPropsKey` case fix) + `mdl/executor/cmd_pages_builder_v3.go` (`astDesignPropToValue` takes theme props) + `mdl/executor/validate_design_properties.go` (new, MDL-WIDGET11/12) wired from `cmd/mxcli/cmd_check.go` + `cmd/mxcli/lsp_diagnostics.go` (cached `themeRegistry`) | Fix `resolveDesignPropsKey` to lower-case the lookup. On write, resolve each flat value's type from the registry **by matching the value against the property's declared options** (see the CE6084 correction below — the control type alone does NOT decide it). On check (`-p` only, when themesource defines properties), walk page/snippet/alter-page widget trees and warn: **MDL-WIDGET11** unknown key (case-sensitivity hint / valid-key list), **MDL-WIDGET12** invalid value (lists allowed values). Warnings, not errors — a newer theme may add keys/values (forward-compat, per `page-styling-support.md:402`). Skip compound (registry doesn't model sub-props) and widgets with no type-specific metadata (pluggable). Bug-test `mdl-examples/bug-tests/typed-design-properties.mdl` | +| Follow-up regression from the row above: after typed design properties merged, `mx check` fails **CE6084** "Expected design property _Flex container_ / _Column gap_ / _Align items Y_ … to be of type **Toggle button group**, but found **Custom**" on any page using a flat `ToggleButtonGroup` value (Atlas flex/spacing/typography, e.g. `'Column gap': 'Medium'`). Broke `TestMxCheck_DoctypeScripts` on `12-styling`, `15c-fragment-bindings`, `31-pluggable-datagrid-gallery-v010` (both engines) — green on unit tests, red only in `make test-integration` | `resolveDesignPropertyValueType` mapped `ToggleButtonGroup`→`custom` by control type. But a ToggleButtonGroup selection picks one of a **fixed option set**, so Studio Pro stores it as an **Option** — a `Custom` value type mismatches the declaration. Only a ColorPicker's **off-list** value (a free-form hex) is genuinely Custom. The value type is decided by the **value**, not the control | `mdl/executor/cmd_pages_builder_v3.go` (`resolveDesignPropertyValueType`, now takes the value and reuses `themeOptionAllowed`) | Make it value-aware: value ∈ declared options → `option` (Dropdown, ToggleButtonGroup, predefined ColorPicker swatch alike); off-list **and** `ColorPicker` → `custom`; else `option`; no metadata → `option`. Verified: the three doctype examples pass `mx check` = 0 errors on both engines. Test `TestAstDesignPropToValue_Typed` extended with the `Column gap: Medium` + ColorPicker swatch/hex cases. **Diagnosis pattern**: a value-type/BSON-`$Type` mapping keyed on a *declared control type* is a trap — verify it against `mx check`, never assert it from the type name alone (this is exactly how the original bug slipped in). **Process lesson**: this shipped red because `make test-integration` (mx-check doctype roundtrips) was not run before merge — run it, not just unit tests, for any page/widget-serialization change | +| `mxcli run --local`: when a page action throws, the browser shows the generic Mendix error dialog and there is nothing to correlate it against — the runtime's own stdout/stderr (server stack trace, microflow `LOG` output) is swallowed, so a server-side bug can't be told apart from a client one | The runtime JVM was spawned with `cmd.Stdout=log; cmd.Stderr=log` where `log` is an in-memory `syncBuffer` surfaced only on a *startup* failure; during normal operation it goes nowhere on disk | `cmd/mxcli/docker/localboot.go` (`spawnAndConfigure`, `openRuntimeLog`, `LocalRuntime.logFile`, `LocalRuntimeOptions.RuntimeLogPath`) + `cmd/mxcli/docker/runlocal.go` (default `/.mxcli/runtime.log`) + `cmd/mxcli/cmd_run.go` (`--runtime-log`) | Tee the JVM's stdout+stderr to `/.mxcli/runtime.log` via `io.MultiWriter(log, file)` (the in-memory buffer still backs startup-error reporting). Append across restarts with a `=== runtime start … ===` marker; close the handle on Stop/reopen. Default on; `--runtime-log ` relocates, `-` disables. Print the path at boot. Test `TestOpenRuntimeLog`. Findings #25 | +| Follow-up to the above (#25 re-test): `run --local` writes `runtime.log` but it stays **nearly empty** — the JVM tee captures startup/JVM output only; **application** logs (microflow `LOG`, server-side exception stack traces) never reach stdout, so a page-action error still can't be diagnosed | A standalone runtime (launched via `runtimelauncher.jar`) attaches **no log subscriber** by default — unlike a Studio Pro / m2ee run, which calls `create_log_subscriber` **after** start. Mendix application logs flow to log *subscribers*, not stdout, so with none attached they go nowhere | `cmd/mxcli/docker/runtime_controller.go` (`RuntimeController.LogSubscriberFile`/`Stdout`, `attachFileLogSubscriber`, called at the end of `Start`) + `cmd/mxcli/docker/localboot.go` (`StartLocalRuntime` sets `ctrl.LogSubscriberFile` to the abs runtime-log path) | After a successful `start` (and on every restart's `Start`, since each fresh JVM has no subscriber), call the `create_log_subscriber` admin action with `{type:"file", name:"mxcli-run-local", autosubscribe:"INFO", filename:, max_size:1GiB, max_rotate:0}`. **`max_rotate:0` is load-bearing**: the JVM stdout tee holds an fd on the same file, and a rotate-rename would detach it. Best-effort (a logging failure must not fail an up runtime — warn to Stdout instead). Pass an **absolute** path (the runtime's cwd is `/runtime`, not mxcli's). Tests `TestStart_AttachesLogSubscriber`, `TestStart_NoLogSubscriberWhenUnset`, `TestStart_LogSubscriberFailureNonFatal`. Findings #25 (round 2) | +| Follow-up to round 2 (#25 re-test): the subscriber is registered but `runtime.log` **still** holds only the 4 JVM-banner lines — a probe microflow's `log info/warning/error` and a forced runtime exception produce **zero** lines. Isolation test: boot+subscriber → 0 probe lines; then call `start_logging` → 6 lines | A standalone runtime boots with logging **not started**, so a registered subscriber sits **inert** — nothing is delivered until the logging subsystem is activated. `create_log_subscriber` alone is necessary but not sufficient | `cmd/mxcli/docker/runtime_controller.go` (`configureRuntimeLogging` — renamed from `attachFileLogSubscriber`; now also calls `start_logging`) | After `create_log_subscriber`, call `CallM2EE(c.opts, "start_logging", nil)` in the same step (order: create subscriber → start_logging). Treat an "already started" response as success (`start` re-runs on the DB-update retry / restart paths on a still-running JVM). Still best-effort (warn to Stdout, never fail an up runtime). Test `TestStart_StartLoggingAlreadyStartedIsSuccess`; `TestStart_AttachesLogSubscriber` now asserts the `[start, create_log_subscriber, start_logging]` sequence. **Diagnosis pattern**: when a registered sink receives nothing, check whether the subsystem that feeds it is even *running* — registration ≠ activation. Findings #25 (round 3) | --- diff --git a/.claude/skills/mendix/atlas-design.md b/.claude/skills/mendix/atlas-design.md new file mode 100644 index 000000000..c5a21d57d --- /dev/null +++ b/.claude/skills/mendix/atlas-design.md @@ -0,0 +1,784 @@ +# Atlas Design — Make a Mendix App Look Designed, Not Bland + +## When to Use This Skill + +Use this skill when: +- The user asks to make an app "look good / professional / branded / less bland" +- You are about to style a Mendix web app or a group of pages +- You are matching a design mock and want it to reach "designed product" quality +- You are re-branding an existing app to a new identity (palette, type, corners) + +This is the **taste + workflow** layer. It sits on top of the styling mechanics +(`theme-styling.md`), the widget syntax (`create-page.md`), the composition +primitives (`fragments.md`), and the design-handoff pipeline +(`migrate-design-prototype.md`). It does **not** re-teach SCSS compilation or +`Class:`/`DesignProperties:` syntax — those skills own that. It adds **which** +tokens/classes to use, **when**, and the **discover → inspect → use** method +built on the Atlas building blocks every Mendix project already ships. + +## Contents + +1. [The thesis: be Atlas-first](#the-thesis-be-atlas-first) +2. [The 4-layer architecture](#the-4-layer-architecture) +3. [The workflow: discover → inspect → use](#the-workflow-discover--inspect--use) +4. [Atlas building blocks — the out-of-the-box inventory](#atlas-building-blocks--the-out-of-the-box-inventory) +5. [Atlas appearance vocabulary — classes & design properties](#atlas-appearance-vocabulary--classes--design-properties) +6. [Brand re-tune (Layer 1) — where most of the win is](#brand-re-tune-layer-1--where-most-of-the-win-is) +7. [Layer-1 brand scaffold — copy into theme/web/custom-variables.scss](#layer-1-brand-scaffold--copy-into-themewebcustom-variablesscss) +8. [Charts — a dataviz-grade theme for the Mendix chart widgets](#charts--a-dataviz-grade-theme-for-the-mendix-chart-widgets) +9. [Dark mode — commit to one theme](#dark-mode--commit-to-one-theme) +10. [Optional dark-mode Atlas-widget overrides](#optional-dark-mode-atlas-widget-overrides) +11. [Verify at runtime — this is mandatory](#verify-at-runtime--this-is-mandatory) +12. [Gotchas catalog](#gotchas-catalog) +13. [Validation checklist](#validation-checklist) +14. [Related skills](#related-skills) + +--- + +## The thesis: be Atlas-first + +Every Mendix project ships **Atlas** — a rich appearance system (`Atlas_Core` +classes + typed design properties) and **39 out-of-the-box building blocks** +(`Atlas_Web_Content`: cards, headers, forms, lists, timelines, wizards, alerts). +The single biggest mistake is hand-rolling `.panel` / `.trip-card` / `.stat` +SCSS that **reinvents what Atlas already gives you for free**. + +Live testing proved the point: a page of **pure Atlas classes, zero custom CSS** +renders real cards, brand-coloured backgrounds and buttons, and flex layouts — +and those Atlas utilities **inherit your retuned brand tokens automatically** +(`background-primary` resolves to *your* `--brand-primary`). + +**Reach *down* the stack first.** Need a card? `class:'card'` (or `'Card style': on`) +before writing a `.panel` rule. Brand blue on a button? Retune `--brand-primary` +before overriding `.btn-primary`. Custom CSS is the **last** resort — for identity +only (a mono metric type, a timeline spine, a bespoke elevation curve). + +--- + +## The 4-layer architecture + +Style from the bottom up. Each layer only does what the layer below can't. + +``` +Layer 3 VERIFY run --local --watch + Playwright screenshot (mx check is NOT enough) +Layer 2 IDENTITY themesource//web/main.scss — custom tokens + recipe classes + (mono type, status pills, timeline spine) — ONLY what Atlas can't provide +Layer 1 BRAND theme/web/custom-variables.scss — retune Atlas tokens (--brand-primary, + backgrounds, semantic colors, radius) so Atlas components inherit the palette +Layer 0 ATLAS Atlas classes / design properties / building blocks — structure & base look +``` + +- **Layer 0 — Atlas.** Compose with the Atlas vocabulary (the class cheat-sheet and + the building-block inventory below). +- **Layer 1 — Brand.** Retune Atlas tokens in `theme/web/custom-variables.scss` so + the whole framework (buttons, backgrounds, form inputs, pluggable widgets like + Switch/Slider/ProgressBar) picks up your palette. Scaffold below. +- **Layer 2 — Identity.** Only the handful of shapes Atlas genuinely can't express + go in `main.scss` as prefixed recipe classes. See `theme-styling.md` for the SCSS + chain and `migrate-design-prototype.md` for the token→component method. +- **Layer 3 — Verify.** Non-negotiable. `mx check` misses client-side crashes; you + must screenshot a *running* build. + +A Layer-1 token retune **cascades down** into Atlas components and pluggable +widgets for free — that is the headline payoff. A full re-brand (new palette, type, +corners) is **theme-only**: retune `custom-variables.scss` + `main.scss`, zero +page/MDL edits, and it hot-applies under `--watch`. + +--- + +## The workflow: discover → inspect → use + +Building blocks are the Mendix-native recipe library. mxcli can **read and +instantiate** them, so the workflow is: + +**1. Discover what your project ships.** +```bash +mxcli -p app.mpr -c "show building blocks" +mxcli -p app.mpr -c "show building blocks in Atlas_Web_Content" +mxcli -p app.mpr -c "select QualifiedName, Category from CATALOG.building_blocks" +``` + +**2. Inspect the block you want to reproduce.** `describe` prints its real widget +tree — the exact classes and typed design properties Mendix itself uses: +```bash +mxcli -p app.mpr -c "describe building block Atlas_Web_Content.Card" +``` +``` +{ + container container2 (DesignProperties: ['Card style': on]) { + dynamictext text22 (Content: 'Card title', RenderMode: H4, Class: 'card-title', + DesignProperties: ['Spacing': ['margin-bottom': 'L']]) + } +} +``` +Note the **two styling channels** Atlas uses side by side: the `Class:` vocabulary +(`card-title`) *and* typed `DesignProperties:` (`'Card style': on`, `Spacing`). + +**3. Use it — one line.** `use building block` deep-copies the block's widget tree +onto your page, exactly like dragging it in from the Studio Pro toolbox. Add +`as ` to rename the copied widgets (so you can drop the same block in twice): + +```mdl +use building block Atlas_Web_Content.Card as cust_ +``` + +That expands to the exact tree `DESCRIBE` showed — here `cust_container2` + +`cust_text22`, carrying the `card-title` class and the `Card style` design property. +It's a page-body element: put it inside a `create page` / `alter page` container, +anywhere a widget or `use fragment` can go. + +**4. Configure the copy afterwards.** A building block has no parameters — it's a raw +widget-tree template — so you bind data / set text by editing the *copied* widgets +with `alter page` (their names are deterministic thanks to the prefix): + +```mdl +alter page Sales.CustomerOverview set cust_text22 (content: 'Customers'); +``` + +> **Capability reality.** Discovery (`SHOW`/`DESCRIBE BUILDING BLOCK`, +> `CATALOG.building_blocks`) **and** instantiation (`USE BUILDING BLOCK`) both work +> today. `use building block` v1 is **deep-copy + optional `as `**; configure +> the copy afterwards with `alter page` (an inline override block is a proposed v1.1). +> It runs on `MXCLI_ENGINE=legacy` today; modelsdk-engine support lands with that +> engine's `ListBuildingBlocks`. + +**When to *mirror* instead.** *Mirroring* — reproducing a block's tree by hand with +`create page`/`alter page` + the same classes and design properties (see below) — is +the fallback: reach for it only to hand-tune a shape Atlas doesn't quite give you, or +on the modelsdk engine before its building-block support lands. Otherwise prefer the +one-line `use building block`. + +--- + +## Atlas building blocks — the out-of-the-box inventory + +Every Mendix project ships **`Atlas_Web_Content`**, a library of **39 building +blocks**: pre-composed widget shapes that Mendix itself uses. They are the canonical +reference for "what a well-made X looks like in Atlas." + +### The inventory (real names, grouped by category) + +| Category | Blocks | +|---|---| +| **Cards** | `Card`, `Card_Action`, `Card_ActionWithImage`, `Card_Background`, `Card_WithImage` | +| **Headers** | `Heroheader`, `Heroheader_Background`, `Heroheader_WithAction`, `Pageheader`, `Pageheader_WithBack`, `Pageheader_WithControls`, `Pageheader_WithSearch`, `PageheaderImage`, `PageheaderImage_WithBack`, `PageheaderImage_WithControls` | +| **Forms** | `Form_Horizontal`, `Form_Horizontal_WithTitle`, `Form_Horizontal_WithAction`, `Form_Vertical`, `Form_Vertical_WithTitle`, `Form_Vertical_WithAction` | +| **Lists** | `List_Cards`, `List_WithImage`, `ListItem_SingleLine`, `ListItem_DoubleLine`, `ListItem_WithImage` | +| **Master Detail** | `Master_Detail` | +| **Timeline** | `Timeline`, `Timeline_WithImage` | +| **Wizards** | `Wizard_Arrow`, `Wizard_Arrow_Step`, `Wizard_Circle`, `Wizard_Circle_Step` | +| **Notifications** | `Alert`, `Alert_WithAction`, `AlertIcon`, `AlertIcon_WithAction` | +| **Breadcrumbs** | `Breadcrumb`, `Breadcrumb_Underline` | + +All are `Platform: Web`, all live in module `Atlas_Web_Content`, referenced as +`Atlas_Web_Content.`. + +> Your project may ship more blocks from installed modules (e.g. a feedback widget). +> Always `show building blocks` on the actual project rather than trusting this list — +> it is the standard Atlas baseline, not an exhaustive per-project inventory. + +### Capability reality: discover, inspect, and instantiate + +| Capability | State | +|---|---| +| **Discover** — `SHOW BUILDING BLOCKS`, `CATALOG.building_blocks` | ✅ shipped | +| **Inspect** — `DESCRIBE BUILDING BLOCK Mod.Name` (full widget tree) | ✅ shipped | +| **Instantiate** — `use building block Mod.Name [as prefix_]` onto a page | ✅ v1 (deep-copy; configure afterwards with `alter page`; legacy engine today) | +| **Author** — `CREATE BUILDING BLOCK` | ❌ not yet (proposed) | + +The one-line `use building block` (above) is the normal path — deep-copy the block, +then configure the copy. **Mirroring** — reproducing a block's widget tree by hand — +is the fallback for hand-tuning or the modelsdk engine; the how-to is below. + +### How to mirror a block + +1. **Inspect it.** `describe building block Atlas_Web_Content.`. +2. **Read both channels.** Atlas blocks style with `Class:` strings *and* typed + `DesignProperties:` — copy both. +3. **Reproduce the tree** on your page, binding real data where the block has + placeholder text (`'Card title'` → your attribute/content). +4. **DRY it** — if the shape repeats, put it in a `define fragment` and `use` it. + +### Worked example — `Card` + +`describe building block Atlas_Web_Content.Card` yields the tree shown above. Mirror +it onto a page, binding real content: + +```mdl +create page MyModule.CardDemo +( + title: 'Card demo', + layout: Atlas_Core.Atlas_Default +) +{ + container myCard (designproperties: ['Card style': on]) { + dynamictext cardTitle + ( + content: 'Customers', + rendermode: H4, + class: 'card-title', + designproperties: ['Spacing': ['margin-bottom': 'L']] + ) + } +}; +``` + +Reusable version — put the card **shell** in a fragment with a `slot`, then fill +the slot with each card's own content. This is the key idiom: one card wrapper, +arbitrary bodies, no copy-paste of the wrapper markup. + +```mdl +define fragment SectionCard as { + container card1 (designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large']]) { + container cardBody (class: 'card-body') { + slot content -- each page's widgets land here + } + } +}; + +create page MyModule.Dashboard +( + title: 'Dashboard', + layout: Atlas_Core.Atlas_Default +) +{ + container page1 (class: 'flex-column') { + use fragment SectionCard { + dynamictext custTitle (content: 'Customers', rendermode: H4, class: 'card-title') + dynamictext custBody (content: 'Recent customer activity') + } + use fragment SectionCard { + dynamictext ordTitle (content: 'Orders', rendermode: H4, class: 'card-title') + datagrid ordGrid (datasource: database MyModule.Order) { } + } + } +}; +``` + +The `slot` marker is resolved at expansion — `describe page` shows the fully +wrapped tree (`card1 > cardBody > custTitle, custBody`), and `mx check` is clean. +The slot name is optional (defaults to `content`); a fragment supports one slot. +Use `as prefix_` when the wrapper's *own* widget names would collide across uses +(the payload keeps the names you give it). For a fixed, content-invariant group +(a footer, a button pair) a plain slotless fragment is still the right tool. + +**Binding data and behaviour (experimental).** A slot varies *what widgets* go +inside; typed **parameters** vary *which entity* and *which microflow*. Declare a +`datasource` and/or `action` parameter and the card becomes a real component: + +```mdl +define fragment EntityCard($data: datasource, $onOpen: action) as { + container card1 (designproperties: ['Card style': on]) { + listview lv (datasource: $data) { + slot content + actionbutton open (caption: 'Open', action: $onOpen, buttonstyle: primary) + } + } +}; +use fragment EntityCard ($data: database Sales.Order, $onOpen: microflow Sales.Open) { + dynamictext cardTitle (content: 'Orders', rendermode: H4, class: 'card-title') +} +``` + +Atlas **building blocks** can't declare params, but `use building block` takes +rebind overrides that rewrite the block's outermost datasource / first button: + +```mdl +use building block Atlas_Web_Content.List_Cards + (datasource: database Sales.Order, action: microflow Sales.Open) as orders_; +``` + +For a binding the override rule can't reach, copy the block in (`as prefix_`) and +`alter page … set datasource/action on prefix_widget`. + +### Worked example — `Pageheader` + +`describe building block Atlas_Web_Content.Pageheader`: + +``` +{ + container container1 (Class: 'pageheader', DesignProperties: ['Item gap': 'None']) { + dynamictext text40 (Content: 'Page header title', RenderMode: H1, Class: 'pageheader-title') + dynamictext text39 (Content: 'Supporting text', RenderMode: Paragraph, Class: 'pageheader-subtitle', + DesignProperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']]) + } +} +``` + +Mirror: + +```mdl +create page MyModule.CustomersHeaderDemo +( + title: 'Customers', + layout: Atlas_Core.Atlas_Default +) +{ + container pageHeader (class: 'pageheader', designproperties: ['Item gap': 'None']) { + dynamictext headerTitle (content: 'Customers', rendermode: H1, class: 'pageheader-title') + dynamictext headerSubtitle + ( + content: 'All active accounts', + rendermode: Paragraph, + class: 'pageheader-subtitle', + designproperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']] + ) + } +}; +``` + +### Block → screen map (which block to reach for) + +| You want | Mirror this block | +|---|---| +| A titled surface panel | `Card` / `Card_Action` (with a trailing action) / `Card_WithImage` | +| A page title + subtitle band | `Pageheader` (+ `_WithBack` / `_WithControls` / `_WithSearch`) | +| A big splash header | `Heroheader` (+ `_Background` / `_WithAction`) | +| A vertical / horizontal form | `Form_Vertical*` / `Form_Horizontal*` | +| A card/list feed | `List_Cards`, `List_WithImage`, `ListItem_*` | +| A master list + detail pane | `Master_Detail` | +| An activity/history feed | `Timeline` / `Timeline_WithImage` | +| A multi-step flow | `Wizard_Arrow` / `Wizard_Circle` (+ their `_Step`) | +| An inline notice | `Alert`, `AlertIcon` (+ `_WithAction`) | +| A path/breadcrumb trail | `Breadcrumb` / `Breadcrumb_Underline` | + +--- + +## Atlas appearance vocabulary — classes & design properties + +Atlas exposes its whole appearance system through the styling channels mxcli can +write today: raw `class:` strings and typed `designproperties:`. **Reach for these +before writing custom CSS.** + +### The cheat-sheet + +Apply via `class:` on any widget (space-join several: `class:'card flex-column'`). + +| Concern | Atlas classes | +|---|---| +| **Cards** | `card`, `cards` (+ Card-style variants) — real CSS, `.card` is ~19 rules | +| **Backgrounds** | `background-{default,main,primary,secondary,success,warning,danger}` | +| **Buttons** | `btn-{primary,secondary,success,warning,danger}`, `btn-{lg,sm,bordered,block,icon-right,icon-top}` | +| **Flex / align** | `flex-{row,column,nowrap,items-grow,items-shrink}`, `align-x-{left,center,right,between,around,evenly}`, `align-y-*` | +| **Spacing utils** | `spacing-{outer,inner}-{top,right,bottom,left}` (+ `-medium` / `-large` / `-none` sizes) | +| **Borders / overflow** | `div-border-toggle-{all,top,…,none}`, `div-overflow-{auto,hidden,visible}` (+ border radius/color/style/width) | +| **Elevation** | `Shadow` toggle | +| **Data grids** | `datagrid-{bordered,hover,striped,lined,lg,sm}` | +| **Group boxes** | `groupbox-{primary,danger,secondary,callout}` | + +Source: `atlas_core/web/design-properties.json` (verified in-project). To see what a +specific widget offers, run `show design properties` / `describe styling` +(`theme-styling.md`). + +### When to reach for each + +- **`card` / `Card style`** — any titled surface panel. This is the workhorse; a + dashboard is mostly cards on a `background-main` page. +- **`background-primary` / `background-success` / …** — coloured section/hero/status + surfaces. These resolve to your **retuned brand tokens** (Layer 1), so a hero band + set to `background-primary` turns *your* brand colour automatically. +- **`btn-*`** — prefer `buttonstyle: primary` on `actionbutton` for the semantic + style; add `btn-lg` / `btn-bordered` / `btn-block` as classes for size and shape. +- **`flex-row` / `flex-column` + `align-x-*` / `align-y-*`** — layout inside a + container without a `layoutgrid`. `flex-row` + `align-x-between` is the standard + "title on the left, action on the right" header row. +- **`spacing-inner-*` / `spacing-outer-*`** — padding/margin without inline `style:`. +- **`datagrid-*`** — reach for these on data grids before overriding grid CSS. +- **`groupbox-*`** — callouts / grouped sections with a semantic tint. + +### Typed design properties — the alternative channel + +Atlas building blocks use **both** channels side by side. The typed channel is what +Studio Pro's Appearance tab reads, so mirror it when you want the block to round-trip +cleanly into Studio Pro. Common mappings: + +| Class-style | Typed design-property equivalent | +|---|---| +| `class:'card'` | `designproperties: ['Card style': on]` | +| `class:'background-primary'` | `designproperties: ['Background color': 'Brand Primary']` | +| `class:'flex-column'` | `designproperties: ['Flex container': 'Vertical (column)']` | +| `class:'flex-row'` | `designproperties: ['Flex container': 'Horizontal (row)']` | +| `class:'align-x-center'` | `designproperties: ['Align items X': 'Center']` | +| `class:'Shadow'` | `designproperties: ['Shadow': 'None' / 'Small' / …]` | +| spacing utilities | `designproperties: ['Spacing': ['margin-bottom': 'L', 'padding-top': 'S']]` | + +**Both channels render identically at runtime** — raw `class:` is sufficient for the +visual result today. The typed channel matters for Studio Pro round-trip and is the +more idiomatic form to mirror from a `describe building block`. Notes: +- Design-property **keys are case-sensitive** — match the `describe` output exactly. +- Compound properties (Spacing, Border) take a **nested list**: + `['Spacing': ['margin-top': 'Large', 'margin-bottom': 'None']]`. +- **Never** put inline `style:` on a `dynamictext` — it crashes MxBuild. Use `class:` + or wrap in a styled `container`. (`theme-styling.md`.) + +--- + +## Brand re-tune (Layer 1) — where most of the win is + +Copy the scaffold below into `theme/web/custom-variables.scss` and set the +placeholder palette. Because Atlas utilities and pluggable widgets read these tokens, +one retune re-skins the whole app: + +- `--brand-primary` → buttons, `background-primary`, links, Switch/Slider/ProgressBar +- background + semantic (`success`/`warning`/`danger`) tokens → alerts, group boxes, + status backgrounds +- `--card-border-radius` and radius tokens → cards, inputs, popups (drop to `0` for a + sharp, industrial identity; raise for a soft, friendly one) + +Only after the token retune, reach for Layer-2 identity classes in `main.scss` — and +only for shapes Atlas can't provide. + +--- + +## Layer-1 brand scaffold — copy into theme/web/custom-variables.scss + +```scss +// ============================================================================= +// Layer 1 — BRAND: retune Atlas tokens +// ----------------------------------------------------------------------------- +// Copy this into theme/web/custom-variables.scss and swap the placeholder +// palette below for your brand. +// +// WHY THIS FILE MATTERS: Atlas classes and pluggable widgets READ these tokens. +// Retuning them here cascades the palette DOWN into buttons, `background-*` +// utilities, form inputs, cards, popups, and pluggable widgets (Switch, Slider, +// RangeSlider, ProgressBar, ProgressCircle, BadgeButton) — with NO per-widget CSS. +// This is the single highest-leverage styling change you can make. +// +// These vars use Atlas's `!default` chain, so they override +// atlas_core/web/variables.scss. See `theme-styling.md` for the compile order. +// Reach for THIS layer before writing any custom class in main.scss (Layer 2). +// ============================================================================= + +// 1. BRAND PRIMARY — the one colour that defines the app. +// Flows into: btn-primary, background-primary, links, active nav, and the +// brand-reading pluggable widgets (Switch / Slider / ProgressBar / …). +$brand-primary: #2b5170 !default; // TODO: your brand colour +$brand-secondary: #5c6a78 !default; // TODO: muted / secondary accent + +// 2. SEMANTIC COLOURS — success / warning / danger / info. +// Flows into: btn-*, background-*, groupbox-*, alerts, status surfaces. +$brand-success: #4a7a5c !default; // TODO +$brand-warning: #c9a227 !default; // TODO +$brand-danger: #a13a2c !default; // TODO +$brand-info: #2f6f9f !default; // TODO + +// 3. BACKGROUNDS & INK — the neutral ground the app sits on. Retune these so +// Atlas surfaces OUTSIDE your scoped classes (form inputs, popups, modals) +// inherit the palette too. +$bg-color: #eef1f4 !default; // TODO: app background +$background-color-page: $bg-color !default; +$font-color-default: #1a2129 !default; // TODO: body ink +$font-color-detail: #5c6a78 !default; // TODO: secondary / muted text +$border-color-default: #dde3ea !default; // TODO: hairline borders + +// Form inputs — keeps inputs on-palette everywhere (incl. popups). +$form-input-bg: #ffffff !default; // TODO +$form-input-border-color: $border-color-default !default; +$form-input-color: $font-color-default !default; + +// 4. SHAPE — corner radius. 0 = sharp/industrial; higher = soft/friendly. +// Cascades into cards, inputs, buttons, popups. +$border-radius-default: 8px !default; // TODO: 0 … 16px +$card-border-radius: $border-radius-default !default; + +// 5. TYPOGRAPHY — set a brand font. If it is a WEB font, `@import` it as the +// FIRST line of main.scss (an @import after any rule is silently dropped), and +// ALWAYS keep a system fallback stack so the layout survives a font-load fail. +$font-family-base: "system-ui", -apple-system, "Segoe UI", sans-serif !default; // TODO + +// Bridge Atlas CSS custom properties to the Sass vars above, so runtime CSS +// (`var(--brand-primary)`, `background-primary`, etc.) resolves to your palette. +:root { + --brand-primary: #{$brand-primary}; + --brand-secondary: #{$brand-secondary}; + --brand-success: #{$brand-success}; + --brand-warning: #{$brand-warning}; + --brand-danger: #{$brand-danger}; + --brand-info: #{$brand-info}; + + --bg-color: #{$bg-color}; + --font-color-default: #{$font-color-default}; + --font-color-detail: #{$font-color-detail}; + --border-color-default: #{$border-color-default}; + --card-border-radius: #{$card-border-radius}; + --font-family-base: #{$font-family-base}; +} +``` + +--- + +## Charts — a dataviz-grade theme for the Mendix chart widgets + +Out of the box the chart widgets (Column / Bar / Area / Pie / Line) render **raw +Plotly defaults**: one flat colour, a floating mode-bar, wide margins, heavy +gridlines, a white paper background. That is the single biggest "not a real product" +tell. Three Plotly hooks — barely used by generated apps — turn them into designed +charts. All three are **plain JSON strings** (no Mendix expression quoting). + +| Property | Plotly layer | Use it for | +|---|---|---| +| `customLayout` | `layout` | transparent `paper_bgcolor` + `plot_bgcolor`, system font, `#8a94a6` ticks, tight `margin`, faint `gridcolor`, `zeroline:false` / `showline:false`, dark `hoverlabel` | +| `customConfigurations` | `config` | `{"displayModeBar":false,"responsive":true}` — removes the floating toolbar | +| `customSeriesOptions` (per series; chart-level on Pie) | trace | brand colour, `marker.cornerradius` (rounded bars), `line.shape:"spline"` + translucent `fillcolor` (area), Pie colour array + white inside labels | + +**The key trick — transparent background = theme-agnostic charts.** Set +`paper_bgcolor` and `plot_bgcolor` to `rgba(0,0,0,0)`; the plot inherits whatever +panel it sits on, so **one config is correct in both light and dark** with zero +per-theme CSS. Pair it with a neutral tick colour (`#8a94a6`) that reads on either +background. Always kill the white paper **and** the mode-bar — the two ugliest +defaults. + +Ready-made `customLayout` (transparent, themed): +```json +{ + "paper_bgcolor": "rgba(0,0,0,0)", + "plot_bgcolor": "rgba(0,0,0,0)", + "font": { "family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "color": "#8a94a6" }, + "margin": { "t": 8, "r": 8, "b": 32, "l": 40 }, + "xaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, + "yaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, + "hoverlabel": { "bgcolor": "#1a2129", "font": { "color": "#ffffff" } } +} +``` + +`customConfigurations` (kill the mode-bar): `{ "displayModeBar": false, "responsive": true }` + +`customSeriesOptions` per type: +```jsonc +// Column / Bar — brand colour + rounded corners +{ "marker": { "color": "#2b5170", "cornerradius": 6 } } +// Area — spline curve + translucent fill +{ "line": { "shape": "spline", "color": "#2b5170" }, "fill": "tozeroy", "fillcolor": "rgba(43,81,112,0.15)" } +// Pie (chart-level) — colour array + white inside labels +{ "marker": { "colors": ["#2b5170", "#4a7a5c", "#c9a227", "#a13a2c"] }, "insidetextfont": { "color": "#ffffff" } } +``` + +Swap the hex values for your brand palette (the same values you set in the Layer-1 +scaffold). The generic `dataviz` skill is the HTML/React analogue of this — same +"kill the defaults, one theme-agnostic config, brand the series" philosophy. + +**Chart gotchas** are in the [gotchas catalog](#gotchas-catalog). All chart types +(incl. Line/Bubble/Heatmap/TimeSeries) are MDL-authorable today — see +`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` and `custom-widgets.md`. + +--- + +## Dark mode — commit to one theme + +A `prefers-color-scheme: dark` flip repaints **your** custom chrome, but Atlas's own +widgets and Plotly ship **light-only** surfaces — on a dark page they render as white +boxes with (often) near-invisible text. **Decide theme-count up front:** + +- A **dark-only** app is simpler and more robust — drop the `@media` gate and make + the widget overrides **unconditional + global** (this also covers portal-rendered + popups/modals that live outside your scoped class). +- If you can't fund the override recipe, ship **light-only**. A half-dark result + (your chrome dark, Atlas widgets light) is **worse** than a consistent light app. + +Charts are the exception — don't CSS them; use the transparent `customLayout` trick +above, which adapts to light **and** dark automatically. + +--- + +## Optional dark-mode Atlas-widget overrides + +Paste into `main.scss` (Layer 2), after the `@import`s. Replace the token +placeholders with your dark palette. Popovers/modals render at ``, so the +popover + modal block must **not** be scoped to your app class — keep it global. + +```scss +// --- Dark palette tokens (TODO: set these) ---------------------------------- +$dk-surface: #1a2129; // panel / row background +$dk-surface-2: #232c37; // header / chip background +$dk-ink: #e6ebf1; // primary text +$dk-ink-mut: #9aa6b4; // muted text +$dk-border: #2f3a47; // hairline + +// Wrap in the media query for a dual-theme app; DELETE the @media line (and its +// closing brace) for a committed dark-only app to make these unconditional. +@media (prefers-color-scheme: dark) { + + // Form controls: text input / textarea / combobox field + .form-control, + .mx-textarea textarea, + .form-control input { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + + // Datagrid: rows, headers, filter chips + .mx-datagrid table, .mx-datagrid tr, .mx-datagrid th, .mx-datagrid td { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + .filter-selector-button { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; + } + + // Datagrid dropdown filter: kill the hardcoded white scroll-fade gradient + .widget-dropdown-filter-menu { + background-image: none; background-color: $dk-surface; + } + .widget-dropdown-filter-menu * { color: $dk-ink; } + + // Accordion / Fieldset + .mx-groupbox, .mx-groupbox-header, fieldset, legend { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + + // TreeNode: expanded child rows carry a WHITE card bg — let the panel show through + .mx-treenode, .mx-treenode .mx-treenode-content { + background: transparent; color: $dk-ink; + } +} + +// Popovers / modals render at — theme these GLOBALLY (unscoped). +// Combobox / tooltip / dropdown-filter popovers and edit popups (.mx-window / +// .modal-content) live outside your app class, so a scoped selector misses them. +.mx-window-content, .modal-content, .mx-window-header, .mx-tooltip, .mx-combobox-menu { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; +} +.mx-window-content .form-control, .modal-content .form-control { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; +} +.mx-window .btn-default, .modal-content .btn-default { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; +} +// Charts: DON'T style them here — use the transparent customLayout (above). +``` + +--- + +## Verify at runtime — this is mandatory + +**Runtime verification is not optional.** `mx check` (and `mxcli check --references`) +validate the *model* — they pass MDL the **browser client still crashes on**: + +- an old ListView carrying `SearchRefs` the client can't render; +- the Slider / RangeSlider tooltip calling React's removed `findDOMNode` — this only + throws **on drag**, so a static check (even a static screenshot) misses it; +- a structural change that leaves the client bundle unbuilt (blank `