diff --git a/.claude/commands/mxcli-dev/review.md b/.claude/commands/mxcli-dev/review.md index 41a458694..9226aee3a 100644 --- a/.claude/commands/mxcli-dev/review.md +++ b/.claude/commands/mxcli-dev/review.md @@ -41,6 +41,10 @@ proactively. Add a row after every review that surfaces something new. | 16 | Bug-fix PR missing `mdl-examples/bug-tests/-description.mdl` — checklist requires one per fix so Studio Pro can validate the regression case | Test coverage | Add minimal MDL that reproduces the symptom; commit alongside the fix; the PR description often contains the exact reproduction snippet already | | 17 | Commit message claims a change (e.g. `"PERF001": "Performance"` mapping in `report.go`) that is not present in the diff — git body overstates the actual change, often referencing an example rule as if it were shipped | Docs quality | Diff the file the commit names (`git show -- `); if the change isn't there, fix the commit body so it doesn't imply shipped behavior | +| 18 | A generated artifact hardcodes a value that an exported constant also declares (e.g. `SwitcherStorageKey = "mxcli-theme"` beside four literal `"mxcli-theme"` in the template) — the constant and the artifact can drift, and a test asserting `Contains(output, TheConst)` keeps passing because it is checking the literal, not the link | Test coverage | Substitute the constant into the template (`{{KEY}}` + `strings.NewReplacer`) so there is one source of truth; assert the placeholder is expanded *and* the expected occurrence count | +| 19 | Docs rewritten in one section while an earlier section still points at the removed content — e.g. "Copy the scaffold below" left in place after the scaffold was replaced by "do not hand-roll a scaffold", producing a direct contradiction two paragraphs apart in a skill `mxcli init` syncs into every user project | Docs quality | After deleting or replacing a doc section, grep the whole file for phrases that referred to it ("below", "scaffold", the old heading) and for the old anchor in the Contents list | +| 20 | Asset-driven feature (themes, templates) whose correctness depends on a toolchain the Go tests never run — SCSS that must compile, a mixin whose name must match its `@include`. A broken asset ships and fails at the user's build, not in CI | Test coverage | Assert the naming/structural contract in Go (`@mixin X {` and `@include X;` both present, every `url()` resolves to a shipped file); compile once by hand against a real project and record it in the proposal | + --- ## After Every Review diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 7057b7419..e0775b351 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -336,6 +336,27 @@ cases for these three BSON types — they fell to `default: return nil`. | `DESCRIBE MICROFLOW` emits **`on error rollback`** on activities authored with no error-handling clause at all, growing the diff on every round-trip. No checker flags it — `"Rollback"` is structurally valid, so `mx check` and every mxcli validator pass | `Rollback` is what `convertErrorHandlingType(nil)` stores for an activity with no clause **and** what the parser falls back to when `ErrorHandlingType` is absent from the BSON. The stored value therefore cannot distinguish an authored clause from the default, and read-back guessed "authored" | `mdl/executor/cmd_microflows_show_helpers.go` (`formatErrorHandlingSuffix`) | Drop the `Rollback` case so it falls through to no suffix. **The asymmetry is the whole argument**: omitting it is lossless (re-executing stores `Rollback` again, so the model is unchanged), while emitting it is lossy in the direction that matters — it puts a clause in the user's script that they never wrote. `Continue` / `Custom` / `CustomWithoutRollback` are never defaults, so they still round-trip. **Generalisable — the shape to look for**: when a formatter renders an enum whose zero/fallback value is also a legal authored value, read-back cannot invert the write; render only the values that are *never* defaults. Ask "what does the parser fall back to?" before trusting a stored enum to mean the author chose it. Repro `mdl-examples/bug-tests/840-describe-invents-on-error-rollback.mdl`; verified end-to-end (describe → exec → describe byte-identical, `mx check` 11.13.0 0 errors). Issue #840 | +| SCSS written to **`themesource//web/main.scss` never reaches the app** — no error, no warning, the build succeeds and the rules are simply absent from `theme-cache/web/theme.compiled.css`. Looks exactly like an SCSS cache problem, so the usual reflex (`rm -rf theme-cache/`) wastes the session | A theme source folder is only compiled when `` matches a **real module in the model**. mxbuild walks the model's modules and pulls each one's `themesource//web/main.scss`; it never globs the `themesource/` directory, so an invented folder (`themesource/my_theme/`) is silently skipped. Verified on 11.13: a probe rule in `themesource/myfirstmodule/` compiled, the identical rule in `themesource/mxcli_theme/` did not | `cmd/mxcli/theme/theme.go` (package doc records the compile order); the target paths live in `cmd/mxcli/theme/assets//files/` | Put app-level styling in **`theme/web/`**, not in an invented theme source folder: `theme/web/main.scss` is compiled **last** — after Atlas Core *and* after every module theme source — so a partial imported from it overrides any Atlas rule without `!important`. Use a module's theme source only when the styling genuinely belongs to that module (it exports with the `.mpk`). **Generalisable — the shape to look for**: when CSS "doesn't apply", first prove the file is *compiled at all* (grep a unique probe selector in `theme-cache/web/theme.compiled.css`) before debugging specificity or caches — absent and overridden look identical in the browser. Note also that `theme/web/custom-variables.scss` is imported once **per module**, so it must hold declarations only; a rule there is emitted N times | + +| A **`CREATE JAVASCRIPT ACTION`** succeeds, `mxcli check` passes and the build is clean, but calling the action in the running app throws **`JavaScript action was not implemented`** and the nanoflow aborts | mxcli wrote the source to `javascriptsource//actions/` using the module's own casing. Mendix reads a **lowercased** directory — a blank Mendix 11 app ships `javascriptsource/nanoflowcommons/`, `/datawidgets/`, `/webactions/` for modules named `NanoflowCommons`, `DataWidgets`, `WebActions`. Finding no source at the path it reads, mxbuild generates a stub whose body is `throw new Error("JavaScript action was not implemented")` and bundles that. Only reproduces on a **case-sensitive filesystem**, which is why it survived: on macOS and Windows the two spellings are the same directory | `mdl/backend/modelsdk/javascript_write.go` and `sdk/mpr/writer_javascriptactions.go` (`jsActionSourceDir`) | `strings.ToLower(moduleName)` in both writers — the comment in each previously asserted the opposite ("unlike javasource, which is lowercased"), so the belief was documented, not tested. **Generalisable — the shape to look for**: when generated *source files* pair with model units, the model unit is not evidence the file is found; the filesystem path is a separate contract, and a case-only mismatch is invisible on the developer's own machine. Check against the directories a blank project already ships rather than against what the code says. Nothing short of running the app catches it: parse, check and build all pass. Test `mdl/backend/modelsdk/javascript_write_dir_test.go`; verified end-to-end (button click flips the theme instead of throwing) | +| A `create rest client` operation reports success, but `describe rest client` omits `Query:`, `Parameters:` and `Headers:` and always prints `Response: none`. BSON shows the query parameters/headers stored **correctly** while `ResponseHandling` is `Rest$NoResponseHandling` — the response mapping is gone. `mx check` passes (0 errors), so nothing anywhere complains | **Two unrelated defects with one symptom.** *Write*: `model.RestClientOperation` documents `BodyType`/`ResponseType` as UPPER-case tokens and every consumer compares against that spelling, but the MDL executor stored the visitor's lower-case source text — so `op.ResponseType == "MAPPING"` never matched and the mapping fell through to the else-branch, which legitimately writes `NoResponseHandling`. *Read*: `restOperationFromGen` populated only Name/HttpMethod/Path/Timeout and type-asserted `*genRest.RestParameter` for **both** parameter lists, while the writer emits `Rest$OperationParameter` and `Rest$QueryParameter` — two different gen types, so both assertions failed silently | `mdl/executor/cmd_rest_clients.go` (`buildRestClientOperation` normalization + `checkInlineMappingBody`), `mdl/backend/modelsdk/integration_read.go` (`restOperationFromGen` + response/body/mapping-tree readers), `mdl/backend/modelsdk/consumed_rest_write.go` and `modelsdk/mpr/serialize_web_services.go` (`EqualFold`), `mdl/executor/validate_rest_mapping.go` (MDL-REST01) | Normalize with `strings.ToUpper` at the one place the AST becomes the semantic model, and make the two serializer comparisons `EqualFold` so the landmine is not left armed for the next producer. **Generalisable — the shape to look for**: a case-sensitive comparison against a *documented-but-unenforced* string constant, where the non-matching branch is a **legitimate** outcome. Nothing errors, because "no response handling" is a real thing an operation can have — the else-branch launders a producer/consumer mismatch into a plausible-looking model. Grep every comparison against the constant, expect one per engine, and check whether the false branch is silent. **Second shape**: a reader that type-asserts one concrete type for two lists the writer builds from two *different* types; the assertion fails to `ok=false` and `continue`s, so a stub reader is indistinguishable from an empty document. The pre-existing round-trip test even created a query parameter — but only asserted the operation *count*, never that the parameter survived. **Third, separate half (#843's headline)**: `Response: mapping Mod.IMM_X` names an import mapping **document**, which Mendix cannot reference — `Rest$RestOperationResponseHandling` has exactly two implementations, inline and none. The clause parsed, contributed no entries, and was written as "none". Now refused at exec *and* `mxcli check` (MDL-REST01, no project needed). **Note** `Rest$QueryParameter` stores no DataType at all, so the MDL type is decorative and `describe` re-emits every query parameter as `String` — do not "fix" that by inventing the authored type back (see the #840 row). Repros `mdl-examples/bug-tests/843-rest-response-mapping.mdl` + `843-rest-response-mapping-no-body.fail.mdl`; verified end-to-end (`mx check` 11.13.0 0 errors, BSON now `Rest$ImplicitMappingResponseHandling` with a full `ImportMappings$ObjectMappingElement` tree, describe → exec → describe byte-identical). Issue #843 | +| A widget datasource bound to a **parameterized** microflow/nanoflow (`datasource: microflow Mod.MF(Name: $x)`) fails with **CE1571** "No argument has been selected for parameter 'X'". `mxcli check` and `exec` both report success; `describe page` shows the microflow but no arguments | The grammar parsed the arguments into `DataSourceV3.Args`, but the builder never read them and `pages.MicroflowSource` had **no field to hold them**, so they were dropped between AST and model. The writer's `microflowSettingsToGen(d.Microflow, nil)` passed a literal `nil` at all three datasource sites, with a comment asserting datasources never carry mappings — true when only actions could take arguments, stale once the grammar accepted them on a datasource | `sdk/pages/pages_datasources.go` (`MicroflowSource`/`NanoflowSource` gain `ParameterMappings`), `mdl/executor/cmd_pages_builder_v3.go` (`flowArgsToParameterMappings`, shared with the action path), `mdl/backend/modelsdk/widget_write.go` (three sites pass `d.ParameterMappings`) | Reuse the action path's `$`-variable-vs-expression rule rather than writing a second one — the two must agree or the same argument binds through different BSON fields depending on where it appears. **Generalisable — the shape to look for**: a hardcoded `nil` argument whose comment explains *why* the data can't exist is a dated assumption; when the grammar grows a construct, grep for the `nil`s that were correct before it. **Also check the model type can hold the value at all** — here the AST parsed it and the writer would have written it, but the struct in between had no field, so nothing was "dropped" by any single line. **Remaining gap**: `describe page` does not yet emit the mappings, so a describe → exec round-trip still loses them (read-back only; the write path is correct). Repro `mdl-examples/bug-tests/835-datagrid-microflow-datasource-params.mdl`; verified end-to-end (`mx check` 11.13.0: CE1571 → 0 errors). Issue #835 | +| Quoted identifiers inside a `create import mapping { }` / `create export mapping { }` body produce **CE1613** "The selected entity `'Mod."Entity"'` no longer exists" on every reference, while the same quoting works everywhere else. `mxcli exec` reports success with no warning | The mapping-body builders read entity/association names with `ctx.QualifiedName().GetText()`, which returns **raw parse text including the quotes**, instead of `buildQualifiedName` (which strips them via `identifierOrKeywordText`, as the rest of the visitor does) | `mdl/visitor/visitor_import_export_mapping.go` (`buildImportRootElement`, `buildImportChild`, `buildExportRootElement`, `buildExportChild` — five sites: root entity, nested association + entity, value-transform converter) | Route every qualified name through `buildQualifiedName(...).String()`. **The error message names the bug**: `'Mod."Entity".RouteId'` mixes a *quoted* entity with an *unquoted* attribute, because the attribute half already went through `identifierOrKeywordText` — when a stored name is half-stripped, one of the two readers is raw. **Generalisable — the shape to look for**: `GetText()` on a parser context is almost always wrong for a name; it is the raw source slice, so any lexical decoration (quotes, whitespace) survives into the model. Grep for `.GetText()` next to `QualifiedName` whenever a reference looks right in the script but not in the .mpr. Repro `mdl-examples/bug-tests/842-mapping-quoted-identifiers.mdl`; verified end-to-end (`mx check` 11.13.0: 3 errors → 0). Issue #842 | +| `ALTER PAGE … ON ` targeting a DataGrid2 column: (a) the authored MDL column name (`column colFoo`) fails with a bare `widget "colFoo" not found`, and (b) a name shared by two columns (duplicate captions) **silently mutates the first** and reports `Altered page`, leaving the second unreachable | DataGrid2 columns carry **no stored name** in the Mendix model (their WidgetObject has no `Name` property), so the authored name is dropped on write and mxcli addresses a column by a *derived* name (attribute leaf, else caption, else `col{N}`). The bare-name resolver `findInWidgetArray` returned on the **first** derived-name match, so duplicates collided silently; the miss path emitted a generic "widget not found" with no hint that columns use a derived name | `mdl/backend/pagemutator/mutator.go` — the column loop in `findInWidgetArray` (now counts matches → `bsonWidgetResult.matchCount`), `findBsonColumn` (returns `(result, error)`), and the mutation entry points `SetWidgetProperty`/`DropWidget`/`InsertWidget`/`ReplaceWidget` (reject `matchCount > 1`, use `widgetNotFoundError` which lists derived names) | Persisting the authored name is **not possible** — columns have no name slot, and inventing one is the Studio-Pro-won't-open hazard (ADR-0005 guard-don't-drop). So implement the finding's two fallbacks: reject an ambiguous `ON ` with an actionable error instead of silently taking the first (a *data hazard*, not just a wart), and on a miss list the addressable derived names + explain the derived-name model (run DESCRIBE PAGE). **Generalisable**: a resolver that returns the first of N matches hides ambiguity — count matches and reject >1 at every *mutating* entry point, not just the read path. Repro `mdl-examples/bug-tests/ledger-78-datagrid-column-addressing.mdl`; A/B: pre-fix binary reports `Altered page` for the duplicate-caption `ON "Amount"`, fixed errors with "ambiguous". Ledger #78 | +| `loop { if then break; }` where the `if` is the **last** statement builds a Decision with only its `true` outgoing flow (→ break). `mxcli check` passes but `mx check` reports **CE0079** "the 'false' condition value should be configured in properties for an outgoing sequence flow", and the microflow won't deploy. (Distinct from the earlier #791 crash — that was a dropped Break/Continue *event*; this is a missing *flow*.) `continue` and break-not-last behaved likewise | The loop-body flow builder (`addLoopStatement`) is a simplified copy of `buildFlowGraph` that connected body statements with a plain `newHorizontalFlow` and **never honoured the deferred `nextFlowCase`** a merge-less split leaves for its FALSE branch. So the split's false case was dropped: mid-body it wired the next statement with no case; as the last statement it wired nothing at all | `mdl/executor/cmd_microflows_builder_control.go` (`addLoopStatement` body loop) | Mirror `buildFlowGraph`: track `pendingCase` between body statements and apply it to the connecting flow; then, for a leftover `pendingCase` at the end of the loop body (a decision whose non-terminal branch falls off the end), synthesize a **ContinueEvent** and wire the split's false flow to it — the valid Mendix representation of "didn't break/return → next iteration". **Trap**: the check-time acceptance test (`TestValidateMicroflow_ConditionalBreakAccepted`) only asserted MDL051 doesn't fire — it never ran `mx check` on the *output*, so the CE0079 microflow shipped green. Assert the produced BSON, not just that check accepts the source. Repro `mdl-examples/bug-tests/ledger-52-break-in-conditional.mdl`; verified raw `mx check` 0 errors (was 1× CE0079) and the split now carries both a true→Break and a false→Continue flow. Ledger #52 | + +| A workflow containing a standalone `annotation '...'` writes a project Mendix **cannot load**: `System.InvalidOperationException: Type ...Workflows.Model.Annotation does not contain a constructor with a parameter of type ...Workflows.Model.Flow`. Not a build error — Studio Pro will not open the project and `mx check` dies before validating anything. `mxcli check` passed and `exec` succeeded | mxcli writes the annotation into the workflow's **activity flow**. Mendix loads that list by constructing every child with a `Flow` parent, and no annotation type takes one: `Workflows$Annotation` carries only `Description` (it attaches to a Flow) and `Workflows$FloatingAnnotation` (the canvas sticky note, which has exactly the `RelativeMiddlePoint`/`Size` fields mxcli was already writing) is not a flow element either. **Placement is the defect, not the storage name** — swapping the `$Type` to FloatingAnnotation reproduces the identical error with the new type name | `mdl/executor/validate_workflow.go` (new `MDL-WF04`), `mdl/executor/cmd_workflows_write.go` (`execCreateWorkflow` guard + `hasStandaloneWorkflowAnnotation`), skill `.claude/skills/mendix/write-workflows.md` (which had documented the construct) | **Refuse rather than emit an unopenable unit** — at check time *and* at exec time, because a user who skips `check` otherwise still loses the whole project. The correct container is not determinable from the gen model (no struct owns a `FloatingAnnotation` list) and CLAUDE.md's rule applies: when the BSON shape is unknown, get a Studio Pro reference rather than guess. **Generalisable**: verify a storage-name hypothesis by *swapping only the name* — if the error is byte-identical with the new type, the bug is where the element is attached, not what it is called. Repro `mdl-examples/bug-tests/it-15-workflow-annotation-refused.fail.mdl`; A/B: pre-fix binary writes it and `mx check` cannot load the project, fixed binary refuses and the project checks 0 errors. issuetracker #15 **Follow-up (CI):** three `-tags integration` round-trip tests asserted this construct *works* and went red on the guard. They exercised mxcli's own write → read → describe → re-execute loop, which a structurally invalid document survives — the loop never loaded the project in Mendix, so it proved nothing about validity. Re-settled by stubbing the guard and running real `mx check`: the project fails at "Loading the mpr file". The tests were pinning the defect, and now assert the refusal (`TestCreateWorkflow_StandaloneAnnotationRefused`). **Second instance in one PR** of a green test codifying a bug (see the `jump to` row) — when a pre-existing test contradicts a new guard, re-derive the ground truth from the layer the symptom lives in before believing either. | + + +| A page datasource navigating an association writes `DestinationEntity: ""`, and the project becomes **unloadable**: `An error occurred when trying to set the 'DestinationEntity' property of a Entity ref step ... ---> System.ArgumentNullException` at `EntityRefStep.set_DestinationEntityId`. Studio Pro will not open it and `mx check` dies before validating anything. `mxcli check` and `exec` both succeed | `resolveAssociationDestination` resolves both ends via `entityQNByID`, which only sees the **project's own** domain models — an association ending in a **System** entity (`from W.Issue to System.Workflow`) yields `""` for that side. The context then matched neither end, and the fallback `return childEntity` returned the empty one. An empty by-name reference is not "absent", it is a reference Mendix resolves to null | `mdl/executor/cmd_pages_builder_v3.go` (`resolveAssociationDestination` fallbacks + a hard guard in the `association` case of `buildDataSourceV3`) | Prefer whichever end actually resolved and is not the context; then **refuse** an unresolved destination rather than write it, pointing at the explicit `Assoc/Module.Entity` form (verified to build 0 errors — it is the construct the reporter had abandoned). **Narrower than reported**: the finding blamed *nesting*, but a one-step probe with the same association reproduces it identically — nesting was incidental. Always re-derive the trigger with the smallest case before fixing the reported shape. **Generalisable**: a resolver that returns `""` on failure will silently produce a null by-name reference; make the write path refuse empty rather than trusting the resolver. Repro `mdl-examples/bug-tests/it-14-assoc-destination-entity.mdl`; A/B: pre-fix binary leaves the project unopenable, fixed binary refuses and the project checks 0 errors. issuetracker #14 | +| `describe workflow` renders a plain `jump to Review;` as `jump to Review comment 'Review';` — a comment clause the author never wrote, which then round-trips back into the model as a real caption | `buildJumpTo` defaults the activity's `Caption` to the **target name**, and the DESCRIBE emitter echoed `Caption` unconditionally (falling back to the activity `Name` when empty). Both are derived values carrying no authored information | `mdl/executor/cmd_workflows.go` (`JumpToActivity` case in `formatWorkflowActivities`) | Emit `comment '...'` only when the caption is genuinely authored — non-empty **and** different from both the target name and the activity name. **Watch for tests that codify the bug**: `TestFormatJumpTo_CaptionCommentFormat` had a "name fallback when caption empty" case asserting the phantom comment, and two issue-619 quoting tests were incidentally coupled to it; a green suite was pinning the defect in place. Tests `mdl/executor/cmd_workflows_describe_test.go`, `mdl/executor/issue619_emitter_quoting_test.go`. issuetracker #16 | +| A workflow `decision ''` referencing the context passes `mxcli check`, executes, then the build fails `[error] [CE0117] "Error(s) in expression." at Decision 'Decision'`. `$WorkflowContext/X` (exact casing) works; `$workflowContext/X` — the spelling this repo's own skill documented — and `$Ctx/X` from the author's `parameter $Ctx:` header both fail | mxcli always stores the context parameter as `WorkflowContext` and Mendix expressions are **case-sensitive**. `normalizeWorkflowContextExpr` already existed but was wired into `autoBindCallMicroflow` only, so `with (...)` mappings were normalized while a decision's condition was written through verbatim. Separately, the header's declared variable name was parsed into `ast.CreateWorkflowStmt.ParameterVar` and then **never consumed** — a field populated but read nowhere, so `$Ctx` resolved to nothing | `mdl/executor/cmd_workflows_write.go` (`contextExprNormalizer` + threading it through `autoBindActivitiesInFlow`), `mdl/executor/cmd_alter_workflow.go`, skill `.claude/skills/mendix/write-workflows.md` (whose examples were the failing spelling) | One normalizer applied to **every** expression an author can write in a workflow — decision conditions, user task due dates and XPath targeting, wait-for-timer delays, call-microflow mappings — rather than a second point fix. The declared name is aliased onto the stored one (whole-word, so `$CtxItem` is not mangled) instead of documenting it as meaningless. **Generalisable**: grep for an AST field that is written by the visitor and read nowhere — that is a silently-discarded user intent, not dead code. Repro `mdl-examples/bug-tests/it-17-workflow-context-expression.mdl`; A/B: pre-fix binary writes it and `mx check` reports CE0117, fixed binary checks 0 errors. issuetracker #17 | +| A page widget bound through an association — `Attribute: Issue_Assignee/Name` — passes `mxcli check`, executes, then fails `[error] [CE1613] "The selected attribute 'IT.Issue.Issue_Assignee/Name' no longer exists."`. The error text is the raw MDL path glued onto the context entity. Same-module paths (`Issue_Project/Code`) work | A domain model keeps associations in **two** lists. `Associations` holds intra-module ones (both ends BY_ID); an association targeting another module is a `DomainModels$CrossAssociation` in **`CrossAssociations`**, where only the local end is BY_ID and the remote end is the BY_NAME `ChildRef`. `associationEndpoints` searched only the first list, so every cross-module hop returned ok=false and the writer fell back to a flat attribute path instead of an `AttributeRef` with an `IndirectEntityRef` of steps | `mdl/executor/cmd_pages_builder_v3.go` (`associationEndpoints`, `resolveAssociationDestination`), `mdl/executor/widget_engine.go` + `cmd_pages_builder_input.go` (`resolveAssociationPathIn`, `storedSystemMemberName`) | **Scope correction — the reported trigger was wrong.** The finding blamed the *System module*; a plain second app module reproduces it identically, so the trigger is cross-module. Fixing "System" alone would have left the commoner case broken — always re-derive the trigger with a neutral variant before fixing the reported one. Two sibling defects in the same finding: (a) a ComboBox's `Association:` was qualified with the module of its **own option list**, because the `DataSource:` mapping runs first and moves `pageBuilder.entityContext` — an association belongs to the *containing* entity, so it now resolves against the context saved at `Build` entry (`outerEntityContext`); (b) `CreatedDate: AutoCreatedDate` is the spelling mxcli **requires** when declaring an audit member, but the member is stored as `createdDate`, so binding the name you just declared failed — `storedSystemMemberName` now maps declared→stored. **Generalisable**: when a resolver reads one collection off a model object, check whether the model splits that concept across two (intra- vs cross-module, own vs inherited). Repro `mdl-examples/bug-tests/it-19-cross-module-attribute-path.mdl`; A/B on Mendix 11.12.1: pre-fix binary → 4 × CE1613, fixed binary → 0 errors. issuetracker #19 | +| GRANT rejects members Mendix does recognise — `entity M.Label has no member(s) Issue_Label` for the non-owning end of an `OWNER Both` reference set, and `has no member(s) createdDate, changedDate` for audit members — and a `read * / write *` rule that looks complete still fails `[error] [CE0066] "Entity access is out of date."`, so partial coverage is worse than none | Two unrelated gaps in "what counts as a member". (a) `OWNER Both` makes an association a member of **both** ends, but the writer emitted the MemberAccess only for the FROM entity (`ParentID`) **and** `ReconcileMemberAccesses` independently applied the same FROM-only rule — so it stripped the entry back out on the next write even if the executor had added it. Two places had to agree. (b) Audit members are entity **flags** (`HasCreatedDate`/`HasChangedDate`), not entries in `entity.Attributes`, so the member walk never yielded them | `mdl/executor/cmd_security_write.go` (`execGrantEntityAccess`, `storedAuditMembers`, `otherModuleBothOwnerAssociations`), `mdl/backend/modelsdk/domainmodel_security_write.go` (`ReconcileMemberAccesses`) | **Ask mxbuild what it wants instead of inferring symmetry.** Emitting a MemberAccess for `createdDate` seemed like the obvious fix for (b) — mxbuild **rejects** it with CE0066, and an entity storing audit members checks clean with no entry. So audit members are accepted as names but per-member rights on them are **refused with the reason** rather than silently dropped; only the `OWNER Both` association actually needed a new entry. **When a symptom has two spellings (a rejection and a build error), check whether they are one bug or two** — here they were two, and fixing them the same way would have introduced a new CE0066. **Generalisable**: a writer and a reconciler that both compute "the expected member set" are one invariant in two places; changing one alone is silently undone. Repro `mdl-examples/bug-tests/it-20-grant-member-coverage.mdl` (+ `it-20-grant-audit-member-rights.fail.mdl`); A/B on Mendix 11.12.1, same module same project: pre-fix binary → CE0066 + the bogus rejection, fixed binary → 0 errors. Controlled: the identical model with `OWNER Default` checks clean pre-fix, so the owner mode is the trigger. issuetracker #20 | +| An unquoted negative number in an XPath constraint fails to parse: `where [Amount > -7]` → `Parse error: extraneous input '7' expecting {',', ')'}`. Reported as "negative numeric literals truncate (`-7` becomes `-`)" | `xpathWord` — the name-part rule inside XPath — is a **negated token set** that did not exclude `MINUS`, so the sign was consumed as a name word and the digits were left stranded (hence the truncation appearance). The lexer deliberately keeps `-` out of `NUMBER_LITERAL` (a leading sign there mis-tokenises `$x -2`), leaving negation to the parser; the general grammar has `unaryExpression` for this and the XPath grammar simply never got the equivalent | `mdl/grammar/domains/MDLPage.g4` (`xpathValueExpr` gains `MINUS xpathValueExpr`; `MINUS` added to the `xpathWord` exclusion set), `mdl/visitor/visitor_xpath.go` (`buildXPathValueExpr`), `mdl/visitor/visitor_page_v3.go` (`xpathExprToString` emits `-7`, not `- 7`) | **The grammar fix alone is worse than the bug.** With the parser accepting `-7` but the XPath AST builder having no case for the new alternative, the constraint parses and silently serializes to `[Amount > ]` — a dropped operand instead of a loud parse error. Caught only because the visitor has a round-trip helper; the microflow write path uses `GetText()` and looked fine. **When adding a grammar alternative, check every consumer of that rule, not just the one your repro exercises.** **Scope correction**: the finding's own example (`addDays([%CurrentDateTime%], -7)`) still fails — `addDays` is a *microflow expression* function, not an XPath one, and it fails `CE0161` with a POSITIVE argument too, so the sign was never its problem. Repro `mdl-examples/bug-tests/it-18-xpath-negative-literal.mdl`; A/B on Mendix 11.12.1: pre-fix the script does not parse, fixed binary writes it and `mx check` reports 0 errors. issuetracker #18 | + +| Text painted by an **Atlas topbar widget is invisible in a dark theme** — the language selector measures ~1.13:1 contrast, glyph pixels spanning 4 luminance values out of 255. A theme override exists and *names the right element*, so it looks handled | Two separate mistakes stacked. (1) **Specificity**: Atlas's own rule is `.navbar-brand .widget-language-selector .current-language-text` at (0,3,0); a bare `.current-language-text` at (0,1,0) never wins, and only appears to on layouts that do not nest the selector under `.navbar-brand`. (2) **Wrong value**: `color: inherit` inherits *body ink*, which is dark, while the rail is dark in both palettes — so even at the winning specificity it measures 1.00:1. Atlas paints from `--bg-color-secondary` with a `#fff` fallback because it assumes a dark rail | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-atlas-map.scss` (the "Atlas fixups" block) | Re-declare **Atlas's own selector shape** so the weights match and source order decides, and resolve the colour through the rail token (`var(--mxt-rail-ink-active, var(--mxt-rail-ink))`) rather than `inherit`. List the bare and the `.navbar-brand`-nested selectors together — each is matched at its own specificity, so one rule covers both layouts. **Generalisable — the shape to look for**: a guard that names the right element is not evidence it applies. Read the *winning* declaration (`CSS.getMatchedStylesForNode` in DevTools, or the computed value) instead of the one you wrote. **Measure contrast, not colour**: reading `getComputedStyle(el).color` once and seeing a plausible value proves nothing — compute the WCAG ratio against the first non-transparent ancestor background, which is what turns "looks fine" into 1.13 vs 19.47. Reported from the RssReader test build; tests in `cmd/mxcli/theme/theme_test.go`, verified in a browser at 17.79:1 light / 19.47:1 dark | + **Key insight:** `microflows$ListRange` stores offset/limit inside a nested `CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before extracting `OffsetExpression`/`LimitExpression`. diff --git a/.claude/skills/mendix/alter-page.md b/.claude/skills/mendix/alter-page.md index b26f36b81..cd288f9fb 100644 --- a/.claude/skills/mendix/alter-page.md +++ b/.claude/skills/mendix/alter-page.md @@ -208,6 +208,10 @@ If an ALTER targeting a DataGrid column completes without error but makes no cha If the column name you copied from DESCRIBE still doesn't work, check whether the column has an attribute binding — attribute names take priority over captions. +**The authored `column colFoo (...)` name is NOT how you address it.** A column carries no stored name in the Mendix model, so the name you wrote in `create page` is dropped on write — always address a column by its *derived* name (the one `describe page` shows). Using the authored name now fails with an error that lists the available column names, rather than a bare "not found". + +**Duplicate captions are ambiguous and rejected.** Two dynamic-text (or custom-content) columns with the same caption derive the same name, so `ON "Amount"` can't tell them apart. mxcli now refuses the operation with an ambiguity error instead of silently mutating the first and leaving the second unreachable. Give such columns distinct captions to address them individually. (Non-attribute column handles are the caption, so `set Caption = ...` also *renames* the handle — plan multi-step caption edits accordingly.) + ### ADD Variables - Add a Page Variable ```sql diff --git a/.claude/skills/mendix/atlas-design.md b/.claude/skills/mendix/atlas-design.md index c5a21d57d..5aab6983a 100644 --- a/.claude/skills/mendix/atlas-design.md +++ b/.claude/skills/mendix/atlas-design.md @@ -24,9 +24,9 @@ built on the Atlas building blocks every Mendix project already ships. 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) +7. [Layer 1 in practice — start from the shipped theme](#layer-1-in-practice--start-from-the-shipped-theme) 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) +9. [Dark mode — Mendix 11 makes this cheap](#dark-mode--mendix-11-makes-this-cheap) 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) @@ -61,8 +61,8 @@ 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 2 IDENTITY theme/web/_.scss, imported from theme/web/main.scss — recipe + classes (mono type, status pills, timeline spine) — ONLY what Atlas can't do 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 @@ -72,13 +72,26 @@ Layer 0 ATLAS Atlas classes / design properties / building blocks — str 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. + Switch/Slider/ProgressBar) picks up your palette. Start from the shipped theme + rather than a blank file — see below. +- **Layer 2 — Identity.** Only the handful of shapes Atlas genuinely can't express. + Put them in a partial imported from **`theme/web/main.scss`**, which compiles + *last* — after Atlas Core and after every module theme source — so your rules win + without `!important`. Use `themesource//web/main.scss` only when the styling + belongs to that module: a theme source folder whose name does not match a real + module is **silently not compiled**. See `theme-styling.md`. - **Layer 3 — Verify.** Non-negotiable. `mx check` misses client-side crashes; you must screenshot a *running* build. +**Start from the shipped default, don't start from nothing.** `mxcli new` applies +the `signal` theme, and `mxcli theme apply -p app.mpr` adds one (`signal`, +`ledger` or `console`) to an existing project. Each carries a full palette in +both light and dark, vendored fonts, the focus ring, the density scale and the +`num` / `pill` / `stat` recipe classes. Re-brand by changing `--mxt-brand` in the +palette block; the block is digest-fenced, so mxcli refuses to overwrite your +edits rather than silently discarding them. `mxcli theme show ` lists +exactly which files it writes. + 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 @@ -415,9 +428,10 @@ more idiomatic form to mirror from a `describe building block`. Notes: ## 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: +Retune the palette in `theme/web/custom-variables.scss` — the file +`mxcli theme apply` writes (see the next section; do not hand-roll one). 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, @@ -430,82 +444,66 @@ only for shapes Atlas can't provide. --- -## Layer-1 brand scaffold — copy into theme/web/custom-variables.scss +## Layer 1 in practice — start from the shipped theme + +**Do not hand-roll a brand scaffold.** `mxcli theme apply -p app.mpr` writes a +complete, verified Layer 1 (and Layer 2) into `theme/web/`, and `mxcli new` +applies one by default. Re-brand it instead of competing with it — the generated +blocks are digest-fenced, so a hand-written palette in the same file will either +be refused on the next apply or silently fight the theme in the cascade. + +```bash +mxcli theme list # signal (default), ledger, console +mxcli theme show signal # palette, and every file it writes +mxcli theme apply signal -p app.mpr # --variant auto | light | dark +``` + +### The token architecture it gives you + +A theme separates the palette from the wiring, and that split is the whole reason +a light/dark flip or a re-brand is cheap: + +| File | Holds | You edit | +|---|---|---| +| `theme/web/custom-variables.scss` | the palette — `--mxt-*` tokens for the default variant | **yes, this one** | +| `theme/web/_mxcli-atlas-map.scss` | ~60 Atlas variables expressed as `var(--mxt-*)` | no | +| `theme/web/_mxcli-.scss` | the other palette, variant blocks, `@font-face`, recipe classes | rarely | + +To re-brand, change one line in the palette: ```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}; + --mxt-brand: #0f6e6b; /* the one colour that defines the app */ + --mxt-ground: #f4f6f8; /* app background */ + --mxt-surface: #ffffff; /* cards, modals, panels */ + --mxt-ink: #14181f; /* primary text */ + --mxt-line: #dce1e7; /* hairlines */ } ``` +Atlas derives `--brand-primary-50` … `-900` from `--brand-primary` with CSS +`color-mix()`, so buttons, links, active navigation, alerts, group boxes and the +brand-aware pluggable widgets (Switch, Slider, RangeSlider, ProgressBar, +ProgressCircle, BadgeButton) all follow — in **both** palettes, with no +per-widget CSS. + +### Two rules that decide whether your styling survives + +1. **Mendix 11 Atlas is CSS-custom-property-first.** Write `:root { --x: … }` + declarations, not SCSS `$x: … !default;`. The stock `custom-variables.scss` is + a `:root` block plus a few SCSS switches (`$font-family-import`, + `$btn-bordered`, `$use-css-variables`); legacy Sass variables are still mapped + for old modules, but they are not the idiom. +2. **Never pin an Atlas variable to a literal colour.** Map it to a token + (`--bg-color: var(--mxt-ground)`), which is what the Atlas map does. A + hardcoded `--font-color-default` is near-black on a near-black ground the + moment anything flips the palette — the failure is total and silent. + +If you genuinely need a token the theme does not expose, add it to the palette +block and reference it from your own Layer-2 rules. See `theme-styling.md` for +the compile order and for why `theme/web/main.scss` is the only correct home for +app-level rules. + --- ## Charts — a dataviz-grade theme for the Mendix chart widgets @@ -564,20 +562,44 @@ scaffold). The generic `dataviz` skill is the HTML/React analogue of this — sa --- -## Dark mode — commit to one theme +## Dark mode — Mendix 11 makes this cheap + +Older guidance here said to commit to a single theme, because a +`prefers-color-scheme` flip repainted your own classes but left Atlas widgets +light. **That was Atlas 3. It does not hold on Mendix 11.** + +Measured by adding `theme-dark` to `` on a running 11.13 app and changing +nothing else: the page ground, cards, form controls, sidebar, buttons and +DataGrid2 all followed. Atlas is CSS-custom-property-first now, so the token +cascade genuinely propagates. And because the class lands on ``, popups and +modals — which Mendix renders at ``, outside any page container — follow it +too, which was the other half of the old objection. + +The practical route is `mxcli theme apply ` with the default +`--variant auto`: it ships both palettes, follows the OS before first paint, and +honours a `theme-light` / `theme-dark` class when a switcher sets one. Add +`mxcli theme switcher install` for a user-facing toggle. + +Three things to know if you build this by hand: + +1. **Mendix ships the slot, not the switcher.** `theme/web/_theme-dark.scss` + declares `:root.theme-dark`; nothing in Atlas ever applies the class. +2. **Your dark block must come after Mendix's** — same specificity, later wins. + Otherwise its stock Mendix blue overrides your brand the moment the class + appears. +3. **Anything you pinned to a literal colour breaks.** This is the whole reason + Layer 1 maps Atlas variables to tokens instead of to hex values. -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:** +The rail is the one place Atlas still assumes: several topbar widgets paint text +with `--color-base`, expecting white because they expect a dark navigation rail. +Keep the rail dark in both palettes, or force `color: inherit` on those widgets. -- 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 remain the exception — series colour lives in the model +(`customSeriesOptions`), not CSS, so it does not follow a runtime flip. Use the +transparent `paper_bgcolor` trick above, which is correct in both palettes. -Charts are the exception — don't CSS them; use the transparent `customLayout` trick -above, which adapts to light **and** dark automatically. +The override sheet below is still useful for a hand-rolled dark theme, or for +Atlas corners a token flip misses. --- diff --git a/.claude/skills/mendix/create-page.md b/.claude/skills/mendix/create-page.md index d49da690c..d366f6b4c 100644 --- a/.claude/skills/mendix/create-page.md +++ b/.claude/skills/mendix/create-page.md @@ -1134,6 +1134,35 @@ The following features are NOT implemented in mxcli and require manual configura > DYNAMICTEXT spacer (Content: ' ') > ``` +### Binding across modules and to audit members + +An attribute path may cross module boundaries, including into the platform's +`System` module — the association does not need to live in the same module as +the entity it targets: + +```sql +create association IT.Issue_Assignee from IT.Issue to System.User; + +DATAVIEW dv (DataSource: $Issue) { + DYNAMICTEXT txtAssignee (Attribute: Issue_Assignee/Name) -- into System + DYNAMICTEXT txtApprover (Attribute: Issue_Approver/Name) -- into another module +} +``` + +A bare association name is qualified with the module of the entity the widget +sits on. On a ComboBox that matters: its `DataSource:` is the *option list*, but +`Association:` names a reference on the containing entity, so +`Association: Issue_Assignee` resolves against the dataview's entity, not the +option list's module. + +Audit members declared with the `Auto*` pseudo-types bind under the name you +declared: + +```sql +create or modify persistent entity IT.Issue ( CreatedDate: AutoCreatedDate ); +DYNAMICTEXT txtCreated (Attribute: CreatedDate) -- also accepts createdDate +``` + **Script Execution Note:** Script execution stops on the first error. If a page fails to create (e.g., invalid widget syntax), earlier statements in the script will have already been committed. Plan scripts with uncertain syntax in phases. ## Tips diff --git a/.claude/skills/mendix/manage-security.md b/.claude/skills/mendix/manage-security.md index 425a72e4b..7236ffbc3 100644 --- a/.claude/skills/mendix/manage-security.md +++ b/.claude/skills/mendix/manage-security.md @@ -311,6 +311,26 @@ show security matrix in Shop; describe user role AppAdmin; ``` +### What counts as a member of an entity + +An access rule has to cover **every** member of the entity, or Mendix fails the +build with `CE0066 "Entity access is out of date"` — a partially covered rule is +worse than no rule at all. `read *` / `write *` cover them all, including the +ones that are easy to overlook: + +| Member | Named in a GRANT as | Notes | +|--------|--------------------|-------| +| Attributes (own and inherited) | the attribute name | see "Inherited members" above | +| Association, `OWNER Default` | the association name | **on the FROM entity only** — adding it to the TO side is itself a CE0066 | +| Association, `OWNER Both` | the association name | on **both** entities — each end owns it | +| `createdDate` / `changedDate` | the member name | covered by the rule's **default** only; Mendix stores no per-member access for them | +| `owner` / `changedBy` | — | emitted automatically as `System.owner` / `System.changedBy` | + +Audit members are the one case where naming a member cannot change its rights: +`grant R on M.E (write *, read (createdDate))` is refused, because Mendix has no +member access to write for it and a rule that carries one fails CE0066. Let the +rule's default cover it, or change the default. + ## Common Mistakes 1. **Creating module roles before the module exists** — `create module` must come first diff --git a/.claude/skills/mendix/rest-client.md b/.claude/skills/mendix/rest-client.md index bad1d92c2..b983bc45b 100644 --- a/.claude/skills/mendix/rest-client.md +++ b/.claude/skills/mendix/rest-client.md @@ -133,6 +133,27 @@ response: mapping Module.ResponseEntity { } ``` +**`mapping` takes an ENTITY plus a body — never a mapping document.** The name +after `mapping` is the target entity, and the `{ ... }` body lists the JSON +fields; Mendix stores the result inline on the operation +(`Rest$ImplicitMappingResponseHandling`). A consumed REST operation has nowhere +to put a reference to a standalone import/export mapping — the metamodel defines +only the inline handler and "no response handling". So this is **wrong**, and is +now rejected by `mxcli check` as `MDL-REST01`: + +```sql +response: mapping Module.IMM_Something -- ✗ names a mapping document, no body +``` + +If you already have an import mapping document you want to reuse, call it from +the microflow instead — `send rest request` with `response: json as $Raw`, then +`$Obj = import from mapping Module.IMM_Something($Raw)`. + +Query parameters carry no type in the Mendix model: `Rest$QueryParameter` stores +a name only. MDL still requires `$name: Type` for the sake of the grammar, but +the type is dropped at write time, so `describe` re-emits every query parameter +as `String`. + ### Step 2 — Call from a Microflow ```sql diff --git a/.claude/skills/mendix/theme-styling.md b/.claude/skills/mendix/theme-styling.md index 1b57c69c2..6f8f96fe0 100644 --- a/.claude/skills/mendix/theme-styling.md +++ b/.claude/skills/mendix/theme-styling.md @@ -49,10 +49,92 @@ MyProject/ 5. MXUI components 6. Core styles (base, animations, spacing, flex) 7. Widget-specific styles -8. Module-specific styles from `themesource/*/web/*.scss` + +Then each **module's** `themesource//web/main.scss`, and **last of all** +`theme/web/main.scss`. Variables declared earlier are overridden by later declarations (with `!default` flag). This means `custom-variables.scss` overrides `atlas_core/web/variables.scss` values. +### Where to put app-level styling — three rules that are not obvious + +Verified against a real Mendix 11.13 project (probe rules compiled with +`mxbuild --target=deploy`, then grepped out of `theme-cache/web/theme.compiled.css`). + +**1. `theme/web/main.scss` compiles LAST — it is the right home for app styling.** +After Atlas Core *and* after every module theme source, so a partial imported +here overrides any Atlas rule with **no `!important`**. It is a three-line file of +Mendix's own imports, not an Atlas-owned file; appending one `@import` is safe: + +```scss +@import "custom-variables"; +@import "theme-dark"; +@import "theme-neutral"; +@import "my-app"; // -> theme/web/_my-app.scss +``` + +**2. A `themesource//` folder is only compiled when `` is a real module.** +mxbuild walks the model's modules and pulls each one's theme source; it never +globs the directory. An invented folder (`themesource/my_theme/`) is **silently +skipped** — build succeeds, rules simply absent. Use a module's theme source only +when the styling belongs to that module (it then exports with the `.mpk`). + +> Debugging "my CSS doesn't apply": first prove the file is compiled *at all* — +> grep a unique probe selector in `theme-cache/web/theme.compiled.css`. Absent and +> overridden look identical in the browser, and only one of them is a +> specificity problem. + +**3. `theme/web/custom-variables.scss` is imported once PER MODULE** (8× in a +blank app). It must hold **declarations only** — a CSS rule there is emitted once +per module. Tokens go here; rules go in the Layer-2 partial. + +### Mendix 11: CSS custom properties, not SCSS variables + +The stock `theme/web/custom-variables.scss` is a `:root { --brand-primary: … }` +block plus a few SCSS switches (`$font-family-import`, `$btn-bordered`, +`$use-css-variables`). Legacy Sass variables are still mapped +(`_css-variables-mappings.scss`), but the modern idiom is `:root` declarations. +The derived ramp (`--brand-primary-50…900`) is built with CSS `color-mix()` +against `var(--brand-primary)`, so retuning the primary re-derives the whole ramp +live — no SCSS recompilation of variants needed. + +### Fonts: vendor them under `theme/web/` + +`theme/web//` is copied to the deployment web root, and +`theme.compiled.css` is served from that root — so fonts at +`theme/web/fonts/x.woff2` are referenced as `url("./fonts/x.woff2")`. Prefer this +over `@import url('…fonts.googleapis…')`: no `@import`-ordering trap, no +third-party request per page load, and the app renders correctly air-gapped. + +`mxcli theme apply` does exactly this — see `mxcli theme show signal`. + +### Light/dark: Mendix ships the slot, not the switcher + +`theme/web/_theme-dark.scss` and `_theme-neutral.scss` declare `:root.theme-dark` +and `:root.theme-neutral`. **Nothing in Atlas ever applies those classes** — grep +`themesource/` and you will find no reference. They are a slot for you to drive. + +Three consequences worth knowing before building any light/dark support: + +1. **A token flip really does repaint Atlas.** Adding `theme-dark` to `` on + a running Mendix 11 app turns the page ground, cards, form controls, sidebar, + buttons and DataGrid2 dark, with no per-widget CSS. This is materially better + than Atlas 3, where the same trick left widgets light. And because the class + is on ``, popups and modals rendered at `` follow it too. +2. **Your dark block must come after Mendix's.** `_theme-dark.scss` hardcodes + stock Mendix blue at `:root.theme-dark`. Declare the same selector from a file + imported later in `theme/web/main.scss` — same specificity, later wins — or + your brand vanishes the moment the class appears. +3. **Never pin an Atlas variable to a literal colour.** Map it to a token + (`--bg-color: var(--my-ground)`) so a variant restates the tokens, not the + wiring. A hardcoded `--font-color-default` is invisible on a dark ground. + +The rail is the one place Atlas still assumes: several topbar widgets paint text +with `--color-base`, expecting white, because they expect a dark navigation rail. +Keep the rail dark in both variants, or force `color: inherit` on those widgets. + +For a working implementation of all of the above, read the generated +`theme/web/_mxcli-atlas-map.scss` in any themed project. + ## CSS Hot-Reload Workflow For theme/styling changes during Docker development: diff --git a/.claude/skills/mendix/write-workflows.md b/.claude/skills/mendix/write-workflows.md index 0c2549faf..ed777c0b7 100644 --- a/.claude/skills/mendix/write-workflows.md +++ b/.claude/skills/mendix/write-workflows.md @@ -43,6 +43,15 @@ end workflow; - The body closer is `end workflow`, **not** `end`. `end;` fails (`missing WORKFLOW`). +**The context is always stored as `WorkflowContext`.** Whatever you name the +variable in the header, mxcli writes the parameter as `WorkflowContext`, so +`$WorkflowContext/Attribute` is the canonical way to reach it in an expression. +The name you declared (`$Context` above) and any casing of the canonical name +(`$workflowContext`) are rewritten to it on write — in decision conditions, user +task due dates and XPath targeting, wait-for-timer delays, and `with (…)` +parameter mappings. Anything else is an undefined variable and Mendix fails the +build with `CE0117 "Error(s) in expression."`. + `create or replace workflow …` and `create or modify workflow …` are supported. ## Activities @@ -93,12 +102,17 @@ begin -- Call a sub-workflow call workflow Module.SubProcess comment 'delegate'; - - -- Sticky-note annotation - annotation 'Escalation path per policy 4.2'; end workflow; ``` +> **Do NOT use `annotation '...'` in a workflow body.** It parses, but the +> annotation is written into the workflow's activity flow, which Mendix loads by +> constructing every child with a `Flow` parent — no annotation type takes one, so +> the resulting `.mpr` **cannot be loaded at all**: Studio Pro will not open the +> project and `mx check` fails before validating anything. `mxcli` now refuses the +> statement (MDL-WF04) at both check and exec time. Keep the note as an MDL comment +> (`-- ...`); workflow canvas annotations are not yet writable. + **Boundary events** attach a timer to a user task / call-microflow / wait: ```sql diff --git a/.claude/skills/mendix/xpath-constraints.md b/.claude/skills/mendix/xpath-constraints.md index 0db67be7e..f7274c29a 100644 --- a/.claude/skills/mendix/xpath-constraints.md +++ b/.claude/skills/mendix/xpath-constraints.md @@ -33,6 +33,23 @@ This skill provides reference for writing XPath constraint expressions in MDL RE > `mxcli check` explains this and shows the workaround when it sees `+`/`*`/`div`/ > `mod` inside a constraint. +> **A negative literal is fine, though.** A leading `-` on a number is a value, +> not arithmetic, and needs no quoting: +> ```mdl +> retrieve $L from Mod.T where [Amount > -7]; +> retrieve $L from Mod.T where [Amount <= -12.5 and Code != 'X']; +> ``` + +> **Date arithmetic is not available in XPath.** `addDays()`, `addMonths()` and +> friends are *Mendix expression* functions — using one in a constraint fails the +> build with `CE0161` regardless of its arguments. For relative dates use the +> date tokens (`[%CurrentDateTime%]`, `[%BeginOfCurrentDay%]`, …), or compute the +> cut-off in a variable first and compare against that: +> ```mdl +> $Cutoff = addDays([%CurrentDateTime%], -7); +> retrieve $L from Mod.T where [DueDate > $Cutoff]; +> ``` + ## Syntax Reference ### Simple Comparisons diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0134061..5408ae4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Default styling — generated apps that look designed on first boot** (`mxcli theme`, `mxcli new --theme`) — three themes ship in the binary: **signal** (the default: cool slate, one teal signal colour, 4px radius, 32px rows, IBM Plex), **ledger** (warm paper, hairline rules instead of card shadows, Source Serif over Source Sans) and **console** (dark-first, Space Grotesk over JetBrains Mono). A theme is files under `theme/` only — the model is never touched, so it hot-applies under `run --local --watch` and cannot affect a build. Atlas Core is untouched, so projects stay upgradable. Re-branding is one line (`--mxt-brand`); Atlas derives the whole colour ramp from it. Fonts are vendored (SIL OFL 1.1) rather than pulled from a CDN, so generated apps render correctly air-gapped. Generated regions are digest-fenced: a block carrying local edits is refused rather than overwritten. `mxcli theme list | show | apply | remove`; `--theme none` opts out. See `docs-site/src/tools/theme.md`. +- **Light/dark palettes and runtime theme switching** — every theme ships both palettes. `--variant auto` (the default) follows the operating system's `prefers-color-scheme` **before first paint** and honours a `theme-light` / `theme-dark` class on the root element; `--variant light|dark` bakes a single palette. Mendix ships the `:root.theme-dark` slot but nothing that applies it, so `mxcli theme switcher install` adds the JavaScript actions and a nanoflow for a toggle button — the one theme subcommand that writes to the model. Known limit: a reload falls back to the OS preference, because Mendix has no page on-load event and the usual substitute (a data view with a nanoflow data source) is not authorable on either engine yet. + +### Fixed + +- **`mxcli theme remove` with no name removed nothing** — it targeted the built-in default rather than the theme actually installed, so on a project themed with `ledger` or `console` it reported every file as unchanged and exited 0, leaving the theme in place. Both `apply` and `remove` now read the installed theme from the `mxcli:theme` markers; removing from a project with no theme is an error rather than a silent no-op. Reported from the RssReader test build. +- **Switching themes orphaned the previous theme's block in `_mxcli-atlas-map.scss`** — the file the three themes share was left with both blocks, doubling it. Harmless while the Atlas maps are identical, but it broke the documented "only one theme at a time" invariant. Reported from the RssReader test build. +- **The topbar language selector was unreadable in every dark palette** (1.13:1 measured contrast, against a WCAG AA target of 4.5). Atlas paints it from `--bg-color-secondary` with a `#fff` fallback at a specificity the theme's guard did not match. Now re-declared at matching specificity and resolved through the rail token — 17.79:1 light, 19.47:1 dark. Reported from the RssReader test build. +- **JavaScript action sources were written to the wrong directory** — `CREATE JAVASCRIPT ACTION` wrote to `javascriptsource//actions/`, but Mendix reads a **lowercased** module directory. MxBuild found nothing there, generated a stub whose body throws `JavaScript action was not implemented`, and bundled that — so the action parsed, passed `mxcli check`, built cleanly, and threw the moment it ran. Only reproduced on a case-sensitive filesystem, which is why it went unnoticed on macOS and Windows. + ## [0.16.0] - 2026-07-12 Headline: **Pluggable chart authoring reaches round-trip fidelity**, plus in-place enum-caption editing, named layout placeholders, and a batch of new pre-build `check` heuristics. Charts gain widget-level datasource attributes, the `LINE`/`SCALECOLOR` object-list keywords, and a `DESCRIBE` that reconstructs them as executable MDL; workflows and widget-less pages now describe cleanly; view-entity OQL is validated before build; and several authoring mistakes are caught at `mxcli check` time instead of only by MxBuild. diff --git a/CLAUDE.md b/CLAUDE.md index 87e7f895f..071b69322 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,49 @@ without guessing, run the new mxbuild's own migration over an old project (`mx convert -p -s `) and diff the BSON: Mendix ships a one-time conversion per renamed property, so the converted document is authoritative. +### Theme Files: Where SCSS Actually Compiles + +Styling written to the wrong place fails **silently** — the build succeeds and the +rules are simply absent, which is indistinguishable in the browser from a +specificity problem. Verified on Mendix 11.13 (probe rules compiled, then grepped +out of `theme-cache/web/theme.compiled.css`): + +- **`theme/web/main.scss` compiles LAST** — after Atlas Core *and* after every + module theme source. A partial imported from it overrides any Atlas rule with no + `!important`. This is the home for app-level styling (Layer 2), and it is a + three-line file of Mendix's own imports, not an Atlas-owned file. +- **`themesource//` is only compiled when `` matches a real module.** + mxbuild walks the model's modules; it never globs the directory. An invented + folder is skipped without a warning. Use a module's theme source only when the + styling belongs to that module. +- **`theme/web/custom-variables.scss` is imported once per module** (8× in a blank + app), so it must hold **declarations only** — a rule there is emitted N times. + Tokens go here (Layer 1); rules go in the partial. +- **Mendix 11 Atlas is CSS-custom-property-first**: `:root { --brand-primary: … }`, + not SCSS `!default`. The derived ramp is CSS `color-mix()` against + `var(--brand-primary)`, so retuning the primary re-derives it live. + +`cmd/mxcli/theme` encodes all four. Its embed uses `//go:embed all:assets` — a +plain `go:embed assets` skips `_`-prefixed files, which is exactly how SCSS spells +a partial. Files the project already owns are written as digest-fenced blocks +(guard-don't-drop, as in ADR-0005): a block with local edits is refused, not +overwritten. + +Two more, learned by flipping the variant on a running app: + +- **Atlas ships `:root.theme-dark` / `:root.theme-neutral` in `theme/web/` but + nothing that applies them** — the slot exists, the switcher does not. A theme's + own dark block must be declared at `:root.theme-dark` *after* Mendix's + `_theme-dark.scss` (same specificity, later wins), or the app reverts to stock + Mendix blue the moment the class appears. Because the class lands on ``, + popups and modals rendered at `` follow it too. +- **Never pin an Atlas leaf to a literal colour.** Map it to a theme token + (`--bg-color: var(--mxt-ground)`) so a variant restates ~30 values instead of + ~60. A hardcoded `--font-color-default` is invisible the moment the ground goes + dark. Two Atlas rules also assume a *dark navigation rail* and paint topbar text + with `--color-base`, so every mxcli theme keeps the rail dark in both variants + and forces `color: inherit` on those widgets. + ### Association Parent/Child Pointer Semantics (Counter-Intuitive) **CRITICAL**: Mendix BSON uses inverted naming for association pointers: @@ -463,7 +506,9 @@ go build -o bin/mxcli ./cmd/mxcli | **Data import** | `import from query '...' into Module.Entity map (...)` | Import from external DB into Mendix app PostgreSQL (batch insert with ID generation) | | **Connector gen** | `sql generate connector into [tables (...)] [views (...)] [exec]` | Auto-generate Database Connector MDL from discovered schema | | **Diagnostics** | `mxcli diag [--bundle]` | Session logs, version info, bug report bundles | -| **New project** | `mxcli new --version X.Y.Z [--output-dir dir]` | Downloads mxbuild, creates blank project, runs init, installs Linux mxcli for devcontainer | +| **New project** | `mxcli new --version X.Y.Z [--output-dir dir] [--theme none]` | Downloads mxbuild, creates blank project, applies default styling, runs init, installs Linux mxcli for devcontainer | +| **Default styling** | `mxcli theme list\|show\|apply\|remove` | Applies a built-in theme (signal/ledger/console) — files under `theme/` only, the model is never touched | +| **Theme switching** | `mxcli theme apply --variant auto\|light\|dark`, `mxcli theme switcher install` | `auto` ships both palettes (follows the OS + honours a `theme-light`/`theme-dark` class); `switcher install` adds the JS actions + nanoflow for a user toggle (**this one does write to the model**) | | **Setup mxcli** | `mxcli setup mxcli [--os linux] [--arch amd64] [--output ./mxcli]` | Download platform-specific mxcli binary from GitHub releases | ### mxcli new @@ -475,7 +520,7 @@ mxcli new MyApp --version 11.8.0 mxcli new MyApp --version 10.24.0 --output-dir ./projects/my-app ``` -Steps performed: downloads MxBuild → `mx create-project` → `mxcli init` → downloads correct Linux mxcli binary for devcontainer. The result is a ready-to-open project with `.devcontainer/`, AI tooling, and a working `./mxcli` binary. +Steps performed: downloads MxBuild → `mx create-project` → `mxcli theme apply` → `mxcli init` → downloads correct Linux mxcli binary for devcontainer. The result is a ready-to-open project with `.devcontainer/`, AI tooling, mxcli's default styling, and a working `./mxcli` binary. Pass `--theme none` for plain Atlas. ### Slash Command Namespaces @@ -549,6 +594,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati ## Current Implementation Status **Implemented:** +- Default styling + runtime theme switching (`mxcli theme list/show/apply/remove/switcher`, `mxcli new --theme`): three embedded themes (**signal** light-first, **ledger** light-first, **console** dark-first), each a palette in `theme/web/custom-variables.scss` + a shared Atlas wiring partial + a theme partial imported from `theme/web/main.scss` (which compiles last), plus vendored fonts. **No model changes**, so it hot-applies under `run --local --watch` and cannot affect a build. Generated regions are digest-fenced: a block carrying local edits is refused rather than overwritten. Applying a theme removes the previous one. `--variant auto` (default) ships both palettes — the app follows `prefers-color-scheme` before first paint and honours a `theme-light`/`theme-dark` class on ``; `light`/`dark` bakes one. `theme switcher install` is the only part that writes to the model (JS actions + a nanoflow for a toggle button). Package: `cmd/mxcli/theme/`. See `docs/11-proposals/PROPOSAL_default_styling.md` - MPR v1/v2 reading and writing - Domain model (entities, attributes, associations) - ALTER ENTITY (add/rename/modify/drop attributes, indexes, documentation) diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 153268ba3..eacfb5931 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -126,6 +126,10 @@ Examples: if wfStmt, ok := stmt.(*ast.CreateWorkflowStmt); ok { violations = append(violations, executor.ValidateWorkflow(wfStmt)...) } + // Check GRANT for member rights Mendix cannot store + if grantStmt, ok := stmt.(*ast.GrantEntityAccessStmt); ok { + violations = append(violations, executor.ValidateGrantEntityAccess(grantStmt)...) + } // Check typed ALTER SETTINGS / CREATE CONFIGURATION property values if setStmt, ok := stmt.(*ast.AlterSettingsStmt); ok { violations = append(violations, executor.ValidateSettings(setStmt)...) @@ -171,6 +175,11 @@ Examples: // under --references, where it would only fire with -p (#836). violations = append(violations, executor.ValidateGrantRoles(prog)...) + // Flag a REST client operation whose Body/Response mapping clause has no + // `{ ... }` body — Mendix cannot reference a mapping document from an + // operation, so the mapping would be dropped in silence (#843). + violations = append(violations, executor.ValidateRestClientMappings(prog)...) + if isStructured { // Always emit structured output (even when clean) formatter.Format(violations, os.Stderr) diff --git a/cmd/mxcli/cmd_new.go b/cmd/mxcli/cmd_new.go index 9ccf45c21..ce026db00 100644 --- a/cmd/mxcli/cmd_new.go +++ b/cmd/mxcli/cmd_new.go @@ -10,6 +10,7 @@ import ( "runtime" "github.com/mendixlabs/mxcli/cmd/mxcli/docker" + "github.com/mendixlabs/mxcli/cmd/mxcli/theme" "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/spf13/cobra" ) @@ -22,13 +23,15 @@ var newCmd = &cobra.Command{ This command performs the following steps: 1. Downloads MxBuild for the specified Mendix version 2. Creates a blank Mendix project using mx create-project - 3. Initializes AI tooling and devcontainer configuration (mxcli init) - 4. Downloads the correct mxcli binary for the devcontainer (linux) + 3. Applies mxcli's default styling (--theme, see 'mxcli theme list') + 4. Initializes AI tooling and devcontainer configuration (mxcli init) + 5. Downloads the correct mxcli binary for the devcontainer (linux) Examples: mxcli new MyApp mxcli new MyApp --version 11.8.0 mxcli new MyApp --version 10.24.0 --output-dir ./projects/my-app + mxcli new MyApp --version 11.8.0 --theme none `, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -36,12 +39,22 @@ Examples: mendixVersion, _ := cmd.Flags().GetString("version") outputDir, _ := cmd.Flags().GetString("output-dir") skipInit, _ := cmd.Flags().GetBool("skip-init") + themeName, _ := cmd.Flags().GetString("theme") if mendixVersion == "" { fmt.Fprintln(os.Stderr, "Error: --version is required (e.g., --version 11.8.0)") os.Exit(1) } + // Validate the theme before downloading ~800MB of MxBuild: a typo should + // fail in a second, not after the slowest step in the command. + if themeName != theme.NoneName { + if _, err := theme.Get(themeName); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + // Resolve output directory if outputDir == "" { outputDir = appName @@ -62,7 +75,7 @@ Examples: // On Windows and macOS, Studio Pro ships a native mx binary — prefer it. // CDN downloads contain Linux ELF binaries that cannot run on those platforms. // On Linux (CI, devcontainers), download mxbuild from CDN and derive mx. - fmt.Printf("Step 1/4: Resolving MxBuild %s...\n", mendixVersion) + fmt.Printf("Step 1/5: Resolving MxBuild %s...\n", mendixVersion) mxPath, err := docker.ResolveMxForNewProject(mendixVersion, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "Error: could not find mx binary for version %s: %v\n", mendixVersion, err) @@ -73,7 +86,7 @@ Examples: } // Step 2: Create project - fmt.Printf("\nStep 2/4: Creating Mendix project '%s'...\n", appName) + fmt.Printf("\nStep 2/5: Creating Mendix project '%s'...\n", appName) if err := os.MkdirAll(absDir, 0755); err != nil { fmt.Fprintf(os.Stderr, "Error creating directory: %v\n", err) os.Exit(1) @@ -138,16 +151,34 @@ Examples: fmt.Printf(" Mendix version: %s\n", created) } - // Step 3: Initialize tooling + // Step 3: Default styling. A blank Atlas app is unmistakably a blank Atlas + // app; a generated one should look like a product on first boot. This + // writes files under theme/ only — the model is untouched, so the theme + // can be re-applied, swapped or removed at any point. + if themeName != theme.NoneName { + fmt.Printf("\nStep 3/5: Applying '%s' styling...\n", themeName) + res, err := theme.Apply(absDir, themeName, theme.Options{}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error applying theme: %v\n", err) + os.Exit(1) + } + for _, f := range res.Files { + fmt.Printf(" %-9s %s\n", f.Action, f.Path) + } + } else { + fmt.Printf("\nStep 3/5: Skipped styling (--theme none)\n") + } + + // Step 4: Initialize tooling if !skipInit { - fmt.Printf("\nStep 3/4: Initializing AI tooling...\n") + fmt.Printf("\nStep 4/5: Initializing AI tooling...\n") initCmd.Run(initCmd, []string{absDir}) } else { - fmt.Printf("\nStep 3/4: Skipped (--skip-init)\n") + fmt.Printf("\nStep 4/5: Skipped (--skip-init)\n") } - // Step 4: Ensure correct mxcli binary for devcontainer - fmt.Printf("\nStep 4/4: Setting up mxcli binary...\n") + // Step 5: Ensure correct mxcli binary for devcontainer + fmt.Printf("\nStep 5/5: Setting up mxcli binary...\n") mxcliBinPath := filepath.Join(absDir, "mxcli") if runtime.GOOS != "linux" { // Running on Windows/macOS — download the Linux binary for devcontainer @@ -244,6 +275,8 @@ func init() { newCmd.Flags().String("version", "", "Mendix version (e.g., 11.8.0) — required") newCmd.Flags().String("output-dir", "", "Output directory (default: ./)") newCmd.Flags().Bool("skip-init", false, "Skip AI tooling initialization (mxcli init)") + newCmd.Flags().String("theme", theme.DefaultName, + "Default styling to apply ('none' to keep plain Atlas; see 'mxcli theme list')") rootCmd.AddCommand(newCmd) } diff --git a/cmd/mxcli/cmd_theme.go b/cmd/mxcli/cmd_theme.go new file mode 100644 index 000000000..da752d969 --- /dev/null +++ b/cmd/mxcli/cmd_theme.go @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: Apache-2.0 + +// cmd_theme.go - `mxcli theme` : apply mxcli's built-in default styling +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mendixlabs/mxcli/cmd/mxcli/theme" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/spf13/cobra" +) + +var themeCmd = &cobra.Command{ + Use: "theme", + Short: "Apply mxcli's built-in default styling to a project", + Long: `Apply mxcli's built-in default styling to a Mendix project. + +Three themes ship in the binary: signal (the default — cool slate, one teal +signal colour), ledger (warm paper, hairline rules, serif headings) and console +(dark-first, geometric). Run 'mxcli theme list' to see them and +'mxcli theme show ' for a theme's palette and the files it writes. + +A theme is a set of files under theme/ — the model (.mpr) is never touched, so +it hot-applies under 'mxcli run --local --watch' and cannot affect a build. +Atlas Core is left untouched too. Each theme is a palette of --mxt-* tokens in +theme/web/custom-variables.scss, a shared map wiring those onto ~60 Atlas +variables, and a partial imported from theme/web/main.scss (which compiles +last). Re-branding is one line: change --mxt-brand. + +Every theme ships light and dark palettes. With --variant auto (the default) the +app follows the operating system before first paint and honours a theme-light or +theme-dark class on the root element; --variant light or dark bakes one palette. +Nothing in Mendix sets that class, so 'mxcli theme switcher install' adds a +toggle — that subcommand is the only one here that writes to the model. + +Only one theme applies at a time: applying removes the previous one, because two +themes mapping the same Atlas variables would fight in the cascade. + +Every generated block is fenced between mxcli:theme markers whose digest records +what mxcli wrote. Edit inside a fence and a later apply refuses rather than +discarding your work; edit outside it and mxcli never touches your lines. + +New projects get the default theme automatically — see 'mxcli new --theme'.`, +} + +var themeListCmd = &cobra.Command{ + Use: "list", + Short: "List the built-in themes", + RunE: func(cmd *cobra.Command, args []string) error { + themes, err := theme.List() + if err != nil { + return err + } + for _, t := range themes { + marker := " " + if t.Name == theme.DefaultName { + marker = "*" + } + fmt.Printf("%s %-10s %-10s %s\n", marker, t.Name, t.Title, t.Summary) + } + fmt.Printf("\n* = applied by default. Use 'mxcli new --theme none' to opt out.\n") + return nil + }, +} + +var themeShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show what a theme contains and which files it writes", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + t, err := theme.Get(args[0]) + if err != nil { + return err + } + fmt.Printf("%s (%s) v%s\n\n%s\n\n%s\n\n", t.Title, t.Name, t.Version, t.Summary, t.Description) + fmt.Printf("Default palette: %s (auto switches to %s)\n\n", t.DefaultVariant, t.AltVariant()) + if len(t.Colorway) > 0 { + fmt.Printf("Colorway: %s\n\n", strings.Join(t.Colorway, " ")) + } + fmt.Println("Files:") + for _, f := range t.Files { + fmt.Printf(" %-42s %-9s %s\n", f.Path, f.Mode, f.Purpose) + } + return nil + }, +} + +var themeApplyCmd = &cobra.Command{ + Use: "apply [name]", + Short: "Apply a theme to an existing project", + Long: `Apply a theme to an existing project. + +Applying is idempotent: re-running replaces only mxcli's own generated blocks. +A block carrying local edits is reported and left alone unless --force is given. +Applying a theme removes any previously applied one, because two themes mapping +the same Atlas variables would fight in the cascade. + +--variant auto (the default) ships both palettes: the app follows the OS and +honours a theme-light / theme-dark class on the root element. --variant light or +dark bakes a single palette with no switching. + +With no name, apply refreshes the theme the project already has, and falls back +to the default only when it has none. + +Examples: + mxcli theme apply -p app.mpr + mxcli theme apply ledger -p app.mpr + mxcli theme apply console -p app.mpr --variant dark + mxcli theme apply signal -p app.mpr --dry-run + mxcli theme apply signal -p app.mpr --force`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := themeProjectDir(cmd) + if err != nil { + return err + } + // A bare `apply` refreshes the theme the project already has; only a + // project with none falls back to the default. + name := theme.DefaultName + if len(args) == 1 { + name = args[0] + } else if name, err = theme.Resolve(dir, theme.DefaultName); err != nil { + return err + } + force, _ := cmd.Flags().GetBool("force") + dryRun, _ := cmd.Flags().GetBool("dry-run") + variantFlag, _ := cmd.Flags().GetString("variant") + variant, err := theme.ParseVariant(variantFlag) + if err != nil { + return err + } + + res, err := theme.Apply(dir, name, theme.Options{Force: force, DryRun: dryRun, Variant: variant}) + printThemeResult(res, dryRun) + if err != nil { + return err + } + if !dryRun && res.Changed() { + if variant == theme.VariantAuto { + fmt.Printf("\nLight and dark follow the OS. Add 'mxcli theme switcher install -p '\n" + + "for a user-facing toggle.\n") + } + fmt.Printf("\nRun 'mxcli run --local --watch -p ' to see it; SCSS edits hot-apply.\n") + } + return nil + }, +} + +var themeRemoveCmd = &cobra.Command{ + Use: "remove [name]", + Short: "Remove a theme's generated blocks from a project", + Long: `Remove a theme's generated blocks from a project. + +With no name, the installed theme is read from the mxcli:theme markers in the +project; if it has no theme, that is an error rather than a silent no-op. + +A block carrying local edits is reported and left alone unless --force is given. + +Examples: + mxcli theme remove -p app.mpr + mxcli theme remove ledger -p app.mpr --dry-run`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := themeProjectDir(cmd) + if err != nil { + return err + } + // No fallback here. Defaulting to the built-in theme meant that on a + // project themed with any other one, remove reported every file as + // unchanged and exited 0 — leaving the theme fully installed. + name := "" + if len(args) == 1 { + name = args[0] + } else if name, err = theme.Resolve(dir, ""); err != nil { + return err + } + force, _ := cmd.Flags().GetBool("force") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + res, err := theme.Remove(dir, name, theme.Options{Force: force, DryRun: dryRun}) + printThemeResult(res, dryRun) + return err + }, +} + +var themeSwitcherCmd = &cobra.Command{ + Use: "switcher", + Short: "Install a runtime light/dark switcher (this one does touch the model)", + Long: `Install a runtime theme switcher. + +Unlike 'theme apply', this writes to the model: three JavaScript actions and two +nanoflows. It has to. A theme's light/dark blocks key off a class on the root +element, Mendix ships the slot but nothing that sets it, and there is no +theme-level hook to run script before first paint — so an explicit user choice +has to come from something the client can execute. + +The CSS still does most of the work: --variant auto already renders the right +palette before first paint by following the OS. The switcher only covers the +case where a user overrides that. + +Use --print to see the MDL without running it.`, +} + +var themeSwitcherInstallCmd = &cobra.Command{ + Use: "install", + Short: "Create the switcher actions and nanoflows in a module", + Long: `Create the theme switcher's JavaScript actions and nanoflows. + +Creates, in the module given by --module: + + ToggleAppTheme flips light/dark, resolving "follow the OS" first, and + remembers the choice in localStorage + SetAppTheme sets a theme explicitly; pass "auto" to clear the override + ApplyStoredTheme re-applies a remembered choice + ACT_ToggleTheme a nanoflow a button can call + +Then wire a button wherever it belongs, typically a layout or settings page: + + actionbutton btnTheme (caption: 'Theme', action: nanoflow .ACT_ToggleTheme) + +A click flips the palette and remembers it. The class is set on , so +popups and modals — rendered at , outside any page container — follow too. + +Reload behaviour: the app goes back to following the OS. Mendix has no page +on-load event to re-apply the stored value from, and the usual substitute (a +data view with a nanoflow data source) is not authorable by mxcli on either +engine yet. ApplyStoredTheme is installed and ready for it — wire it in Studio +Pro if the choice must survive a reload. + +Use --print to see the MDL without running it. + +Examples: + mxcli theme switcher install -p app.mpr + mxcli theme switcher install -p app.mpr --module Ops + mxcli theme switcher install --print --module Ops`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + module, _ := cmd.Flags().GetString("module") + script := theme.SwitcherMDL(module) + + if printOnly, _ := cmd.Flags().GetBool("print"); printOnly { + fmt.Print(script) + return nil + } + + projectPath, _ := cmd.Flags().GetString("project") + if projectPath == "" { + return fmt.Errorf("--project is required (or use --print to see the MDL)") + } + if err := execThemeMDL(projectPath, script); err != nil { + return err + } + fmt.Println() + fmt.Println(theme.SwitcherNextSteps(module)) + return nil + }, +} + +// execThemeMDL runs a generated MDL script against a project, the same way +// `mxcli exec` runs one from a file. +func execThemeMDL(projectPath, script string) error { + ex, logger := newLoggedExecutor("theme") + defer logger.Close() + defer ex.Close() + + connect := fmt.Sprintf("CONNECT LOCAL '%s';", visitor.QuoteString(projectPath)) + prog, errs := visitor.Build(connect) + if len(errs) > 0 { + return fmt.Errorf("connecting to %s: %v", projectPath, errs[0]) + } + if err := ex.ExecuteProgram(prog); err != nil { + return fmt.Errorf("connecting to %s: %w", projectPath, err) + } + + prog, errs = visitor.Build(script) + if len(errs) > 0 { + return fmt.Errorf("parsing the generated switcher MDL: %v", errs[0]) + } + return ex.ExecuteProgram(prog) +} + +// themeProjectDir resolves the folder holding the .mpr — the theme/ tree sits +// beside it. Accepts -p pointing at either the .mpr or its directory. +func themeProjectDir(cmd *cobra.Command) (string, error) { + p, _ := cmd.Flags().GetString("project") + if p == "" { + wd, err := os.Getwd() + if err != nil { + return "", err + } + return wd, nil + } + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + if info, err := os.Stat(abs); err == nil && info.IsDir() { + return abs, nil + } + return filepath.Dir(abs), nil +} + +func printThemeResult(res *theme.Result, dryRun bool) { + if res == nil { + return + } + verb := "" + if dryRun { + verb = " (dry run)" + } + fmt.Printf("Theme '%s'%s\n", res.Theme, verb) + for _, f := range res.Files { + fmt.Printf(" %-9s %s\n", f.Action, f.Path) + } +} + +func init() { + for _, c := range []*cobra.Command{themeApplyCmd, themeRemoveCmd} { + c.Flags().StringP("project", "p", "", "Path to the .mpr file or project directory") + c.Flags().Bool("force", false, "Overwrite blocks that carry local edits") + c.Flags().Bool("dry-run", false, "Report what would change without writing") + } + themeApplyCmd.Flags().String("variant", string(theme.VariantAuto), + "Light/dark behaviour: auto (follow the OS + honour a theme class), light, or dark") + themeSwitcherInstallCmd.Flags().StringP("project", "p", "", "Path to the .mpr file") + themeSwitcherInstallCmd.Flags().String("module", "MyFirstModule", "Module to create the actions in") + themeSwitcherInstallCmd.Flags().Bool("print", false, "Print the MDL instead of running it") + themeSwitcherCmd.AddCommand(themeSwitcherInstallCmd) + themeCmd.AddCommand(themeListCmd, themeShowCmd, themeApplyCmd, themeRemoveCmd, themeSwitcherCmd) + rootCmd.AddCommand(themeCmd) +} diff --git a/cmd/mxcli/cmd_theme_test.go b/cmd/mxcli/cmd_theme_test.go new file mode 100644 index 000000000..337afd0cb --- /dev/null +++ b/cmd/mxcli/cmd_theme_test.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/theme" + "github.com/spf13/cobra" +) + +// runTheme drives the real cobra command rather than the package API. That +// distinction is the whole point of this file: the bug these tests cover lived +// in the command's argument handling, not in the theme package, so a test that +// calls theme.Resolve directly would keep passing while the CLI stayed broken. +func runTheme(t *testing.T, args ...string) (string, error) { + t.Helper() + for _, c := range []*cobra.Command{themeApplyCmd, themeRemoveCmd} { + resetCmdFlags(c) + } + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs(append([]string{"theme"}, args...)) + err := rootCmd.ExecuteContext(context.Background()) + return out.String(), err +} + +// themeProject fakes the parts of a Mendix project a theme touches. +func themeProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for path, body := range map[string]string{ + "App.mpr": "", + "themesource/atlas_core/web/main.scss": "// atlas\n", + "theme/web/main.scss": "@import \"custom-variables\";\n@import \"theme-dark\";\n", + "theme/web/custom-variables.scss": ":root {\n --brand-primary: #264ae5;\n}\n", + } { + full := filepath.Join(dir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func installedThemes(t *testing.T, dir string) []string { + t.Helper() + got, err := theme.Installed(dir) + if err != nil { + t.Fatal(err) + } + return got +} + +// `mxcli theme remove -p app.mpr` — the invocation the docs show — used to +// target the default theme regardless of what was installed. On a project +// themed with anything else it removed nothing, reported every file as +// unchanged and exited 0, leaving the theme fully in place. +func TestThemeRemove_BareInvocationRemovesTheInstalledTheme(t *testing.T) { + dir := themeProject(t) + if _, err := runTheme(t, "apply", "ledger", "-p", dir); err != nil { + t.Fatalf("apply ledger: %v", err) + } + if got := installedThemes(t, dir); len(got) != 1 || got[0] != "ledger" { + t.Fatalf("setup failed: installed = %v", got) + } + + if _, err := runTheme(t, "remove", "-p", dir); err != nil { + t.Fatalf("bare remove: %v", err) + } + + if got := installedThemes(t, dir); len(got) != 0 { + t.Errorf("bare remove left %v installed", got) + } + main := filepath.Join(dir, "theme", "web", "main.scss") + body, err := os.ReadFile(main) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "mxcli:theme") { + t.Errorf("main.scss still carries a theme block:\n%s", body) + } +} + +// Removing from a project that has no theme is a mistake worth reporting, not +// a no-op that exits 0. +func TestThemeRemove_UnthemedProjectErrors(t *testing.T) { + dir := themeProject(t) + + _, err := runTheme(t, "remove", "-p", dir) + if err == nil { + t.Fatal("expected an error removing from an unthemed project") + } + if !strings.Contains(err.Error(), "no mxcli theme found") { + t.Errorf("error should say what is missing, got: %v", err) + } +} + +// A bare `apply` refreshes what is installed. Silently switching a ledger +// project to the default is as surprising as the remove bug was. +func TestThemeApply_BareInvocationRefreshesTheInstalledTheme(t *testing.T) { + dir := themeProject(t) + if _, err := runTheme(t, "apply", "console", "-p", dir); err != nil { + t.Fatalf("apply console: %v", err) + } + + if _, err := runTheme(t, "apply", "-p", dir); err != nil { + t.Fatalf("bare apply: %v", err) + } + + got := installedThemes(t, dir) + if len(got) != 1 || got[0] != "console" { + t.Errorf("bare apply changed the installed theme to %v, want [console]", got) + } +} + +// An unthemed project is exactly when falling back to the default is right. +func TestThemeApply_BareInvocationInstallsTheDefaultWhenThereIsNone(t *testing.T) { + dir := themeProject(t) + + if _, err := runTheme(t, "apply", "-p", dir); err != nil { + t.Fatalf("bare apply: %v", err) + } + got := installedThemes(t, dir) + if len(got) != 1 || got[0] != theme.DefaultName { + t.Errorf("installed = %v, want [%s]", got, theme.DefaultName) + } +} + +// Switching themes must leave exactly one theme behind in every file it +// touches — including the shared Atlas map, where the outgoing block used to +// survive and the incoming one was appended beside it. +func TestThemeApply_SwitchingLeavesNoOrphanBlocks(t *testing.T) { + dir := themeProject(t) + for _, name := range []string{"signal", "ledger", "console"} { + if _, err := runTheme(t, "apply", name, "-p", dir); err != nil { + t.Fatalf("apply %s: %v", name, err) + } + if got := installedThemes(t, dir); len(got) != 1 || got[0] != name { + t.Fatalf("after apply %s, installed = %v", name, got) + } + } + + // And a bare remove then leaves nothing, which is the combination the + // report flagged: switch once, then run the documented removal. + if _, err := runTheme(t, "remove", "-p", dir); err != nil { + t.Fatalf("bare remove after switching: %v", err) + } + if got := installedThemes(t, dir); len(got) != 0 { + t.Errorf("remove after switching left %v", got) + } +} diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index aec81f788..764b8d465 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -156,8 +156,8 @@ func init() { "body", "response", "mapping", "authentication", "json structure", "import mapping", "export mapping", }, - Syntax: "CREATE [OR MODIFY] REST CLIENT Module.Name (\n BaseUrl: 'https://...',\n Authentication: NONE | BASIC (...)\n)\n{\n OPERATION Name {\n Method: GET|POST|PUT|DELETE|PATCH,\n Path: '/path/{param}',\n Parameters: ($param: Type),\n Headers: ('Key' = 'Value'),\n Body: JSON FROM $var | MAPPING Entity { ... },\n Response: JSON AS $var | MAPPING Entity { ... }\n }\n};", - Example: "CREATE REST CLIENT Module.PetStore (\n BaseUrl: 'https://petstore.example.com/api',\n Authentication: NONE\n)\n{\n OPERATION GetPet {\n Method: GET,\n Path: '/pets/{id}',\n Parameters: ($id: String),\n Response: JSON AS $Result\n }\n};", + Syntax: "CREATE [OR MODIFY] REST CLIENT Module.Name (\n BaseUrl: 'https://...',\n Authentication: NONE | BASIC (...)\n)\n{\n OPERATION Name {\n Method: GET|POST|PUT|DELETE|PATCH,\n Path: '/path/{param}',\n Parameters: ($param: Type),\n Query: ($param: Type),\n Headers: ('Key' = 'Value'),\n Timeout: 30,\n Body: JSON FROM $var | MAPPING Entity { jsonField = Attribute, ... },\n Response: JSON AS $var | MAPPING Entity { Attribute = jsonField, ... }\n }\n};\n\n-- MAPPING takes a target ENTITY plus a body listing the JSON fields; Mendix\n-- stores it inline on the operation. An existing import/export mapping\n-- document cannot be referenced here (rejected as MDL-REST01).", + Example: "CREATE REST CLIENT Module.PetStore (\n BaseUrl: 'https://petstore.example.com/api',\n Authentication: NONE\n)\n{\n OPERATION GetPet {\n Method: GET,\n Path: '/pets/{id}',\n Parameters: ($id: String),\n Query: ($verbose: String),\n Response: MAPPING Module.Pet {\n Name = name,\n Status = status\n }\n }\n};", SeeAlso: []string{"rest", "rest.published"}, }) diff --git a/cmd/mxcli/theme/assets.go b/cmd/mxcli/theme/assets.go new file mode 100644 index 000000000..e7087945a --- /dev/null +++ b/cmd/mxcli/theme/assets.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 + +package theme + +import "embed" + +// assetsRoot is the embedded directory holding one folder per theme. +const assetsRoot = "assets" + +// Themes are embedded so `mxcli new` works with no network and no companion +// files next to the binary. Each theme is assets//theme.json plus an +// assets//files/ tree that mirrors its layout inside the project. +// +// The `all:` prefix is load-bearing: a plain `go:embed assets` silently skips +// files whose name starts with "_", which is exactly how SCSS spells a partial. +// Without it _mxcli-signal.scss is missing from the binary and every generated +// app ships an @import pointing at nothing. +// +//go:embed all:assets +var assetsFS embed.FS diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-atlas-map.scss b/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-atlas-map.scss new file mode 100644 index 000000000..17ed472eb --- /dev/null +++ b/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-atlas-map.scss @@ -0,0 +1,205 @@ +// The Atlas wiring — shared by every mxcli theme, identical in each. +// +// Atlas components, form controls and the brand-aware pluggable widgets +// (Switch, Slider, ProgressBar, BadgeButton) read the ~60 variables below. +// Expressing them once, in terms of the theme's own --mxt-* tokens, is what +// makes a palette swap or a light/dark flip a matter of restating ~30 values +// instead of rewiring the framework. +// +// A theme never edits this file: it sets --mxt-* and includes the mixin. + +// --------------------------------------------------------------------------- +// The Atlas wiring. Every variable Atlas components read, expressed in terms of +// the theme's own tokens — so a variant only has to restate the ~30 --mxt-* +// values, never these. +// --------------------------------------------------------------------------- +@mixin mxcli-atlas-map { + --brand-primary: var(--mxt-brand); + --brand-primary-hover: var(--mxt-brand-hover); + --brand-success: var(--mxt-success); + --brand-warning: var(--mxt-warning); + --brand-danger: var(--mxt-danger); + --brand-info: var(--mxt-info); + --brand-default: var(--mxt-tint-neutral); + --gray: var(--mxt-ink-faint); + + --color-base: var(--mxt-surface); + --color-contrast: var(--mxt-ink); + --bg-color: var(--mxt-ground); + --bg-color-secondary: var(--mxt-surface); + + --font-color-default: var(--mxt-ink); + --font-color-detail: var(--mxt-ink-muted); + --font-color-header: var(--mxt-ink); + // Atlas uses this for topbar and navigation text, not only for text on a + // brand fill — so it tracks the rail, which is the surface it actually + // sits on. Text on a brand-filled button comes from --btn-*-color below. + --font-color-contrast: var(--mxt-rail-ink-active); + --link-color: var(--mxt-brand); + --link-hover-color: var(--mxt-brand-hover); + + --border-color-default: var(--mxt-line); + --border-radius-s: var(--mxt-radius); + --border-radius-m: var(--mxt-radius); + --border-radius-l: var(--mxt-radius-lg); + --border-radius-default: var(--mxt-radius); + + --shadow-color: transparent; + --shadow-small: var(--mxt-shadow); + --shadow-medium: 0 2px 4px 0 rgba(0, 0, 0, 0.1); + --shadow-large: 0 8px 16px 0 rgba(0, 0, 0, 0.14); + + // Visible on every focusable element; the soft halo is added by the rule + // further down, which also stops anything from suppressing it. + --focus-outline: 2px solid var(--mxt-brand); + --focus-outline-offset: 1px; + + --font-family-base: var(--mxt-font); + --font-size-default: var(--mxt-font-size); + --font-size-small: 12px; + --font-size-large: 16px; + --line-height-base: var(--mxt-line-height); + + // An operational scale — tighter than Atlas's marketing-page defaults. + --font-size-h1: 28px; + --font-size-h2: 22px; + --font-size-h3: 18px; + --font-size-h4: 16px; + --font-size-h5: var(--mxt-font-size); + --font-size-h6: 12px; + --font-weight-header: 600; + --font-header-margin: 0 0 8px 0; + + // Density: 32px controls on desktop, grown to a 44px touch target below the + // tablet breakpoint by the media query at the end of this file. + --form-input-height: var(--mxt-control-height); + --form-input-padding-y: 4px; + --form-input-padding-x: 8px; + --form-input-font-size: var(--mxt-font-size); + --form-input-border-radius: var(--mxt-radius); + --form-input-bg: var(--mxt-surface); + --form-input-bg-hover: var(--mxt-surface); + --form-input-bg-focus: var(--mxt-surface); + --form-input-bg-disabled: var(--mxt-surface-alt); + --form-input-color: var(--mxt-ink); + --form-input-placeholder-color: var(--mxt-ink-faint); + --form-input-border-color: var(--mxt-line); + --form-input-border-focus-color: var(--mxt-brand); + --form-label-color: var(--mxt-ink-muted); + --form-label-weight: 500; + --form-label-gutter: 6px; + --form-group-margin-bottom: 12px; + --form-group-gutter: 12px; + + --padding-table-cell-top: 6px; + --padding-table-cell-bottom: 6px; + --padding-table-cell-left: 12px; + --padding-table-cell-right: 12px; + --grid-border-color: var(--mxt-line); + --grid-bg: transparent; + --grid-bg-header: transparent; + --grid-bg-striped: var(--mxt-surface-alt); + --grid-bg-hover: var(--mxt-surface-hover); + --grid-bg-selected: var(--mxt-surface-selected); + --grid-bg-selected-hover: var(--mxt-surface-selected); + --grid-selected-color: var(--mxt-ink); + + --card-bg: var(--mxt-surface); + --card-border: 1px solid var(--mxt-line); + --card-border-radius: var(--mxt-radius); + --card-padding: 16px; + --card-margin-bottom: 16px; + --card-shadow: var(--mxt-shadow); + + --btn-border-radius: var(--mxt-radius); + --btn-font-size: var(--mxt-font-size); + --btn-default-bg: var(--mxt-surface); + --btn-default-border-color: var(--mxt-line); + --btn-default-color: var(--mxt-ink); + --btn-default-icon-color: var(--mxt-ink-muted); + --btn-link-bg-hover: var(--mxt-surface-hover); + --btn-primary-color: var(--mxt-brand-ink); + --btn-success-color: var(--mxt-brand-ink); + --btn-warning-color: var(--mxt-brand-ink); + --btn-danger-color: var(--mxt-brand-ink); + + // A flat rail, not Atlas's blue gradient. + --topbar-bg: var(--mxt-rail); + --topbar-border-color: var(--mxt-rail-line); + --sidebar-bg: var(--mxt-rail); + --navigation-bg: var(--mxt-rail); + --navigation-bg-hover: rgba(255, 255, 255, 0.08); + --navigation-bg-active: rgba(255, 255, 255, 0.14); + --navigation-color: var(--mxt-rail-ink); + --navigation-color-hover: var(--mxt-rail-ink-active); + --navigation-color-active: var(--mxt-rail-ink-active); + --navigation-sub-bg: rgba(255, 255, 255, 0.05); + --navigation-sub-color: var(--mxt-rail-ink); + --navigation-sub-color-hover: var(--mxt-rail-ink-active); + --navigation-sub-color-active: var(--mxt-rail-ink-active); + --navigation-border-color: rgba(255, 255, 255, 0.08); + --navigation-border-radius: var(--mxt-radius); + + --navsidebar-bg: var(--mxt-rail); + --navsidebar-bg-hover: rgba(255, 255, 255, 0.08); + --navsidebar-bg-active: rgba(255, 255, 255, 0.14); + --navsidebar-sub-bg: rgba(255, 255, 255, 0.05); + --navsidebar-color: var(--mxt-rail-ink); + --navsidebar-color-hover: var(--mxt-rail-ink-active); + --navsidebar-color-active: var(--mxt-rail-ink-active); + --navsidebar-border-color: rgba(255, 255, 255, 0.08); + --navsidebar-border-radius: var(--mxt-radius); + + --navtopbar-bg: var(--mxt-rail); + --navtopbar-bg-hover: rgba(255, 255, 255, 0.08); + --navtopbar-bg-active: rgba(255, 255, 255, 0.14); + --navtopbar-color: var(--mxt-rail-ink); + --navtopbar-color-hover: var(--mxt-rail-ink-active); + --navtopbar-color-active: var(--mxt-rail-ink-active); + --navtopbar-sub-color: var(--mxt-rail-ink); + --navtopbar-sub-color-hover: var(--mxt-rail-ink-active); + --navtopbar-sub-color-active: var(--mxt-rail-ink-active); + --navtopbar-border-color: var(--mxt-rail-line); + --navtopbar-border-radius: var(--mxt-radius); + + --header-bg-color: var(--mxt-brand); + --header-text-color: var(--mxt-brand-ink); + --header-text-color-detail: rgba(255, 255, 255, 0.7); + + --modal-header-border-color: var(--mxt-line); + --modal-body-bg: var(--mxt-surface); + --modal-footer-bg: var(--mxt-surface); + + --tabs-border-color: var(--mxt-line); + --tabs-lined-border-color: var(--mxt-brand); + --tabs-lined-border-width: 2px; + --tabs-bg-pills: var(--mxt-tint-neutral); +} + +// --------------------------------------------------------------------------- +// Atlas fixups — rules, not mappings, for the few places Atlas hardcodes a +// colour instead of reading a variable. +// +// The topbar language selector paints itself with --bg-color-secondary and a +// #fff fallback, which assumes a dark rail. Every mxcli theme does keep the +// rail dark, but --bg-color-secondary is the light *surface* colour: white in a +// light palette (so it reads by luck) and near-black in a dark one, where the +// text drops to a 1.13:1 contrast ratio and is effectively invisible. +// +// Two things matter in the fix. Atlas's own selector is +// `.navbar-brand .widget-language-selector .current-language-text` at (0,3,0), +// so a bare `.current-language-text` at (0,1,0) never wins — it only appeared +// to work on layouts that do not nest the selector under .navbar-brand. And +// `color: inherit` is the wrong value even at the right weight: the inherited +// colour is body ink, which is dark, and the rail is dark in both palettes. +// The rail has its own token; use it. +// +// Both selector shapes are listed so the rule applies whether or not the +// layout uses .navbar-brand; each is matched at its own specificity. +// --------------------------------------------------------------------------- +.current-language-text, +.language-arrow, +.navbar-brand .widget-language-selector .current-language-text, +.navbar-brand .widget-language-selector .language-arrow { + color: var(--mxt-rail-ink-active, var(--mxt-rail-ink)); +} diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-console.scss b/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-console.scss new file mode 100644 index 000000000..f4c0abd9d --- /dev/null +++ b/cmd/mxcli/theme/assets/console/files/theme/web/_mxcli-console.scss @@ -0,0 +1,315 @@ +// Layer 2 — Console: the light palette, the variant blocks and the recipe classes. +// The Atlas wiring both variants run through is in _mxcli-atlas-map.scss. +// +// Imported from theme/web/main.scss, which compiles AFTER Atlas Core and after +// every module theme source. That matters twice over: these rules override +// Atlas without !important, and the variant blocks below outrank Mendix's own +// theme/web/_theme-dark.scss, which would otherwise repaint the app in stock +// Mendix blue the moment a theme-dark class appears. +// +// Edit the palette in custom-variables.scss, not here. + +$mxcli-theme-variant: {{VARIANT}} !default; + +// --------------------------------------------------------------------------- +// The light palette. Only the theme's own tokens — the Atlas wiring is written +// in terms of these, so it does not need restating. +// --------------------------------------------------------------------------- +@mixin mxcli-console-light { + --mxt-brand: #0d9488; + --mxt-brand-hover: #0b7f75; + --mxt-brand-ink: #ffffff; + --mxt-accent: #7c5ce0; + + --mxt-success: #2a8a3c; + --mxt-warning: #a96b0e; + --mxt-danger: #c13a32; + --mxt-info: #2563c7; + + --mxt-ground: #f5f7fa; + --mxt-surface: #ffffff; + --mxt-surface-alt: #f0f3f7; + --mxt-surface-hover: #e8edf3; + --mxt-surface-selected: #e0f2ef; + --mxt-ink: #10151c; + --mxt-ink-muted: #55606e; + --mxt-ink-faint: #8a94a2; + --mxt-line: #dbe1e8; + + --mxt-rail: #10151c; + --mxt-rail-line: #232a34; + --mxt-rail-ink: #c8d0da; + --mxt-rail-ink-active: #ffffff; + + --mxt-tint-ok: rgba(42, 138, 60, 0.12); + --mxt-tone-ok: #226e30; + --mxt-tint-warn: rgba(169, 107, 14, 0.12); + --mxt-tone-warn: #86550b; + --mxt-tint-risk: rgba(193, 58, 50, 0.12); + --mxt-tone-risk: #9a2e28; + --mxt-tint-info: rgba(13, 148, 136, 0.12); + --mxt-tone-info: #0b7f75; + --mxt-tint-neutral: #e7ebf1; + + --mxt-shadow: none; + --mxt-focus-halo: 0 0 0 3px rgba(13, 148, 136, 0.2); +} + +// --------------------------------------------------------------------------- +// Variant selection. +// +// auto — follow the OS, and honour an explicit theme-light / theme-dark +// class on so a switcher can override it +// dark — Console's default palette, no switching +// light — the light palette, no switching +// +// `dark` emits nothing beyond the base: it IS the base. And an explicit +// .theme-dark needs no block either, because the media query below excludes +// it — which is the whole reason for the :not(). +// --------------------------------------------------------------------------- +:root { + @include mxcli-atlas-map; +} + +@if $mxcli-theme-variant == auto { + @media (prefers-color-scheme: light) { + :root:not(.theme-dark) { + @include mxcli-console-light; + @include mxcli-atlas-map; + } + } + + // Specificity matters here: Mendix's own _theme-dark.scss also declares + // :root.theme-dark. Same weight, so the later declaration wins — and this + // file is imported after it. + :root.theme-light { + @include mxcli-console-light; + @include mxcli-atlas-map; + } +} @else if $mxcli-theme-variant == light { + :root { + @include mxcli-console-light; + @include mxcli-atlas-map; + } +} + +// --------------------------------------------------------------------------- +// Fonts. Vendored (SIL OFL 1.1, see mxcli-fonts/OFL.txt) rather than pulled +// from a CDN: no @import ordering trap, no third-party request at runtime, and +// the app renders correctly air-gapped. The URLs are relative to +// theme.compiled.css, which Mendix deploys to the web root alongside +// theme/web/mxcli-fonts/. +// --------------------------------------------------------------------------- +@each $weight in (400, 500, 600, 700) { + @font-face { + font-family: "Space Grotesk"; + src: url("./mxcli-fonts/space-grotesk-latin-#{$weight}-normal.woff2") format("woff2"); + font-weight: $weight; + font-style: normal; + font-display: swap; + } +} + +@each $weight in (400, 500, 600) { + @font-face { + font-family: "JetBrains Mono"; + src: url("./mxcli-fonts/jetbrains-mono-latin-#{$weight}-normal.woff2") format("woff2"); + font-weight: $weight; + font-style: normal; + font-display: swap; + } +} + +h1, +h2, +h3, +h4, +h5, +h6, +.mx-title { + font-family: var(--mxt-font-heading); +} + +// --------------------------------------------------------------------------- +// Console separates surfaces by lightness rather than by shadow, so nothing +// casts one — the panel is a lighter plane on a darker ground. +// --------------------------------------------------------------------------- +.card, +.stat { + box-shadow: none; +} + +// --------------------------------------------------------------------------- +// Focus. An invisible focus state is the most common accessibility regression +// in a generated app, so this adds the halo and makes sure nothing removes it. +// --------------------------------------------------------------------------- +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible, +.mx-link:focus-visible, +.btn:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); + box-shadow: var(--mxt-focus-halo); +} + +h1, +h2, +h3, +h4, +h5, +h6, +.mx-title { + font-family: var(--mxt-font-heading); +} + +// --------------------------------------------------------------------------- +// Numerics. Monospace with tabular figures so ids, amounts and dates align +// vertically down a column. Apply with class: 'num'. +// --------------------------------------------------------------------------- +.num, +.num input, +.num textarea, +.num .form-control-static { + font-family: var(--mxt-font-mono); + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + letter-spacing: -0.01em; +} + +// A field's label is prose, not a number. Without this the class set on a +// textbox to align its value also renders the label in monospace, because +// Mendix nests the label inside the widget root the class lands on. +.num label, +.num .control-label { + font-family: var(--mxt-font); + font-variant-numeric: normal; + letter-spacing: normal; +} + +.num-right { + text-align: right; +} + +// --------------------------------------------------------------------------- +// Status pills. One shape, five tones, all resolved through tokens so they +// follow a variant flip instead of staying stuck in light-mode tints. +// --------------------------------------------------------------------------- +.pill { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + background: var(--mxt-tint-neutral); + color: var(--mxt-ink-muted); +} + +.pill-ok { + background: var(--mxt-tint-ok); + color: var(--mxt-tone-ok); +} + +.pill-warn { + background: var(--mxt-tint-warn); + color: var(--mxt-tone-warn); +} + +.pill-risk, +.pill-danger { + background: var(--mxt-tint-risk); + color: var(--mxt-tone-risk); +} + +.pill-info { + background: var(--mxt-tint-info); + color: var(--mxt-tone-info); +} + +// --------------------------------------------------------------------------- +// KPI tile. +// --------------------------------------------------------------------------- +.stat { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--card-padding); + background: var(--mxt-surface); + border: 1px solid var(--mxt-line); + border-radius: var(--mxt-radius); + box-shadow: var(--mxt-shadow); +} + +.stat-label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--mxt-ink-muted); +} + +.stat-value { + font-family: var(--mxt-font-mono); + font-variant-numeric: tabular-nums; + font-size: 26px; + font-weight: 600; + line-height: 1.1; + color: var(--mxt-ink); +} + +.stat-delta { + font-size: 12px; + color: var(--mxt-ink-muted); +} + +.stat-delta-up { + color: var(--mxt-success); +} + +.stat-delta-down { + color: var(--mxt-danger); +} + +// --------------------------------------------------------------------------- +// Grid rhythm. Header rows read as labels rather than as another data row. +// --------------------------------------------------------------------------- +.mx-datagrid th, +.mx-datagrid-head, +.th .column-header { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--mxt-ink-muted); +} + +.density-compact { + --padding-table-cell-top: 4px; + --padding-table-cell-bottom: 4px; + --form-input-height: 28px; +} + +// --------------------------------------------------------------------------- +// Touch. The desktop density must never reach a finger: below the tablet +// breakpoint every control grows to a 44px minimum target. +// --------------------------------------------------------------------------- +@media (max-width: 767px) { + :root { + --form-input-height: 44px; + --form-input-padding-y: 11px; + --padding-table-cell-top: 12px; + --padding-table-cell-bottom: 12px; + } + + .btn, + .mx-link, + input.form-control, + select.form-control { + min-height: 44px; + } +} diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/custom-variables.scss b/cmd/mxcli/theme/assets/console/files/theme/web/custom-variables.scss new file mode 100644 index 000000000..57c2875df --- /dev/null +++ b/cmd/mxcli/theme/assets/console/files/theme/web/custom-variables.scss @@ -0,0 +1,70 @@ +// Layer 1 — the Console palette. This is the file to edit. +// +// These are the theme's own semantic tokens. _mxcli-atlas-map.scss maps them +// onto the ~60 Atlas variables that Atlas components, form controls and the +// brand-aware pluggable widgets (Switch, Slider, ProgressBar, BadgeButton) +// actually read — so changing --mxt-brand here re-brands the entire app, with +// no per-widget CSS. +// +// The values below are the DARK palette, which is Console's default. The light palette lives in _mxcli-console.scss. +// +// Mendix 11 Atlas is CSS-custom-property-first, so these are `:root` +// declarations rather than SCSS `!default` variables. This file is imported +// once per module, so it must hold declarations only — never rules. + +:root { + /* ---- the one colour that defines the app --------------------------- */ + --mxt-brand: #2dd4bf; + --mxt-brand-hover: #5ee7d6; + --mxt-brand-ink: #04211d; + --mxt-accent: #a78bfa; + + /* ---- semantic status ----------------------------------------------- */ + --mxt-success: #3fb950; + --mxt-warning: #d29922; + --mxt-danger: #f85149; + --mxt-info: #58a6ff; + + /* ---- surfaces and ink — separated by lightness, never by shadow ----- */ + --mxt-ground: #0e1116; + --mxt-surface: #161b22; + --mxt-surface-alt: #1c2129; + --mxt-surface-hover: #222831; + --mxt-surface-selected: #10312e; + --mxt-ink: #e6edf3; + --mxt-ink-muted: #9aa6b4; + --mxt-ink-faint: #6e7b8b; + --mxt-line: #262c36; + + /* ---- the navigation rail -------------------------------------------- */ + --mxt-rail: #0a0d11; + --mxt-rail-line: #1c222b; + --mxt-rail-ink: #b6c0cc; + --mxt-rail-ink-active: #ffffff; + + /* ---- status pill tones ---------------------------------------------- */ + --mxt-tint-ok: rgba(63, 185, 80, 0.16); + --mxt-tone-ok: #56d364; + --mxt-tint-warn: rgba(210, 153, 34, 0.16); + --mxt-tone-warn: #e3b341; + --mxt-tint-risk: rgba(248, 81, 73, 0.16); + --mxt-tone-risk: #ff7b72; + --mxt-tint-info: rgba(45, 212, 191, 0.18); + --mxt-tone-info: #5ee7d6; + --mxt-tint-neutral: #21262d; + + /* ---- shape, density, elevation --------------------------------------- */ + --mxt-radius: 6px; + --mxt-radius-lg: 8px; + --mxt-row-height: 28px; + --mxt-control-height: 30px; + --mxt-shadow: none; + --mxt-focus-halo: 0 0 0 3px rgba(45, 212, 191, 0.3); + + /* ---- type ------------------------------------------------------------ */ + --mxt-font: "Space Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif; + --mxt-font-heading: var(--mxt-font); + --mxt-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --mxt-font-size: 14px; + --mxt-line-height: 1.45; +} diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/main.scss b/cmd/mxcli/theme/assets/console/files/theme/web/main.scss new file mode 100644 index 000000000..fa97920ad --- /dev/null +++ b/cmd/mxcli/theme/assets/console/files/theme/web/main.scss @@ -0,0 +1,9 @@ +// This file compiles after Atlas Core and after every module theme source, so +// the partials imported here can override any Atlas rule without !important — +// including Mendix's own _theme-dark.scss above. +// +// Variant: auto follows the OS and honours a theme-light / theme-dark class on +// ; light or dark bakes one palette with no switching. +$mxcli-theme-variant: {{VARIANT}}; +@import "mxcli-atlas-map"; +@import "mxcli-console"; diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/OFL.txt b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/OFL.txt new file mode 100644 index 000000000..5ceee0025 --- /dev/null +++ b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-400-normal.woff2 b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-400-normal.woff2 new file mode 100644 index 000000000..585887339 Binary files /dev/null and b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-400-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-500-normal.woff2 b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-500-normal.woff2 new file mode 100644 index 000000000..be878e68f Binary files /dev/null and b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-500-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-600-normal.woff2 b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-600-normal.woff2 new file mode 100644 index 000000000..59c24e7cb Binary files /dev/null and b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/jetbrains-mono-latin-600-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-400-normal.woff2 b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-400-normal.woff2 new file mode 100644 index 000000000..0e6347113 Binary files /dev/null and b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-400-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-500-normal.woff2 b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-500-normal.woff2 new file mode 100644 index 000000000..0db251fc0 Binary files /dev/null and b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-500-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-600-normal.woff2 b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-600-normal.woff2 new file mode 100644 index 000000000..a1db41a0a Binary files /dev/null and b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-600-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-700-normal.woff2 b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-700-normal.woff2 new file mode 100644 index 000000000..44604a0bd Binary files /dev/null and b/cmd/mxcli/theme/assets/console/files/theme/web/mxcli-fonts/space-grotesk-latin-700-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/console/theme.json b/cmd/mxcli/theme/assets/console/theme.json new file mode 100644 index 000000000..1d151bde0 --- /dev/null +++ b/cmd/mxcli/theme/assets/console/theme.json @@ -0,0 +1,43 @@ +{ + "name": "console", + "title": "Console", + "version": "1", + "summary": "Dark-first, high contrast, geometric type, 28px rows, 6px radius", + "description": "A dark-first operational theme: near-black ground, teal signal colour with a violet accent, Space Grotesk over JetBrains Mono, 6px corners and 28px rows. Surfaces separate by lightness rather than by shadow. Ships dark and light palettes; dark is the default.", + "colorway": [ + "#2DD4BF", + "#A78BFA", + "#3FB950", + "#D29922", + "#F85149", + "#9AA6B4" + ], + "defaultVariant": "dark", + "files": [ + { + "path": "theme/web/custom-variables.scss", + "mode": "block", + "purpose": "Layer 1 \u2014 the Console palette (dark): brand, surfaces, ink, density" + }, + { + "path": "theme/web/_mxcli-atlas-map.scss", + "mode": "block", + "purpose": "the Atlas wiring \u2014 shared by every mxcli theme, identical in each" + }, + { + "path": "theme/web/_mxcli-console.scss", + "mode": "block", + "purpose": "Layer 2 \u2014 light palette, variant blocks, fonts, recipes" + }, + { + "path": "theme/web/main.scss", + "mode": "block", + "purpose": "variant switch + imports \u2014 this file compiles last, so the partials win" + }, + { + "path": "theme/web/mxcli-fonts/", + "mode": "verbatim", + "purpose": "vendored Space Grotesk + JetBrains Mono (SIL OFL 1.1) \u2014 no CDN at runtime" + } + ] +} diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-atlas-map.scss b/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-atlas-map.scss new file mode 100644 index 000000000..17ed472eb --- /dev/null +++ b/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-atlas-map.scss @@ -0,0 +1,205 @@ +// The Atlas wiring — shared by every mxcli theme, identical in each. +// +// Atlas components, form controls and the brand-aware pluggable widgets +// (Switch, Slider, ProgressBar, BadgeButton) read the ~60 variables below. +// Expressing them once, in terms of the theme's own --mxt-* tokens, is what +// makes a palette swap or a light/dark flip a matter of restating ~30 values +// instead of rewiring the framework. +// +// A theme never edits this file: it sets --mxt-* and includes the mixin. + +// --------------------------------------------------------------------------- +// The Atlas wiring. Every variable Atlas components read, expressed in terms of +// the theme's own tokens — so a variant only has to restate the ~30 --mxt-* +// values, never these. +// --------------------------------------------------------------------------- +@mixin mxcli-atlas-map { + --brand-primary: var(--mxt-brand); + --brand-primary-hover: var(--mxt-brand-hover); + --brand-success: var(--mxt-success); + --brand-warning: var(--mxt-warning); + --brand-danger: var(--mxt-danger); + --brand-info: var(--mxt-info); + --brand-default: var(--mxt-tint-neutral); + --gray: var(--mxt-ink-faint); + + --color-base: var(--mxt-surface); + --color-contrast: var(--mxt-ink); + --bg-color: var(--mxt-ground); + --bg-color-secondary: var(--mxt-surface); + + --font-color-default: var(--mxt-ink); + --font-color-detail: var(--mxt-ink-muted); + --font-color-header: var(--mxt-ink); + // Atlas uses this for topbar and navigation text, not only for text on a + // brand fill — so it tracks the rail, which is the surface it actually + // sits on. Text on a brand-filled button comes from --btn-*-color below. + --font-color-contrast: var(--mxt-rail-ink-active); + --link-color: var(--mxt-brand); + --link-hover-color: var(--mxt-brand-hover); + + --border-color-default: var(--mxt-line); + --border-radius-s: var(--mxt-radius); + --border-radius-m: var(--mxt-radius); + --border-radius-l: var(--mxt-radius-lg); + --border-radius-default: var(--mxt-radius); + + --shadow-color: transparent; + --shadow-small: var(--mxt-shadow); + --shadow-medium: 0 2px 4px 0 rgba(0, 0, 0, 0.1); + --shadow-large: 0 8px 16px 0 rgba(0, 0, 0, 0.14); + + // Visible on every focusable element; the soft halo is added by the rule + // further down, which also stops anything from suppressing it. + --focus-outline: 2px solid var(--mxt-brand); + --focus-outline-offset: 1px; + + --font-family-base: var(--mxt-font); + --font-size-default: var(--mxt-font-size); + --font-size-small: 12px; + --font-size-large: 16px; + --line-height-base: var(--mxt-line-height); + + // An operational scale — tighter than Atlas's marketing-page defaults. + --font-size-h1: 28px; + --font-size-h2: 22px; + --font-size-h3: 18px; + --font-size-h4: 16px; + --font-size-h5: var(--mxt-font-size); + --font-size-h6: 12px; + --font-weight-header: 600; + --font-header-margin: 0 0 8px 0; + + // Density: 32px controls on desktop, grown to a 44px touch target below the + // tablet breakpoint by the media query at the end of this file. + --form-input-height: var(--mxt-control-height); + --form-input-padding-y: 4px; + --form-input-padding-x: 8px; + --form-input-font-size: var(--mxt-font-size); + --form-input-border-radius: var(--mxt-radius); + --form-input-bg: var(--mxt-surface); + --form-input-bg-hover: var(--mxt-surface); + --form-input-bg-focus: var(--mxt-surface); + --form-input-bg-disabled: var(--mxt-surface-alt); + --form-input-color: var(--mxt-ink); + --form-input-placeholder-color: var(--mxt-ink-faint); + --form-input-border-color: var(--mxt-line); + --form-input-border-focus-color: var(--mxt-brand); + --form-label-color: var(--mxt-ink-muted); + --form-label-weight: 500; + --form-label-gutter: 6px; + --form-group-margin-bottom: 12px; + --form-group-gutter: 12px; + + --padding-table-cell-top: 6px; + --padding-table-cell-bottom: 6px; + --padding-table-cell-left: 12px; + --padding-table-cell-right: 12px; + --grid-border-color: var(--mxt-line); + --grid-bg: transparent; + --grid-bg-header: transparent; + --grid-bg-striped: var(--mxt-surface-alt); + --grid-bg-hover: var(--mxt-surface-hover); + --grid-bg-selected: var(--mxt-surface-selected); + --grid-bg-selected-hover: var(--mxt-surface-selected); + --grid-selected-color: var(--mxt-ink); + + --card-bg: var(--mxt-surface); + --card-border: 1px solid var(--mxt-line); + --card-border-radius: var(--mxt-radius); + --card-padding: 16px; + --card-margin-bottom: 16px; + --card-shadow: var(--mxt-shadow); + + --btn-border-radius: var(--mxt-radius); + --btn-font-size: var(--mxt-font-size); + --btn-default-bg: var(--mxt-surface); + --btn-default-border-color: var(--mxt-line); + --btn-default-color: var(--mxt-ink); + --btn-default-icon-color: var(--mxt-ink-muted); + --btn-link-bg-hover: var(--mxt-surface-hover); + --btn-primary-color: var(--mxt-brand-ink); + --btn-success-color: var(--mxt-brand-ink); + --btn-warning-color: var(--mxt-brand-ink); + --btn-danger-color: var(--mxt-brand-ink); + + // A flat rail, not Atlas's blue gradient. + --topbar-bg: var(--mxt-rail); + --topbar-border-color: var(--mxt-rail-line); + --sidebar-bg: var(--mxt-rail); + --navigation-bg: var(--mxt-rail); + --navigation-bg-hover: rgba(255, 255, 255, 0.08); + --navigation-bg-active: rgba(255, 255, 255, 0.14); + --navigation-color: var(--mxt-rail-ink); + --navigation-color-hover: var(--mxt-rail-ink-active); + --navigation-color-active: var(--mxt-rail-ink-active); + --navigation-sub-bg: rgba(255, 255, 255, 0.05); + --navigation-sub-color: var(--mxt-rail-ink); + --navigation-sub-color-hover: var(--mxt-rail-ink-active); + --navigation-sub-color-active: var(--mxt-rail-ink-active); + --navigation-border-color: rgba(255, 255, 255, 0.08); + --navigation-border-radius: var(--mxt-radius); + + --navsidebar-bg: var(--mxt-rail); + --navsidebar-bg-hover: rgba(255, 255, 255, 0.08); + --navsidebar-bg-active: rgba(255, 255, 255, 0.14); + --navsidebar-sub-bg: rgba(255, 255, 255, 0.05); + --navsidebar-color: var(--mxt-rail-ink); + --navsidebar-color-hover: var(--mxt-rail-ink-active); + --navsidebar-color-active: var(--mxt-rail-ink-active); + --navsidebar-border-color: rgba(255, 255, 255, 0.08); + --navsidebar-border-radius: var(--mxt-radius); + + --navtopbar-bg: var(--mxt-rail); + --navtopbar-bg-hover: rgba(255, 255, 255, 0.08); + --navtopbar-bg-active: rgba(255, 255, 255, 0.14); + --navtopbar-color: var(--mxt-rail-ink); + --navtopbar-color-hover: var(--mxt-rail-ink-active); + --navtopbar-color-active: var(--mxt-rail-ink-active); + --navtopbar-sub-color: var(--mxt-rail-ink); + --navtopbar-sub-color-hover: var(--mxt-rail-ink-active); + --navtopbar-sub-color-active: var(--mxt-rail-ink-active); + --navtopbar-border-color: var(--mxt-rail-line); + --navtopbar-border-radius: var(--mxt-radius); + + --header-bg-color: var(--mxt-brand); + --header-text-color: var(--mxt-brand-ink); + --header-text-color-detail: rgba(255, 255, 255, 0.7); + + --modal-header-border-color: var(--mxt-line); + --modal-body-bg: var(--mxt-surface); + --modal-footer-bg: var(--mxt-surface); + + --tabs-border-color: var(--mxt-line); + --tabs-lined-border-color: var(--mxt-brand); + --tabs-lined-border-width: 2px; + --tabs-bg-pills: var(--mxt-tint-neutral); +} + +// --------------------------------------------------------------------------- +// Atlas fixups — rules, not mappings, for the few places Atlas hardcodes a +// colour instead of reading a variable. +// +// The topbar language selector paints itself with --bg-color-secondary and a +// #fff fallback, which assumes a dark rail. Every mxcli theme does keep the +// rail dark, but --bg-color-secondary is the light *surface* colour: white in a +// light palette (so it reads by luck) and near-black in a dark one, where the +// text drops to a 1.13:1 contrast ratio and is effectively invisible. +// +// Two things matter in the fix. Atlas's own selector is +// `.navbar-brand .widget-language-selector .current-language-text` at (0,3,0), +// so a bare `.current-language-text` at (0,1,0) never wins — it only appeared +// to work on layouts that do not nest the selector under .navbar-brand. And +// `color: inherit` is the wrong value even at the right weight: the inherited +// colour is body ink, which is dark, and the rail is dark in both palettes. +// The rail has its own token; use it. +// +// Both selector shapes are listed so the rule applies whether or not the +// layout uses .navbar-brand; each is matched at its own specificity. +// --------------------------------------------------------------------------- +.current-language-text, +.language-arrow, +.navbar-brand .widget-language-selector .current-language-text, +.navbar-brand .widget-language-selector .language-arrow { + color: var(--mxt-rail-ink-active, var(--mxt-rail-ink)); +} diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-ledger.scss b/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-ledger.scss new file mode 100644 index 000000000..9dde6d61d --- /dev/null +++ b/cmd/mxcli/theme/assets/ledger/files/theme/web/_mxcli-ledger.scss @@ -0,0 +1,318 @@ +// Layer 2 — Ledger: the dark palette, the variant blocks and the recipe classes. +// The Atlas wiring both variants run through is in _mxcli-atlas-map.scss. +// +// Imported from theme/web/main.scss, which compiles AFTER Atlas Core and after +// every module theme source. That matters twice over: these rules override +// Atlas without !important, and the variant blocks below outrank Mendix's own +// theme/web/_theme-dark.scss, which would otherwise repaint the app in stock +// Mendix blue the moment a theme-dark class appears. +// +// Edit the palette in custom-variables.scss, not here. + +$mxcli-theme-variant: {{VARIANT}} !default; + +// --------------------------------------------------------------------------- +// The dark palette. Only the theme's own tokens — the Atlas wiring is written +// in terms of these, so it does not need restating. +// --------------------------------------------------------------------------- +@mixin mxcli-ledger-dark { + --mxt-brand: #7fa3cc; + --mxt-brand-hover: #9dbadd; + --mxt-brand-ink: #0e1a26; + --mxt-accent: #e08a4f; + + --mxt-success: #6fbf93; + --mxt-warning: #d9a441; + --mxt-danger: #e0715c; + --mxt-info: #7fa3cc; + + --mxt-ground: #171614; + --mxt-surface: #1f1d1a; + --mxt-surface-alt: #262320; + --mxt-surface-hover: #2d2925; + --mxt-surface-selected: #23303d; + --mxt-ink: #ede8df; + --mxt-ink-muted: #a69c8d; + --mxt-ink-faint: #7a7164; + --mxt-line: #35302a; + + --mxt-rail: #12110f; + --mxt-rail-line: #2a2621; + --mxt-rail-ink: #c3baa9; + --mxt-rail-ink-active: #ffffff; + + --mxt-tint-ok: rgba(111, 191, 147, 0.16); + --mxt-tone-ok: #8ed3ac; + --mxt-tint-warn: rgba(217, 164, 65, 0.16); + --mxt-tone-warn: #e6bc6b; + --mxt-tint-risk: rgba(224, 113, 92, 0.16); + --mxt-tone-risk: #ec9280; + --mxt-tint-info: rgba(127, 163, 204, 0.16); + --mxt-tone-info: #9dbadd; + --mxt-tint-neutral: #2b2723; + + --mxt-shadow: none; + --mxt-focus-halo: 0 0 0 3px rgba(127, 163, 204, 0.28); +} + +// --------------------------------------------------------------------------- +// Variant selection. +// +// auto — follow the OS, and honour an explicit theme-light / theme-dark +// class on so a switcher can override it +// light — Ledger's default palette, no switching +// dark — the dark palette, no switching +// +// `light` emits nothing beyond the base: it IS the base. And an explicit +// .theme-light needs no block either, because the media query below excludes +// it — which is the whole reason for the :not(). +// --------------------------------------------------------------------------- +:root { + @include mxcli-atlas-map; +} + +@if $mxcli-theme-variant == auto { + @media (prefers-color-scheme: dark) { + :root:not(.theme-light) { + @include mxcli-ledger-dark; + @include mxcli-atlas-map; + } + } + + // Specificity matters here: Mendix's own _theme-dark.scss also declares + // :root.theme-dark. Same weight, so the later declaration wins — and this + // file is imported after it. + :root.theme-dark { + @include mxcli-ledger-dark; + @include mxcli-atlas-map; + } +} @else if $mxcli-theme-variant == dark { + :root { + @include mxcli-ledger-dark; + @include mxcli-atlas-map; + } +} + +// --------------------------------------------------------------------------- +// Fonts. Vendored (SIL OFL 1.1, see mxcli-fonts/OFL.txt) rather than pulled +// from a CDN: no @import ordering trap, no third-party request at runtime, and +// the app renders correctly air-gapped. The URLs are relative to +// theme.compiled.css, which Mendix deploys to the web root alongside +// theme/web/mxcli-fonts/. +// --------------------------------------------------------------------------- +@each $weight in (400, 500, 600, 700) { + @font-face { + font-family: "Source Sans 3"; + src: url("./mxcli-fonts/source-sans-3-latin-#{$weight}-normal.woff2") format("woff2"); + font-weight: $weight; + font-style: normal; + font-display: swap; + } +} + +@each $weight in (400, 600, 700) { + @font-face { + font-family: "Source Serif 4"; + src: url("./mxcli-fonts/source-serif-4-latin-#{$weight}-normal.woff2") format("woff2"); + font-weight: $weight; + font-style: normal; + font-display: swap; + } +} + +h1, +h2, +h3, +h4, +h5, +h6, +.mx-title { + font-family: var(--mxt-font-heading); +} + +// --------------------------------------------------------------------------- +// Hairlines, not cards. Ledger separates content with 1px rules and relies on +// no elevation at all, so the Atlas card is flattened to a ruled block. +// --------------------------------------------------------------------------- +.card { + box-shadow: none; +} + +.stat { + box-shadow: none; +} + +// --------------------------------------------------------------------------- +// Focus. An invisible focus state is the most common accessibility regression +// in a generated app, so this adds the halo and makes sure nothing removes it. +// --------------------------------------------------------------------------- +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible, +.mx-link:focus-visible, +.btn:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); + box-shadow: var(--mxt-focus-halo); +} + +h1, +h2, +h3, +h4, +h5, +h6, +.mx-title { + font-family: var(--mxt-font-heading); +} + +// --------------------------------------------------------------------------- +// Numerics. Monospace with tabular figures so ids, amounts and dates align +// vertically down a column. Apply with class: 'num'. +// --------------------------------------------------------------------------- +.num, +.num input, +.num textarea, +.num .form-control-static { + font-family: var(--mxt-font-mono); + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + letter-spacing: -0.01em; +} + +// A field's label is prose, not a number. Without this the class set on a +// textbox to align its value also renders the label in monospace, because +// Mendix nests the label inside the widget root the class lands on. +.num label, +.num .control-label { + font-family: var(--mxt-font); + font-variant-numeric: normal; + letter-spacing: normal; +} + +.num-right { + text-align: right; +} + +// --------------------------------------------------------------------------- +// Status pills. One shape, five tones, all resolved through tokens so they +// follow a variant flip instead of staying stuck in light-mode tints. +// --------------------------------------------------------------------------- +.pill { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + background: var(--mxt-tint-neutral); + color: var(--mxt-ink-muted); +} + +.pill-ok { + background: var(--mxt-tint-ok); + color: var(--mxt-tone-ok); +} + +.pill-warn { + background: var(--mxt-tint-warn); + color: var(--mxt-tone-warn); +} + +.pill-risk, +.pill-danger { + background: var(--mxt-tint-risk); + color: var(--mxt-tone-risk); +} + +.pill-info { + background: var(--mxt-tint-info); + color: var(--mxt-tone-info); +} + +// --------------------------------------------------------------------------- +// KPI tile. +// --------------------------------------------------------------------------- +.stat { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--card-padding); + background: var(--mxt-surface); + border: 1px solid var(--mxt-line); + border-radius: var(--mxt-radius); + box-shadow: var(--mxt-shadow); +} + +.stat-label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--mxt-ink-muted); +} + +.stat-value { + font-family: var(--mxt-font-mono); + font-variant-numeric: tabular-nums; + font-size: 26px; + font-weight: 600; + line-height: 1.1; + color: var(--mxt-ink); +} + +.stat-delta { + font-size: 12px; + color: var(--mxt-ink-muted); +} + +.stat-delta-up { + color: var(--mxt-success); +} + +.stat-delta-down { + color: var(--mxt-danger); +} + +// --------------------------------------------------------------------------- +// Grid rhythm. Header rows read as labels rather than as another data row. +// --------------------------------------------------------------------------- +.mx-datagrid th, +.mx-datagrid-head, +.th .column-header { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--mxt-ink-muted); +} + +.density-compact { + --padding-table-cell-top: 4px; + --padding-table-cell-bottom: 4px; + --form-input-height: 28px; +} + +// --------------------------------------------------------------------------- +// Touch. The desktop density must never reach a finger: below the tablet +// breakpoint every control grows to a 44px minimum target. +// --------------------------------------------------------------------------- +@media (max-width: 767px) { + :root { + --form-input-height: 44px; + --form-input-padding-y: 11px; + --padding-table-cell-top: 12px; + --padding-table-cell-bottom: 12px; + } + + .btn, + .mx-link, + input.form-control, + select.form-control { + min-height: 44px; + } +} diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/custom-variables.scss b/cmd/mxcli/theme/assets/ledger/files/theme/web/custom-variables.scss new file mode 100644 index 000000000..33415e5f6 --- /dev/null +++ b/cmd/mxcli/theme/assets/ledger/files/theme/web/custom-variables.scss @@ -0,0 +1,75 @@ +// Layer 1 — the Ledger palette. This is the file to edit. +// +// These are the theme's own semantic tokens. _mxcli-atlas-map.scss maps them +// onto the ~60 Atlas variables that Atlas components, form controls and the +// brand-aware pluggable widgets (Switch, Slider, ProgressBar, BadgeButton) +// actually read — so changing --mxt-brand here re-brands the entire app, with +// no per-widget CSS. +// +// The values below are the LIGHT palette, which is Ledger's default. The dark palette lives in _mxcli-ledger.scss. +// +// Mendix 11 Atlas is CSS-custom-property-first, so these are `:root` +// declarations rather than SCSS `!default` variables. This file is imported +// once per module, so it must hold declarations only — never rules. + +:root { + /* ---- the one colour that defines the app --------------------------- */ + --mxt-brand: #1f3a5f; + --mxt-brand-hover: #2b4f7d; + --mxt-brand-ink: #ffffff; + --mxt-accent: #b4531f; + + /* ---- semantic status ----------------------------------------------- */ + --mxt-success: #2f6b4f; + --mxt-warning: #a66a15; + --mxt-danger: #9b2c1f; + --mxt-info: #1f3a5f; + + /* ---- surfaces and ink ----------------------------------------------- */ + --mxt-ground: #f7f4ee; + --mxt-surface: #fffdf9; + --mxt-surface-alt: #f3efe7; + --mxt-surface-hover: #efe9dd; + --mxt-surface-selected: #e8eef5; + --mxt-ink: #1c1917; + --mxt-ink-muted: #6b6257; + --mxt-ink-faint: #9b9082; + --mxt-line: #e2dbce; + + /* ---- the navigation rail -------------------------------------------- + Warm near-black rather than paper: several Atlas widgets in the topbar + (the language selector among them) hardcode --color-base for their text, + which is the light surface colour. A light rail makes them invisible, so + every mxcli theme keeps the rail dark in both variants. */ + --mxt-rail: #211e1a; + --mxt-rail-line: #35302a; + --mxt-rail-ink: #c3baa9; + --mxt-rail-ink-active: #ffffff; + + /* ---- status pill tones ---------------------------------------------- */ + --mxt-tint-ok: rgba(47, 107, 79, 0.12); + --mxt-tone-ok: #285b43; + --mxt-tint-warn: rgba(166, 106, 21, 0.12); + --mxt-tone-warn: #855511; + --mxt-tint-risk: rgba(155, 44, 31, 0.12); + --mxt-tone-risk: #7d2419; + --mxt-tint-info: rgba(31, 58, 95, 0.12); + --mxt-tone-info: #1f3a5f; + --mxt-tint-neutral: #ebe5d9; + + /* ---- shape, density, elevation --------------------------------------- */ + /* No elevation anywhere: 1px rules carry the structure instead. */ + --mxt-radius: 2px; + --mxt-radius-lg: 2px; + --mxt-row-height: 30px; + --mxt-control-height: 30px; + --mxt-shadow: none; + --mxt-focus-halo: 0 0 0 3px rgba(31, 58, 95, 0.18); + + /* ---- type ------------------------------------------------------------ */ + --mxt-font: "Source Sans 3", system-ui, -apple-system, "Segoe UI", sans-serif; + --mxt-font-heading: "Source Serif 4", Georgia, "Times New Roman", serif; + --mxt-font-mono: "Source Sans 3", ui-monospace, SFMono-Regular, Menlo, monospace; + --mxt-font-size: 14px; + --mxt-line-height: 1.5; +} diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/main.scss b/cmd/mxcli/theme/assets/ledger/files/theme/web/main.scss new file mode 100644 index 000000000..260016738 --- /dev/null +++ b/cmd/mxcli/theme/assets/ledger/files/theme/web/main.scss @@ -0,0 +1,9 @@ +// This file compiles after Atlas Core and after every module theme source, so +// the partials imported here can override any Atlas rule without !important — +// including Mendix's own _theme-dark.scss above. +// +// Variant: auto follows the OS and honours a theme-light / theme-dark class on +// ; light or dark bakes one palette with no switching. +$mxcli-theme-variant: {{VARIANT}}; +@import "mxcli-atlas-map"; +@import "mxcli-ledger"; diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/OFL.txt b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/OFL.txt new file mode 100644 index 000000000..5871e1f3d --- /dev/null +++ b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-400-normal.woff2 b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-400-normal.woff2 new file mode 100644 index 000000000..0c4242ed4 Binary files /dev/null and b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-400-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-500-normal.woff2 b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-500-normal.woff2 new file mode 100644 index 000000000..59189166c Binary files /dev/null and b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-500-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-600-normal.woff2 b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-600-normal.woff2 new file mode 100644 index 000000000..93511778f Binary files /dev/null and b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-600-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-700-normal.woff2 b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-700-normal.woff2 new file mode 100644 index 000000000..3cb944ca3 Binary files /dev/null and b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-sans-3-latin-700-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-400-normal.woff2 b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-400-normal.woff2 new file mode 100644 index 000000000..e8c6f71d1 Binary files /dev/null and b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-400-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-600-normal.woff2 b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-600-normal.woff2 new file mode 100644 index 000000000..fcbd11d2b Binary files /dev/null and b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-600-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-700-normal.woff2 b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-700-normal.woff2 new file mode 100644 index 000000000..34a307f5b Binary files /dev/null and b/cmd/mxcli/theme/assets/ledger/files/theme/web/mxcli-fonts/source-serif-4-latin-700-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/ledger/theme.json b/cmd/mxcli/theme/assets/ledger/theme.json new file mode 100644 index 000000000..988837b18 --- /dev/null +++ b/cmd/mxcli/theme/assets/ledger/theme.json @@ -0,0 +1,43 @@ +{ + "name": "ledger", + "title": "Ledger", + "version": "1", + "summary": "Warm paper, hairline rules instead of cards, serif headings, 30px rows", + "description": "A document-like alternative to Signal: warm paper ground, 1px rules instead of card elevation, Source Serif headings over Source Sans body, 2px corners and 30px rows. Same Atlas wiring as every mxcli theme, so it swaps in without touching a page. Ships light and dark palettes.", + "colorway": [ + "#1F3A5F", + "#B4531F", + "#2F6B4F", + "#A66A15", + "#9B2C1F", + "#6B6257" + ], + "defaultVariant": "light", + "files": [ + { + "path": "theme/web/custom-variables.scss", + "mode": "block", + "purpose": "Layer 1 \u2014 the Ledger palette (light): brand, surfaces, ink, density" + }, + { + "path": "theme/web/_mxcli-atlas-map.scss", + "mode": "block", + "purpose": "the Atlas wiring \u2014 shared by every mxcli theme, identical in each" + }, + { + "path": "theme/web/_mxcli-ledger.scss", + "mode": "block", + "purpose": "Layer 2 \u2014 dark palette, variant blocks, fonts, recipes" + }, + { + "path": "theme/web/main.scss", + "mode": "block", + "purpose": "variant switch + imports \u2014 this file compiles last, so the partials win" + }, + { + "path": "theme/web/mxcli-fonts/", + "mode": "verbatim", + "purpose": "vendored Source Sans 3 + Source Serif 4 (SIL OFL 1.1) \u2014 no CDN at runtime" + } + ] +} diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-atlas-map.scss b/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-atlas-map.scss new file mode 100644 index 000000000..17ed472eb --- /dev/null +++ b/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-atlas-map.scss @@ -0,0 +1,205 @@ +// The Atlas wiring — shared by every mxcli theme, identical in each. +// +// Atlas components, form controls and the brand-aware pluggable widgets +// (Switch, Slider, ProgressBar, BadgeButton) read the ~60 variables below. +// Expressing them once, in terms of the theme's own --mxt-* tokens, is what +// makes a palette swap or a light/dark flip a matter of restating ~30 values +// instead of rewiring the framework. +// +// A theme never edits this file: it sets --mxt-* and includes the mixin. + +// --------------------------------------------------------------------------- +// The Atlas wiring. Every variable Atlas components read, expressed in terms of +// the theme's own tokens — so a variant only has to restate the ~30 --mxt-* +// values, never these. +// --------------------------------------------------------------------------- +@mixin mxcli-atlas-map { + --brand-primary: var(--mxt-brand); + --brand-primary-hover: var(--mxt-brand-hover); + --brand-success: var(--mxt-success); + --brand-warning: var(--mxt-warning); + --brand-danger: var(--mxt-danger); + --brand-info: var(--mxt-info); + --brand-default: var(--mxt-tint-neutral); + --gray: var(--mxt-ink-faint); + + --color-base: var(--mxt-surface); + --color-contrast: var(--mxt-ink); + --bg-color: var(--mxt-ground); + --bg-color-secondary: var(--mxt-surface); + + --font-color-default: var(--mxt-ink); + --font-color-detail: var(--mxt-ink-muted); + --font-color-header: var(--mxt-ink); + // Atlas uses this for topbar and navigation text, not only for text on a + // brand fill — so it tracks the rail, which is the surface it actually + // sits on. Text on a brand-filled button comes from --btn-*-color below. + --font-color-contrast: var(--mxt-rail-ink-active); + --link-color: var(--mxt-brand); + --link-hover-color: var(--mxt-brand-hover); + + --border-color-default: var(--mxt-line); + --border-radius-s: var(--mxt-radius); + --border-radius-m: var(--mxt-radius); + --border-radius-l: var(--mxt-radius-lg); + --border-radius-default: var(--mxt-radius); + + --shadow-color: transparent; + --shadow-small: var(--mxt-shadow); + --shadow-medium: 0 2px 4px 0 rgba(0, 0, 0, 0.1); + --shadow-large: 0 8px 16px 0 rgba(0, 0, 0, 0.14); + + // Visible on every focusable element; the soft halo is added by the rule + // further down, which also stops anything from suppressing it. + --focus-outline: 2px solid var(--mxt-brand); + --focus-outline-offset: 1px; + + --font-family-base: var(--mxt-font); + --font-size-default: var(--mxt-font-size); + --font-size-small: 12px; + --font-size-large: 16px; + --line-height-base: var(--mxt-line-height); + + // An operational scale — tighter than Atlas's marketing-page defaults. + --font-size-h1: 28px; + --font-size-h2: 22px; + --font-size-h3: 18px; + --font-size-h4: 16px; + --font-size-h5: var(--mxt-font-size); + --font-size-h6: 12px; + --font-weight-header: 600; + --font-header-margin: 0 0 8px 0; + + // Density: 32px controls on desktop, grown to a 44px touch target below the + // tablet breakpoint by the media query at the end of this file. + --form-input-height: var(--mxt-control-height); + --form-input-padding-y: 4px; + --form-input-padding-x: 8px; + --form-input-font-size: var(--mxt-font-size); + --form-input-border-radius: var(--mxt-radius); + --form-input-bg: var(--mxt-surface); + --form-input-bg-hover: var(--mxt-surface); + --form-input-bg-focus: var(--mxt-surface); + --form-input-bg-disabled: var(--mxt-surface-alt); + --form-input-color: var(--mxt-ink); + --form-input-placeholder-color: var(--mxt-ink-faint); + --form-input-border-color: var(--mxt-line); + --form-input-border-focus-color: var(--mxt-brand); + --form-label-color: var(--mxt-ink-muted); + --form-label-weight: 500; + --form-label-gutter: 6px; + --form-group-margin-bottom: 12px; + --form-group-gutter: 12px; + + --padding-table-cell-top: 6px; + --padding-table-cell-bottom: 6px; + --padding-table-cell-left: 12px; + --padding-table-cell-right: 12px; + --grid-border-color: var(--mxt-line); + --grid-bg: transparent; + --grid-bg-header: transparent; + --grid-bg-striped: var(--mxt-surface-alt); + --grid-bg-hover: var(--mxt-surface-hover); + --grid-bg-selected: var(--mxt-surface-selected); + --grid-bg-selected-hover: var(--mxt-surface-selected); + --grid-selected-color: var(--mxt-ink); + + --card-bg: var(--mxt-surface); + --card-border: 1px solid var(--mxt-line); + --card-border-radius: var(--mxt-radius); + --card-padding: 16px; + --card-margin-bottom: 16px; + --card-shadow: var(--mxt-shadow); + + --btn-border-radius: var(--mxt-radius); + --btn-font-size: var(--mxt-font-size); + --btn-default-bg: var(--mxt-surface); + --btn-default-border-color: var(--mxt-line); + --btn-default-color: var(--mxt-ink); + --btn-default-icon-color: var(--mxt-ink-muted); + --btn-link-bg-hover: var(--mxt-surface-hover); + --btn-primary-color: var(--mxt-brand-ink); + --btn-success-color: var(--mxt-brand-ink); + --btn-warning-color: var(--mxt-brand-ink); + --btn-danger-color: var(--mxt-brand-ink); + + // A flat rail, not Atlas's blue gradient. + --topbar-bg: var(--mxt-rail); + --topbar-border-color: var(--mxt-rail-line); + --sidebar-bg: var(--mxt-rail); + --navigation-bg: var(--mxt-rail); + --navigation-bg-hover: rgba(255, 255, 255, 0.08); + --navigation-bg-active: rgba(255, 255, 255, 0.14); + --navigation-color: var(--mxt-rail-ink); + --navigation-color-hover: var(--mxt-rail-ink-active); + --navigation-color-active: var(--mxt-rail-ink-active); + --navigation-sub-bg: rgba(255, 255, 255, 0.05); + --navigation-sub-color: var(--mxt-rail-ink); + --navigation-sub-color-hover: var(--mxt-rail-ink-active); + --navigation-sub-color-active: var(--mxt-rail-ink-active); + --navigation-border-color: rgba(255, 255, 255, 0.08); + --navigation-border-radius: var(--mxt-radius); + + --navsidebar-bg: var(--mxt-rail); + --navsidebar-bg-hover: rgba(255, 255, 255, 0.08); + --navsidebar-bg-active: rgba(255, 255, 255, 0.14); + --navsidebar-sub-bg: rgba(255, 255, 255, 0.05); + --navsidebar-color: var(--mxt-rail-ink); + --navsidebar-color-hover: var(--mxt-rail-ink-active); + --navsidebar-color-active: var(--mxt-rail-ink-active); + --navsidebar-border-color: rgba(255, 255, 255, 0.08); + --navsidebar-border-radius: var(--mxt-radius); + + --navtopbar-bg: var(--mxt-rail); + --navtopbar-bg-hover: rgba(255, 255, 255, 0.08); + --navtopbar-bg-active: rgba(255, 255, 255, 0.14); + --navtopbar-color: var(--mxt-rail-ink); + --navtopbar-color-hover: var(--mxt-rail-ink-active); + --navtopbar-color-active: var(--mxt-rail-ink-active); + --navtopbar-sub-color: var(--mxt-rail-ink); + --navtopbar-sub-color-hover: var(--mxt-rail-ink-active); + --navtopbar-sub-color-active: var(--mxt-rail-ink-active); + --navtopbar-border-color: var(--mxt-rail-line); + --navtopbar-border-radius: var(--mxt-radius); + + --header-bg-color: var(--mxt-brand); + --header-text-color: var(--mxt-brand-ink); + --header-text-color-detail: rgba(255, 255, 255, 0.7); + + --modal-header-border-color: var(--mxt-line); + --modal-body-bg: var(--mxt-surface); + --modal-footer-bg: var(--mxt-surface); + + --tabs-border-color: var(--mxt-line); + --tabs-lined-border-color: var(--mxt-brand); + --tabs-lined-border-width: 2px; + --tabs-bg-pills: var(--mxt-tint-neutral); +} + +// --------------------------------------------------------------------------- +// Atlas fixups — rules, not mappings, for the few places Atlas hardcodes a +// colour instead of reading a variable. +// +// The topbar language selector paints itself with --bg-color-secondary and a +// #fff fallback, which assumes a dark rail. Every mxcli theme does keep the +// rail dark, but --bg-color-secondary is the light *surface* colour: white in a +// light palette (so it reads by luck) and near-black in a dark one, where the +// text drops to a 1.13:1 contrast ratio and is effectively invisible. +// +// Two things matter in the fix. Atlas's own selector is +// `.navbar-brand .widget-language-selector .current-language-text` at (0,3,0), +// so a bare `.current-language-text` at (0,1,0) never wins — it only appeared +// to work on layouts that do not nest the selector under .navbar-brand. And +// `color: inherit` is the wrong value even at the right weight: the inherited +// colour is body ink, which is dark, and the rail is dark in both palettes. +// The rail has its own token; use it. +// +// Both selector shapes are listed so the rule applies whether or not the +// layout uses .navbar-brand; each is matched at its own specificity. +// --------------------------------------------------------------------------- +.current-language-text, +.language-arrow, +.navbar-brand .widget-language-selector .current-language-text, +.navbar-brand .widget-language-selector .language-arrow { + color: var(--mxt-rail-ink-active, var(--mxt-rail-ink)); +} diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-signal.scss b/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-signal.scss new file mode 100644 index 000000000..4b2ed9d16 --- /dev/null +++ b/cmd/mxcli/theme/assets/signal/files/theme/web/_mxcli-signal.scss @@ -0,0 +1,295 @@ +// Layer 2 — Signal: the dark palette, the variant blocks and the recipe classes. +// The Atlas wiring both variants run through is in _mxcli-atlas-map.scss. +// +// Imported from theme/web/main.scss, which compiles AFTER Atlas Core and after +// every module theme source. That matters twice over: these rules override +// Atlas without !important, and the variant blocks below outrank Mendix's own +// theme/web/_theme-dark.scss, which would otherwise repaint the app in stock +// Mendix blue the moment a theme-dark class appears. +// +// Edit the palette in custom-variables.scss, not here. + +$mxcli-theme-variant: {{VARIANT}} !default; + +// --------------------------------------------------------------------------- +// The dark palette. Only the theme's own tokens — the Atlas wiring above is +// written in terms of these, so it does not need restating. +// --------------------------------------------------------------------------- +@mixin mxcli-signal-dark { + --mxt-brand: #2aa39f; + --mxt-brand-hover: #3fbdb8; + --mxt-brand-ink: #06231f; + + --mxt-success: #3fb950; + --mxt-warning: #d29922; + --mxt-danger: #f85149; + --mxt-info: #58a6ff; + + --mxt-ground: #0e1116; + --mxt-surface: #161b22; + --mxt-surface-alt: #1b212a; + --mxt-surface-hover: #1f2630; + --mxt-surface-selected: #14312f; + --mxt-ink: #e6edf3; + --mxt-ink-muted: #98a3b3; + --mxt-ink-faint: #6b7787; + --mxt-line: #262c36; + + --mxt-rail: #0a0d11; + --mxt-rail-line: #1c222b; + --mxt-rail-ink: #b6c0cc; + --mxt-rail-ink-active: #ffffff; + + --mxt-tint-ok: rgba(63, 185, 80, 0.16); + --mxt-tone-ok: #56d364; + --mxt-tint-warn: rgba(210, 153, 34, 0.16); + --mxt-tone-warn: #e3b341; + --mxt-tint-risk: rgba(248, 81, 73, 0.16); + --mxt-tone-risk: #ff7b72; + --mxt-tint-info: rgba(42, 163, 159, 0.18); + --mxt-tone-info: #3fbdb8; + --mxt-tint-neutral: #21262d; + + --mxt-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.4); + --mxt-focus-halo: 0 0 0 3px rgba(42, 163, 159, 0.28); +} + +// --------------------------------------------------------------------------- +// Variant selection. +// +// auto — follow the OS, and honour an explicit theme-light / theme-dark +// class on so a switcher can override it +// light — Signal's default palette, no switching +// dark — the dark palette, no switching +// +// `light` emits nothing beyond the base: it IS the base. And an explicit +// .theme-light needs no block either, because the media query below excludes +// it — which is the whole reason for the :not(). +// --------------------------------------------------------------------------- +:root { + @include mxcli-atlas-map; +} + +@if $mxcli-theme-variant == auto { + @media (prefers-color-scheme: dark) { + :root:not(.theme-light) { + @include mxcli-signal-dark; + @include mxcli-atlas-map; + } + } + + // Specificity matters here: Mendix's own _theme-dark.scss also declares + // :root.theme-dark. Same weight, so the later declaration wins — and this + // file is imported after it. + :root.theme-dark { + @include mxcli-signal-dark; + @include mxcli-atlas-map; + } +} @else if $mxcli-theme-variant == dark { + :root { + @include mxcli-signal-dark; + @include mxcli-atlas-map; + } +} + +// --------------------------------------------------------------------------- +// Fonts. Vendored (SIL OFL 1.1, see mxcli-fonts/OFL.txt) rather than pulled +// from a CDN: no @import ordering trap, no third-party request at runtime, and +// the app renders correctly air-gapped. The URLs are relative to +// theme.compiled.css, which Mendix deploys to the web root alongside +// theme/web/mxcli-fonts/. +// --------------------------------------------------------------------------- +@each $weight in (400, 500, 600, 700) { + @font-face { + font-family: "IBM Plex Sans"; + src: url("./mxcli-fonts/ibm-plex-sans-latin-#{$weight}-normal.woff2") format("woff2"); + font-weight: $weight; + font-style: normal; + font-display: swap; + } +} + +@each $weight in (400, 500, 600) { + @font-face { + font-family: "IBM Plex Mono"; + src: url("./mxcli-fonts/ibm-plex-mono-latin-#{$weight}-normal.woff2") format("woff2"); + font-weight: $weight; + font-style: normal; + font-display: swap; + } +} + +// --------------------------------------------------------------------------- +// Focus. An invisible focus state is the most common accessibility regression +// in a generated app, so this adds the halo and makes sure nothing removes it. +// --------------------------------------------------------------------------- +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible, +.mx-link:focus-visible, +.btn:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); + box-shadow: var(--mxt-focus-halo); +} + +h1, +h2, +h3, +h4, +h5, +h6, +.mx-title { + font-family: var(--mxt-font-heading); +} + +// --------------------------------------------------------------------------- +// Numerics. Monospace with tabular figures so ids, amounts and dates align +// vertically down a column. Apply with class: 'num'. +// --------------------------------------------------------------------------- +.num, +.num input, +.num textarea, +.num .form-control-static { + font-family: var(--mxt-font-mono); + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + letter-spacing: -0.01em; +} + +// A field's label is prose, not a number. Without this the class set on a +// textbox to align its value also renders the label in monospace, because +// Mendix nests the label inside the widget root the class lands on. +.num label, +.num .control-label { + font-family: var(--mxt-font); + font-variant-numeric: normal; + letter-spacing: normal; +} + +.num-right { + text-align: right; +} + +// --------------------------------------------------------------------------- +// Status pills. One shape, five tones, all resolved through tokens so they +// follow a variant flip instead of staying stuck in light-mode tints. +// --------------------------------------------------------------------------- +.pill { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + background: var(--mxt-tint-neutral); + color: var(--mxt-ink-muted); +} + +.pill-ok { + background: var(--mxt-tint-ok); + color: var(--mxt-tone-ok); +} + +.pill-warn { + background: var(--mxt-tint-warn); + color: var(--mxt-tone-warn); +} + +.pill-risk, +.pill-danger { + background: var(--mxt-tint-risk); + color: var(--mxt-tone-risk); +} + +.pill-info { + background: var(--mxt-tint-info); + color: var(--mxt-tone-info); +} + +// --------------------------------------------------------------------------- +// KPI tile. +// --------------------------------------------------------------------------- +.stat { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--card-padding); + background: var(--mxt-surface); + border: 1px solid var(--mxt-line); + border-radius: var(--mxt-radius); + box-shadow: var(--mxt-shadow); +} + +.stat-label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--mxt-ink-muted); +} + +.stat-value { + font-family: var(--mxt-font-mono); + font-variant-numeric: tabular-nums; + font-size: 26px; + font-weight: 600; + line-height: 1.1; + color: var(--mxt-ink); +} + +.stat-delta { + font-size: 12px; + color: var(--mxt-ink-muted); +} + +.stat-delta-up { + color: var(--mxt-success); +} + +.stat-delta-down { + color: var(--mxt-danger); +} + +// --------------------------------------------------------------------------- +// Grid rhythm. Header rows read as labels rather than as another data row. +// --------------------------------------------------------------------------- +.mx-datagrid th, +.mx-datagrid-head, +.th .column-header { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--mxt-ink-muted); +} + +.density-compact { + --padding-table-cell-top: 4px; + --padding-table-cell-bottom: 4px; + --form-input-height: 28px; +} + +// --------------------------------------------------------------------------- +// Touch. The desktop density must never reach a finger: below the tablet +// breakpoint every control grows to a 44px minimum target. +// --------------------------------------------------------------------------- +@media (max-width: 767px) { + :root { + --form-input-height: 44px; + --form-input-padding-y: 11px; + --padding-table-cell-top: 12px; + --padding-table-cell-bottom: 12px; + } + + .btn, + .mx-link, + input.form-control, + select.form-control { + min-height: 44px; + } +} diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/custom-variables.scss b/cmd/mxcli/theme/assets/signal/files/theme/web/custom-variables.scss new file mode 100644 index 000000000..1a641c6d9 --- /dev/null +++ b/cmd/mxcli/theme/assets/signal/files/theme/web/custom-variables.scss @@ -0,0 +1,70 @@ +// Layer 1 — the Signal palette. This is the file to edit. +// +// These are the theme's own semantic tokens. The next file down +// (_mxcli-signal.scss) maps them onto the ~60 Atlas variables that Atlas +// components, form controls and the brand-aware pluggable widgets (Switch, +// Slider, ProgressBar, BadgeButton) actually read — so changing --mxt-brand +// here re-brands the entire app, with no per-widget CSS. +// +// The values below are the LIGHT palette, which is Signal's default. The dark +// palette lives beside the light-to-Atlas mapping in _mxcli-signal.scss. +// +// Mendix 11 Atlas is CSS-custom-property-first, so these are `:root` +// declarations rather than SCSS `!default` variables. This file is imported +// once per module, so it must hold declarations only — never rules. + +:root { + /* ---- the one colour that defines the app --------------------------- */ + --mxt-brand: #0f6e6b; + --mxt-brand-hover: #0b5754; + --mxt-brand-ink: #ffffff; /* text on a brand-filled surface */ + + /* ---- semantic status ----------------------------------------------- */ + --mxt-success: #1f7a4d; + --mxt-warning: #b45309; + --mxt-danger: #b42318; + --mxt-info: #1f5fa8; + + /* ---- surfaces and ink ----------------------------------------------- */ + --mxt-ground: #f4f6f8; /* the app background */ + --mxt-surface: #ffffff; /* cards, modals, panels */ + --mxt-surface-alt: #f9fafb; /* striped rows, subtle fills */ + --mxt-surface-hover: #edf2f2; + --mxt-surface-selected: #e3efef; + --mxt-ink: #14181f; + --mxt-ink-muted: #5a6572; + --mxt-ink-faint: #98a1ad; + --mxt-line: #dce1e7; + + /* ---- the navigation rail -------------------------------------------- */ + --mxt-rail: #14181f; + --mxt-rail-line: #232a34; + --mxt-rail-ink: #d3d8de; + --mxt-rail-ink-active: #ffffff; + + /* ---- status pill tones ---------------------------------------------- */ + --mxt-tint-ok: rgba(31, 122, 77, 0.12); + --mxt-tone-ok: #196340; + --mxt-tint-warn: rgba(180, 83, 9, 0.12); + --mxt-tone-warn: #8f4207; + --mxt-tint-risk: rgba(180, 35, 24, 0.12); + --mxt-tone-risk: #8f1c13; + --mxt-tint-info: rgba(15, 110, 107, 0.12); + --mxt-tone-info: #0f6e6b; + --mxt-tint-neutral: #edeff2; + + /* ---- shape, density, elevation --------------------------------------- */ + --mxt-radius: 4px; + --mxt-radius-lg: 6px; + --mxt-row-height: 32px; + --mxt-control-height: 32px; + --mxt-shadow: 0 1px 2px 0 rgba(20, 24, 31, 0.08); + --mxt-focus-halo: 0 0 0 3px rgba(15, 110, 107, 0.16); + + /* ---- type ------------------------------------------------------------ */ + --mxt-font: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif; + --mxt-font-heading: var(--mxt-font); + --mxt-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --mxt-font-size: 14px; + --mxt-line-height: 1.45; +} diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/main.scss b/cmd/mxcli/theme/assets/signal/files/theme/web/main.scss new file mode 100644 index 000000000..6ebdf074d --- /dev/null +++ b/cmd/mxcli/theme/assets/signal/files/theme/web/main.scss @@ -0,0 +1,9 @@ +// This file compiles after Atlas Core and after every module theme source, so +// the partials imported here can override any Atlas rule without !important — +// including Mendix's own _theme-dark.scss above. +// +// Variant: auto follows the OS and honours a theme-light / theme-dark class on +// ; light or dark bakes one palette with no switching. +$mxcli-theme-variant: {{VARIANT}}; +@import "mxcli-atlas-map"; +@import "mxcli-signal"; diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/OFL.txt b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/OFL.txt new file mode 100644 index 000000000..01497cc60 --- /dev/null +++ b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-400-normal.woff2 b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 000000000..0804aaff9 Binary files /dev/null and b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-500-normal.woff2 b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 000000000..090f82f7e Binary files /dev/null and b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-600-normal.woff2 b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-600-normal.woff2 new file mode 100644 index 000000000..67aeeb010 Binary files /dev/null and b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-mono-latin-600-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-400-normal.woff2 b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 000000000..93bcd6430 Binary files /dev/null and b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-500-normal.woff2 b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-500-normal.woff2 new file mode 100644 index 000000000..adbbd4c3d Binary files /dev/null and b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-500-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-600-normal.woff2 b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-600-normal.woff2 new file mode 100644 index 000000000..0ac91d60f Binary files /dev/null and b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-600-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-700-normal.woff2 b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-700-normal.woff2 new file mode 100644 index 000000000..da7d57ff9 Binary files /dev/null and b/cmd/mxcli/theme/assets/signal/files/theme/web/mxcli-fonts/ibm-plex-sans-latin-700-normal.woff2 differ diff --git a/cmd/mxcli/theme/assets/signal/theme.json b/cmd/mxcli/theme/assets/signal/theme.json new file mode 100644 index 000000000..9ddc93bf8 --- /dev/null +++ b/cmd/mxcli/theme/assets/signal/theme.json @@ -0,0 +1,43 @@ +{ + "name": "signal", + "title": "Signal", + "version": "1", + "summary": "Cool slate, one teal signal colour, 4px radius, 32px rows", + "description": "mxcli's default styling. Atlas Core is untouched: the theme is a palette in theme/web/custom-variables.scss plus one partial imported from theme/web/main.scss, so a generated app stays upgradable across Mendix releases and is re-brandable by editing a single colour. Ships light and dark palettes that follow the OS and honour a theme-light / theme-dark class on the root element.", + "colorway": [ + "#0F6E6B", + "#1F5FA8", + "#1F7A4D", + "#B45309", + "#B42318", + "#5A6572" + ], + "files": [ + { + "path": "theme/web/custom-variables.scss", + "mode": "block", + "purpose": "Layer 1 \u2014 the Signal palette (light): brand, surfaces, ink, density" + }, + { + "path": "theme/web/_mxcli-atlas-map.scss", + "mode": "block", + "purpose": "the Atlas wiring \u2014 shared by every mxcli theme, identical in each" + }, + { + "path": "theme/web/_mxcli-signal.scss", + "mode": "block", + "purpose": "Layer 2 \u2014 dark palette, variant blocks, fonts, recipes" + }, + { + "path": "theme/web/main.scss", + "mode": "block", + "purpose": "variant switch + imports \u2014 this file compiles last, so the partials win" + }, + { + "path": "theme/web/mxcli-fonts/", + "mode": "verbatim", + "purpose": "vendored IBM Plex Sans + Mono (SIL OFL 1.1) \u2014 no CDN at runtime" + } + ], + "defaultVariant": "light" +} diff --git a/cmd/mxcli/theme/block.go b/cmd/mxcli/theme/block.go new file mode 100644 index 000000000..7d7eac1a8 --- /dev/null +++ b/cmd/mxcli/theme/block.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 + +package theme + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// A theme writes into files the project already owns (theme/web/main.scss is +// three lines of Mendix's own, custom-variables.scss carries Atlas's defaults), +// so it can never rewrite a whole file. Instead each write is fenced between +// markers and only the fenced region is ever replaced — the guard-don't-drop +// rule from ADR-0005, applied to files instead of BSON. +// +// The end marker carries a digest of the body that was written. On re-apply the +// digest is recomputed from what is on disk: equal means mxcli's own output is +// still there and may be replaced, different means a human edited inside the +// fence and the write is refused unless forced. That keeps the record in the +// file itself — no sidecar state to drift out of sync with the project. +const ( + beginMarker = "mxcli:theme:begin" + endMarker = "mxcli:theme:end" +) + +// Action is what applying one file did. +type Action string + +const ( + // ActionCreated means the file did not exist and was written. + ActionCreated Action = "created" + // ActionAdded means the file existed without a block and the block was appended. + ActionAdded Action = "added" + // ActionUpdated means an existing, unmodified block was replaced. + ActionUpdated Action = "updated" + // ActionUnchanged means the block on disk already matches what would be written. + ActionUnchanged Action = "unchanged" + // ActionRemoved means the block (and its trailing blank line) was cut out. + ActionRemoved Action = "removed" + // ActionSkipped means a hand-edited block was left alone. + ActionSkipped Action = "skipped" +) + +// ErrBlockModified reports a fenced block whose contents no longer match the +// digest mxcli recorded, i.e. someone edited inside the fence. +type ErrBlockModified struct { + Theme string + Path string +} + +func (e *ErrBlockModified) Error() string { + return fmt.Sprintf("%s: the '%s' block has local edits, so it was left alone\n"+ + " keep them: move your lines outside the mxcli:theme markers\n"+ + " discard them: re-run with --force", e.Path, e.Theme) +} + +// digest is the short content hash recorded in the end marker. +func digest(body string) string { + sum := sha256.Sum256([]byte(body)) + return hex.EncodeToString(sum[:])[:16] +} + +// commentSyntax returns the line-comment prefix for a file the theme writes. +// Only SCSS/CSS/JS carry blocks today and all three use "//"; anything else is +// copied verbatim and never reaches this function. +func commentSyntax() string { return "//" } + +func beginLine(name, version string) string { + return fmt.Sprintf("%s %s %s v%s — generated by `mxcli theme apply`; edit outside this block", + commentSyntax(), beginMarker, name, version) +} + +func endLine(name, body string) string { + return fmt.Sprintf("%s %s %s %s", commentSyntax(), endMarker, name, digest(body)) +} + +// block is a fenced region located inside a file. +type block struct { + // start and end are line indices: start is the begin marker, end the end marker. + start, end int + // body is everything between the markers, without a trailing newline. + body string + // recorded is the digest the end marker claims the body has. + recorded string +} + +// findBlock locates the fenced region for a theme. found is false when the file +// carries no block for that theme. +func findBlock(content, name string) (b block, found bool) { + lines := strings.Split(content, "\n") + beginNeedle := beginMarker + " " + name + " " + endNeedle := endMarker + " " + name + " " + + b.start, b.end = -1, -1 + for i, line := range lines { + switch { + case b.start < 0 && strings.Contains(line, beginNeedle): + b.start = i + case b.start >= 0 && strings.Contains(line, endNeedle): + b.end = i + fields := strings.Fields(line) + b.recorded = fields[len(fields)-1] + } + if b.end >= 0 { + break + } + } + if b.start < 0 || b.end < 0 { + return block{}, false + } + b.body = strings.Join(lines[b.start+1:b.end], "\n") + return b, true +} + +// applyBlock returns the new file content with body fenced into existing. +// +// A block that is absent is appended; one that is present and untouched is +// replaced; one that a human has edited is refused unless force is set, because +// silently discarding someone's styling is exactly the failure this fencing +// exists to prevent. +func applyBlock(existing, name, version, body string, force bool) (string, Action, error) { + body = strings.TrimRight(body, "\n") + fenced := beginLine(name, version) + "\n" + body + "\n" + endLine(name, body) + + b, found := findBlock(existing, name) + if !found { + if strings.TrimSpace(existing) == "" { + return fenced + "\n", ActionCreated, nil + } + return strings.TrimRight(existing, "\n") + "\n\n" + fenced + "\n", ActionAdded, nil + } + + if b.recorded != digest(b.body) && !force { + return existing, ActionSkipped, &ErrBlockModified{Theme: name, Path: ""} + } + if b.body == body { + return existing, ActionUnchanged, nil + } + + lines := strings.Split(existing, "\n") + out := append([]string{}, lines[:b.start]...) + out = append(out, strings.Split(fenced, "\n")...) + out = append(out, lines[b.end+1:]...) + return strings.Join(out, "\n"), ActionUpdated, nil +} + +// removeBlock cuts a theme's fenced region back out of a file, leaving whatever +// the project had around it. A hand-edited block is kept unless force is set. +func removeBlock(existing, name string, force bool) (string, Action, error) { + b, found := findBlock(existing, name) + if !found { + return existing, ActionUnchanged, nil + } + if b.recorded != digest(b.body) && !force { + return existing, ActionSkipped, &ErrBlockModified{Theme: name, Path: ""} + } + + lines := strings.Split(existing, "\n") + out := append([]string{}, lines[:b.start]...) + rest := lines[b.end+1:] + // Drop the blank line applyBlock inserted before the fence, so repeated + // apply/remove cycles do not accumulate whitespace. + for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { + out = out[:len(out)-1] + } + for len(rest) > 0 && strings.TrimSpace(rest[0]) == "" { + rest = rest[1:] + } + out = append(out, rest...) + + joined := strings.Join(out, "\n") + if strings.TrimSpace(joined) == "" { + return "", ActionRemoved, nil + } + return strings.TrimRight(joined, "\n") + "\n", ActionRemoved, nil +} diff --git a/cmd/mxcli/theme/block_test.go b/cmd/mxcli/theme/block_test.go new file mode 100644 index 000000000..2f5fb598c --- /dev/null +++ b/cmd/mxcli/theme/block_test.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 + +package theme + +import ( + "errors" + "strings" + "testing" +) + +func TestApplyBlock_AppendsToExistingFileWithoutLosingIt(t *testing.T) { + existing := "// Mendix wrote this\n$brand-logo: false;\n" + + out, action, err := applyBlock(existing, "signal", "1", ":root { --x: 1; }", false) + if err != nil { + t.Fatal(err) + } + if action != ActionAdded { + t.Errorf("action = %q, want %q", action, ActionAdded) + } + if !strings.Contains(out, "$brand-logo: false;") { + t.Error("the project's own content must survive an apply") + } + if !strings.Contains(out, ":root { --x: 1; }") { + t.Error("block body missing from output") + } +} + +func TestApplyBlock_CreatesEmptyFileWithoutLeadingBlankLine(t *testing.T) { + out, action, err := applyBlock("", "signal", "1", "body", false) + if err != nil { + t.Fatal(err) + } + if action != ActionCreated { + t.Errorf("action = %q, want %q", action, ActionCreated) + } + if strings.HasPrefix(out, "\n") { + t.Errorf("unexpected leading blank line: %q", out) + } +} + +func TestApplyBlock_IsIdempotent(t *testing.T) { + once, _, err := applyBlock("head\n", "signal", "1", "body", false) + if err != nil { + t.Fatal(err) + } + twice, action, err := applyBlock(once, "signal", "1", "body", false) + if err != nil { + t.Fatal(err) + } + if action != ActionUnchanged { + t.Errorf("action = %q, want %q", action, ActionUnchanged) + } + if once != twice { + t.Errorf("re-apply changed the file:\n--- first ---\n%s\n--- second ---\n%s", once, twice) + } +} + +func TestApplyBlock_ReplacesOwnBlockOnUpgrade(t *testing.T) { + v1, _, err := applyBlock("head\n", "signal", "1", "old body", false) + if err != nil { + t.Fatal(err) + } + v2, action, err := applyBlock(v1, "signal", "2", "new body", false) + if err != nil { + t.Fatal(err) + } + if action != ActionUpdated { + t.Errorf("action = %q, want %q", action, ActionUpdated) + } + if strings.Contains(v2, "old body") { + t.Error("stale block body left behind") + } + if !strings.Contains(v2, "new body") || !strings.Contains(v2, "head") { + t.Errorf("unexpected output:\n%s", v2) + } + if strings.Count(v2, beginMarker) != 1 { + t.Errorf("block duplicated instead of replaced:\n%s", v2) + } +} + +// The guard this whole scheme exists for: once a human has edited inside the +// fence, mxcli must not overwrite it. Without the digest check the edit is +// silently discarded on the next `theme apply` or `mxcli new`-style re-run. +func TestApplyBlock_RefusesToClobberLocalEdits(t *testing.T) { + generated, _, err := applyBlock("", "signal", "1", "--brand-primary: #0f6e6b;", false) + if err != nil { + t.Fatal(err) + } + edited := strings.Replace(generated, "#0f6e6b", "#ff0000", 1) + if edited == generated { + t.Fatal("test setup failed to modify the block") + } + + out, action, err := applyBlock(edited, "signal", "1", "--brand-primary: #0f6e6b;", false) + var modified *ErrBlockModified + if !errors.As(err, &modified) { + t.Fatalf("err = %v, want ErrBlockModified", err) + } + if action != ActionSkipped { + t.Errorf("action = %q, want %q", action, ActionSkipped) + } + if out != edited { + t.Error("file must be left exactly as the user wrote it") + } + if !strings.Contains(out, "#ff0000") { + t.Error("user's edit was lost") + } +} + +func TestApplyBlock_ForceOverwritesLocalEdits(t *testing.T) { + generated, _, _ := applyBlock("", "signal", "1", "original", false) + edited := strings.Replace(generated, "original", "hand written", 1) + + out, action, err := applyBlock(edited, "signal", "1", "original", true) + if err != nil { + t.Fatal(err) + } + if action != ActionUpdated { + t.Errorf("action = %q, want %q", action, ActionUpdated) + } + if strings.Contains(out, "hand written") { + t.Error("--force should have replaced the edited block") + } +} + +func TestApplyBlock_LeavesOtherThemesAlone(t *testing.T) { + withOther, _, _ := applyBlock("", "ledger", "1", "ledger body", false) + out, _, err := applyBlock(withOther, "signal", "1", "signal body", false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "ledger body") || !strings.Contains(out, "signal body") { + t.Errorf("both blocks should coexist:\n%s", out) + } +} + +func TestRemoveBlock_RestoresTheOriginalFile(t *testing.T) { + original := "// Mendix wrote this\n$brand-logo: false;\n" + applied, _, err := applyBlock(original, "signal", "1", "body", false) + if err != nil { + t.Fatal(err) + } + out, action, err := removeBlock(applied, "signal", false) + if err != nil { + t.Fatal(err) + } + if action != ActionRemoved { + t.Errorf("action = %q, want %q", action, ActionRemoved) + } + if out != original { + t.Errorf("remove did not restore the file byte for byte:\nwant %q\ngot %q", original, out) + } +} + +func TestRemoveBlock_EmptiesAFileThatIsEntirelyOurs(t *testing.T) { + applied, _, _ := applyBlock("", "signal", "1", "body", false) + out, action, err := removeBlock(applied, "signal", false) + if err != nil { + t.Fatal(err) + } + if action != ActionRemoved || out != "" { + t.Errorf("action = %q, out = %q; want removed and empty", action, out) + } +} + +func TestRemoveBlock_KeepsEditedBlocks(t *testing.T) { + applied, _, _ := applyBlock("head\n", "signal", "1", "body", false) + edited := strings.Replace(applied, "body", "my body", 1) + + out, action, err := removeBlock(edited, "signal", false) + var modified *ErrBlockModified + if !errors.As(err, &modified) { + t.Fatalf("err = %v, want ErrBlockModified", err) + } + if action != ActionSkipped || out != edited { + t.Error("an edited block must survive remove without --force") + } +} + +func TestRemoveBlock_NoBlockIsNotAnError(t *testing.T) { + out, action, err := removeBlock("nothing here\n", "signal", false) + if err != nil || action != ActionUnchanged || out != "nothing here\n" { + t.Errorf("got (%q, %q, %v)", out, action, err) + } +} diff --git a/cmd/mxcli/theme/switcher.go b/cmd/mxcli/theme/switcher.go new file mode 100644 index 000000000..f92004b19 --- /dev/null +++ b/cmd/mxcli/theme/switcher.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +package theme + +import ( + "fmt" + "strings" +) + +// SwitcherMDL returns the MDL that installs a runtime theme switcher into +// module. The browser-storage key is substituted rather than written into the +// template literally, so SwitcherStorageKey cannot drift from what the +// generated JavaScript actually reads. +// +// This is the one part of theming that cannot be files alone. A theme's +// light/dark blocks key off a class on the root element, and nothing in Mendix +// puts it there: Atlas ships the `:root.theme-dark` slot but no switcher, and +// there is no theme-level hook to run script before first paint — +// deployment/web/index.html is generated by mxbuild, and theme/web/settings.json +// only accepts cssFiles. So an explicit user choice has to come from the model: +// JavaScript actions the client can run, wrapped in nanoflows a button can call. +// +// The CSS still does the heavy lifting. `--variant auto` already renders the +// right palette before first paint by following the OS, so the switcher only +// has to handle the case where a user's stored choice differs from their OS +// preference — which is also the only case that can flash. +func SwitcherMDL(module string) string { + r := strings.NewReplacer( + "{{MODULE}}", module, + "{{STORAGE_KEY}}", SwitcherStorageKey, + ) + return r.Replace(switcherTemplate) +} + +// SwitcherStorageKey is where an explicit choice is remembered in the browser. +const SwitcherStorageKey = "mxcli-theme" + +const switcherTemplate = `-- Runtime theme switcher, installed by ` + "`mxcli theme switcher install`" + `. +-- +-- The theme's SCSS already ships both palettes and follows the OS. These +-- actions exist for the one thing CSS cannot do: let a user override that +-- choice and have it remembered. +-- +-- The class goes on (":root"), so popups and modals — which Mendix +-- renders at , outside any page container — follow it too. + +-- Flip between light and dark, resolving "follow the OS" to whatever the OS is +-- currently saying, and remember the choice. Returns the theme now in force. +create or modify javascript action {{MODULE}}.ToggleAppTheme() returns String +exposed as 'Toggle app theme' in 'Theme' +as $$ +var root = document.documentElement; +var effective; +if (root.classList.contains("theme-dark")) { + effective = "dark"; +} else if (root.classList.contains("theme-light")) { + effective = "light"; +} else { + effective = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} +var next = effective === "dark" ? "light" : "dark"; +root.classList.remove("theme-light", "theme-dark"); +root.classList.add("theme-" + next); +try { + window.localStorage.setItem("{{STORAGE_KEY}}", next); +} catch (e) { + // Private browsing and some embedded webviews reject storage; the class is + // already applied, so the only thing lost is persistence across reloads. +} +return Promise.resolve(next); +$$; + +-- Set the theme explicitly. Pass "auto" to drop the override and go back to +-- following the OS. +create or modify javascript action {{MODULE}}.SetAppTheme( + Theme: String not null +) returns Boolean +exposed as 'Set app theme' in 'Theme' +as $$ +var root = document.documentElement; +root.classList.remove("theme-light", "theme-dark"); +try { + if (Theme === "light" || Theme === "dark") { + root.classList.add("theme-" + Theme); + window.localStorage.setItem("{{STORAGE_KEY}}", Theme); + } else { + window.localStorage.removeItem("{{STORAGE_KEY}}"); + } +} catch (e) { + if (Theme === "light" || Theme === "dark") { + root.classList.add("theme-" + Theme); + } +} +return Promise.resolve(true); +$$; + +-- Re-apply a remembered choice. Nothing calls this automatically: Mendix pages +-- have no on-load event, and the usual substitute — a data view whose data +-- source is a nanoflow — is not something mxcli can author yet (the modelsdk +-- engine refuses a NanoflowSource data view, and the legacy engine writes one +-- with the nanoflow reference missing, which fails the build with "No nanoflow +-- configured for the data source of this data view"). Until that is closed, +-- wire it in Studio Pro if you want an explicit choice to survive a reload. +create or modify javascript action {{MODULE}}.ApplyStoredTheme() returns Boolean +exposed as 'Apply stored theme' in 'Theme' +as $$ +var stored = null; +try { + stored = window.localStorage.getItem("{{STORAGE_KEY}}"); +} catch (e) { + // No storage available: following the OS is the right fallback. +} +var root = document.documentElement; +root.classList.remove("theme-light", "theme-dark"); +if (stored === "light" || stored === "dark") { + root.classList.add("theme-" + stored); +} +return Promise.resolve(true); +$$; + +-- Wire this to a button: actionbutton btnTheme (caption: 'Theme', action: nanoflow {{MODULE}}.ACT_ToggleTheme) +create or replace nanoflow {{MODULE}}.ACT_ToggleTheme() +returns String as $Theme +begin + $Theme = call javascript action {{MODULE}}.ToggleAppTheme(); + return $Theme; +end; + +-- Ready for whatever ends up calling it on load; nothing does today (see above). +create or replace nanoflow {{MODULE}}.ACT_ApplyStoredTheme() +returns Boolean as $Applied +begin + $Applied = call javascript action {{MODULE}}.ApplyStoredTheme(); + return $Applied; +end; +` + +// SwitcherNextSteps is printed after an install: the wiring mxcli does not do +// for the user, because it lands on a page or layout it did not create. +func SwitcherNextSteps(module string) string { + return fmt.Sprintf(`Installed into %[1]s. One step left — add a toggle wherever it belongs, +typically a layout or a settings page: + + actionbutton btnTheme (caption: 'Theme', action: nanoflow %[1]s.ACT_ToggleTheme) + +That is enough to switch: the click flips the palette and remembers the choice. + +Reload behaviour: the app goes back to following the OS, because Mendix has no +page on-load event to re-apply the stored value from, and the usual substitute +(a data view with a nanoflow data source) is not authorable by mxcli yet on +either engine. %[1]s.ApplyStoredTheme is installed and ready for it — wire it in +Studio Pro if you need the choice to persist across reloads.`, module) +} diff --git a/cmd/mxcli/theme/theme.go b/cmd/mxcli/theme/theme.go new file mode 100644 index 000000000..8d7c6a3aa --- /dev/null +++ b/cmd/mxcli/theme/theme.go @@ -0,0 +1,546 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package theme applies mxcli's built-in default styling to a Mendix project. +// +// A theme is a set of files dropped into the project's theme/ folder — no model +// (.mpr) changes at all — so it compiles through Mendix's normal SCSS chain, hot +// applies under `mxcli run --local --watch`, and is removed by deleting a block. +// +// Where the files go was settled against a real Mendix 11.13 project rather than +// assumed, and two placements matter: +// +// - theme/web/custom-variables.scss is imported by *every* module's theme +// source (once per module), so it holds declarations only — never rules. +// Atlas maps these CSS custom properties onto its components and onto the +// brand-aware pluggable widgets, which is why a token retune re-brands the +// whole app for free. +// +// - theme/web/main.scss compiles LAST — after Atlas Core and after every +// module theme source — so the partial it imports can override any Atlas +// rule without !important. This is also why a theme must not write to +// themesource//: a theme source folder is only compiled when +// matches a real module in the model, so an invented folder is silently +// dropped from the build. +package theme + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +// DefaultName is the theme applied when the user does not choose one. +const DefaultName = "signal" + +// NoneName opts out of default styling, leaving Atlas untouched. +const NoneName = "none" + +// Variant selects which light/dark behaviour a theme is written with. +type Variant string + +const ( + // VariantAuto follows the OS preference and honours a theme-light / + // theme-dark class on the root element. The default. + VariantAuto Variant = "auto" + // VariantLight bakes the light palette with no switching. + VariantLight Variant = "light" + // VariantDark bakes the dark palette with no switching. + VariantDark Variant = "dark" +) + +// ParseVariant validates a user-supplied variant name. +func ParseVariant(s string) (Variant, error) { + switch Variant(s) { + case VariantAuto, VariantLight, VariantDark: + return Variant(s), nil + } + return "", fmt.Errorf("unknown variant %q (want auto, light or dark)", s) +} + +// FileSpec documents one file a theme writes, for `mxcli theme show`. +type FileSpec struct { + Path string `json:"path"` + Mode string `json:"mode"` // "block" or "verbatim" + Purpose string `json:"purpose"` +} + +// Theme is a named styling package embedded in the mxcli binary. +type Theme struct { + Name string `json:"name"` + Title string `json:"title"` + Version string `json:"version"` + Summary string `json:"summary"` + Description string `json:"description"` + Colorway []string `json:"colorway"` + // DefaultVariant is the palette the theme is written around; the other one + // is what `auto` switches to. Console is dark-first, Signal and Ledger are + // light-first. + DefaultVariant Variant `json:"defaultVariant"` + Files []FileSpec `json:"files"` +} + +// AltVariant is the variant `auto` switches to — the opposite of the default. +func (t *Theme) AltVariant() Variant { + if t.DefaultVariant == VariantDark { + return VariantLight + } + return VariantDark +} + +// FileResult is what applying one file did. +type FileResult struct { + Path string + Action Action +} + +// Result is the outcome of an Apply or Remove. +type Result struct { + Theme string + Files []FileResult +} + +// Changed reports whether anything on disk actually moved. +func (r *Result) Changed() bool { + for _, f := range r.Files { + if f.Action != ActionUnchanged && f.Action != ActionSkipped { + return true + } + } + return false +} + +// Options tunes an Apply or Remove. +type Options struct { + // Force overwrites blocks that carry local edits. + Force bool + // DryRun reports what would change without writing. + DryRun bool + // Variant selects light/dark behaviour. Empty means VariantAuto. + Variant Variant + // KeepOthers leaves other themes' blocks in place. Off by default: two + // themes both mapping the Atlas leaves would fight in the cascade, and + // which one won would depend on import order rather than on intent. + KeepOthers bool +} + +// List returns the embedded themes, ordered by name. +func List() ([]Theme, error) { + entries, err := fs.ReadDir(assetsFS, assetsRoot) + if err != nil { + return nil, fmt.Errorf("reading embedded themes: %w", err) + } + var themes []Theme + for _, e := range entries { + if !e.IsDir() { + continue + } + t, err := Get(e.Name()) + if err != nil { + return nil, err + } + themes = append(themes, *t) + } + sort.Slice(themes, func(i, j int) bool { return themes[i].Name < themes[j].Name }) + return themes, nil +} + +// Get returns one embedded theme by name. +func Get(name string) (*Theme, error) { + raw, err := assetsFS.ReadFile(path.Join(assetsRoot, name, "theme.json")) + if err != nil { + return nil, fmt.Errorf("unknown theme %q (run `mxcli theme list`)", name) + } + var t Theme + if err := json.Unmarshal(raw, &t); err != nil { + return nil, fmt.Errorf("theme %q: malformed theme.json: %w", name, err) + } + if t.Name != name { + return nil, fmt.Errorf("theme %q: theme.json declares name %q", name, t.Name) + } + return &t, nil +} + +// Installed reports which embedded themes have a block in projectDir, read from +// the mxcli:theme:begin markers rather than assumed. +// +// `theme remove` with no name used to fall back to the default theme, which on a +// project themed with anything else removed nothing, reported every file as +// unchanged and exited 0 — a silent no-op on the documented invocation. +func Installed(projectDir string) ([]string, error) { + all, err := List() + if err != nil { + return nil, err + } + var found []string + for _, t := range all { + paths, err := assetPaths(t.Name) + if err != nil { + return nil, err + } + if themeHasBlockIn(projectDir, paths, t.Name) { + found = append(found, t.Name) + } + } + return found, nil +} + +func themeHasBlockIn(projectDir string, paths map[string]bool, name string) bool { + for rel := range paths { + if !isBlockFile(rel) { + continue + } + body, err := os.ReadFile(filepath.Join(projectDir, filepath.FromSlash(rel))) + if err != nil { + continue + } + if _, ok := findBlock(string(body), name); ok { + return true + } + } + return false +} + +// Resolve picks the theme a bare `apply` or `remove` should act on: whatever is +// installed, falling back to fallback when nothing is. A project carrying two +// themes is reported rather than guessed at. +func Resolve(projectDir, fallback string) (string, error) { + installed, err := Installed(projectDir) + if err != nil { + return "", err + } + switch len(installed) { + case 0: + if fallback == "" { + return "", fmt.Errorf("no mxcli theme found in %s (run `mxcli theme apply` to add one)", projectDir) + } + return fallback, nil + case 1: + return installed[0], nil + default: + return "", fmt.Errorf("%s carries more than one theme (%s); name the one you mean", + projectDir, strings.Join(installed, ", ")) + } +} + +// Apply writes a theme's files into projectDir. +// +// projectDir is the folder holding the .mpr — the theme/ tree sits beside it. +func Apply(projectDir, name string, opts Options) (*Result, error) { + t, err := Get(name) + if err != nil { + return nil, err + } + if err := requireMendixProject(projectDir); err != nil { + return nil, err + } + if opts.Variant == "" { + opts.Variant = VariantAuto + } + + // Only one theme at a time. Both would map the same Atlas leaves, so which + // palette won would come down to SCSS import order rather than to what the + // user asked for. Removing first also cleans up the previous theme's fonts. + if !opts.KeepOthers { + if err := removeRivalThemes(projectDir, t, opts); err != nil { + return nil, err + } + } + + res := &Result{Theme: name} + root := path.Join(assetsRoot, name, "files") + // Edited blocks are collected rather than thrown on the first hit: a user who + // has hand-tuned two files should see both named once, not discover the second + // only after dealing with the first. + var skipped []error + + walkErr := fs.WalkDir(assetsFS, root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel := strings.TrimPrefix(p, root+"/") + target := filepath.Join(projectDir, filepath.FromSlash(rel)) + + body, err := assetsFS.ReadFile(p) + if err != nil { + return err + } + + var fr FileResult + if isBlockFile(rel) { + fr, err = applyBlockFile(target, rel, t, expand(string(body), t, opts), opts) + } else { + fr, err = applyVerbatimFile(target, rel, body, opts) + } + if err != nil { + var modified *ErrBlockModified + if !errors.As(err, &modified) { + return err + } + skipped = append(skipped, err) + } + res.Files = append(res.Files, fr) + return nil + }) + if walkErr != nil { + return res, walkErr + } + + sort.Slice(res.Files, func(i, j int) bool { return res.Files[i].Path < res.Files[j].Path }) + return res, errors.Join(skipped...) +} + +// Remove cuts a theme's blocks back out and deletes the files it owns outright. +func Remove(projectDir, name string, opts Options) (*Result, error) { + return remove(projectDir, name, opts, nil) +} + +// remove is Remove with a set of project-relative paths that must survive, +// because another theme is about to write them. +func remove(projectDir, name string, opts Options, protect map[string]bool) (*Result, error) { + t, err := Get(name) + if err != nil { + return nil, err + } + + res := &Result{Theme: name} + root := path.Join(assetsRoot, name, "files") + var skipped []error + + walkErr := fs.WalkDir(assetsFS, root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel := strings.TrimPrefix(p, root+"/") + target := filepath.Join(projectDir, filepath.FromSlash(rel)) + + existing, readErr := os.ReadFile(target) + if os.IsNotExist(readErr) { + res.Files = append(res.Files, FileResult{Path: rel, Action: ActionUnchanged}) + return nil + } + if readErr != nil { + return readErr + } + + if !isBlockFile(rel) { + // A verbatim file another theme is about to write (every theme ships + // mxcli-fonts/OFL.txt) stays put; deleting it would make the incoming + // apply report a change on a project that ends up identical. + if protect[rel] { + res.Files = append(res.Files, FileResult{Path: rel, Action: ActionUnchanged}) + return nil + } + if !opts.DryRun { + if err := os.Remove(target); err != nil { + return err + } + } + res.Files = append(res.Files, FileResult{Path: rel, Action: ActionRemoved}) + return nil + } + + out, action, err := removeBlock(string(existing), t.Name, opts.Force) + if err != nil { + var modified *ErrBlockModified + if !errors.As(err, &modified) { + return err + } + modified.Path = rel + skipped = append(skipped, modified) + res.Files = append(res.Files, FileResult{Path: rel, Action: ActionSkipped}) + return nil + } + // A file that is entirely ours is deleted rather than left empty — unless + // the incoming theme is about to write it, in which case the shell is + // truncated and left for its apply to fill. Truncating is the point: an + // earlier version returned here without writing, so the outgoing theme's + // block survived and the incoming apply appended a second one, leaving + // two themes mapping the same Atlas variables in one file. + if out == "" && protect[rel] { + if !opts.DryRun { + if err := os.WriteFile(target, nil, 0o644); err != nil { + return err + } + } + res.Files = append(res.Files, FileResult{Path: rel, Action: action}) + return nil + } + if out == "" { + if !opts.DryRun { + if err := os.Remove(target); err != nil { + return err + } + } + res.Files = append(res.Files, FileResult{Path: rel, Action: ActionRemoved}) + return nil + } + if action != ActionUnchanged && !opts.DryRun { + if err := os.WriteFile(target, []byte(out), 0o644); err != nil { + return err + } + } + res.Files = append(res.Files, FileResult{Path: rel, Action: action}) + return nil + }) + if walkErr != nil { + return res, walkErr + } + pruneEmptyThemeDirs(projectDir, root) + + sort.Slice(res.Files, func(i, j int) bool { return res.Files[i].Path < res.Files[j].Path }) + return res, errors.Join(skipped...) +} + +// pruneEmptyThemeDirs removes directories the theme introduced once its files +// are gone, so `theme remove` leaves no empty theme/web/mxcli-fonts/ behind. +// os.Remove is the guard: it refuses a directory that still holds anything the +// project put there. +func pruneEmptyThemeDirs(projectDir, root string) { + var dirs []string + _ = fs.WalkDir(assetsFS, root, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() || p == root { + return err + } + dirs = append(dirs, strings.TrimPrefix(p, root+"/")) + return nil + }) + // Deepest first, so a nested tree empties from the bottom up. + sort.Sort(sort.Reverse(sort.StringSlice(dirs))) + for _, rel := range dirs { + _ = os.Remove(filepath.Join(projectDir, filepath.FromSlash(rel))) + } +} + +func applyBlockFile(target, rel string, t *Theme, body string, opts Options) (FileResult, error) { + existing, err := os.ReadFile(target) + if err != nil && !os.IsNotExist(err) { + return FileResult{}, err + } + + out, action, err := applyBlock(string(existing), t.Name, t.Version, body, opts.Force) + if err != nil { + var modified *ErrBlockModified + if errors.As(err, &modified) { + modified.Path = rel + return FileResult{Path: rel, Action: ActionSkipped}, modified + } + return FileResult{}, err + } + if action == ActionUnchanged || opts.DryRun { + return FileResult{Path: rel, Action: action}, nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return FileResult{}, err + } + if err := os.WriteFile(target, []byte(out), 0o644); err != nil { + return FileResult{}, err + } + return FileResult{Path: rel, Action: action}, nil +} + +func applyVerbatimFile(target, rel string, body []byte, opts Options) (FileResult, error) { + existing, err := os.ReadFile(target) + switch { + case err == nil && string(existing) == string(body): + return FileResult{Path: rel, Action: ActionUnchanged}, nil + case err != nil && !os.IsNotExist(err): + return FileResult{}, err + } + + action := ActionCreated + if err == nil { + action = ActionUpdated + } + if opts.DryRun { + return FileResult{Path: rel, Action: action}, nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return FileResult{}, err + } + if err := os.WriteFile(target, body, 0o644); err != nil { + return FileResult{}, err + } + return FileResult{Path: rel, Action: action}, nil +} + +// expand fills the placeholders a theme asset may carry. Kept deliberately +// tiny — the assets are real SCSS that must stay readable and editable in +// place, so the only things templated are the two values a user chooses at +// apply time. +func expand(body string, t *Theme, opts Options) string { + r := strings.NewReplacer( + "{{VARIANT}}", string(opts.Variant), + "{{THEME}}", t.Name, + ) + return r.Replace(body) +} + +// removeRivalThemes strips every other embedded theme from the project. A +// hand-edited rival block is left alone and reported, same as anywhere else. +// +// Files the incoming theme also ships are protected. Themes share paths — +// every one of them writes theme/web/mxcli-fonts/OFL.txt — so without this the +// rival pass deletes a file that is about to be written again, which shows up +// as an apply that is never idempotent. +func removeRivalThemes(projectDir string, incoming *Theme, opts Options) error { + all, err := List() + if err != nil { + return err + } + protect, err := assetPaths(incoming.Name) + if err != nil { + return err + } + for _, other := range all { + if other.Name == incoming.Name { + continue + } + if _, err := remove(projectDir, other.Name, opts, protect); err != nil { + return fmt.Errorf("removing previous theme %q: %w", other.Name, err) + } + } + return nil +} + +// assetPaths is the set of project-relative paths a theme writes. +func assetPaths(name string) (map[string]bool, error) { + root := path.Join(assetsRoot, name, "files") + out := map[string]bool{} + err := fs.WalkDir(assetsFS, root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + out[strings.TrimPrefix(p, root+"/")] = true + return nil + }) + return out, err +} + +// isBlockFile reports whether a file gets the marker treatment. Anything else +// (fonts, licences) is copied byte for byte. +func isBlockFile(rel string) bool { + switch strings.ToLower(filepath.Ext(rel)) { + case ".scss", ".css", ".js": + return true + } + return false +} + +// requireMendixProject refuses to scatter theme files into a directory that is +// not a Mendix project. Applying to the wrong folder is silent otherwise: the +// files land, nothing compiles them, and the app just looks unstyled. +func requireMendixProject(dir string) error { + if entries, err := filepath.Glob(filepath.Join(dir, "*.mpr")); err == nil && len(entries) > 0 { + return nil + } + if _, err := os.Stat(filepath.Join(dir, "themesource", "atlas_core")); err == nil { + return nil + } + return fmt.Errorf("%s does not look like a Mendix project (no .mpr and no themesource/atlas_core)", dir) +} diff --git a/cmd/mxcli/theme/theme_test.go b/cmd/mxcli/theme/theme_test.go new file mode 100644 index 000000000..6af792e72 --- /dev/null +++ b/cmd/mxcli/theme/theme_test.go @@ -0,0 +1,617 @@ +// SPDX-License-Identifier: Apache-2.0 + +package theme + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// newProject fakes the parts of a Mendix project a theme touches: the .mpr, the +// three-line theme/web/main.scss Mendix ships, and the stock custom-variables. +func newProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + write(t, filepath.Join(dir, "App.mpr"), "") + write(t, filepath.Join(dir, "themesource", "atlas_core", "web", "main.scss"), "// atlas\n") + write(t, filepath.Join(dir, "theme", "web", "main.scss"), + "@import \"custom-variables\";\n@import \"theme-dark\";\n@import \"theme-neutral\";\n") + write(t, filepath.Join(dir, "theme", "web", "custom-variables.scss"), + "$brand-logo: false;\n:root {\n --brand-primary: #264ae5;\n}\n") + return dir +} + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func read(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestDefaultThemeIsEmbeddedAndWellFormed(t *testing.T) { + themes, err := List() + if err != nil { + t.Fatal(err) + } + if len(themes) == 0 { + t.Fatal("no themes embedded") + } + def, err := Get(DefaultName) + if err != nil { + t.Fatalf("the default theme must be embedded: %v", err) + } + if def.Title == "" || def.Version == "" || def.Summary == "" { + t.Errorf("theme.json is missing display fields: %+v", def) + } + if len(def.Colorway) == 0 { + t.Error("colorway is empty; the chart theme in P2 reads it") + } +} + +func TestApply_WritesTheThreeLayersAndTheFonts(t *testing.T) { + dir := newProject(t) + + res, err := Apply(dir, DefaultName, Options{}) + if err != nil { + t.Fatal(err) + } + if !res.Changed() { + t.Fatal("apply reported no changes") + } + + // Layer 1: the palette lands in the file every module imports. It declares + // the theme's own tokens; the Atlas variables are mapped from them one file + // down, which is what lets a variant restate ~30 values instead of ~60. + vars := read(t, filepath.Join(dir, "theme", "web", "custom-variables.scss")) + if !strings.Contains(vars, "--mxt-brand: #0f6e6b") { + t.Error("brand token not written") + } + atlasMap := read(t, filepath.Join(dir, "theme", "web", "_mxcli-atlas-map.scss")) + if !strings.Contains(atlasMap, "--brand-primary: var(--mxt-brand)") { + t.Error("Atlas wiring not written") + } + if !strings.Contains(vars, "$brand-logo: false;") { + t.Error("Mendix's own content was dropped from custom-variables.scss") + } + + // Layer 2: the partial, plus the one-line import from the file that + // compiles last. Without the import the partial is dead weight. + if _, err := os.Stat(filepath.Join(dir, "theme", "web", "_mxcli-signal.scss")); err != nil { + t.Errorf("Layer 2 partial not written: %v", err) + } + main := read(t, filepath.Join(dir, "theme", "web", "main.scss")) + if !strings.Contains(main, `@import "mxcli-signal"`) { + t.Error("partial is not imported from theme/web/main.scss") + } + if !strings.Contains(main, `@import "theme-dark"`) { + t.Error("Mendix's own imports were dropped from main.scss") + } + + // Fonts are vendored, so the app renders correctly with no network. + fonts, err := filepath.Glob(filepath.Join(dir, "theme", "web", "mxcli-fonts", "*.woff2")) + if err != nil || len(fonts) == 0 { + t.Errorf("no fonts vendored: %v", err) + } +} + +// Every url() the theme emits must resolve to a file the theme also ships. +// A typo here compiles clean and only shows up as a silent fallback to +// system-ui in the browser. +func TestApply_EveryFontURLResolvesToAVendoredFile(t *testing.T) { + dir := newProject(t) + if _, err := Apply(dir, DefaultName, Options{}); err != nil { + t.Fatal(err) + } + + partial := read(t, filepath.Join(dir, "theme", "web", "_mxcli-signal.scss")) + weights := map[string][]string{ + "sans": {"400", "500", "600", "700"}, + "mono": {"400", "500", "600"}, + } + for family, ws := range weights { + for _, w := range ws { + name := "ibm-plex-" + family + "-latin-" + w + "-normal.woff2" + if _, err := os.Stat(filepath.Join(dir, "theme", "web", "mxcli-fonts", name)); err != nil { + t.Errorf("%s referenced by the @font-face loop but not vendored", name) + } + } + } + if !strings.Contains(partial, `url("./mxcli-fonts/`) { + t.Error("font URLs must be relative to theme.compiled.css at the web root") + } +} + +func TestApply_IsIdempotent(t *testing.T) { + dir := newProject(t) + if _, err := Apply(dir, DefaultName, Options{}); err != nil { + t.Fatal(err) + } + before := read(t, filepath.Join(dir, "theme", "web", "custom-variables.scss")) + + res, err := Apply(dir, DefaultName, Options{}) + if err != nil { + t.Fatal(err) + } + if res.Changed() { + t.Errorf("second apply reported changes: %+v", res.Files) + } + if after := read(t, filepath.Join(dir, "theme", "web", "custom-variables.scss")); after != before { + t.Error("second apply rewrote the file") + } +} + +func TestApply_RefusesWhenTheUserHasEditedTheBlock(t *testing.T) { + dir := newProject(t) + if _, err := Apply(dir, DefaultName, Options{}); err != nil { + t.Fatal(err) + } + + varsPath := filepath.Join(dir, "theme", "web", "custom-variables.scss") + edited := strings.Replace(read(t, varsPath), "#0f6e6b", "#ff6b35", 1) + write(t, varsPath, edited) + + _, err := Apply(dir, DefaultName, Options{}) + var modified *ErrBlockModified + if !errors.As(err, &modified) { + t.Fatalf("err = %v, want ErrBlockModified", err) + } + if modified.Path == "" { + t.Error("the error must name the file so the user can find it") + } + if !strings.Contains(read(t, varsPath), "#ff6b35") { + t.Fatal("the user's re-brand was overwritten") + } +} + +func TestApply_DryRunWritesNothing(t *testing.T) { + dir := newProject(t) + before := read(t, filepath.Join(dir, "theme", "web", "main.scss")) + + res, err := Apply(dir, DefaultName, Options{DryRun: true}) + if err != nil { + t.Fatal(err) + } + if !res.Changed() { + t.Error("dry run should still report the changes it would make") + } + if read(t, filepath.Join(dir, "theme", "web", "main.scss")) != before { + t.Error("dry run modified main.scss") + } + if _, err := os.Stat(filepath.Join(dir, "theme", "web", "_mxcli-signal.scss")); !os.IsNotExist(err) { + t.Error("dry run created the partial") + } +} + +func TestRemove_LeavesTheProjectAsItWas(t *testing.T) { + dir := newProject(t) + mainBefore := read(t, filepath.Join(dir, "theme", "web", "main.scss")) + varsBefore := read(t, filepath.Join(dir, "theme", "web", "custom-variables.scss")) + + if _, err := Apply(dir, DefaultName, Options{}); err != nil { + t.Fatal(err) + } + if _, err := Remove(dir, DefaultName, Options{}); err != nil { + t.Fatal(err) + } + + if got := read(t, filepath.Join(dir, "theme", "web", "main.scss")); got != mainBefore { + t.Errorf("main.scss not restored:\nwant %q\ngot %q", mainBefore, got) + } + if got := read(t, filepath.Join(dir, "theme", "web", "custom-variables.scss")); got != varsBefore { + t.Errorf("custom-variables.scss not restored:\nwant %q\ngot %q", varsBefore, got) + } + if _, err := os.Stat(filepath.Join(dir, "theme", "web", "_mxcli-signal.scss")); !os.IsNotExist(err) { + t.Error("the partial should be deleted, not left empty") + } + if fonts, _ := filepath.Glob(filepath.Join(dir, "theme", "web", "mxcli-fonts", "*.woff2")); len(fonts) > 0 { + t.Error("vendored fonts should be removed with the theme") + } + if _, err := os.Stat(filepath.Join(dir, "theme", "web", "mxcli-fonts")); !os.IsNotExist(err) { + t.Error("the font directory should be pruned, not left empty") + } +} + +// A directory the theme owns must not be pruned when the user has put something +// of their own in it. +func TestRemove_KeepsADirectoryHoldingUserFiles(t *testing.T) { + dir := newProject(t) + if _, err := Apply(dir, DefaultName, Options{}); err != nil { + t.Fatal(err) + } + mine := filepath.Join(dir, "theme", "web", "mxcli-fonts", "my-brand-font.woff2") + write(t, mine, "not really a font") + + if _, err := Remove(dir, DefaultName, Options{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(mine); err != nil { + t.Errorf("user's own file in a theme directory was deleted: %v", err) + } +} + +func TestApply_RefusesADirectoryThatIsNotAMendixProject(t *testing.T) { + if _, err := Apply(t.TempDir(), DefaultName, Options{}); err == nil { + t.Fatal("expected a refusal for a non-project directory") + } +} + +func TestGet_UnknownThemeNamesTheDiscoveryCommand(t *testing.T) { + _, err := Get("nope") + if err == nil || !strings.Contains(err.Error(), "mxcli theme list") { + t.Errorf("err = %v; should point at `mxcli theme list`", err) + } +} + +// The Layer-1 file is imported once per module, so a CSS rule there is emitted +// once per module too. Declarations only. +func TestLayer1BlockContainsNoRules(t *testing.T) { + body, err := assetsFS.ReadFile("assets/signal/files/theme/web/custom-variables.scss") + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(string(body), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasSuffix(trimmed, "{") && !strings.HasPrefix(trimmed, ":root") { + t.Errorf("custom-variables.scss must hold declarations only, found selector: %q", trimmed) + } + } +} + +// --------------------------------------------------------------------------- +// Variants and the multi-theme registry +// --------------------------------------------------------------------------- + +func TestAllThemesAreWellFormed(t *testing.T) { + themes, err := List() + if err != nil { + t.Fatal(err) + } + if len(themes) < 3 { + t.Fatalf("expected signal, ledger and console; got %d", len(themes)) + } + for _, th := range themes { + if th.DefaultVariant != VariantLight && th.DefaultVariant != VariantDark { + t.Errorf("%s: defaultVariant %q is neither light nor dark", th.Name, th.DefaultVariant) + } + if th.AltVariant() == th.DefaultVariant { + t.Errorf("%s: alt variant equals the default", th.Name) + } + if len(th.Colorway) == 0 || th.Summary == "" || th.Title == "" { + t.Errorf("%s: incomplete theme.json: %+v", th.Name, th) + } + } +} + +// The Atlas wiring is what makes a palette swap cheap, so every theme has to +// run through the same one. Shipped per theme (a theme package is meant to be +// self-contained), which is exactly why it can drift. +func TestAtlasMapIsIdenticalInEveryTheme(t *testing.T) { + themes, err := List() + if err != nil { + t.Fatal(err) + } + var reference []byte + var referenceName string + for _, th := range themes { + body, err := assetsFS.ReadFile("assets/" + th.Name + "/files/theme/web/_mxcli-atlas-map.scss") + if err != nil { + t.Fatalf("%s ships no Atlas map: %v", th.Name, err) + } + if reference == nil { + reference, referenceName = body, th.Name + continue + } + if string(body) != string(reference) { + t.Errorf("%s's Atlas map has drifted from %s's", th.Name, referenceName) + } + } +} + +// A palette that pins Atlas leaves to literal colours cannot survive a variant +// flip: the ink stays near-black on a near-black ground. Every theme must go +// through --mxt-* instead, which is what the Atlas map exists for. +func TestPalettesDeclareOnlyThemeTokens(t *testing.T) { + themes, err := List() + if err != nil { + t.Fatal(err) + } + for _, th := range themes { + body, err := assetsFS.ReadFile("assets/" + th.Name + "/files/theme/web/custom-variables.scss") + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(body), "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "--") { + continue + } + if !strings.HasPrefix(trimmed, "--mxt-") { + t.Errorf("%s custom-variables.scss:%d declares an Atlas variable directly: %q", + th.Name, i+1, trimmed) + } + } + } +} + +func TestApplyVariant_AutoShipsBothPalettes(t *testing.T) { + dir := newProject(t) + if _, err := Apply(dir, DefaultName, Options{Variant: VariantAuto}); err != nil { + t.Fatal(err) + } + main := read(t, filepath.Join(dir, "theme", "web", "main.scss")) + if !strings.Contains(main, "$mxcli-theme-variant: auto;") { + t.Errorf("variant not written into main.scss:\n%s", main) + } + partial := read(t, filepath.Join(dir, "theme", "web", "_mxcli-signal.scss")) + if !strings.Contains(partial, "prefers-color-scheme") { + t.Error("auto must follow the OS") + } + // The explicit-class path has to outrank Mendix's own _theme-dark.scss, + // which also declares :root.theme-dark. + if !strings.Contains(partial, ":root.theme-dark") { + t.Error("auto must honour an explicit theme-dark class") + } +} + +func TestApplyVariant_PinnedIsWrittenThrough(t *testing.T) { + for _, v := range []Variant{VariantLight, VariantDark} { + dir := newProject(t) + if _, err := Apply(dir, DefaultName, Options{Variant: v}); err != nil { + t.Fatal(err) + } + main := read(t, filepath.Join(dir, "theme", "web", "main.scss")) + if !strings.Contains(main, "$mxcli-theme-variant: "+string(v)+";") { + t.Errorf("variant %q not written into main.scss:\n%s", v, main) + } + if strings.Contains(main, "{{VARIANT}}") { + t.Errorf("variant placeholder left unexpanded for %q", v) + } + } +} + +func TestParseVariant_RejectsNonsense(t *testing.T) { + if _, err := ParseVariant("sepia"); err == nil { + t.Fatal("expected an error for an unknown variant") + } + for _, ok := range []string{"auto", "light", "dark"} { + if _, err := ParseVariant(ok); err != nil { + t.Errorf("ParseVariant(%q) = %v", ok, err) + } + } +} + +// Two themes at once would both map the Atlas leaves, and which palette won +// would come down to SCSS import order rather than to what was asked for. +func TestApply_RemovesThePreviousTheme(t *testing.T) { + dir := newProject(t) + if _, err := Apply(dir, "ledger", Options{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "theme", "web", "_mxcli-ledger.scss")); err != nil { + t.Fatalf("ledger not applied: %v", err) + } + + if _, err := Apply(dir, "signal", Options{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "theme", "web", "_mxcli-ledger.scss")); !os.IsNotExist(err) { + t.Error("ledger's partial survived a switch to signal") + } + main := read(t, filepath.Join(dir, "theme", "web", "main.scss")) + if strings.Contains(main, "mxcli-ledger") { + t.Errorf("ledger is still imported after switching to signal:\n%s", main) + } + if !strings.Contains(main, "mxcli-signal") { + t.Error("signal is not imported") + } + // Ledger's fonts must go too, or the binary's payload accumulates in the + // project every time someone tries a theme. + if got, _ := filepath.Glob(filepath.Join(dir, "theme", "web", "mxcli-fonts", "source-*.woff2")); len(got) > 0 { + t.Errorf("ledger's fonts survived the switch: %v", got) + } +} + +func TestSwitcherMDL_TargetsTheRequestedModule(t *testing.T) { + mdl := SwitcherMDL("Ops") + if strings.Contains(mdl, "{{MODULE}}") { + t.Error("module placeholder left unexpanded") + } + for _, want := range []string{ + "create or modify javascript action Ops.ToggleAppTheme", + "create or modify javascript action Ops.SetAppTheme", + "create or modify javascript action Ops.ApplyStoredTheme", + "create or replace nanoflow Ops.ACT_ToggleTheme", + SwitcherStorageKey, + } { + if !strings.Contains(mdl, want) { + t.Errorf("switcher MDL is missing %q", want) + } + } + // The class has to land on the root element: popups and modals render at + // , outside any page container, and must follow the theme too. + if !strings.Contains(mdl, "document.documentElement") { + t.Error("the theme class must be set on the root element") + } +} + +// SCSS is not compiled by the Go tests, so a theme whose variant mixin is named +// differently from the one it includes would ship broken and only fail at the +// user's next build. Assert the naming contract instead. +func TestEveryThemeDefinesAndIncludesItsAltPaletteMixin(t *testing.T) { + themes, err := List() + if err != nil { + t.Fatal(err) + } + for _, th := range themes { + body, err := assetsFS.ReadFile( + "assets/" + th.Name + "/files/theme/web/_mxcli-" + th.Name + ".scss") + if err != nil { + t.Fatalf("%s ships no theme partial: %v", th.Name, err) + } + mixin := "mxcli-" + th.Name + "-" + string(th.AltVariant()) + src := string(body) + if !strings.Contains(src, "@mixin "+mixin+" {") { + t.Errorf("%s does not define @mixin %s", th.Name, mixin) + } + if !strings.Contains(src, "@include "+mixin+";") { + t.Errorf("%s defines %s but never includes it", th.Name, mixin) + } + if !strings.Contains(src, "@include mxcli-atlas-map;") { + t.Errorf("%s never includes the Atlas map", th.Name) + } + // The alt palette must be reachable both ways: from the OS preference + // and from an explicit class a switcher sets. + if !strings.Contains(src, "prefers-color-scheme: "+string(th.AltVariant())) { + t.Errorf("%s does not follow the OS into its alt palette", th.Name) + } + if !strings.Contains(src, ":root.theme-"+string(th.AltVariant())+" {") { + t.Errorf("%s does not honour an explicit theme-%s class", th.Name, th.AltVariant()) + } + } +} + +// The storage key the docs and the switcher's JavaScript refer to must be the +// same string; before this was templated, the constant could drift from the +// generated code while the test that checked it still passed. +func TestSwitcherStorageKeyIsSubstitutedNotHardcoded(t *testing.T) { + mdl := SwitcherMDL("Ops") + if strings.Contains(mdl, "{{STORAGE_KEY}}") { + t.Error("storage-key placeholder left unexpanded") + } + if got := strings.Count(mdl, `"`+SwitcherStorageKey+`"`); got != 4 { + t.Errorf("storage key appears %d times in the generated JavaScript, want 4", got) + } +} + +// --------------------------------------------------------------------------- +// Regressions reported from the RssReader test build (MXCLI-FINDINGS 15-17) +// --------------------------------------------------------------------------- + +// Finding 15. `theme remove` with no name fell back to the default theme, so on +// a project themed with any other one it removed nothing, reported every file +// as unchanged and exited 0 — a silent no-op on the documented invocation. +func TestResolve_FindsTheInstalledThemeNotTheDefault(t *testing.T) { + dir := newProject(t) + if _, err := Apply(dir, "ledger", Options{}); err != nil { + t.Fatal(err) + } + + installed, err := Installed(dir) + if err != nil { + t.Fatal(err) + } + if len(installed) != 1 || installed[0] != "ledger" { + t.Fatalf("Installed = %v, want [ledger]", installed) + } + + // This is the call `theme remove` makes with no argument. Falling back to + // the default here is exactly the bug. + got, err := Resolve(dir, "") + if err != nil { + t.Fatal(err) + } + if got != "ledger" { + t.Errorf("Resolve = %q, want ledger", got) + } + if got == DefaultName { + t.Error("resolved to the default theme rather than the installed one") + } +} + +func TestResolve_UnthemedProjectIsAnErrorNotASilentDefault(t *testing.T) { + dir := newProject(t) + + if _, err := Resolve(dir, ""); err == nil { + t.Fatal("expected an error for a project with no theme") + } + // `apply` may still fall back — a project with no theme is exactly when + // installing the default is right. + got, err := Resolve(dir, DefaultName) + if err != nil || got != DefaultName { + t.Errorf("Resolve(fallback) = (%q, %v), want (%q, nil)", got, err, DefaultName) + } +} + +// Finding 16. Switching themes replaced the block in custom-variables.scss and +// main.scss but appended to _mxcli-atlas-map.scss, leaving the outgoing theme's +// block in place and doubling the file — two themes mapping the same Atlas +// variables in one file, resolved by source order rather than intent. +func TestApply_SwitchingLeavesExactlyOneBlockInEveryFile(t *testing.T) { + dir := newProject(t) + for _, name := range []string{"signal", "ledger", "console", "signal"} { + if _, err := Apply(dir, name, Options{}); err != nil { + t.Fatalf("apply %s: %v", name, err) + } + installed, err := Installed(dir) + if err != nil { + t.Fatal(err) + } + if len(installed) != 1 || installed[0] != name { + t.Fatalf("after apply %s, Installed = %v, want [%s]", name, installed, name) + } + + blocks, err := filepath.Glob(filepath.Join(dir, "theme", "web", "*.scss")) + if err != nil { + t.Fatal(err) + } + for _, f := range blocks { + body := read(t, f) + if n := strings.Count(body, beginMarker); n > 1 { + t.Errorf("after apply %s, %s carries %d theme blocks", + name, filepath.Base(f), n) + } + } + } +} + +// Finding 17. The topbar language selector was unreadable in every dark palette +// (1.13:1). Atlas paints it at (0,3,0) from --bg-color-secondary with a #fff +// fallback; the guard was a bare .current-language-text at (0,1,0), which only +// won on layouts that do not nest the selector under .navbar-brand. +func TestAtlasMap_LanguageSelectorMatchesAtlasSpecificityAndUsesTheRailToken(t *testing.T) { + themes, err := List() + if err != nil { + t.Fatal(err) + } + for _, th := range themes { + body, err := assetsFS.ReadFile( + "assets/" + th.Name + "/files/theme/web/_mxcli-atlas-map.scss") + if err != nil { + t.Fatal(err) + } + src := string(body) + if !strings.Contains(src, ".navbar-brand .widget-language-selector .current-language-text") { + t.Errorf("%s: does not match Atlas's own (0,3,0) selector, so its rule cannot win", th.Name) + } + if !strings.Contains(src, "var(--mxt-rail-ink-active, var(--mxt-rail-ink))") { + t.Errorf("%s: topbar text must resolve through the rail token", th.Name) + } + // `color: inherit` inherits body ink, which is dark on a dark rail — + // wrong even at the right specificity. Match the declaration, not the + // comment that explains why it is wrong. + for _, line := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + continue + } + if strings.HasPrefix(trimmed, "color: inherit") { + t.Errorf("%s: still declares color: inherit for topbar text", th.Name) + } + } + } +} diff --git a/cmd/mxcli/tunnelhub/admin.go b/cmd/mxcli/tunnelhub/admin.go index 0f945472a..a6274e5e6 100644 --- a/cmd/mxcli/tunnelhub/admin.go +++ b/cmd/mxcli/tunnelhub/admin.go @@ -23,6 +23,28 @@ func NewAdmin(_ *Registry) http.Handler { }) } +// Column layout for the per-session endpoint tables. The widths are declared +// here rather than derived from content so that a column is the same width in +// every session card — with table-layout:fixed the colgroup, not the longest +// cell, decides. URL takes the remainder (it is the column worth seeing in +// full); tableMinWidth is the sum of the fixed columns plus a usable share for +// it, and is applied to the card as well as the table so a narrow viewport +// scrolls every card together in .wrap. +const ( + tableMinWidth = "68rem" + + epColgroup = "" + + "" + // Status + "" + // Project + "" + // Branch + "" + // URL (flexible remainder) + "" + // First seen + "" + // Last seen + "" + // Last used + "" + // Uptime + "" +) + const adminHTML = ` @@ -37,7 +59,8 @@ const adminHTML = ` .meta { color:var(--mut); font-size:.85rem; } .wrap { padding:1rem 1.4rem; overflow-x:auto; } table { border-collapse:collapse; width:100%; min-width:52rem; } - th,td { text-align:left; padding:.5rem .7rem; border-bottom:1px solid var(--line); white-space:nowrap; } + th,td { text-align:left; padding:.5rem .7rem; border-bottom:1px solid var(--line); white-space:nowrap; + overflow:hidden; text-overflow:ellipsis; } th { font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--mut); cursor:pointer; user-select:none; } th.sorted::after { content:" \25BC"; font-size:.7em; } tbody tr:nth-child(even){ background:var(--row); } @@ -48,15 +71,20 @@ const adminHTML = ` tr.stale, tr.offline { color:var(--mut); } .sol { color:var(--mut); font-size:.82rem; } .empty { color:var(--mut); padding:2rem 0; } - .ses { border:1px solid var(--line); border-radius:.6rem; margin-bottom:1rem; overflow:hidden; } + /* Every session card is the same width and its table is laid out from the + colgroup below, so a column lines up across all cards regardless of how long + the project names or URLs in any one card happen to be. The shared min-width + keeps the cards aligned while .wrap scrolls them horizontally as a unit. */ + .ses { border:1px solid var(--line); border-radius:.6rem; margin-bottom:1rem; overflow:hidden; min-width:` + tableMinWidth + `; } .ses.offline { opacity:.72; } .sh { display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; padding:.6rem .8rem; background:var(--row); border-bottom:1px solid var(--line); } .sh .sid { font-weight:600; font-family:ui-monospace,monospace; font-size:.9rem; } .sh .sid a { color:var(--accent); text-decoration:none; } .sh .sid a:hover { text-decoration:underline; } .sh .own { color:var(--mut); font-size:.85rem; } .sh .own::before { content:"@"; } .sh .cnt { color:var(--mut); font-size:.82rem; } - .sh .ls { color:var(--mut); font-size:.82rem; margin-left:auto; } - .ses table { min-width:0; } + .sh .fs { color:var(--mut); font-size:.82rem; margin-left:auto; } + .sh .ls { color:var(--mut); font-size:.82rem; } + .ses table { table-layout:fixed; min-width:` + tableMinWidth + `; } .ses td, .ses th { white-space:nowrap; } code { font-family:ui-monospace,monospace; } .who { color:var(--mut); font-size:.85rem; } @@ -92,31 +120,46 @@ const adminHTML = ` if(sec<86400) return Math.floor(sec/3600)+"h"; return Math.floor(sec/86400)+"d"; } function esc(s){ var d=document.createElement("div"); d.textContent=s==null?"":s; return d.innerHTML; } - function short(u){ return esc(u.replace(/^https?:\/\//,"")); } + // esc() leaves quotes alone, so attribute values need their own escaper. + function attr(s){ + return String(s==null?"":s).replace(/&/g,"&").replace(//g,">") + .replace(/"/g,""").replace(/'/g,"'"); + } + function short(u){ return esc(String(u||"").replace(/^https?:\/\//,"")); } + // Cells are ellipsised at a fixed width, so every truncatable one carries the + // full value as a tooltip; timestamps show the absolute time behind "3d ago". + function at(t){ + if(!t || String(t).startsWith("0001")) return ""; + var d = new Date(t); + return isNaN(d) ? "" : " title='"+attr(d.toLocaleString())+"'"; + } function epRow(e){ var name = (e.prefix?esc(e.prefix)+" · ":"")+esc(e.project)+(e.solution?" ("+esc(e.solution)+")":""); + var plain = (e.prefix?e.prefix+" · ":"")+(e.project||"")+(e.solution?" ("+e.solution+")":""); var url = e.state==="offline" ? short(e.url) - : ""+short(e.url)+""; - return ""+ - ""+esc(e.state)+""+ - ""+name+""+ - ""+esc(e.branch||"—")+""+ - ""+url+""+ - ""+ago(e.lastSeenAt)+""+ - ""+ago(e.lastUsedAt)+""+ + : ""+short(e.url)+""; + return ""+ + ""+esc(e.state)+""+ + ""+name+""+ + ""+esc(e.branch||"—")+""+ + ""+url+""+ + ""+ago(e.firstSeenAt)+""+ + ""+ago(e.lastSeenAt)+""+ + ""+ago(e.lastUsedAt)+""+ ""+(e.uptimeSec?dur(e.uptimeSec):"—")+""; } function sessionCard(s){ var eps = s.endpoints||[]; var label = s.session ? esc(s.session) : "(no session)"; - var sid = s.sessionUrl ? ""+label+"" : label; + var sid = s.sessionUrl ? ""+label+"" : label; var head = "
"+ ""+sid+""+ (s.owner?""+esc(s.owner)+"":"")+ ""+eps.length+" endpoint"+(eps.length===1?"":"s")+""+ + "since "+ago(s.firstSeen)+""+ "seen "+ago(s.lastSeen)+"
"; - var table = ""+ - ""+ + var table = "
StatusProjectBranchURLLast seenLast usedUptime
` + epColgroup + `"+ + ""+ eps.map(epRow).join("")+"
StatusProjectBranchURLFirst seenLast seenLast usedUptime
"; return "
"+head+table+"
"; } diff --git a/cmd/mxcli/tunnelhub/registry.go b/cmd/mxcli/tunnelhub/registry.go index 28bf83e27..d6478a1d1 100644 --- a/cmd/mxcli/tunnelhub/registry.go +++ b/cmd/mxcli/tunnelhub/registry.go @@ -307,6 +307,28 @@ func (r *Registry) Sessions(viewerLogin string) []SessionView { history := r.sessions.Snapshot() // nil-safe r.mu.Unlock() + // Earliest sighting per endpoint slot. A live backend's RegisteredAt restarts + // whenever it re-registers after a reap, so first-seen has to come from the + // durable log to survive a reconnect (and to carry across sessions, since the + // slot — and therefore the URL — is the same one). + firstSeen := map[epKey]time.Time{} + for _, rec := range history { + if rec.RegisteredAt.IsZero() { + continue + } + k := rec.slot() + if cur, ok := firstSeen[k]; !ok || rec.RegisteredAt.Before(cur) { + firstSeen[k] = rec.RegisteredAt + } + } + for k, ev := range live { + ev.FirstSeenAt = ev.RegisteredAt + if f, ok := firstSeen[k]; ok && (ev.FirstSeenAt.IsZero() || f.Before(ev.FirstSeenAt)) { + ev.FirstSeenAt = f + } + live[k] = ev + } + // Group by session. Live endpoints override any offline record for the same slot. type grp struct { owner string @@ -333,14 +355,19 @@ func (r *Registry) Sessions(viewerLogin string) []SessionView { continue } g := ensure(rec.Session, rec.Owner) - k := strings.Join([]string{rec.Owner, rec.Prefix, rec.Solution, rec.Project, rec.Branch, rec.Worktree}, "\x00") + k := rec.slot() if _, isLive := g.eps[k]; isLive { continue // live entry wins } + first := rec.RegisteredAt + if f, ok := firstSeen[k]; ok && (first.IsZero() || f.Before(first)) { + first = f + } g.eps[k] = EndpointView{ Subdomain: rec.Subdomain, URL: rec.URL, Prefix: rec.Prefix, Project: rec.Project, Solution: rec.Solution, Branch: rec.Branch, Worktree: rec.Worktree, - State: "offline", RegisteredAt: rec.RegisteredAt, LastSeenAt: rec.LastSeenAt, + State: "offline", RegisteredAt: rec.RegisteredAt, FirstSeenAt: first, + LastSeenAt: rec.LastSeenAt, } } @@ -352,8 +379,8 @@ func (r *Registry) Sessions(viewerLogin string) []SessionView { if ev.State != "offline" { sv.Online = true } - if sv.FirstSeen.IsZero() || (!ev.RegisteredAt.IsZero() && ev.RegisteredAt.Before(sv.FirstSeen)) { - sv.FirstSeen = ev.RegisteredAt + if sv.FirstSeen.IsZero() || (!ev.FirstSeenAt.IsZero() && ev.FirstSeenAt.Before(sv.FirstSeen)) { + sv.FirstSeen = ev.FirstSeenAt } if ev.LastSeenAt.After(sv.LastSeen) { sv.LastSeen = ev.LastSeenAt diff --git a/cmd/mxcli/tunnelhub/sessions.go b/cmd/mxcli/tunnelhub/sessions.go index 953f9fcd3..d2a74f615 100644 --- a/cmd/mxcli/tunnelhub/sessions.go +++ b/cmd/mxcli/tunnelhub/sessions.go @@ -43,7 +43,14 @@ type EndpointRecord struct { // + slot re-registers to the same record (so a reconnect updates rather than // duplicates). It mirrors Backend.identity() with the session prepended. func (e *EndpointRecord) key() string { - return strings.Join([]string{e.Session, e.Owner, e.Prefix, e.Solution, e.Project, e.Branch, e.Worktree}, "\x00") + return e.Session + "\x00" + e.slot() +} + +// slot is the identity of the endpoint itself, without the session — it mirrors +// Backend.identity(), so a record can be matched against a live backend (and +// against the same endpoint re-exposed by a later session). +func (e *EndpointRecord) slot() string { + return strings.Join([]string{e.Owner, e.Prefix, e.Solution, e.Project, e.Branch, e.Worktree}, "\x00") } // SessionLog is the durable history of endpoints seen per session. Records are @@ -230,9 +237,13 @@ type EndpointView struct { Worktree string `json:"worktree"` State string `json:"state"` // "available" | "stale" | "offline" RegisteredAt time.Time `json:"registeredAt"` - LastSeenAt time.Time `json:"lastSeenAt"` - LastUsedAt time.Time `json:"lastUsedAt"` - UptimeSec int64 `json:"uptimeSec"` + // FirstSeenAt is the earliest sighting of this endpoint slot across the + // durable history — unlike RegisteredAt, which restarts every time a reaped + // backend re-registers, so it answers "how long has this URL existed?". + FirstSeenAt time.Time `json:"firstSeenAt"` + LastSeenAt time.Time `json:"lastSeenAt"` + LastUsedAt time.Time `json:"lastUsedAt"` + UptimeSec int64 `json:"uptimeSec"` } // SessionView groups the endpoints a single Claude Code session exposed, live diff --git a/cmd/mxcli/tunnelhub/sessions_test.go b/cmd/mxcli/tunnelhub/sessions_test.go index 58d6bde90..f15480203 100644 --- a/cmd/mxcli/tunnelhub/sessions_test.go +++ b/cmd/mxcli/tunnelhub/sessions_test.go @@ -136,6 +136,48 @@ func TestRegistry_SessionsGroupsAndRetainsOffline(t *testing.T) { } } +// A reaped backend that reconnects gets a fresh RegisteredAt, so first-seen must +// come from the durable log instead — otherwise the overview reports a +// long-running preview as brand new after every container reap. +func TestRegistry_SessionsFirstSeenSurvivesReconnect(t *testing.T) { + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + now := base + reg := NewRegistry(RegistryOptions{ + Domain: "example.com", ExpireFor: 10 * time.Minute, + Sessions: NewSessionLog(30 * 24 * time.Hour), Now: func() time.Time { return now }, + }) + reg.sessions.now = func() time.Time { return now } + + req := RegisterRequest{Session: "cse_A", Owner: "alice", Project: "Web", Branch: "main"} + if _, err := reg.Register(req); err != nil { + t.Fatalf("Register: %v", err) + } + + // Idle past expiry so the live backend is reaped, then reconnect. + now = base.Add(3 * time.Hour) + if _, err := reg.Register(req); err != nil { + t.Fatalf("re-Register: %v", err) + } + + sessions := reg.Sessions("") + if len(sessions) != 1 || len(sessions[0].Endpoints) != 1 { + t.Fatalf("want 1 session with 1 endpoint, got %+v", sessions) + } + ep := sessions[0].Endpoints[0] + if ep.State != "available" { + t.Fatalf("endpoint should be live after reconnect, got %q", ep.State) + } + if !ep.RegisteredAt.Equal(now) { + t.Errorf("RegisteredAt = %v, want the reconnect time %v", ep.RegisteredAt, now) + } + if !ep.FirstSeenAt.Equal(base) { + t.Errorf("FirstSeenAt = %v, want the original registration %v", ep.FirstSeenAt, base) + } + if !sessions[0].FirstSeen.Equal(base) { + t.Errorf("session FirstSeen = %v, want %v", sessions[0].FirstSeen, base) + } +} + func TestRegistry_SessionsViewerScoped(t *testing.T) { now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) reg := NewRegistry(RegistryOptions{ diff --git a/docs-site/src/SUMMARY.md b/docs-site/src/SUMMARY.md index 4cdff47a9..b782443f5 100644 --- a/docs-site/src/SUMMARY.md +++ b/docs-site/src/SUMMARY.md @@ -117,6 +117,7 @@ # Part V: Project Tools +- [Default Styling](tools/theme.md) - [Code Navigation](tools/code-navigation.md) - [SHOW CALLERS / CALLEES](tools/callers-callees.md) - [SHOW REFERENCES / IMPACT](tools/references-impact.md) diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index ffb280a4c..43649d900 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -186,6 +186,10 @@ projects, solutions, branches, and worktrees — with a sortable overview at older ones: the hub persists a per-session endpoint history to `--sessions-file` (default `~/.mxcli/hub-sessions.json`, survives restarts) and prunes it after `--session-retention` (default 30 days). Re-registering keeps a **stable URL**. +- Each endpoint shows **first seen** alongside last-seen, last-used, and uptime. First seen + is read from that persisted history, so it is the first time the endpoint was *ever* + exposed — unlike uptime, it does not reset when an idle container is reaped and the + preview reconnects. - `--hub` **implies `--local`**, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA and `originURI` cookie work), and the tunnel reconnects forever. Combine with `--watch` for the full remote loop: edit here → hot-apply → refresh the tab. diff --git a/docs-site/src/tools/theme.md b/docs-site/src/tools/theme.md new file mode 100644 index 000000000..cfe4bc97f --- /dev/null +++ b/docs-site/src/tools/theme.md @@ -0,0 +1,187 @@ +# Default Styling (`mxcli theme`) + +A blank Mendix app looks like a blank Mendix app. `mxcli new` applies a default +theme so a generated app looks like a product on first boot, and +`mxcli theme apply` adds the same theme to a project you already have. + +```bash +mxcli theme list # built-in themes; the default is marked * +mxcli theme show signal # palette, colorway, and the files it writes +mxcli theme apply -p app.mpr # apply to an existing project +mxcli theme apply ledger -p app.mpr # switch theme (the previous one is removed) +mxcli theme apply -p app.mpr --dry-run # report changes without writing +mxcli theme remove -p app.mpr # take it back out + +mxcli new MyApp --version 11.13.0 # applies `signal` +mxcli new MyApp --version 11.13.0 --theme none # plain Atlas +``` + +## The themes + +| Name | Default palette | Character | +|---|---|---| +| **signal** (default) | light | Cool slate, one teal signal colour, 4px radius, 32px rows, IBM Plex | +| **ledger** | light | Warm paper, hairline rules instead of card shadows, Source Serif headings over Source Sans, 2px radius, 30px rows | +| **console** | dark | Near-black ground, teal with a violet accent, Space Grotesk over JetBrains Mono, 6px radius, 28px rows, surfaces separated by lightness | + +All three share the same density discipline: an 8px spacing unit, monospace +numerics, a visible focus ring on every focusable element, and every control +growing to a 44px touch target below 768px. + +Only one theme applies at a time — `theme apply` removes the previous one, +because two themes mapping the same Atlas variables would fight in the cascade. + +## What it writes + +Five things, all under `theme/`: + +| File | What | +|---|---| +| `theme/web/custom-variables.scss` | the theme's palette — this is the file to edit | +| `theme/web/_mxcli-atlas-map.scss` | the Atlas wiring: ~60 Atlas variables expressed in terms of the palette | +| `theme/web/_mxcli-.scss` | the other palette, the variant blocks, `@font-face`, recipe classes | +| `theme/web/main.scss` | the variant switch plus two `@import` lines | +| `theme/web/mxcli-fonts/` | vendored fonts (SIL OFL 1.1) | + +**The model is never touched.** No `.mpr` changes, so nothing here can affect a +build, and the theme hot-applies under `mxcli run --local --watch`. + +**Atlas Core is never touched either.** Because Atlas components read the brand +tokens, retuning them cascades into buttons, backgrounds, form inputs, cards, +modals and the brand-aware pluggable widgets (Switch, Slider, ProgressBar, +BadgeButton) with no per-widget CSS. That is also why the project stays +upgradable across Mendix releases. + +## Light and dark + +`--variant auto` is the default and ships both palettes: + +```bash +mxcli theme apply signal -p app.mpr # auto +mxcli theme apply signal -p app.mpr --variant dark # bake one palette, no switching +``` + +Under `auto` the app follows the operating system's `prefers-color-scheme` +**before first paint** — no flash, no script — and honours a `theme-light` or +`theme-dark` class on the root element when something sets one. + +Mendix ships that slot (`theme/web/_theme-dark.scss` declares `:root.theme-dark`) +but nothing that applies it, and its palette is stock Mendix blue. An mxcli theme +re-declares the same selector from a file that compiles later, so the theme's own +dark palette wins. + +### A user-facing toggle + +```bash +mxcli theme switcher install -p app.mpr --module MyFirstModule +``` + +**This is the one theme command that writes to the model.** It has to: the class +has to be set by something the browser can run, and there is no theme-level hook +to run script before first paint. It creates three JavaScript actions +(`ToggleAppTheme`, `SetAppTheme`, `ApplyStoredTheme`) and a nanoflow, then you +wire a button: + +```sql +actionbutton btnTheme (caption: 'Theme', action: nanoflow MyFirstModule.ACT_ToggleTheme) +``` + +A click flips the palette and remembers the choice in `localStorage`. The class +goes on ``, so popups and modals — which Mendix renders at ``, +outside any page container — follow it too. + +**Known limit:** after a reload the app goes back to following the OS. Mendix has +no page on-load event to re-apply the stored value, and the usual substitute (a +data view with a nanoflow data source) is not authorable by mxcli on either +engine yet. `ApplyStoredTheme` is installed and ready — wire it in Studio Pro if +you need the choice to persist across reloads. + +## Re-branding + +Change one line in `theme/web/custom-variables.scss`: + +```scss +:root { + --mxt-brand: #0f6e6b; /* <- the one signal colour */ +``` + +`_mxcli-atlas-map.scss` maps that onto `--brand-primary`, and Atlas builds the +whole derived ramp (`--brand-primary-50` … `-900`) from it with CSS +`color-mix()` — so buttons, links, active navigation and the brand-aware +pluggable widgets follow immediately, in both palettes. + +The palette file declares only `--mxt-*` tokens, never Atlas variables directly. +That is what makes a variant cheap: the dark block restates about thirty values +instead of rewiring sixty. Pinning an Atlas variable to a literal colour is the +one thing that breaks switching — a hardcoded `--font-color-default` is +invisible the moment the ground goes dark. + +### Import order in `main.scss` + +`apply` appends its block to the **end** of `theme/web/main.scss`, so it lands +after any `@import` the project already had there. If your app carries a +stylesheet imported last specifically to win the cascade over Atlas, the theme's +partial now comes after it. Higher-specificity app classes are unaffected; a +rule that relied purely on being last is not. Move your `@import` below the +mxcli block — anything outside the fence is never touched. + +### Two Atlas constraints worth knowing + +- **The navigation rail stays dark in both palettes.** Atlas topbar widgets paint + their own text assuming a dark rail — the language selector uses + `--bg-color-secondary` with a `#fff` fallback, at a specificity + (`.navbar-brand .widget-language-selector .current-language-text`) that a + simple override cannot beat. Every mxcli theme keeps the rail dark and + re-declares those selectors at matching specificity, resolving the colour + through the rail token. +- **`themesource//` is only compiled when `` matches a real module**, + so a theme never writes there. `theme/web/main.scss` compiles last and is the + correct home for app-level styling. + +## Recipe classes + +Apply with `class:` on any widget. + +| Class | Use | +|---|---| +| `num` | monospace with tabular figures — ids, amounts, dates, so columns align | +| `num-right` | right-align a numeric column | +| `pill` + `pill-ok` / `pill-warn` / `pill-risk` / `pill-info` | status pills; pair with a `dynamicclasses` expression mapping an enum | +| `stat` + `stat-label` / `stat-value` / `stat-delta` / `stat-delta-up` / `stat-delta-down` | KPI tile | +| `density-compact` | 28px rows and inputs inside this container | + +## Your edits are safe + +Every generated region is fenced, and the closing marker records a digest of what +mxcli wrote: + +```scss +// mxcli:theme:begin signal v1 — generated by `mxcli theme apply`; edit outside this block +… +// mxcli:theme:end signal b538a13336af87f6 +``` + +- Edit **outside** the markers and mxcli never touches your lines. +- Edit **inside** them and the next `apply` refuses rather than discarding your + work, naming the file and offering `--force`. +- `apply` is idempotent — re-running an unchanged theme reports `unchanged` for + every file. +- `remove` cuts the blocks back out and restores the file byte for byte. + +## Verifying + +A theme that compiles cleanly can still render wrong, so check a running app +rather than the build log: + +```bash +mxcli run --local --watch --screenshot -p app.mpr +``` + +SCSS edits hot-apply, so `--watch` gives you a tight loop. If a rule seems not to +apply, first confirm it is compiled at all — grep for the selector in +`theme-cache/web/theme.compiled.css`. Absent and overridden look identical in the +browser, and only one of them is a specificity problem. + +See also the `atlas-design` skill for the method behind the theme, and +`theme-styling` for the SCSS compilation chain — both are installed into your +project by `mxcli init`. diff --git a/docs/11-proposals/PROPOSAL_default_styling.md b/docs/11-proposals/PROPOSAL_default_styling.md new file mode 100644 index 000000000..f9883ba71 --- /dev/null +++ b/docs/11-proposals/PROPOSAL_default_styling.md @@ -0,0 +1,326 @@ +--- +title: Default styling — generated apps that look designed on first boot +status: partial +date: 2026-08-06 +related: + - PROPOSAL_atlas_design_system.md + - docs/plans/2026-03-27-enhance-theme-system-design.md + - .claude/skills/mendix/atlas-design.md + - .claude/skills/mendix/theme-styling.md +--- + +# Default styling — generated apps that look designed on first boot + +## Problem + +`mxcli new` produces a blank Atlas app. It is unmistakably a blank Atlas app: +Mendix's stock blue gradient chrome, Open Sans, 8px radii, generous marketing-page +spacing. `PROPOSAL_atlas_design_system.md` already established the method for +fixing that — the 4-layer architecture, Atlas-first composition, the verify loop — +and shipped it as the `atlas-design` skill. What it did **not** ship is a default: +the Layer-1 scaffold in that skill is a template full of `// TODO: your brand +colour`, so every generated app either stays stock or gets a palette an agent +invented on the spot. + +This proposal closes that gap. When the user does not ask for a theme, mxcli +applies a recognisable one. + +## The design spec + +The visual direction is **Signal** (from the design bundle, `1a` of three +concepts, with a fully worked reference app): cool slate ground, one teal signal +colour, 4px radius, 8px spacing unit, 32px grid rows and inputs, monospace for +every number/id/date, a 3px focus ring that is never suppressed, and a 44px +minimum touch target below the tablet breakpoint. `1b` Ledger and `1c` Console are +the alternates; both are a directory drop once the mechanism below exists. + +## What the spec costs, split by where the value has to live + +| Tier | What | Lives in | Phase | +|---|---|---|---| +| 1. Tokens + CSS | palette, IBM Plex, radius, density, focus ring, touch scaling, pills, KPI tiles | plain files under `theme/` — **no model changes at all** | **P1 (this proposal, shipped)** | +| 2. Model defaults | Plotly chart theme, `class:'num'` on numeric/date bindings, Atlas classes on generated CRUD pages | `.mpr` BSON — the page and chart builders | P2 | +| 3. Behaviour / structure | `/` search, J/K, ⌘S; phone bottom nav and sheets; tablet two-pane | JS, new layouts | P3 | +| — | light/dark palettes + runtime switching | `theme/` files, plus JS actions for a toggle | **shipped, see below** | + +Tier 1 is roughly 60% of the visual identity for a fraction of the work, and it is +pure file I/O — no BSON, no metamodel, no `mx check` exposure. That asymmetry +drives the phasing. + +## Mechanics — settled empirically, not assumed + +Everything below was verified against a real Mendix **11.13.0** project created +with `mx create-project`, compiled with `mxbuild --target=deploy`, and read back +out of `theme-cache/web/theme.compiled.css`. Two of the four findings contradict +what the existing skills say, which is why they are recorded here rather than +inferred. + +1. **A theme source folder is only compiled when its name matches a real module.** + A probe rule in `themesource/myfirstmodule/web/main.scss` compiled; the + identical rule in `themesource/mxcli_theme/web/main.scss` did **not** — no + error, no warning, silently absent from the output. So a theme must not invent + a theme source folder. (`atlas-design.md` names + `themesource//web/main.scss` as the Layer-2 home; that is correct only + when `` is a module the app actually has.) + +2. **`theme/web/main.scss` compiles last** — after Atlas Core *and* after every + module theme source (`.pill` landed at line 30805 of 30924; `.btn-primary` at + 19271). It is a three-line file of Mendix's own imports, not an Atlas-owned + file, so a partial imported from it overrides anything without `!important`. + This is the correct Layer-2 home for app-level styling. + +3. **`theme/web/custom-variables.scss` is imported once per module** (8× in a + blank app — `atlas_core/web/main.scss` pulls it in, and so does every module's + own `main.scss`). It must therefore hold **declarations only**; a rule there is + emitted once per module. This matches what Atlas itself does with the file. + +4. **Mendix 11 Atlas is CSS-custom-property-first.** The stock + `custom-variables.scss` is a `:root { --brand-primary: … }` block plus a few + SCSS switches (`$font-family-import`, `$btn-bordered`, `$use-css-variables`), + with legacy Sass variables mapped in `_css-variables-mappings.scss`. Layer 1 is + therefore `:root` declarations, not SCSS `!default` variables. The derived ramp + (`--brand-primary-50…900`) is built with CSS `color-mix()` against + `var(--brand-primary)`, so it re-derives live from a retuned primary. + +5. **`theme/web//` is deployed to the web root**, so vendored fonts at + `theme/web/mxcli-fonts/` are served at `/mxcli-fonts/` and reachable from + `theme.compiled.css` as `url("./mxcli-fonts/…")`. + +## P1 — the drop (implemented) + +### Shape + +A theme is an embedded directory: a manifest plus a `files/` tree that mirrors its +layout inside the project. + +``` +cmd/mxcli/theme/ + theme.go List / Get / Apply / Remove + block.go marker fencing + digest guard + assets.go //go:embed all:assets <- `all:` is load-bearing + assets/signal/ + theme.json + files/theme/web/custom-variables.scss Layer 1 token block + files/theme/web/_mxcli-signal.scss Layer 2 partial + files/theme/web/main.scss one @import line + files/theme/web/mxcli-fonts/*.woff2 vendored IBM Plex (OFL 1.1) +``` + +`//go:embed assets` silently skips files whose name begins with `_` — which is +exactly how SCSS spells a partial. Without `all:` the binary ships an `@import` +pointing at nothing. A test asserts every `@font-face` URL resolves to a vendored +file for the same reason. + +### Fencing — guard, don't drop + +Two of the three text targets are files the project already owns, so a theme can +never rewrite a whole file. Each write is fenced: + +```scss +// mxcli:theme:begin signal v1 — generated by `mxcli theme apply`; edit outside this block +… +// mxcli:theme:end signal b538a13336af87f6 +``` + +The end marker carries a digest of the body. On re-apply the digest is recomputed +from disk: equal means mxcli's own output is still there and may be replaced, +different means a human edited inside the fence and the write is **refused** +unless `--force`. The record lives in the file itself — no sidecar state to drift. +This is ADR-0005's guard-don't-drop rule applied to files instead of BSON. + +`theme remove` inverts it: blocks are cut back out, files that are wholly ours are +deleted, and directories the theme introduced are pruned — but only if empty, so a +user's own file in `mxcli-fonts/` survives. + +### Surface + +``` +mxcli theme list # built-in themes, default marked +mxcli theme show signal # tokens, colorway, files it writes +mxcli theme apply [name] -p app.mpr # idempotent; --dry-run, --force +mxcli theme remove [name] -p app.mpr +mxcli new MyApp --version 11.13.0 # applies `signal` by default +mxcli new MyApp --version 11.13.0 --theme none +``` + +`--theme` is validated before MxBuild is downloaded, so a typo fails in a second +rather than after the slowest step in the command. + +### Why fonts are vendored rather than `@import`ed from Google + +The `@import url()` ordering trap (must be the first line or the browser drops it) +is a known gotcha in the catalog, but the stronger reasons are that a CDN font +breaks air-gapped and on-prem deployments, and adds a third-party request to every +page load of every generated app. IBM Plex is SIL OFL 1.1, so vendoring is clean: +7 latin woff2 files, ~144KB in the binary. + +### Verification + +`mx check` and a clean compile prove nothing about appearance, so this was taken +to a browser: + +- `mxbuild --target=deploy` on a real 11.13.0 project — BUILD SUCCEEDED, tokens + present in `theme.compiled.css`, 7 `@font-face` blocks, Layer-2 rules after all + Atlas components. +- `mxcli run --local` + Playwright against the running app — + `getComputedStyle(body).fontFamily` is `"IBM Plex Sans", …`, + `--brand-primary` resolves to `#0f6e6b`, `--border-radius-s` to `4px`, and the + woff2 is served `200 font/woff2`. +- A showcase page exercising the recipe classes, Atlas buttons, form inputs and a + data grid, screenshotted in Chromium. + +## Light/dark and runtime switching (shipped) + +This was originally deferred to P3 on the strength of the finding in +`PROPOSAL_atlas_design_system` that Atlas widgets are light-only, so a +`prefers-color-scheme` flip produces a half-dark app. **That finding does not +hold on Mendix 11** and the deferral was wrong. + +Measured by adding `theme-dark` to `` on a running app and changing nothing +else: the page ground, cards, form controls, sidebar, buttons and DataGrid2 (a +pluggable widget) all followed. Mendix 11's Atlas is CSS-custom-property-first, +so the token cascade genuinely propagates. The class also sits on ``, which +disposes of the old objection about popups and modals rendering at ``, +outside any page-scoped container. + +Everything that *did* break in that experiment was the theme's own fault: it had +pinned `--font-color-default`, the pill tints and the neutral surface to literal +colours. That is the real lesson, and it drove the restructure below. + +### Architecture + +Three files per theme instead of two: + +| File | Holds | +|---|---| +| `custom-variables.scss` | the palette — `--mxt-*` tokens only, for the theme's default variant | +| `_mxcli-atlas-map.scss` | the Atlas wiring: ~60 Atlas variables expressed as `var(--mxt-*)`. Identical in every theme | +| `_mxcli-.scss` | the other palette, the variant blocks, `@font-face`, recipe classes | + +A variant therefore restates ~30 tokens, never the wiring. A test asserts the +palette files declare no Atlas variable directly, and another asserts the three +Atlas maps have not drifted apart. + +`--variant auto` (the default) emits a `prefers-color-scheme` block plus an +explicit `:root.theme-dark` block; `light` / `dark` bake one palette. The +explicit block must be declared *after* Mendix's `_theme-dark.scss` — same +specificity, later wins — or the app reverts to stock Mendix blue whenever the +class appears. `light` needs no block of its own: the media query carries +`:not(.theme-light)`, so an explicit light choice simply falls through to the +base palette. + +### The switcher + +Atlas ships `:root.theme-dark` and `:root.theme-neutral` but **nothing that +applies them**, and there is no theme-level hook to run script before first paint +(`index.html` is generated by mxbuild; `theme/web/settings.json` accepts only +`cssFiles`). So `mxcli theme switcher install` is the one theme command that +writes to the model: three JavaScript actions and a nanoflow a button can call. + +The CSS still does the heavy lifting — `auto` already renders the right palette +before first paint — so the switcher only covers an explicit override. + +**Known limit:** a reload falls back to the OS preference. Mendix has no page +on-load event, and the usual substitute (a data view with a nanoflow data source) +is not authorable by mxcli on either engine: the modelsdk engine refuses +`NanoflowSource` outright, and the legacy engine writes the data view with the +nanoflow reference missing, which fails the build with *"No nanoflow configured +for the data source of this data view"*. `ApplyStoredTheme` is installed and +ready for whatever closes that gap. + +### Two Atlas constraints found by measuring + +- **The navigation rail must stay dark in both variants.** Several topbar widgets + paint their text with `--color-base`, which Atlas assumes is white because it + assumes a dark rail. Ledger's paper-coloured rail (faithful to concept `1b`) + made the language selector invisible. All three themes now keep a dark rail, + and the shared map forces `color: inherit` on those widgets. +- **`--font-color-contrast` is topbar text, not just text on a brand fill.** + Mapping it to the brand's ink colour breaks the topbar; it tracks the rail + instead, and button text comes from `--btn-*-color`. + +### A bug this uncovered + +`create javascript action` wrote its source to +`javascriptsource//actions/`, but Mendix reads a **lowercased** +directory. mxbuild found nothing there, generated a stub that throws +*"JavaScript action was not implemented"*, and bundled it — so the action parsed, +passed `mxcli check`, built cleanly, and threw when clicked. It only reproduces +on a case-sensitive filesystem, which is why it survived. Fixed in both writers +with a regression test. + +## P2 — model defaults (next) + +The parts of the spec CSS cannot reach, in rough order of payoff: + +- **Chart theme from the manifest colorway.** Chart series colour lives in the + model (`customSeriesOptions`), not CSS — the one thing that does not re-skin + when the palette changes. Injecting `customLayout` (transparent paper, themed + ticks, faint grid), `customConfigurations` (`displayModeBar:false`) and per-type + `customSeriesOptions` from `theme.json`'s `colorway` when a chart is created + without explicit values closes the existing P2 ask in + `PROPOSAL_atlas_design_system.md`. +- **`class:'num'` applied automatically** when a generator binds an + Integer/Long/Decimal/DateTime/AutoNumber attribute. The generator knows the + type; this is what turns "monospace for every number" from a class the author + must remember into a property of generated output. +- **Atlas classes on generated CRUD pages** so the density and card shapes match + the reference app. + +## P3 — deferred + +Keyboard-first interaction needs JS with no Mendix equivalent. Phone bottom-nav +and sheet navigation need layout documents. **Dark mode should stay deferred**: +the spec ships dark tokens under the same names, and that works for *our* classes, +but Atlas's own widgets and Plotly are light-only — the half-dark result is worse +than consistent light, as `PROPOSAL_atlas_design_system.md` established at cost. +When it lands it should be a committed single-theme variant (`signal-dark`) with +unconditional global widget overrides, not a `prefers-color-scheme` flip. Mendix +11 ships `:root.theme-dark` / `:root.theme-neutral` variant hooks in +`theme/web/`, which is the natural mechanism to build on. + +## Open questions + +- **Every mxcli app looking identical** is the point (shadcn and Vercel do exactly + this), but it is a deliberate choice. Mitigated by putting `--brand-primary` + first in the generated block with a one-line swap comment, rather than by + randomising per app. +- ~~**Ledger and Console**~~ — shipped. Each is a `theme.json`, a palette, a + theme partial and vendored fonts; the Atlas wiring is shared. Applying one + removes the other, since two themes mapping the same Atlas variables would + fight in the cascade. +- **Should `mxcli init` apply the theme too?** Today only `new` does. `init` runs + against existing projects that may already have styling, so applying there + should probably stay explicit (`mxcli theme apply`). + +--- + +## External validation (RssReader test build) + +The theming work was exercised independently against a real app — Feedline, ~1,900 +lines of MDL and ~1,400 lines of custom SCSS — and reported in +`ako/mxcli-rssreader:MXCLI-FINDINGS.md`. Three defects came back, all fixed: + +| # | Defect | Root cause | +|---|---|---| +| 15 | `theme remove` with no name removed nothing and exited 0 | the bare invocation targeted the default theme instead of the installed one | +| 16 | switching themes doubled `_mxcli-atlas-map.scss` | the protected-path branch in `remove` returned without writing the truncation, so the outgoing block survived and the incoming one was appended | +| 17 | topbar language selector unreadable in every dark palette (1.13:1) | the guard matched at (0,1,0) against Atlas's (0,3,0), and `color: inherit` was the wrong value regardless | + +Two lessons worth carrying forward: + +- **A guard that names the right element is not evidence it applies.** #17 had a + rule targeting exactly the right selector; it lost the cascade. Read the + *winning* declaration, not the one you wrote. +- **Measure contrast, not colour.** The original verification read + `getComputedStyle(el).color`, saw white and moved on. Computing the WCAG ratio + against the first non-transparent ancestor background is what separates + 1.13:1 from 19.47:1 — and it is a two-line addition to a Playwright probe. + +The report also confirmed the parts that hold: fences refuse edits and `--force` +overrides them, `--dry-run` writes nothing, `--variant dark` really bakes, the +switcher works end to end with the documented reload limitation reproducing +exactly, vendored fonts render with no outbound network, and applying a theme to +a heavily customised project was non-destructive. One behaviour worth documenting +rather than fixing: `apply` appends its block to the end of `main.scss`, after any +`@import` the project already had there. diff --git a/docs/11-proposals/PROPOSAL_workflow_microflow_syntax_alignment.md b/docs/11-proposals/PROPOSAL_workflow_microflow_syntax_alignment.md new file mode 100644 index 000000000..8563d8c65 --- /dev/null +++ b/docs/11-proposals/PROPOSAL_workflow_microflow_syntax_alignment.md @@ -0,0 +1,257 @@ +--- +title: Workflow / Microflow Syntax Alignment +status: draft +date: 2026-08-06 +--- + +# Proposal: Workflow / Microflow Syntax Alignment + +**Status:** Draft +**Date:** 2026-08-06 + +## Problem Statement + +MDL spells the same concept differently depending on which document type you are +authoring. The clearest case is binding a parameter to a call, which has **three** +spellings today: + +| Context | Syntax | Value | +|---|---|---| +| Microflow call | `call microflow M.F($p = $x, Level = 'INFO')` | expression | +| Page / widget datasource | `microflow M.F(Task: $Task)` | expression, **colon** | +| Workflow call | `call microflow M.F with (p = 'expr')` | **string literal only** | + +This violates the project's own design rule. `.claude/skills/design-mdl-syntax.md`, +principle 2, states: *"Reuse existing patterns. Never create a second syntax for the +same concept."* The checklist item "No new keyword overloading" is likewise not met — +`annotation` attaches a note in microflows but is a separate (and, until recently, +project-corrupting) statement in workflows. + +Beyond the developer-experience cost, this has a measurable quality cost. + +### Defects cluster in the divergent surface + +Every workflow defect reported across the two external test projects sits in a +workflow-only construct or its bespoke write path — code with no microflow +counterpart to inherit already-exercised machinery from: + +| Finding | Construct | Severity | +|---|---|---| +| issuetracker #15 | `annotation '...'` statement | **model unloadable** (fixed: refused, MDL-WF04) | +| issuetracker #16 | `jump to ` | target written as a comment (CE6680 + CE0495) | +| issuetracker #17 | `decision ''` | CE0117 "Error(s) in expression" | +| ledger #39 | workflow call-microflow storage name | CE6686 / CE0117 | +| ledger #41 | non-bare `with (...)` param names | rejected at check | + +By contrast the constructs that *share* a code path with microflows — expressions, +qualified-name resolution, microflow references — have been comparatively stable. +The bespoke surface is where the defect density is, because it is the surface with +no second consumer keeping it honest. + +### Who benefits + +- **Authors and LLMs.** One pattern per concept means one example is enough to + generate correct variants (design principle 3). Today an LLM that has learned + microflow call syntax will produce an invalid workflow call. +- **Maintainers.** Fewer bespoke grammar rules to keep correct. The `with (...)` + clause alone required a dedicated validator (`validate_workflow_refs.go`) to reject + the qualified-name form the grammar still advertises. + +## Scope: what is accidental vs. warranted + +The useful test is **not** "are they different?" but *does the syntactic difference +track a semantic one?* Alignment is proposed only where it does not. + +### Accidental (in scope) + +| Divergence | Why it is accidental | +|---|---| +| `with (p = 'expr')` vs `(p = expr)` | Both write parameter mappings. The workflow form forces the value to be a `STRING_LITERAL`, so what is an expression everywhere else must be quoted — the direct cause of issuetracker #17's note that `'$workflowContext'` must be a quoted literal. | +| Grammar says `qualifiedName` for the param | The executor **already** prepends the microflow QN (`mfQN + "." + pm.Parameter`, `cmd_workflows_write.go`), and ledger #41 added a validator rejecting anything non-bare. The grammar advertises a form the tool refuses. | +| `comment 'x'` vs `@annotation 'x'` | Same intent, and the collision sent a user to the corrupting `annotation` statement. | +| No decorators at all in workflows | `workflowActivityStmt` has no `annotation*` prefix, so workflows cannot express `@position` — microflows can. The `annotation` rule is already domain-neutral (`MDLSettings.g4`), just not wired in. | +| Boolean `decision ''` | Writes `workflows.ExclusiveSplitActivity{Expression, outcomes}` — structurally what a microflow `if` already compiles to, but through a bespoke expression path (which is what fails CE0117). | + +### Warranted (explicitly out of scope) + +These have no microflow equivalent and should **not** be made to look like one: + +- **Outcomes** on user tasks and call-microflow are first-class named model objects + (`Workflows$UserTaskOutcome`), not flow labels. `if/else` cannot express them. +- **Boundary events**, **targeting** (users/groups, microflow/XPath), **due dates**, + **multi user task** — workflow-only concepts. +- **No variables or assignment.** A workflow body has no `$var = ...`; there is only + `$workflowContext`. This is the strongest argument *against* over-unification: + making a workflow body look like imperative microflow code invites users to expect + assignment that does not exist. +- **`{ }` vs `begin … end`.** Cosmetic. Changing it churns every existing script and + all DESCRIBE round-trip tests for zero semantic gain. + +**Guiding rule for this proposal:** *same spelling for the same concept; keep +distinct spellings where the semantics differ.* Not "make them look alike." + +## BSON Structure + +**No new BSON, and no change to any stored shape.** This is a front-end (grammar → +AST → visitor) change that routes into write paths that already exist and are already +exercised: + +| Aligned construct | Existing write path | Status | +|---|---|---| +| Call-argument binding | `workflows.ParameterMapping{Parameter, Expression}` → `buildCallMicroflowTask` | exists; `Expression` is already a free-form string field | +| Activity note | `BaseWorkflowActivity.Annotation` → `appendActivityBaseFields` → nested `Workflows$Annotation{Description}` | exists; what `comment` already sets | +| Boolean decision | `workflows.ExclusiveSplitActivity{Expression, Outcomes}` → `buildExclusiveSplit` | exists; boolean outcomes already detected | + +The one construct where placement genuinely is unresolved — the standalone +`annotation` statement — is deliberately **not** revived here. It has no valid +container (`Workflows$Annotation` takes only `Description` and attaches to a `Flow`; +`Workflows$FloatingAnnotation` is not a flow element either, and no struct in +`modelsdk/gen/workflows` owns a list of them). It stays refused under MDL-WF04 until +a Studio Pro reference establishes the correct container, per CLAUDE.md's rule on +unknown BSON shapes. + +## Proposed MDL Syntax + +All changes are **additive**. Every current form keeps parsing. + +### 1. Call arguments (highest value) + +```mdl +-- proposed (matches the microflow spelling; value is an expression) +call microflow Module.ACT_Escalate(Issue = $workflowContext, Level = 'High'); + +-- still accepted, unchanged +call microflow Module.ACT_Escalate with (Issue = '$workflowContext'); +``` + +The bare `(...)` form takes an `expression`, so `$workflowContext` no longer has to +be smuggled through as a quoted string. + +### 2. Activity notes and decorators + +```mdl +-- proposed +@annotation 'Escalation path per policy 4.2' +user task Triage 'Triage the issue' page Module.TaskPage + outcomes 'Done' { } 'Reject' { }; + +-- still accepted, unchanged +user task Triage 'Triage the issue' page Module.TaskPage comment 'Escalation path…' + outcomes 'Done' { } 'Reject' { }; +``` + +Wiring the shared `annotation*` prefix into `workflowActivityStmt` also makes +`@position(x, y)` available to workflows, which currently cannot express layout at +all. (Honouring `@position` is a follow-up, not part of this proposal — see Open +Questions.) + +### 3. Boolean decisions + +```mdl +-- proposed sugar for a two-outcome boolean decision +if $workflowContext/Priority = Module.Priority.Critical then { + call microflow Module.ACT_Escalate(Issue = $workflowContext); +} else { + call microflow Module.ACT_Normal(Issue = $workflowContext); +} + +-- still accepted, unchanged +decision 'Priority check' + outcomes true -> { … } false -> { … }; +``` + +Enumeration decisions keep the `decision … outcomes 'Value' -> { }` form — they map +to `EnumerationValueConditionOutcome`, which `if/else` cannot express. + +### Rejected alternative: unify on `comment` / SQL-shaped + +ADR-0003 says MDL is SQL-shaped, and `COMMENT` is SQL-native, so the opposite +direction is defensible: add `comment '...'` to microflows and treat decorators as +legacy. Rejected because (a) it touches the far larger microflow surface, (b) it +fights the established decorator family (`@position`, `@caption`, `@anchor`) whose +purpose — presentation metadata held out-of-band from the logic — is exactly what an +activity note is, and (c) it is a breaking change in spirit for the more heavily used +document type. + +## Implementation Plan + +Phased, each phase independently shippable and independently revertable. + +### Phase 1 — call arguments + +| File | Change | +|------|--------| +| `mdl/grammar/domains/MDLWorkflow.g4` | `workflowCallMicroflowStmt` / `workflowCallWorkflowStmt`: accept `LPAREN callArgumentList? RPAREN` as an alternative to `WITH LPAREN … RPAREN` | +| `mdl/visitor/visitor_workflow.go` | Build `ParameterMappings` from either form; expression form stores the rendered expression | +| `mdl/executor/validate_workflow_refs.go` | Message update: suggest the bare-paren form | +| `mdl/executor/cmd_workflows.go` | DESCRIBE emits the new canonical form | +| `mdl-examples/doctype-tests/` | Cover both spellings | + +### Phase 2 — decorators on workflow activities + +| File | Change | +|------|--------| +| `mdl/grammar/domains/MDLWorkflow.g4` | `workflowActivityStmt : annotation* …` (rule already exists in `MDLSettings.g4`) | +| `mdl/visitor/visitor_workflow.go` | Map `@annotation` → the activity's `Annotation` property; ignore unknown decorators with a check-time warning | +| `.claude/skills/mendix/write-workflows.md` | Document `@annotation`; keep `comment` documented as the alias | + +### Phase 3 — `if/else` sugar for boolean decisions + +| File | Change | +|------|--------| +| `mdl/grammar/domains/MDLWorkflow.g4` | `workflowIfStmt : IF expression THEN LBRACE workflowBody RBRACE (ELSE LBRACE workflowBody RBRACE)?` | +| `mdl/visitor/visitor_workflow.go` | Lower to `WorkflowDecisionNode` with `true`/`false` outcomes — no new executor or writer code | + +### Canonical form in DESCRIBE + +DESCRIBE must emit exactly one spelling or round-trip tests become ambiguous. Proposal: +emit the **new** form from the phase that introduced it, and add a one-time note to the +changelog, since DESCRIBE output is not a stability contract (it is regenerated). + +## Version Compatibility + +Not Mendix-version-gated. Every aligned construct writes BSON that today's code +already writes, for every supported Mendix version. No `sdk/versions/*.yaml` entry is +required. + +Backward compatibility with existing **scripts** is total: all current forms continue +to parse and produce identical BSON. + +## Test Plan + +- `mdl-examples/doctype-tests/` — workflow examples exercising both spellings of each + aligned construct, so the alias path stays covered. +- **BSON-equivalence tests** (the load-bearing ones): assert that the old and new + spellings of the same workflow produce byte-identical documents apart from + regenerated UUIDs. This is what proves alignment is purely syntactic. +- `mdl-examples/bug-tests/it-15-workflow-annotation-refused.fail.mdl` — unchanged; + MDL-WF04 must keep firing for the standalone statement. +- Integration: `mx check` = 0 errors on a workflow authored entirely in the new + spelling, on Mendix 11.12.1. +- Round-trip: `describe workflow` output re-executes cleanly. + +## Open Questions + +1. **Canonical DESCRIBE spelling.** Switching output changes every workflow round-trip + fixture in one commit. Acceptable, or should DESCRIBE keep emitting the legacy form + for a release while the new one is accepted on input? +2. **`@position` for workflows.** Wiring the decorator prefix makes `@position` + *parseable* immediately; honouring it needs workflow layout support that does not + exist. Warn-and-ignore, or reject unknown decorators until implemented? +3. **Should the page/widget `Name: value` colon form also converge?** It is the third + spelling and out of scope here, but leaving it makes "one way to do each thing" + still only two-thirds true. Possibly a follow-up proposal. +4. **Is `jump to` (issuetracker #16) in or out?** It is broken independently of + alignment. Fixing it is a bug fix; whether its syntax should also change is a + separate question this proposal does not answer. + +## Related + +- ADR-0003 — MDL is SQL-shaped (the constraint this proposal works within) +- `.claude/skills/design-mdl-syntax.md` — principle 2 ("never create a second syntax + for the same concept") and the keyword-overloading anti-pattern +- `PROPOSAL_mdl_syntax_improvements_v2.md` — a broader, more radical restyling + (proposes dropping `create`/`begin`/`end`); shares the "unified experience" goal but + is not additive and is not assumed here +- `PROPOSAL_workflow_improvements.md` — ALTER WORKFLOW + cross-references; capability + gaps rather than syntax shape, largely shipped diff --git a/docs/11-proposals/README.md b/docs/11-proposals/README.md index ccb4629b1..a75fd6f3f 100644 --- a/docs/11-proposals/README.md +++ b/docs/11-proposals/README.md @@ -26,15 +26,16 @@ for display in this README): -## Active Proposals (84) +## Active Proposals (88) -### In Progress (partial) (10) +### In Progress (partial) (11) | Proposal | Status | Summary | |----------|--------|---------| | [Add Styling Support to MDL Pages](page-styling-support.md) | Partial | Mendix has four styling mechanisms on every widget, all stored in a BSON Forms$Appearance object: | | [Atlas Design System — a skill for visually appealing Mendix apps](PROPOSAL_atlas_design_system.md) | Partial | mxcli can generate a fully-functional Mendix app, but the default output looks bland — it leans | +| [Default styling — generated apps that look designed on first boot](PROPOSAL_default_styling.md) | Partial | mxcli new produces a blank Atlas app. | | [Eval Framework for mxcli + Claude Code](proposal-eval-framework.md) | Partial | An evaluation framework that systematically tests how well Claude Code + mxcli handles real-world Mendix app generation tasks. | | [Implement `mxcli structure` Command](mxcli-structure-proposal.md) | Partial | mxcli is a Go CLI tool for working with Mendix projects. | | [Multi-Version Support: Consolidated Architecture & Status](MULTI_VERSION_SUPPORT.md) | Partial | Mendix projects vary along three versioning axes, and mxcli must handle all of them correctly: | @@ -44,7 +45,7 @@ for display in this README): | [Podman Support as Docker Alternative](PROPOSAL_podman_support.md) | Partial | Docker Desktop requires a paid subscription for larger organizations. | | [SHOW/DESCRIBE/USE Building Blocks](show-describe-building-blocks.md) | Partial | Document type: Pages$BuildingBlock (NOT Forms$BuildingBlock — the reader now | -### Proposed (34) +### Proposed (36) | Proposal | Status | Summary | |----------|--------|---------| @@ -56,6 +57,8 @@ for display in this README): | [Large Source Files](refactor-large-files.md) | Proposed | This proposal addresses the refactoring of 6 large non-generated source files to improve maintainability and extensibility, especially for a | | [Mendix Automated Testing Pipeline](proposal-playwright-testing.md) | Proposed | Implementation Proposal for Claude Code | | [mxcli Feature Request: Runtime Integration via M2EE Admin API](proposal-runtime-admin-port.md) | Proposed | During development with mxcli + Docker, we reverse-engineered the Mendix runtime's M2EE admin API | +| [mxcli microflow debugger — breakpoints by name against a running runtime](PROPOSAL_microflow_debugger.md) | Proposed | Add first-class microflow-debugger support to mxcli: set breakpoints, inspect paused | +| [mxcli tunnel-hub — GitHub authentication (hosted hub.mxcli.org)](PROPOSAL_hub_authentication.md) | Proposed | This proposal adds authentication to the multi-tenant tunnel-hub so a hosted | | [OData Clients, OData Services, and External Entities Support](odata-services-proposal.md) | Proposed | This proposal outlines the implementation plan for adding support for: | | [Optimize mxcli Documentation for LLM Training](github-for-llms.md) | Proposed | mxcli is a Go CLI + library enabling AI coding assistants to read/modify Mendix projects via MDL. | | [Page Composition and Partial Updates](proposal_page_composition.md) | Proposed | Large MDL page scripts become unwieldy to write, read, and maintain. | @@ -83,7 +86,7 @@ for display in this README): | [VS Code Search — Quick Pick + Workspace Symbol](PROPOSAL_vscode_search.md) | Proposed | Full-text search exists in mxcli (mxcli search) but is only accessible via the terminal. | | [Workflow Improvements: ALTER WORKFLOW + Cross-References](PROPOSAL_workflow_improvements.md) | Proposed | Workflow support in mxcli has full CREATE/DESCRIBE/DROP/SHOW coverage with 13 activity types and BSON round-trip fidelity. | -### Draft (39) +### Draft (40) | Proposal | Status | Summary | |----------|--------|---------| @@ -93,7 +96,6 @@ for display in this README): | [Backend Strategy — adopt engalar's modelsdk base + multi-backend (MCP first)](PROPOSAL_backend_strategy.md) | Draft | - Adopt engalar's modelsdk foundation as the base rather than merging 1109 | | [Bulk Change Custom Widget Properties](PROPOSAL_bulk_widget_property_updates.md) | Draft | Custom widgets (pluggable widgets) in Mendix have complex nested property structures. | | [Bulk External Action Support from OData Contracts](PROPOSAL_external_actions_bulk_create.md) | Draft | Issue #143 requests importing all entities and actions from a consumed OData service. | -| [check heuristics for constructs MxBuild rejects](PROPOSAL_check_mxbuild_gap_heuristics.md) | Draft | Relates to: PROPOSAL_expression_type_checking.md (shares the ModelResolver | | [Claude Code Prompt: Sprotty-based Domain Model Visualization PoC](proposal_sprotty_visualization.md) | Draft | I'm building a VS Code extension that visualizes Mendix Domain Models using Sprotty. | | [Deterministic AI-Capability Dataset & Report Generator](PROPOSAL_ai_capability_dataset.md) | Draft | We publish an AI Capabilities report (ai-capabilities-report-.html) that | | [Entity Positioning in ALTER ENTITY](PROPOSAL_entity_position.md) | Draft | Entity positions in the domain model canvas can only be set during create entity via the @position annotation. | @@ -118,6 +120,7 @@ for display in this README): | [mxcli auth — Mendix Platform Authentication](PROPOSAL_platform_auth.md) | Draft | A growing set of mxcli features need to talk to Mendix platform APIs on behalf of the user: | | [mxcli catalog — Mendix Catalog Integration](PROPOSAL_catalog_integration.md) | Draft | ⚠️ TERMINOLOGY NOTE: This proposal covers the external Mendix Catalog service at catalog.mendix.com (CLI: mxcli catalog search), which is se | | [mxcli check — mine Mendix's diagnostics catalog to close the check↔mxbuild gap](PROPOSAL_check_diagnostics_catalog.md) | Draft | Relates to: PROPOSAL_check_mxbuild_gap_heuristics.md (one tactical slice of this — | +| [mxcli marketplace diff — detect local modification and plan an ID-preserving module upgrade](PROPOSAL_marketplace_module_upgrade.md) | Draft | Follows on from PROPOSAL_marketplace_modules.md, | | [mxcli Playground](mxcli-playground.md) | Draft | A public GitHub repository (mendixlabs/mxcli-playground) containing a ready-to-use Mendix project pre-configured with mxcli, Claude Code ski | | [Playwright Session Reuse and Lifecycle Control](PROPOSAL_playwright_session_reuse.md) | Draft | Builds on proposal-playwright-cli.md, which | | [Project Brain — Persistent Knowledge and Session Scaffolding for Long-Term AI Collaboration](PROPOSAL_project_brain.md) | Draft | mxcli is developed with significant AI involvement, yet the project's knowledge infrastructure is built for static human reading rather than | @@ -126,6 +129,7 @@ for display in this README): | [Self-Describing Syntax Feature Registry](syntax-feature-registry.md) | Draft | Branch: research/recursive-help-discovery | | [Version-Aware Agent Support](PROPOSAL_version_aware_agent_support.md) | Draft | Three use cases require mxcli to be version-aware at the MDL level: | | [warm dev loop — Docker-free run and iPad split-screen preview](PROPOSAL_mxcli_dev_warm_loop.md) | Draft | Relates to: PROPOSAL_check_mxbuild_gap_heuristics.md (the static-check gate that | +| [Workflow / Microflow Syntax Alignment](PROPOSAL_workflow_microflow_syntax_alignment.md) | Draft | MDL spells the same concept differently depending on which document type you are | ## Archived (25) diff --git a/mdl-examples/bug-tests/835-datagrid-microflow-datasource-params.mdl b/mdl-examples/bug-tests/835-datagrid-microflow-datasource-params.mdl new file mode 100644 index 000000000..d96a22fae --- /dev/null +++ b/mdl-examples/bug-tests/835-datagrid-microflow-datasource-params.mdl @@ -0,0 +1,56 @@ +-- Bug #835: a parameterized microflow datasource silently dropped its argument +-- binding, so the widget failed to build with CE1571. +-- +-- datagrid dg1 (datasource: microflow ZKT35.GET_ItemByName(Name: 'abc')) +-- +-- The grammar parsed the arguments into DataSourceV3.Args, but the builder never +-- read them and pages.MicroflowSource had no field to hold them, so they were +-- dropped between AST and model. mxcli check and exec both reported success; +-- mxbuild then reported: +-- +-- [error] [CE1571] "No argument has been selected for parameter 'Name' and no +-- default is available. Please select an argument manually." at Data grid 2 'dg1' +-- +-- The tell was in the writer: microflowSettingsToGen(d.Microflow, nil) passed a +-- literal nil for the mappings at all three datasource sites, with a comment +-- asserting datasources never have any — true when only actions could carry +-- arguments, stale once the grammar accepted them on a datasource. +-- +-- Fix: MicroflowSource/NanoflowSource carry ParameterMappings, the builder fills +-- them from ds.Args via the shared flowArgsToParameterMappings (same $-variable +-- vs expression rule the action path uses), and the writer passes them through. +-- +-- KNOWN REMAINING GAP: `describe page` does not yet report the parameter +-- mappings, so a describe → exec round-trip still loses them. The write path is +-- correct (this script builds clean); the read-back is tracked separately. +-- +-- Manual verification (needs a project, so `make check-mdl` only syntax-checks this): +-- +-- mxcli exec 835-datagrid-microflow-datasource-params.mdl -p app.mpr +-- mx check -p app.mpr +-- +-- Expect 0 errors. Before the fix this produced CE1571 on the data grid. + +create module Issue835; +create module role Issue835.User; + +@position(100, 100) +create persistent entity Issue835.Item ( + Name: string(100) +); + +create microflow Issue835.GET_ItemByName ( Name: string ) +returns list of Issue835.Item as $items +begin + retrieve $items from Issue835.Item; + return $items; +end; + +create page Issue835."P_Grid" ( + Title: 'Grid', + Layout: Atlas_Core.Atlas_Default +) { + datagrid dg1 (datasource: microflow Issue835.GET_ItemByName(Name: 'abc')) { + column c1 (attribute: Name, caption: 'Name') + } +}; diff --git a/mdl-examples/bug-tests/842-mapping-quoted-identifiers.mdl b/mdl-examples/bug-tests/842-mapping-quoted-identifiers.mdl new file mode 100644 index 000000000..eb028643d --- /dev/null +++ b/mdl-examples/bug-tests/842-mapping-quoted-identifiers.mdl @@ -0,0 +1,65 @@ +-- Bug #842: quoted identifiers inside a mapping body were not stripped (CE1613). +-- +-- Quoting identifiers is the documented way to avoid MDL keyword collisions and +-- is stripped generically everywhere else. Inside an import/export mapping body +-- the entity and association names were read with ctx.QualifiedName().GetText(), +-- which returns the raw parse text — quotes included — so the reference was +-- stored as `ZZB."Routing"`. +-- +-- mxcli reported success with no warnings; Mendix then reported, on an +-- otherwise clean project: +-- +-- [error] [CE1613] "The selected entity 'ZZB."Routing"' no longer exists." +-- at Object mapping element 'Root' +-- [error] [CE1613] "The selected attribute 'ZZB."Routing".RouteId' no longer +-- exists." at Value mapping element '_id' +-- [error] [CE1613] "The selected attribute 'ZZB."Routing".RouteName' no longer +-- exists." at Value mapping element 'Name' +-- +-- Note the shape of that second message: the attribute half (`RouteId`) already +-- came through unquoted because it goes via identifierOrKeywordText, so the +-- stored name was a mix of stripped and unstripped parts. That is the tell for +-- "one of the two readers is raw". +-- +-- Fix (mdl/visitor/visitor_import_export_mapping.go): every qualified name in a +-- mapping body goes through buildQualifiedName, the same helper the rest of the +-- visitor uses. Five sites across the import and export builders — root entity, +-- nested association + entity, and the value-transform converter. +-- +-- Manual verification (needs a project, so `make check-mdl` only syntax-checks this): +-- +-- mxcli exec 842-mapping-quoted-identifiers.mdl -p app.mpr +-- mx check -p app.mpr +-- +-- Expect 0 errors. Before the fix this produced three CE1613 errors. + +create module Issue842; +create module role Issue842.User; + +@position(100, 100) +create persistent entity Issue842."Routing" ( + "RouteId": string(100), + "RouteName": string(100) +); + +create json structure Issue842."JSON_Route" + snippet $${"id":"a","name":"b"}$$; + +-- Every identifier quoted on purpose: this is the documented style. +create import mapping Issue842."IMM_Route" + with json structure Issue842."JSON_Route" +{ + create Issue842."Routing" { + "RouteId" = id, + "RouteName" = name + } +}; + +create export mapping Issue842."EMM_Route" + with json structure Issue842."JSON_Route" +{ + Issue842."Routing" { + id = "RouteId", + name = "RouteName" + } +}; diff --git a/mdl-examples/bug-tests/843-rest-response-mapping-no-body.fail.mdl b/mdl-examples/bug-tests/843-rest-response-mapping-no-body.fail.mdl new file mode 100644 index 000000000..c797a52c9 --- /dev/null +++ b/mdl-examples/bug-tests/843-rest-response-mapping-no-body.fail.mdl @@ -0,0 +1,51 @@ +-- Issue #843, second half: the syntax the reporter actually wrote. +-- +-- NEGATIVE TEST (.fail.mdl) — EXPECTED to fail `mxcli check`. +-- `make check-mdl` inverts the exit code: an unexpected pass is a regression of +-- the MDL-REST01 rule. +-- +-- `Response: mapping ZZB."IMM_R10"` points at an import mapping *document*. The +-- clause expects an entity plus a `{ ... }` body listing the JSON fields, and +-- Mendix has nowhere to store a document reference: the metamodel defines +-- exactly two response handlers, Rest$ImplicitMappingResponseHandling (inline) +-- and Rest$NoResponseHandling. +-- +-- So the statement parsed, named a mapping document where an entity belongs, +-- contributed no field mappings, and was written out as "no response handling" — +-- accepted in silence. It is now refused, at `mxcli check` time (no project +-- needed) as well as at exec time. + +create module ZZB; +create module role ZZB.User; + +@position(100, 100) +create non-persistent entity ZZB."Routing" ( + "RoutingCode": String(100), + "RoutingName": String(200) +); + +create json structure ZZB."JSON_R10" + snippet $${"routing_code":"RT-PCBA-001","routing_name":"Routing for PCBA line 1"}$$; + +create import mapping ZZB."IMM_R10" + with json structure ZZB."JSON_R10" +{ + create ZZB.Routing { + RoutingCode = routing_code, + RoutingName = routing_name + } +}; + +create rest client ZZB."MESSlice" ( + BaseUrl: 'http://localhost:3001/api/mes/core/v1', + Authentication: none +) +{ + operation "SearchRoutes" { + Method: get, + Path: '/routes', + Query: ($page: String, $pageSize: String), + Timeout: 300, + Response: mapping ZZB."IMM_R10" + } +}; diff --git a/mdl-examples/bug-tests/843-rest-response-mapping.mdl b/mdl-examples/bug-tests/843-rest-response-mapping.mdl new file mode 100644 index 000000000..a6f587f8b --- /dev/null +++ b/mdl-examples/bug-tests/843-rest-response-mapping.mdl @@ -0,0 +1,59 @@ +-- Issue #843: a consumed REST operation's response mapping was silently dropped. +-- +-- Symptom (as reported): `create rest client` succeeded without warnings, but +-- `describe rest client` showed no `Query:` line and `Response: none`. BSON +-- inspection showed the query parameters stored correctly while ResponseHandling +-- was Rest$NoResponseHandling — the mapping was gone. +-- +-- Two independent defects, both fixed: +-- +-- 1. WRITE PATH. model.RestClientOperation documents BodyType/ResponseType as +-- upper-case tokens, and both serializers plus the REST-call microflow +-- builder compare against that spelling. The MDL executor stored the +-- visitor's lower-case source text, so `ResponseType == "MAPPING"` never +-- matched and every response mapping fell through to the else-branch: +-- Rest$NoResponseHandling. Nothing errored, because the else-branch is a +-- legitimate outcome for an operation with no mapping. +-- +-- 2. READ PATH. restOperationFromGen only populated Name/HttpMethod/Path/ +-- Timeout, and type-asserted Rest$RestParameter for both parameter lists — +-- the writer emits Rest$OperationParameter and Rest$QueryParameter, two +-- different types. So parameters, headers and the response were all dropped +-- on read even when stored correctly. +-- +-- After the fix this script stores Rest$ImplicitMappingResponseHandling with a +-- full ImportMappings$ObjectMappingElement tree, and `describe rest client` +-- re-emits it verbatim (describe → exec → describe is a fixed point). +-- +-- Verified on Mendix 11.13.0: `mx check` reports 0 errors. + +create module ZZB; +create module role ZZB.User; + +@position(100, 100) +create non-persistent entity ZZB."Routing" ( + "RoutingCode": String(100), + "RoutingName": String(200) +); + +create rest client ZZB."MESSlice" ( + BaseUrl: 'http://localhost:3001/api/mes/core/v1', + Authentication: none +) +{ + operation "SearchRoutes" { + Method: get, + Path: '/routes/{id}', + Parameters: ($id: String), + Query: ($page: String, $pageSize: String), + Headers: ('X-Tenant-Id' = 'demo-tenant-01'), + Timeout: 300, + -- The clause names the target ENTITY and maps JSON fields onto it. Mendix + -- stores this inline on the operation; it is not a reference to an import + -- mapping document (see 843-rest-response-mapping-no-body.fail.mdl). + Response: mapping ZZB.Routing { + RoutingCode = routing_code, + RoutingName = routing_name + } + } +}; diff --git a/mdl-examples/bug-tests/it-14-assoc-destination-entity.mdl b/mdl-examples/bug-tests/it-14-assoc-destination-entity.mdl new file mode 100644 index 000000000..4c2b13b66 --- /dev/null +++ b/mdl-examples/bug-tests/it-14-assoc-destination-entity.mdl @@ -0,0 +1,51 @@ +-- ============================================================================ +-- Issue-tracker finding #14: an association datasource wrote a null +-- DestinationEntity, making the .mpr unloadable +-- ============================================================================ +-- +-- Symptom (before fix): a page datasource navigating an association whose other +-- end is a System entity wrote `DestinationEntity: ""`. That is a by-name +-- reference Mendix resolves to null, so the project could not be LOADED: +-- +-- System.InvalidOperationException: An error occurred when trying to set the +-- 'DestinationEntity' property of a Entity ref step in a Page with ID ... +-- ---> System.ArgumentNullException: Value cannot be null. (Parameter 'value') +-- at ...DomainModels.Refs.EntityRefStep.set_DestinationEntityId +-- +-- Not a build error — Studio Pro would not open the project and `mx check` died +-- before validating anything, taking the whole project down rather than one page. +-- +-- Narrower than first reported: nesting is irrelevant. The trigger is any +-- association datasource whose destination cannot be resolved, which happens +-- when one end lives in System — resolveAssociationDestination only sees the +-- project's own domain models, so that side came back "" and the fallback +-- returned the empty end. +-- +-- Fix: prefer whichever end actually resolved, and — decisively — REFUSE to +-- write an unresolved destination rather than emit a structurally invalid unit. +-- Naming the destination explicitly (`Assoc/Module.Entity`) always works and is +-- what the error suggests; that form is exercised below. +-- +-- Verified on Mendix 11.12.1: this script checks 0 errors, and the same page +-- written without the explicit destination is refused instead of corrupting the +-- project. +-- ============================================================================ + +create module IT14; +create entity IT14.Issue (Title: String); +create association IT14.Issue_Workflow from IT14.Issue to System.Workflow; + +create page IT14.TaskPage ( + Title: 'Task', + Layout: Atlas_Core.Atlas_Default, + Params: { $Task: System.WorkflowUserTask } +) { + layoutgrid g { row r { column c (DesktopWidth: 12) { + dataview dvWf (datasource: $Task/System.WorkflowUserTask_Workflow) { + -- Explicit destination: required when the association ends in System. + gallery galIssue (datasource: $currentObject/IT14.Issue_Workflow/IT14.Issue) { + dynamictext t (Content: '{1}', ContentParams: [{1} = Title]) + } + } + } } } +} diff --git a/mdl-examples/bug-tests/it-15-workflow-annotation-refused.fail.mdl b/mdl-examples/bug-tests/it-15-workflow-annotation-refused.fail.mdl new file mode 100644 index 000000000..44d299c9b --- /dev/null +++ b/mdl-examples/bug-tests/it-15-workflow-annotation-refused.fail.mdl @@ -0,0 +1,44 @@ +-- ============================================================================ +-- Issue-tracker finding #15: a standalone `annotation` in a workflow body +-- produced a model Mendix could not LOAD +-- ============================================================================ +-- +-- Symptom (before fix): `annotation '...'` passed `mxcli check`, executed, and +-- then the project could not be opened at all: +-- +-- ERROR: System.InvalidOperationException: Type +-- Mendix.Modeler.Workflows.Model.Annotation does not contain a constructor +-- with a parameter of type Mendix.Modeler.Workflows.Model.Flow. +-- +-- Not a build error — a LOAD failure. Studio Pro would not open the project and +-- `mx check` died before validating anything, so the whole project went down, +-- not one document. +-- +-- Root cause: mxcli writes the annotation into the workflow's activity flow. +-- Mendix constructs every child of that list with a Flow parent, and neither +-- `Workflows$Annotation` (Description only — it attaches to a Flow) nor +-- `Workflows$FloatingAnnotation` (the canvas sticky note) accepts one. Switching +-- the storage name alone does not help: the placement is what is wrong, and the +-- correct container is not determinable without a Studio Pro reference. +-- +-- Fix: refuse the statement rather than emit an unopenable model — MDL-WF04 at +-- check time and a hard error at exec time. Note stays as an MDL comment. +-- +-- Usage (expected to FAIL check): +-- mxcli check mdl-examples/bug-tests/it-15-workflow-annotation-refused.fail.mdl +-- ============================================================================ + +create module IT15; +create entity IT15.Issue (Title: String); +create page IT15.ReviewPage (Title: 'Review', Layout: Atlas_Core.Atlas_Default) { + layoutgrid g { row r { column c (DesktopWidth: 12) { dynamictext t (Content: 'review') } } } +} + +create workflow IT15.WF_Issue + parameter $Context: IT15.Issue +begin + annotation 'Escalation path per policy 4.2'; -- MDL-WF04: unloadable model + user task ReviewTask 'Review the issue' + page IT15.ReviewPage + outcomes 'Approve' { } 'Reject' { }; +end workflow; diff --git a/mdl-examples/bug-tests/it-17-workflow-context-expression.mdl b/mdl-examples/bug-tests/it-17-workflow-context-expression.mdl new file mode 100644 index 000000000..ca65a5597 --- /dev/null +++ b/mdl-examples/bug-tests/it-17-workflow-context-expression.mdl @@ -0,0 +1,61 @@ +-- ============================================================================ +-- Issue-tracker finding #17: a workflow decision's condition failed CE0117 +-- ============================================================================ +-- +-- Symptom (before fix): a `decision ''` referencing the workflow +-- context passed `mxcli check` and then failed the Mendix build: +-- +-- [error] [CE0117] "Error(s) in expression." at Decision 'Decision' +-- +-- Root cause: mxcli always stores the workflow's context parameter under the +-- name `WorkflowContext`, and Mendix expressions are case-sensitive. Two ways +-- of naming the context therefore reached Mendix as undefined variables: +-- +-- 1. `$workflowContext` — the spelling this repo's own workflow skill used +-- in its examples. Only call-microflow `with (...)` mappings normalized +-- the casing; a decision's condition was written through verbatim. +-- 2. `$Ctx` — whatever the author declared in `parameter $Ctx:`. The +-- grammar accepts a variable name there, but it was parsed and then +-- discarded, so the declared name resolved to nothing. +-- +-- Fix: one normalizer aliases the declared name onto `WorkflowContext` and +-- normalizes casing, applied to every expression an author can write in a +-- workflow — decision conditions, user task due dates and XPath targeting, +-- wait-for-timer delays, and call-microflow parameter mappings. +-- +-- Expected after fix: this script executes and `mx check` reports 0 errors. +-- Verify with `describe workflow` that all three spellings round-trip as +-- `$WorkflowContext`. +-- ============================================================================ + +create module IT17; + +create or modify persistent entity IT17.Request ( + Title: String(200), + Total: Decimal +); + +-- The header declares `$Ctx`; every spelling below must resolve to the stored +-- `WorkflowContext` parameter. +create or replace workflow IT17.ApprovalFlow + parameter $Ctx: IT17.Request + display 'IT17 Approval' +begin + -- 1. the author's own declared name + decision '$Ctx/Total > 1000' + outcomes + true -> { } + false -> { }; + + -- 2. the canonical name in the casing the skill used to document + decision '$workflowContext/Title != ''''' + outcomes + true -> { } + false -> { }; + + -- 3. the canonical name, spelled exactly + decision '$WorkflowContext/Total > 0' + outcomes + true -> { } + false -> { }; +end workflow; diff --git a/mdl-examples/bug-tests/it-18-xpath-negative-literal.mdl b/mdl-examples/bug-tests/it-18-xpath-negative-literal.mdl new file mode 100644 index 000000000..fe15b87b4 --- /dev/null +++ b/mdl-examples/bug-tests/it-18-xpath-negative-literal.mdl @@ -0,0 +1,82 @@ +-- ============================================================================ +-- Issue-tracker finding #18: a negative numeric literal in an XPath constraint +-- ============================================================================ +-- +-- Symptom (before fix): an unquoted negative number inside `[...]` failed to +-- parse: +-- +-- retrieve $L from M.T where [Amount > -7]; +-- Parse error: extraneous input '7' expecting {',', ')'} +-- +-- Root cause: `xpathWord` — the rule for a name part inside XPath — is a +-- negated token set that did not exclude MINUS, so `-` was consumed as a name +-- and the digits were left stranded. That is why the finding described it as +-- "negative numeric literals truncate (`-7` becomes `-`)": from the outside the +-- sign appears to swallow the number. +-- +-- The lexer deliberately keeps `-` out of NUMBER_LITERAL (a leading sign there +-- makes `$x -2` tokenise as `$x` `-2`), leaving negation to the parser. The +-- general expression grammar has `unaryExpression` for exactly this; the XPath +-- grammar simply never got the equivalent. +-- +-- Fixing the grammar alone would have been WORSE than the parse error: the +-- constraint would parse and then serialize to `[Amount > ]`, silently dropping +-- the operand, because the XPath AST builder had no case for the new +-- alternative. Both halves are needed. +-- +-- Expected after fix: this script executes and `mx check` reports 0 errors, +-- with the signs intact in the stored constraints. +-- +-- NOT part of this fix — the finding's own example line, +-- +-- where [DueDate > addDays([%CurrentDateTime%], -7)] +-- +-- still fails, but for an unrelated reason: `addDays()` is a *microflow +-- expression* function, not an XPath function. Verified on mxbuild 11.12.1 that +-- it fails CE0161 with a POSITIVE argument too, so the sign was never the +-- problem there. Use Mendix XPath date tokens for relative dates. +-- ============================================================================ + +create module IT18; + +create or modify persistent entity IT18.T ( + Amount: Decimal, + Code: String(50), + DueDate: DateTime +); + +-- negative integer, right-hand side +create or modify microflow IT18.MF_NegInteger () +begin + retrieve $A from IT18.T where [Amount > -7]; + return; +end; + +-- negative decimal +create or modify microflow IT18.MF_NegDecimal () +begin + retrieve $B from IT18.T where [Amount <= -12.5]; + return; +end; + +-- negative literal in a compound constraint +create or modify microflow IT18.MF_Compound () +begin + retrieve $C from IT18.T where [Amount > -1 and Code != 'X']; + return; +end; + +-- regression guard: MINUS was removed from the name-word set, so hyphenated +-- XPath functions must still parse (they lex as a single HYPHENATED_ID) +create or modify microflow IT18.MF_HyphenatedFunc () +begin + retrieve $D from IT18.T where [starts-with(Code, 'AB')]; + return; +end; + +-- regression guard: positive literals unchanged +create or modify microflow IT18.MF_Positive () +begin + retrieve $E from IT18.T where [Amount > 7]; + return; +end; diff --git a/mdl-examples/bug-tests/it-19-cross-module-attribute-path.mdl b/mdl-examples/bug-tests/it-19-cross-module-attribute-path.mdl new file mode 100644 index 000000000..55295d839 --- /dev/null +++ b/mdl-examples/bug-tests/it-19-cross-module-attribute-path.mdl @@ -0,0 +1,90 @@ +-- ============================================================================ +-- Issue-tracker finding #19: a widget could not bind through a CROSS-MODULE +-- association +-- ============================================================================ +-- +-- Symptom (before fix): `Issue_Assignee/Name` passed `mxcli check`, executed, +-- and then failed the build: +-- +-- [error] [CE1613] "The selected attribute +-- 'IT19.Issue.Issue_Assignee/Name' no longer exists." at Text 'txtAssignee' +-- +-- The error text is literally the raw MDL path glued onto the context entity: +-- resolution failed and the writer fell back to a flat attribute name instead +-- of a DomainModels$AttributeRef with an IndirectEntityRef of hops. +-- +-- Root cause: a domain model keeps associations in TWO lists. `Associations` +-- holds the intra-module ones (both ends BY_ID); an association whose target is +-- in another module is a `DomainModels$CrossAssociation` in `CrossAssociations`, +-- where only the local end is BY_ID and the remote end is the BY_NAME +-- `ChildRef`. The resolvers searched only the first list. +-- +-- SCOPE CORRECTION: the finding framed this as a System-module limitation, but +-- the trigger is cross-module, not System — a plain second app module (the +-- IT19B hop below) reproduces it identically. Fixing "System" alone would have +-- left the more common case broken. +-- +-- Two related quirks in the same finding, also fixed: +-- +-- * A ComboBox's `Association:` was qualified with the module of its OWN +-- option list, because the `DataSource:` mapping runs first and moves the +-- entity context. A bare `Issue_Assignee` on a ComboBox over `System.User` +-- became `System.Issue_Assignee` → CE1613 "The selected association … no +-- longer exists". An association belongs to the CONTAINING entity. +-- * `CreatedDate: AutoCreatedDate` is the spelling mxcli requires when +-- declaring an audit member (it rejects any other name and tells you to use +-- this one), but the member is stored as `createdDate` — so binding a +-- widget to the name you just declared failed CE1613, while the +-- undocumented lowercase form worked. +-- +-- Expected after fix: this script executes and `mx check` reports 0 errors. +-- ============================================================================ + +create module IT19; +create module IT19B; + +create or modify persistent entity IT19.Project ( + Code: String(50) +); + +create or modify persistent entity IT19B.Approver ( + ApproverName: String(100) +); + +create or modify persistent entity IT19.Issue ( + Title: String(200), + CreatedDate: AutoCreatedDate +); + +create or modify association IT19.Issue_Project from IT19.Issue to IT19.Project; +create or modify association IT19.Issue_Approver from IT19.Issue to IT19B.Approver; +create or modify association IT19.Issue_Assignee from IT19.Issue to System.User; + +create or replace page IT19.IssuePage +( + Title: 'Issue', + Layout: Atlas_Core.Atlas_Default, + Params: { $Issue: IT19.Issue } +) +{ + DATAVIEW dvIssue (DataSource: $Issue) { + -- same module: worked before the fix, kept as a regression guard + DYNAMICTEXT txtProject (Attribute: Issue_Project/Code) + -- another app module: CE1613 before the fix + DYNAMICTEXT txtApprover (Attribute: Issue_Approver/ApproverName) + -- the platform's System module: CE1613 before the fix + DYNAMICTEXT txtAssignee (Attribute: Issue_Assignee/Name) + -- the audit member under its DECLARED name: CE1613 before the fix + DYNAMICTEXT txtCreated (Attribute: CreatedDate) + + -- a ComboBox binds an association on the CONTAINING entity while its own + -- datasource is the option list; the bare name must not pick up the option + -- list's module + COMBOBOX cbAssignee ( + Label: 'Assignee', + Association: Issue_Assignee, + DataSource: DATABASE System.User, + CaptionAttribute: Name + ) + } +} diff --git a/mdl-examples/bug-tests/it-20-grant-audit-member-rights.fail.mdl b/mdl-examples/bug-tests/it-20-grant-audit-member-rights.fail.mdl new file mode 100644 index 000000000..f527fd59f --- /dev/null +++ b/mdl-examples/bug-tests/it-20-grant-audit-member-rights.fail.mdl @@ -0,0 +1,35 @@ +-- ============================================================================ +-- Issue-tracker finding #20 (companion): per-member rights on an audit member +-- are refused, not silently dropped +-- ============================================================================ +-- +-- Mendix stores no MemberAccess for `createdDate` / `changedDate`. An entity +-- that stores them checks clean with no entry, and a rule that carries one +-- fails the build with CE0066 (verified on mxbuild 11.12.1). Their access can +-- therefore only come from the rule's DEFAULT. +-- +-- Naming one is fine (it is a real member — see +-- it-20-grant-member-coverage.mdl). Asking for rights that differ from the +-- default is not expressible, so mxcli refuses with that reason instead of +-- accepting the statement and quietly writing something else. +-- +-- Expected: the final GRANT fails with +-- +-- IT20F.Doc.createdDate is a Mendix audit member: its access follows the +-- rule's default and cannot be set per member ... +-- +-- NOT with the old, misleading "entity IT20F.Doc has no member(s) createdDate". +-- ============================================================================ + +create module IT20F; + +create or modify persistent entity IT20F.Doc ( + DocTitle: String(200), + CreatedDate: AutoCreatedDate +); + +create module role IT20F.Admin; +create user role IT20FAdmin (IT20F.Admin); + +-- Default is ReadWrite (write *), but createdDate is asked for read-only. +grant IT20F.Admin on IT20F.Doc (create, delete, write *, read (createdDate)); diff --git a/mdl-examples/bug-tests/it-20-grant-member-coverage.mdl b/mdl-examples/bug-tests/it-20-grant-member-coverage.mdl new file mode 100644 index 000000000..18bb5cf91 --- /dev/null +++ b/mdl-examples/bug-tests/it-20-grant-member-coverage.mdl @@ -0,0 +1,71 @@ +-- ============================================================================ +-- Issue-tracker finding #20: GRANT could not cover an entity's full member set +-- ============================================================================ +-- +-- Two categories of member were rejected by mxcli's own validator: +-- +-- Error: entity IT.Issue has no member(s) changedDate, createdDate; ... +-- Error: entity IT.Label has no member(s) Issue_Label; ... +-- +-- and a `read * / write *` rule that looked complete still failed the build: +-- +-- [error] [CE0066] "Entity access is out of date. Please update security by +-- clicking the 'Update security' button in the domain model editor." +-- +-- which makes partial coverage worse than none. +-- +-- Root causes — two different things, both about what counts as a member: +-- +-- 1. `OWNER Both` makes a reference set a member of BOTH ends. mxcli emitted +-- the MemberAccess only for the FROM entity (ParentID), and +-- ReconcileMemberAccesses independently applied the same FROM-only rule, +-- so it also stripped the entry back out on every subsequent write. The TO +-- entity's rule was therefore always incomplete → CE0066. Verified on +-- mxbuild 11.12.1: the identical model with `OWNER Default` checks clean, +-- so the owner mode is the trigger, not the reference set. +-- 2. Audit members (createdDate / changedDate) are entity FLAGS, not entries +-- in the attribute list, so the member walk never yielded them and naming +-- one was reported as "no member" — which is simply wrong, Mendix does +-- treat them as members. +-- +-- What Mendix will NOT accept is a MemberAccess row for an audit member: an +-- entity storing them checks clean with no entry, and a rule that carries one +-- fails CE0066 (verified). Their access comes from the rule's default. So +-- naming one is now accepted, and asking for rights that DIFFER from the +-- default is refused with that reason rather than silently dropped — see +-- it-20-grant-audit-member-rights.fail.mdl. +-- +-- Expected after fix: this script executes and `mx check` reports 0 errors. +-- ============================================================================ + +create module IT20; + +create or modify persistent entity IT20.Issue ( + IssueTitle: String(200), + CreatedDate: AutoCreatedDate, + ChangedDate: AutoChangedDate +); + +create or modify persistent entity IT20.Label ( + LabelName: String(50) +); + +-- Owned by BOTH ends: the association is a member of Issue AND of Label. +create or modify association IT20.Issue_Label + from IT20.Issue to IT20.Label + type ReferenceSet + owner Both; + +create module role IT20.Admin; +create user role IT20Admin (IT20.Admin); + +-- Wildcards cover every member, including the audit members and the +-- association on both ends. +grant IT20.Admin on IT20.Issue (create, delete, read *, write *); +grant IT20.Admin on IT20.Label (create, delete, read *, write *); + +-- The TO-side association can also be named for per-member rights. +grant IT20.Admin on IT20.Label (create, delete, write *, read (Issue_Label)); + +-- Naming an audit member at the rule's own default is a no-op, not an error. +grant IT20.Admin on IT20.Issue (create, delete, read *, read (createdDate, changedDate)); diff --git a/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl b/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl new file mode 100644 index 000000000..2dc648fe3 --- /dev/null +++ b/mdl-examples/bug-tests/javascript-action-source-dir-case.mdl @@ -0,0 +1,53 @@ +-- ============================================================================ +-- Bug: a JavaScript action created by mxcli threw at runtime +-- ============================================================================ +-- +-- Symptom +-- CREATE JAVASCRIPT ACTION succeeded, `mxcli check` passed and the project +-- built cleanly, but calling the action in the running app threw +-- +-- JavaScript action was not implemented +-- +-- and the calling nanoflow aborted. +-- +-- Cause +-- mxcli wrote the source to javascriptsource//actions/, using the +-- module's own casing. Mendix reads a LOWERCASED module directory — a blank +-- Mendix 11 app ships javascriptsource/nanoflowcommons/, /datawidgets/ and +-- /webactions/ for modules named NanoflowCommons, DataWidgets, WebActions. +-- Finding no source at the path it reads, mxbuild generated a stub whose body +-- is `throw new Error("JavaScript action was not implemented")` and bundled +-- that. Only reproduces on a case-sensitive filesystem: on macOS and Windows +-- the two spellings are the same directory. +-- +-- Verify +-- mxcli exec mdl-examples/bug-tests/javascript-action-source-dir-case.mdl -p app.mpr +-- +-- Then check the file landed where Mendix reads it — lowercase, and with the +-- real body rather than the throwing stub: +-- +-- test -f javascriptsource/jsdircase/actions/JsDirCase_Echo.js +-- grep -q "MXCLI_MARKER" javascriptsource/jsdircase/actions/JsDirCase_Echo.js +-- +-- Before the fix the file was at javascriptsource/JsDirCase/actions/ and the +-- lowercase path held a generated stub. Nothing short of running the app +-- caught it: parse, check and build all pass either way. +-- ============================================================================ + +create module JsDirCase; + +create or modify javascript action JsDirCase.JsDirCase_Echo( + Value: String not null +) returns String +as $$ +// MXCLI_MARKER — if this text is missing from the deployed bundle, the source +// was written to a directory Mendix does not read. +return Promise.resolve(Value); +$$; + +create or replace nanoflow JsDirCase.ACT_Echo() +returns String as $Echoed +begin + $Echoed = call javascript action JsDirCase.JsDirCase_Echo(Value = 'ok'); + return $Echoed; +end; diff --git a/mdl-examples/bug-tests/ledger-52-break-in-conditional.mdl b/mdl-examples/bug-tests/ledger-52-break-in-conditional.mdl index fce56aa77..b0c7cdadf 100644 --- a/mdl-examples/bug-tests/ledger-52-break-in-conditional.mdl +++ b/mdl-examples/bug-tests/ledger-52-break-in-conditional.mdl @@ -1,39 +1,50 @@ -- ============================================================================ --- Ledger finding #52: `break` inside a conditional in a loop → unloadable model +-- Ledger finding #52: `break`/`continue` inside a conditional in a loop -- ============================================================================ -- --- Symptom (before fix): a `break` nested inside an `if`/`case` within a loop --- passed `mxcli check --references` but serialized a DANGLING sequence-flow --- reference. `mx check` then CRASHED loading the project with an unhandled --- System.AggregateException ("The given key '' was not present in the --- dictionary") — an unrecoverable project-load failure, not a normal error. +-- History: +-- * Originally, a `break` nested in an `if` within a loop passed +-- `mxcli check` but serialized a DANGLING sequence-flow reference, and +-- `mx check` CRASHED loading the project (unrecoverable AggregateException). +-- * mendixlabs/mxcli#791 fixed the serialization (the Break/Continue event was +-- being dropped); MDL051 (an interim reject-at-check guard) was then removed. +-- * This finding surfaced the remaining gap: when the `if then break` +-- was the LAST statement in the loop body, the decision was built with only +-- its TRUE outgoing flow (→ break) — the FALSE case was deferred to a +-- following statement that never came, so `mx check` reported CE0079 +-- ("the 'false' condition value should be configured on an outgoing +-- sequence flow"). Recoverable, but the microflow would not deploy. -- --- A `break` placed DIRECTLY in the loop body serializes fine; only the useful --- conditional form (`if then break`) is affected. +-- Fix: the loop-body builder now honours the deferred FALSE case and, when it +-- falls off the end of the loop body, wires it to a Continue event — the valid +-- Mendix representation of "didn't break, so continue to the next iteration". -- --- After fix: MDL051 rejects the conditional-break form at check time (a diagnostic --- beats a crash) until the flow serialization is fixed. Workaround — a guard --- variable (verified clean on mx check): --- declare $Done Boolean = false; --- loop $R in $Rules begin --- if not($Done) then --- if then set $Done = true; end if; --- end if; --- end loop --- --- Usage (expected to FAIL check): --- mxcli check mdl-examples/bug-tests/ledger-52-break-in-conditional.fail.mdl +-- Verified: exec -> raw `mx check` (Mendix 11.12.1) = 0 errors; the decision +-- carries both a `true` flow (→ Break) and a `false` flow (→ Continue). -- ============================================================================ -create microflow MyModule.FirstActive ( - $Rules: list of MyModule.Rule -) -returns boolean +create entity Ledger52.Rule (Name: String, Active: Boolean); +create module role Ledger52.User; + +-- break as the LAST statement in the loop (the CE0079 case) +create microflow Ledger52.FirstActive ($Rules: list of Ledger52.Rule) returns Boolean +begin + loop $R in $Rules begin + if $R/Active then + break; + end if; + end loop; + return true; +end + +-- continue in a conditional +create microflow Ledger52.SkipInactive ($Rules: list of Ledger52.Rule) returns Boolean begin loop $R in $Rules begin if $R/Active then - break; -- MDL051: crashes mx check (unloadable model) + continue; end if; + commit $R; end loop; return true; end diff --git a/mdl-examples/bug-tests/ledger-78-datagrid-column-addressing.mdl b/mdl-examples/bug-tests/ledger-78-datagrid-column-addressing.mdl new file mode 100644 index 000000000..a5ad46d04 --- /dev/null +++ b/mdl-examples/bug-tests/ledger-78-datagrid-column-addressing.mdl @@ -0,0 +1,41 @@ +-- ============================================================================ +-- Ledger finding #78: DataGrid2 column addressing +-- ============================================================================ +-- +-- DataGrid2 columns carry no stored name in the Mendix model, so the authored +-- MDL name (`column colFoo (...)`) does not survive a write. mxcli addresses a +-- column by a *derived* name: the bound attribute for an attribute column, the +-- caption otherwise. `describe page` shows the addressable names. +-- +-- Two hazards this fix removes: +-- +-- 1. A bare `ON ` that matches MORE THAN ONE column (duplicate captions +-- on dynamic-text/custom columns collide) is now REJECTED with an +-- ambiguity error, instead of silently mutating the first and leaving the +-- second unreachable. +-- 2. Addressing a column by a name that resolves to nothing now lists the +-- available (derived) column names and explains the derived-name model, +-- instead of a bare "widget not found". +-- +-- This script creates a grid and then addresses its columns by their DERIVED +-- names (Merchant, Who) — the names `describe page` reports and `ON` accepts. +-- ============================================================================ + +create entity Ledger.Txn (Merchant: String, Amount: Decimal); + +create or replace page Ledger.Grid78 (Title: 'Grid', Layout: Atlas_Core.Atlas_Default) { + layoutgrid g { row r { column c (DesktopWidth: 12) { + datagrid dg (DataSource: DATABASE FROM Ledger.Txn) { + -- attribute column → derived name is the attribute leaf: "Merchant" + column colMerchant (attribute: Merchant, caption: 'Store') + -- dynamic-text column → derived name is the caption: "Who" + column colAmount (ShowContentAs: dynamicText, caption: 'Who', Content: '{1}', ContentParams: [{1} = Amount]) + } + } } } +} + +-- Address the columns by their DERIVED names (not the authored colMerchant/colAmount). +alter page Ledger.Grid78 { + set Alignment = right ON Merchant; + set WrapText = true ON "Who"; +}; diff --git a/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl b/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl index 302a47dab..0d46c557e 100644 --- a/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl +++ b/mdl-examples/doctype-tests/07b-javascript-action-examples.mdl @@ -4,7 +4,8 @@ -- -- Demonstrates CREATE / DROP JAVASCRIPT ACTION. A JavaScript action writes both -- a model unit (JavaScriptActions$JavaScriptAction) and a source file under --- javascriptsource//actions/.js. Actions are callable from +-- javascriptsource//actions/.js — note the module directory is +-- LOWERCASED, which is where Mendix reads it from. Actions are callable from -- nanoflows via CALL JAVASCRIPT ACTION. -- -- Syntax: diff --git a/mdl-examples/doctype-tests/37-theme-switcher-examples.mdl b/mdl-examples/doctype-tests/37-theme-switcher-examples.mdl new file mode 100644 index 000000000..cb8dbb78d --- /dev/null +++ b/mdl-examples/doctype-tests/37-theme-switcher-examples.mdl @@ -0,0 +1,135 @@ +-- ============================================================================ +-- Theme Switcher Examples — the MDL behind `mxcli theme switcher install` +-- ============================================================================ +-- +-- `mxcli theme apply` writes files only; the model is never touched. This is +-- the exception: a theme's light/dark blocks key off a class on the root +-- element, Mendix ships the `:root.theme-dark` slot but nothing that applies +-- it, and there is no theme-level hook to run script before first paint. So a +-- user-facing toggle needs JavaScript actions the client can run, wrapped in a +-- nanoflow a button can call. +-- +-- `mxcli theme switcher install --module ` generates and executes exactly +-- this. Run it with --print to see the script for your own module. +-- +-- The CSS still does the heavy lifting: `--variant auto` already renders the +-- right palette before first paint by following the OS. These actions only +-- cover an explicit user override. +-- +-- Usage: +-- mxcli check mdl-examples/doctype-tests/37-theme-switcher-examples.mdl +-- mxcli exec mdl-examples/doctype-tests/37-theme-switcher-examples.mdl -p app.mpr +-- +-- NOTE ON `mxcli check --references`: the two nanoflows call actions created +-- earlier in this same script, so a reference check against a project that does +-- not have them yet reports "javascript action not found". That is the known +-- forward-reference behaviour (see resolve-forward-references.md), not an error +-- in this script — `exec` runs the statements in order and resolves them. +-- ============================================================================ + +-- MARK: Setup + +create module ThemeSwitchTest; + +-- MARK: Toggle — flip light/dark and remember the choice + +-- Resolves "follow the OS" to whatever the OS is currently saying before +-- flipping, so the first click always visibly changes something. +create or modify javascript action ThemeSwitchTest.ToggleAppTheme() returns String +exposed as 'Toggle app theme' in 'Theme' +as $$ +var root = document.documentElement; +var effective; +if (root.classList.contains("theme-dark")) { + effective = "dark"; +} else if (root.classList.contains("theme-light")) { + effective = "light"; +} else { + effective = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} +var next = effective === "dark" ? "light" : "dark"; +root.classList.remove("theme-light", "theme-dark"); +root.classList.add("theme-" + next); +try { + window.localStorage.setItem("mxcli-theme", next); +} catch (e) { + // Private browsing and some embedded webviews reject storage; the class is + // already applied, so the only thing lost is persistence across reloads. +} +return Promise.resolve(next); +$$; + +-- MARK: Explicit set — "auto" clears the override and follows the OS again + +create or modify javascript action ThemeSwitchTest.SetAppTheme( + Theme: String not null +) returns Boolean +exposed as 'Set app theme' in 'Theme' +as $$ +var root = document.documentElement; +root.classList.remove("theme-light", "theme-dark"); +try { + if (Theme === "light" || Theme === "dark") { + root.classList.add("theme-" + Theme); + window.localStorage.setItem("mxcli-theme", Theme); + } else { + window.localStorage.removeItem("mxcli-theme"); + } +} catch (e) { + if (Theme === "light" || Theme === "dark") { + root.classList.add("theme-" + Theme); + } +} +return Promise.resolve(true); +$$; + +-- MARK: Restore — ready to wire, but nothing calls it automatically today + +create or modify javascript action ThemeSwitchTest.ApplyStoredTheme() returns Boolean +exposed as 'Apply stored theme' in 'Theme' +as $$ +var stored = null; +try { + stored = window.localStorage.getItem("mxcli-theme"); +} catch (e) { + // No storage available: following the OS is the right fallback. +} +var root = document.documentElement; +root.classList.remove("theme-light", "theme-dark"); +if (stored === "light" || stored === "dark") { + root.classList.add("theme-" + stored); +} +return Promise.resolve(true); +$$; + +-- MARK: Nanoflows a button can call + +create or replace nanoflow ThemeSwitchTest.ACT_ToggleTheme() +returns String as $Theme +begin + $Theme = call javascript action ThemeSwitchTest.ToggleAppTheme(); + return $Theme; +end; + +create or replace nanoflow ThemeSwitchTest.ACT_ApplyStoredTheme() +returns Boolean as $Applied +begin + $Applied = call javascript action ThemeSwitchTest.ApplyStoredTheme(); + return $Applied; +end; + +-- MARK: Wiring +-- +-- Put the toggle wherever it belongs — a layout, a settings page, a header: +-- +-- actionbutton btnTheme ( +-- caption: 'Theme', +-- action: nanoflow ThemeSwitchTest.ACT_ToggleTheme) +-- +-- The class is set on , so popups and modals — which Mendix renders at +-- , outside any page container — follow the theme too. + +describe javascript action ThemeSwitchTest.ToggleAppTheme; +describe nanoflow ThemeSwitchTest.ACT_ToggleTheme; diff --git a/mdl/backend/modelsdk/consumed_rest_roundtrip_test.go b/mdl/backend/modelsdk/consumed_rest_roundtrip_test.go new file mode 100644 index 000000000..82d37af59 --- /dev/null +++ b/mdl/backend/modelsdk/consumed_rest_roundtrip_test.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" +) + +// TestConsumedRestOperation_DetailRoundTrip covers the two halves of #843. +// +// Writing: an operation whose response is an implicit mapping must serialize to +// Rest$ImplicitMappingResponseHandling. It used to fall through to +// Rest$NoResponseHandling because the writer compared ResponseType against the +// uppercase "MAPPING" documented on model.RestClientOperation while the MDL +// executor stored the visitor's lowercase "mapping" — so every response mapping +// authored in MDL was dropped without a warning. +// +// Reading: restOperationFromGen only populated Name/HttpMethod/Path/Timeout, so +// `describe rest client` printed neither the query parameters nor the response +// even when both were stored correctly. The pre-existing round-trip test built +// an operation with a query parameter but never asserted it survived, which is +// why the gap went unnoticed. +func TestConsumedRestOperation_DetailRoundTrip(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + + svc := &model.ConsumedRestService{ + ContainerID: mod.ID, + Name: "ZzDetailClient", + BaseUrl: "https://api.example.com", + Operations: []*model.RestClientOperation{{ + Name: "GetRoute", + HttpMethod: "GET", + Path: "/routes/{id}", + Timeout: 300, + Parameters: []*model.RestClientParameter{ + {Name: "id", DataType: "String"}, + }, + QueryParameters: []*model.RestClientParameter{ + {Name: "page", DataType: "Integer"}, + {Name: "pageSize", DataType: "Integer"}, + }, + Headers: []*model.RestClientHeader{ + {Name: "X-Tenant-Id", Value: "demo-tenant-01"}, + }, + ResponseType: "MAPPING", + ResponseEntity: "MyFirstModule.Routing", + ResponseMappings: []*model.RestResponseMapping{ + {Attribute: "RoutingCode", ExposedName: "routing_code"}, + }, + }}, + } + if err := b.CreateConsumedRestService(svc); err != nil { + t.Fatalf("CreateConsumedRestService: %v", err) + } + + b2 := New() + if err := b2.Connect(proj); err != nil { + t.Fatalf("reconnect: %v", err) + } + t.Cleanup(func() { _ = b2.Disconnect() }) + + all, err := b2.ListConsumedRestServices() + if err != nil { + t.Fatalf("ListConsumedRestServices: %v", err) + } + var op *model.RestClientOperation + for _, s := range all { + if s.Name == "ZzDetailClient" && len(s.Operations) == 1 { + op = s.Operations[0] + } + } + if op == nil { + t.Fatalf("ZzDetailClient/GetRoute not found after create") + } + + // Path parameters: writer emits Rest$OperationParameter, so a reader that + // type-asserts Rest$RestParameter silently drops every one of them. + if len(op.Parameters) != 1 { + t.Fatalf("Parameters = %d, want 1", len(op.Parameters)) + } + if op.Parameters[0].Name != "id" { + t.Errorf("Parameters[0].Name = %q, want %q", op.Parameters[0].Name, "id") + } + if op.Parameters[0].DataType != "String" { + t.Errorf("Parameters[0].DataType = %q, want %q", op.Parameters[0].DataType, "String") + } + + // Query parameters: writer emits Rest$QueryParameter — a different gen type + // again. Mendix stores no DataType for these, so only the name round-trips. + if len(op.QueryParameters) != 2 { + t.Fatalf("QueryParameters = %d, want 2", len(op.QueryParameters)) + } + if op.QueryParameters[0].Name != "page" || op.QueryParameters[1].Name != "pageSize" { + t.Errorf("QueryParameters = %q/%q, want page/pageSize", + op.QueryParameters[0].Name, op.QueryParameters[1].Name) + } + + // Headers: the writer appends an Accept header of its own, so assert on the + // authored one rather than the slice length. + var tenant *model.RestClientHeader + for _, h := range op.Headers { + if h.Name == "X-Tenant-Id" { + tenant = h + } + } + if tenant == nil { + t.Fatalf("X-Tenant-Id header not round-tripped (got %d headers)", len(op.Headers)) + } + if tenant.Value != "demo-tenant-01" { + t.Errorf("X-Tenant-Id = %q, want %q", tenant.Value, "demo-tenant-01") + } + + // The response mapping itself — the silent data loss reported in #843. + if op.ResponseType != "MAPPING" { + t.Fatalf("ResponseType = %q, want MAPPING (response handling stored as NoResponseHandling?)", op.ResponseType) + } + if len(op.ResponseMappings) != 1 { + t.Fatalf("ResponseMappings = %d, want 1", len(op.ResponseMappings)) + } + if got := op.ResponseMappings[0]; got.Attribute != "RoutingCode" || got.ExposedName != "routing_code" { + t.Errorf("ResponseMappings[0] = %+v, want RoutingCode = routing_code", got) + } +} + +// TestConsumedRestOperation_LowercaseResponseTypeStillMaps pins the specific +// regression: the MDL executor's lowercase "mapping" must produce the same +// implicit-mapping response handling as the documented uppercase "MAPPING". +// Without normalization the two disagree and the lowercase form loses the +// mapping — which is exactly what the reporter hit. +func TestConsumedRestOperation_LowercaseResponseTypeStillMaps(t *testing.T) { + proj := copyFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = b.Disconnect() }) + + mod, err := b.GetModuleByName("MyFirstModule") + if err != nil || mod == nil { + t.Fatalf("GetModuleByName: %v", err) + } + + svc := &model.ConsumedRestService{ + ContainerID: mod.ID, + Name: "ZzLowerClient", + BaseUrl: "https://api.example.com", + Operations: []*model.RestClientOperation{{ + Name: "GetRoute", + HttpMethod: "GET", + Path: "/routes", + ResponseType: "mapping", + ResponseEntity: "MyFirstModule.Routing", + ResponseMappings: []*model.RestResponseMapping{ + {Attribute: "RoutingCode", ExposedName: "routing_code"}, + }, + }}, + } + if err := b.CreateConsumedRestService(svc); err != nil { + t.Fatalf("CreateConsumedRestService: %v", err) + } + + b2 := New() + if err := b2.Connect(proj); err != nil { + t.Fatalf("reconnect: %v", err) + } + t.Cleanup(func() { _ = b2.Disconnect() }) + + all, err := b2.ListConsumedRestServices() + if err != nil { + t.Fatalf("ListConsumedRestServices: %v", err) + } + for _, s := range all { + if s.Name != "ZzLowerClient" { + continue + } + if len(s.Operations) != 1 { + t.Fatalf("operations = %d, want 1", len(s.Operations)) + } + if len(s.Operations[0].ResponseMappings) != 1 { + t.Fatalf("lowercase %q lost the response mapping", "mapping") + } + return + } + t.Fatalf("ZzLowerClient not found after create") +} diff --git a/mdl/backend/modelsdk/consumed_rest_write.go b/mdl/backend/modelsdk/consumed_rest_write.go index 732461209..3a4a19db8 100644 --- a/mdl/backend/modelsdk/consumed_rest_write.go +++ b/mdl/backend/modelsdk/consumed_rest_write.go @@ -177,7 +177,10 @@ func restOperationToGen(op *model.RestClientOperation) element.Element { addPartList(g, "QueryParameters", queryParams) } - if op.ResponseType == "MAPPING" && op.ResponseEntity != "" && len(op.ResponseMappings) > 0 { + // Case-insensitive on purpose: the else-branch silently downgrades to + // Rest$NoResponseHandling, so a producer that spells the token differently + // loses the mapping with no error anywhere. That is how #843 happened. + if strings.EqualFold(op.ResponseType, "MAPPING") && op.ResponseEntity != "" && len(op.ResponseMappings) > 0 { addPart(g, "ResponseHandling", restImplicitMappingResponseToGen(op.ResponseEntity, op.ResponseMappings)) } else { addPart(g, "ResponseHandling", restResponseHandlingToGen(op.ResponseType)) @@ -198,7 +201,7 @@ func restMethodToGen(op *model.RestClientOperation) element.Element { } g := newElem("Rest$RestOperationMethodWithBody", "") addStr(g, "HttpMethod", httpMethod) - if op.BodyType == "EXPORT_MAPPING" && len(op.BodyMappings) > 0 { + if strings.EqualFold(op.BodyType, "EXPORT_MAPPING") && len(op.BodyMappings) > 0 { addPart(g, "Body", restImplicitMappingBodyToGen(op.BodyVariable, op.BodyMappings)) } else { bodyType := op.BodyType diff --git a/mdl/backend/modelsdk/domainmodel_security_write.go b/mdl/backend/modelsdk/domainmodel_security_write.go index 227dca293..790aff92a 100644 --- a/mdl/backend/modelsdk/domainmodel_security_write.go +++ b/mdl/backend/modelsdk/domainmodel_security_write.go @@ -361,7 +361,19 @@ func (b *Backend) ReconcileMemberAccesses(unitID model.ID, moduleName string) (i } } for _, ae := range dm.AssociationsItems() { - if a, ok := ae.(*genDm.Association); ok && string(a.ParentRefID()) == entityID { + a, ok := ae.(*genDm.Association) + if !ok { + continue + } + // `OWNER Both` makes the association a member of BOTH ends, so the TO + // entity's rule needs an entry too. Reconciling on the FROM side alone + // stripped it back out on every run — including the one the executor + // had just added — leaving the TO entity's rule incomplete, which + // Mendix reports as CE0066 "Entity access is out of date" + // (issuetracker #20). Verified on mxbuild 11.12.1: the same model with + // `OWNER Default` checks clean, so the owner mode is the trigger. + if string(a.ParentRefID()) == entityID || + (a.Owner() == "Both" && string(a.ChildRefID()) == entityID) { addAssoc(a.Name()) } } @@ -374,6 +386,12 @@ func (b *Backend) ReconcileMemberAccesses(unitID model.ID, moduleName string) (i // Implicit system associations from NoGeneralization flags. var sysRefs []string sysSet := map[string]bool{} + // Audit DATE members (createdDate/changedDate) are stored as flags too, but + // they are attributes rather than associations. Mendix has no MemberAccess + // for them at all: an entity storing them checks clean with no entry, and + // mxbuild rejects a rule that carries one with CE0066 "Entity access is out + // of date" (verified on 11.12.1). So they are neither added here nor + // preserved — the executor refuses to author one (issuetracker #20). if ng, ok := ent.Generalization().(*genDm.NoGeneralization); ok { if ng.HasOwner() { sysRefs = append(sysRefs, "System.owner") diff --git a/mdl/backend/modelsdk/integration_read.go b/mdl/backend/modelsdk/integration_read.go index fe000731d..7f05d1e13 100644 --- a/mdl/backend/modelsdk/integration_read.go +++ b/mdl/backend/modelsdk/integration_read.go @@ -3,11 +3,15 @@ package modelsdkbackend import ( + "strings" + "github.com/mendixlabs/mxcli/mdl/dbconnector" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/modelsdk/element" genBe "github.com/mendixlabs/mxcli/modelsdk/gen/businessevents" genDb "github.com/mendixlabs/mxcli/modelsdk/gen/databaseconnector" + genExportMappings "github.com/mendixlabs/mxcli/modelsdk/gen/exportmappings" + genImportMappings "github.com/mendixlabs/mxcli/modelsdk/gen/importmappings" genOp "github.com/mendixlabs/mxcli/modelsdk/gen/odatapublish" genRest "github.com/mendixlabs/mxcli/modelsdk/gen/rest" "github.com/mendixlabs/mxcli/modelsdk/mprread" @@ -177,25 +181,175 @@ func restOperationFromGen(op *genRest.RestOperation) *model.RestClientOperation switch m := op.Method().(type) { case *genRest.RestOperationMethodWithBody: out.HttpMethod = httpMethodUpper(m.HttpMethod()) + restBodyFromGen(m.Body(), out) case *genRest.RestOperationMethodWithoutBody: out.HttpMethod = httpMethodUpper(m.HttpMethod()) } if pt, ok := op.Path().(*genRest.ValueTemplate); ok && pt != nil { out.Path = pt.Value() } + // Path and query parameters are *different* gen types — Rest$OperationParameter + // and Rest$QueryParameter, which is what the writer emits. Asserting + // Rest$RestParameter (a third type, used by REST call activities) matched + // neither, so both lists read back empty and `describe rest client` printed + // no Parameters/Query line at all (#843). for _, pEl := range op.ParametersItems() { - if p, ok := pEl.(*genRest.RestParameter); ok { - out.Parameters = append(out.Parameters, &model.RestClientParameter{Name: p.Name()}) + if p, ok := pEl.(*genRest.OperationParameter); ok { + out.Parameters = append(out.Parameters, &model.RestClientParameter{ + Name: p.Name(), + DataType: restDataTypeName(p.DataType()), + }) } } for _, qEl := range op.QueryParametersItems() { - if q, ok := qEl.(*genRest.RestParameter); ok { + if q, ok := qEl.(*genRest.QueryParameter); ok { + // Rest$QueryParameter carries no DataType — Mendix does not model one + // for query parameters, so there is nothing to read back here. out.QueryParameters = append(out.QueryParameters, &model.RestClientParameter{Name: q.Name()}) } } + for _, hEl := range op.HeadersItems() { + h, ok := hEl.(*genRest.HeaderWithValueTemplate) + if !ok { + continue + } + header := &model.RestClientHeader{Name: h.Name()} + if vt, ok := h.Value().(*genRest.ValueTemplate); ok && vt != nil { + header.Value = vt.Value() + } + out.Headers = append(out.Headers, header) + } + restResponseFromGen(op.ResponseHandling(), out) + return out +} + +// restResponseFromGen fills in the response half of the operation, mirroring the +// legacy parseRestOperation. Without it `describe rest client` reported every +// operation as `Response: none` regardless of what was stored (#843). +func restResponseFromGen(handling element.Element, out *model.RestClientOperation) { + switch r := handling.(type) { + case *genRest.NoResponseHandling: + // The writer encodes the declared response type in ContentType so a + // no-handling response still round-trips as json/string/file. + switch r.ContentType() { + case "application/json": + out.ResponseType = "JSON" + case "text/plain": + out.ResponseType = "STRING" + case "application/octet-stream": + out.ResponseType = "FILE" + default: + out.ResponseType = "NONE" + } + case *genRest.ImplicitMappingResponseHandling: + out.ResponseType = "MAPPING" + if root, ok := r.RootMappingElement().(*genImportMappings.ImportObjectMappingElement); ok && root != nil { + out.ResponseEntity = root.EntityQualifiedName() + out.ResponseMappings = importMappingChildrenFromGen(root) + } + } +} + +// restBodyFromGen fills in the body half of the operation. Same read-back gap as +// the response: a body authored as an implicit export mapping did not describe. +func restBodyFromGen(body element.Element, out *model.RestClientOperation) { + switch b := body.(type) { + case *genRest.JsonBody: + out.BodyType = "JSON" + out.BodyVariable = b.Value() + case *genRest.StringBody: + out.BodyType = "TEMPLATE" + if vt, ok := b.ValueTemplate().(*genRest.ValueTemplate); ok && vt != nil { + out.BodyVariable = vt.Value() + } + case *genRest.ImplicitMappingBody: + out.BodyType = "EXPORT_MAPPING" + if root, ok := b.RootMappingElement().(*genExportMappings.ExportObjectMappingElement); ok && root != nil { + out.BodyVariable = root.EntityQualifiedName() + out.BodyMappings = exportMappingChildrenFromGen(root) + } + } +} + +// importMappingChildrenFromGen walks an ImportMappings object element tree into +// the semantic mapping entries describe re-emits as `Attr = jsonField`. +func importMappingChildrenFromGen(parent *genImportMappings.ImportObjectMappingElement) []*model.RestResponseMapping { + entityPrefix := parent.EntityQualifiedName() + "." + var out []*model.RestResponseMapping + for _, childEl := range parent.ChildrenItems() { + switch c := childEl.(type) { + case *genImportMappings.ImportValueMappingElement: + attr, exposed := c.AttributeQualifiedName(), c.ExposedName() + if attr == "" || exposed == "" { + continue + } + out = append(out, &model.RestResponseMapping{ + Attribute: strings.TrimPrefix(attr, entityPrefix), + ExposedName: exposed, + JsonPath: c.JsonPath(), + }) + case *genImportMappings.ImportObjectMappingElement: + out = append(out, &model.RestResponseMapping{ + Entity: c.EntityQualifiedName(), + Association: c.AssociationQualifiedName(), + ExposedName: c.ExposedName(), + JsonPath: c.JsonPath(), + Children: importMappingChildrenFromGen(c), + }) + } + } + return out +} + +// exportMappingChildrenFromGen is the export-direction twin of +// importMappingChildrenFromGen (ExportMappings$* elements). +func exportMappingChildrenFromGen(parent *genExportMappings.ExportObjectMappingElement) []*model.RestResponseMapping { + entityPrefix := parent.EntityQualifiedName() + "." + var out []*model.RestResponseMapping + for _, childEl := range parent.ChildrenItems() { + switch c := childEl.(type) { + case *genExportMappings.ExportValueMappingElement: + attr, exposed := c.AttributeQualifiedName(), c.ExposedName() + if attr == "" || exposed == "" { + continue + } + out = append(out, &model.RestResponseMapping{ + Attribute: strings.TrimPrefix(attr, entityPrefix), + ExposedName: exposed, + JsonPath: c.JsonPath(), + }) + case *genExportMappings.ExportObjectMappingElement: + out = append(out, &model.RestResponseMapping{ + Entity: c.EntityQualifiedName(), + Association: c.AssociationQualifiedName(), + ExposedName: c.ExposedName(), + JsonPath: c.JsonPath(), + Children: exportMappingChildrenFromGen(c), + }) + } + } return out } +// restDataTypeName is the inverse of restDataTypeElem: DataTypes$* element back +// to the MDL type name describe prints. +func restDataTypeName(dt element.Element) string { + if dt == nil { + return "" + } + switch dt.TypeName() { + case "DataTypes$IntegerType": + return "Integer" + case "DataTypes$DecimalType": + return "Decimal" + case "DataTypes$BooleanType": + return "Boolean" + case "DataTypes$StringType": + return "String" + } + return "" +} + // restValueOf extracts a string from a polymorphic Rest$Value (StringValue or // ConstantValue), mirroring the legacy extractRestValue. func restValueOf(el element.Element) string { diff --git a/mdl/backend/modelsdk/javascript_write.go b/mdl/backend/modelsdk/javascript_write.go index 43472b04a..d093a3b61 100644 --- a/mdl/backend/modelsdk/javascript_write.go +++ b/mdl/backend/modelsdk/javascript_write.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" @@ -146,10 +147,19 @@ func rewriteJavaToJavaScriptTypes(v any) { } } -// jsActionSourceDir returns javascriptsource//actions using the original -// module-name casing Studio Pro writes. +// jsActionSourceDir returns javascriptsource//actions with the module +// name LOWERCASED, which is where Mendix looks: a blank Mendix 11 app ships +// javascriptsource/nanoflowcommons/, /datawidgets/ and /webactions/ for modules +// named NanoflowCommons, DataWidgets and WebActions. +// +// Writing the original casing instead is silent and total: mxbuild finds no +// source at the path it reads, generates a stub whose body throws +// "JavaScript action was not implemented", and bundles that. The action parses, +// passes `mxcli check` and builds cleanly, then throws when a user clicks it. +// Only reproduces on a case-sensitive filesystem — on macOS and Windows the two +// spellings are the same directory, which is why it went unnoticed. func (b *Backend) jsActionSourceDir(moduleName string) string { - return filepath.Join(filepath.Dir(b.path), "javascriptsource", moduleName, "actions") + return filepath.Join(filepath.Dir(b.path), "javascriptsource", strings.ToLower(moduleName), "actions") } // WriteJavaScriptSourceFile writes javascriptsource//actions/.js. diff --git a/mdl/backend/modelsdk/javascript_write_dir_test.go b/mdl/backend/modelsdk/javascript_write_dir_test.go new file mode 100644 index 000000000..7ffe420bc --- /dev/null +++ b/mdl/backend/modelsdk/javascript_write_dir_test.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "path/filepath" + "strings" + "testing" +) + +// Mendix stores JavaScript action sources under a LOWERCASED module directory — +// a blank Mendix 11 app ships javascriptsource/nanoflowcommons/, +// /datawidgets/, /webactions/ and /feedbackmodule/ for modules named +// NanoflowCommons, DataWidgets, WebActions and FeedbackModule. +// +// Writing to javascriptsource// instead means mxbuild finds no +// source at the path it looks in, generates a stub whose body is +// `throw new Error("JavaScript action was not implemented")`, and bundles that. +// The action then parses, passes `mxcli check`, builds cleanly, and throws the +// moment a user clicks the button. +// +// It only reproduces on a case-sensitive filesystem, which is why it survived: +// on macOS and Windows the two spellings are the same directory. +func TestJSActionSourceDirIsLowercased(t *testing.T) { + b := &Backend{path: filepath.Join("app", "App.mpr")} + + cases := map[string]string{ + "ThemeProof": "themeproof", + "NanoflowCommons": "nanoflowcommons", + "MyFirstModule": "myfirstmodule", + "already_lower": "already_lower", + } + for module, wantDir := range cases { + got := b.jsActionSourceDir(module) + want := filepath.Join("app", "javascriptsource", wantDir, "actions") + if got != want { + t.Errorf("jsActionSourceDir(%q) = %q, want %q", module, got, want) + } + if strings.Contains(got, module) && module != wantDir { + t.Errorf("jsActionSourceDir(%q) kept the module casing: %q", module, got) + } + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 203e1a5cf..e526caa45 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -1121,7 +1121,7 @@ func dataViewSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings)) return ms, nil case *pages.AssociationSource: @@ -1214,7 +1214,7 @@ func listViewSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings)) return ms, nil case *pages.AssociationSource: return associationSourceToGen(d), nil @@ -1268,7 +1268,7 @@ func customWidgetDataSourceToGen(ds pages.DataSource) (element.Element, error) { } assignID(ms) ms.SetForceFullObjects(false) - ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, nil)) + ms.SetMicroflowSettings(microflowSettingsToGen(d.Microflow, d.ParameterMappings)) return ms, nil case *pages.AssociationSource: @@ -1313,7 +1313,8 @@ func associationSourceToGen(d *pages.AssociationSource) element.Element { // microflowSettingsToGen builds the Forms$MicroflowSettings shared by the // microflow DataView source and the call-microflow action. mappings carries the -// call-microflow action's argument bindings (nil for a datasource); dropping them +// argument bindings — for an action's call, and (since #835) for a parameterized +// source microflow, which Mendix requires arguments for just the same; dropping them // left a parameterized button/row invoking its microflow with no argument — the // widget no-ops at runtime yet mx check passes clean (Bug 1). func microflowSettingsToGen(microflowName string, mappings []*pages.MicroflowParameterMapping) element.Element { diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 0f2c98104..8613c2456 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -101,10 +101,13 @@ func (m *Mutator) SetWidgetProperty(widgetRef string, prop string, value any) er } result := m.widgetFinder(m.rawData, widgetRef) if result == nil { - return fmt.Errorf("widget %q not found", widgetRef) + return m.widgetNotFoundError(widgetRef) } // DataGrid2 columns are WidgetObjects, not form widgets — use the column setter. if len(result.colPropKeys) > 0 { + if n := m.columnMatchCount(widgetRef); n > 1 { + return columnAmbiguityError(widgetRef, n) + } return setColumnPropertyMut(result.widget, result.colPropKeys, prop, value) } return setRawWidgetPropertyMut(result.widget, prop, value) @@ -124,9 +127,9 @@ func (m *Mutator) SetWidgetDataSource(widgetRef string, ds pages.DataSource) err } func (m *Mutator) SetColumnProperty(gridRef string, columnRef string, prop string, value any) error { - result := findBsonColumn(m.rawData, gridRef, columnRef, m.widgetFinder) - if result == nil { - return fmt.Errorf("column %q on grid %q not found", columnRef, gridRef) + result, err := findBsonColumn(m.rawData, gridRef, columnRef, m.widgetFinder) + if err != nil { + return err } return setColumnPropertyMut(result.widget, result.colPropKeys, prop, value) } @@ -167,15 +170,19 @@ func (m *Mutator) findStyleableWidget(widgetRef string) (bson.D, error) { func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error { var result *bsonWidgetResult if columnRef != "" { - result = findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) + r, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) + if err != nil { + return err + } + result = r } else { result = m.widgetFinder(m.rawData, widgetRef) - } - if result == nil { - if columnRef != "" { - return fmt.Errorf("column %q on widget %q not found", columnRef, widgetRef) + if result == nil { + return m.widgetNotFoundError(widgetRef) + } + if n := m.columnMatchCount(widgetRef); n > 1 { + return columnAmbiguityError(widgetRef, n) } - return fmt.Errorf("widget %q not found", widgetRef) } // Serialize widgets @@ -290,12 +297,19 @@ func (m *Mutator) DropWidget(refs []backend.WidgetRef) error { // Re-find widget each iteration because previous drops mutate the tree. var result *bsonWidgetResult if ref.IsColumn() { - result = findBsonColumn(m.rawData, ref.Widget, ref.Column, m.widgetFinder) + r, err := findBsonColumn(m.rawData, ref.Widget, ref.Column, m.widgetFinder) + if err != nil { + return err + } + result = r } else { result = m.widgetFinder(m.rawData, ref.Widget) - } - if result == nil { - return fmt.Errorf("widget %q not found", ref.Name()) + if result == nil { + return m.widgetNotFoundError(ref.Name()) + } + if n := m.columnMatchCount(ref.Name()); n > 1 { + return columnAmbiguityError(ref.Name(), n) + } } newArr := make([]any, 0, len(result.parentArr)-1) newArr = append(newArr, result.parentArr[:result.index]...) @@ -308,15 +322,19 @@ func (m *Mutator) DropWidget(refs []backend.WidgetRef) error { func (m *Mutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pages.Widget) error { var result *bsonWidgetResult if columnRef != "" { - result = findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) + r, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) + if err != nil { + return err + } + result = r } else { result = m.widgetFinder(m.rawData, widgetRef) - } - if result == nil { - if columnRef != "" { - return fmt.Errorf("column %q on widget %q not found", columnRef, widgetRef) + if result == nil { + return m.widgetNotFoundError(widgetRef) + } + if n := m.columnMatchCount(widgetRef); n > 1 { + return columnAmbiguityError(widgetRef, n) } - return fmt.Errorf("widget %q not found", widgetRef) } newBsonWidgets, err := m.serializeWidgets(widgets) @@ -339,9 +357,9 @@ func (m *Mutator) InsertColumns(gridRef, afterColumnRef string, position backend if afterColumnRef == "" { return fmt.Errorf("InsertColumns requires a column reference") } - result := findBsonColumn(m.rawData, gridRef, afterColumnRef, m.widgetFinder) - if result == nil { - return fmt.Errorf("column %q on widget %q not found", afterColumnRef, gridRef) + result, err := findBsonColumn(m.rawData, gridRef, afterColumnRef, m.widgetFinder) + if err != nil { + return err } gridResult := m.widgetFinder(m.rawData, gridRef) if gridResult == nil { @@ -381,9 +399,9 @@ func (m *Mutator) ReplaceColumn(gridRef, columnRef string, columns []*backend.Da if columnRef == "" { return fmt.Errorf("ReplaceColumn requires a column reference") } - result := findBsonColumn(m.rawData, gridRef, columnRef, m.widgetFinder) - if result == nil { - return fmt.Errorf("column %q on widget %q not found", columnRef, gridRef) + result, err := findBsonColumn(m.rawData, gridRef, columnRef, m.widgetFinder) + if err != nil { + return err } gridResult := m.widgetFinder(m.rawData, gridRef) if gridResult == nil { @@ -1078,17 +1096,34 @@ func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { // --------------------------------------------------------------------------- // findBsonColumn finds a column inside a DataGrid2 widget by derived name. -func findBsonColumn(rawData bson.D, gridName, columnName string, find widgetFinder) *bsonWidgetResult { +// findBsonColumn locates a DataGrid2 column inside a grid by its *derived* name. +// +// DataGrid2 columns carry no stored name in the Mendix model — mxcli addresses +// them by a name derived from their content: the bound attribute for an +// attribute column, the caption otherwise, falling back to col{N} +// (deriveColumnNameBson). Two consequences the caller must surface rather than +// paper over (ledger #78): +// +// - the authored MDL name (`column colFoo (...)`) never survives a write, so +// addressing a column by it fails — report the derived names that DO work; +// - duplicate captions derive the same name, so `ON "Amount"` can match more +// than one column. Silently mutating the first is a data hazard; reject the +// ambiguity instead. +// +// Returns a non-nil error (and nil result) when the grid/column can't be +// resolved unambiguously; the error is actionable (lists available columns, or +// names the ambiguity). +func findBsonColumn(rawData bson.D, gridName, columnName string, find widgetFinder) (*bsonWidgetResult, error) { gridResult := find(rawData, gridName) if gridResult == nil { - return nil + return nil, fmt.Errorf("widget %q not found", gridName) } gridPropKeyMap := buildPropKeyMap(gridResult.widget) obj := bsonnav.DGetDoc(gridResult.widget, "Object") if obj == nil { - return nil + return nil, fmt.Errorf("widget %q has no columns (not a DataGrid2)", gridName) } props := bsonnav.DGetArrayElements(bsonnav.DGet(obj, "Properties")) @@ -1105,34 +1140,173 @@ func findBsonColumn(rawData bson.D, gridName, columnName string, find widgetFind valDoc := bsonnav.DGetDoc(propDoc, "Value") if valDoc == nil { - return nil + return nil, fmt.Errorf("column %q on grid %q not found", columnName, gridName) } colPropKeyMap := buildColumnPropKeyMap(gridResult.widget, typePointerID) columns := bsonnav.DGetArrayElements(bsonnav.DGet(valDoc, "Objects")) + var matches []*bsonWidgetResult + available := make([]string, 0, len(columns)) for i, colItem := range columns { colDoc, ok := colItem.(bson.D) if !ok { continue } derived := deriveColumnNameBson(colDoc, colPropKeyMap, i) + available = append(available, derived) if derived == columnName { - return &bsonWidgetResult{ + matches = append(matches, &bsonWidgetResult{ widget: colDoc, parentArr: columns, parentKey: "Objects", parentDoc: valDoc, index: i, colPropKeys: colPropKeyMap, - } + }) } } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return nil, fmt.Errorf( + "column %q on grid %q not found — columns are addressed by a derived name "+ + "(the bound attribute for an attribute column, the caption otherwise), "+ + "not the name written in MDL; available columns: %s", + columnName, gridName, formatColumnNameList(available)) + default: + return nil, fmt.Errorf( + "column %q on grid %q is ambiguous: %d columns derive that name. "+ + "Dynamic-text and custom-content columns have no stored name and are keyed by "+ + "their caption, so identical captions collide — give them distinct captions to "+ + "address them individually", + columnName, gridName, len(matches)) + } + } + return nil, fmt.Errorf("widget %q has no columns (not a DataGrid2)", gridName) +} + +// columnAmbiguityError builds the error for a bare `ON ` that resolves to +// more than one DataGrid2 column. Shared by the explicit-column path +// (findBsonColumn, within-grid) and the bare-name path (columnMatchCount, +// page-wide — covers duplicates across two grids too). +func columnAmbiguityError(name string, count int) error { + return fmt.Errorf( + "column %q is ambiguous: %d columns on this page derive that name — either "+ + "identical captions in one grid (dynamic-text/custom columns are keyed by "+ + "caption), or a same-named column in more than one grid. Give the columns "+ + "distinct captions, or qualify the reference as `ON gridName.%s`, to address one", + name, count, name) +} + +// collectColumnNamesBson walks the raw page/snippet tree and appends the derived +// name of every DataGrid2 column it finds, so a "not found" error can list the +// names that actually work (the authored MDL name never survives a write). +func collectColumnNamesBson(node any, out *[]string) { + switch v := node.(type) { + case bson.D: + *out = append(*out, gridColumnNames(v)...) + for _, e := range v { + collectColumnNamesBson(e.Value, out) + } + case bson.A: + for _, e := range v { + collectColumnNamesBson(e, out) + } + } +} + +// columnMatchCount counts how many DataGrid2 columns anywhere on the page derive +// the given name. >1 means a bare `ON ` is ambiguous — whether the +// duplicates sit in the same grid (identical captions) or in two different grids +// on the page — and mutating the first silently is a data hazard (ledger #78). +func (m *Mutator) columnMatchCount(name string) int { + var cols []string + collectColumnNamesBson(m.rawData, &cols) + n := 0 + for _, c := range cols { + if c == name { + n++ + } + } + return n +} + +// gridColumnNames returns the derived names of a DataGrid2's columns, or nil if +// the node is not a grid with a columns property. +func gridColumnNames(wDoc bson.D) []string { + obj := bsonnav.DGetDoc(wDoc, "Object") + if obj == nil { return nil } + propKeyMap := buildPropKeyMap(wDoc) + for _, prop := range bsonnav.DGetArrayElements(bsonnav.DGet(obj, "Properties")) { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(propDoc, "TypePointer")) + if propKeyMap[typePointerID] != "columns" { + continue + } + valDoc := bsonnav.DGetDoc(propDoc, "Value") + if valDoc == nil { + return nil + } + colPropKeyMap := buildColumnPropKeyMap(wDoc, typePointerID) + var names []string + for i, colItem := range bsonnav.DGetArrayElements(bsonnav.DGet(valDoc, "Objects")) { + if colDoc, ok := colItem.(bson.D); ok { + names = append(names, deriveColumnNameBson(colDoc, colPropKeyMap, i)) + } + } + return names + } return nil } +// widgetNotFoundError builds a "not found" error for a bare widget/column +// reference. When the page carries DataGrid2 columns, it adds the addressable +// column names — columns are keyed by a derived name (attribute or caption), not +// the authored MDL name, which is the usual cause of the miss (ledger #78). +func (m *Mutator) widgetNotFoundError(name string) error { + var cols []string + collectColumnNamesBson(m.rawData, &cols) + if len(cols) > 0 { + return fmt.Errorf( + "widget %q not found. DataGrid2 columns are addressed by a derived name "+ + "(the bound attribute, or the caption), not the name written in MDL — "+ + "available columns: %s (run DESCRIBE PAGE to confirm)", + name, formatColumnNameList(cols)) + } + return fmt.Errorf("widget %q not found", name) +} + +// formatColumnNameList renders derived column names for an error message: each +// unique name once, in first-seen order, quoted when it isn't a bare identifier +// (so a caption-derived name with spaces reads as the `ON "..."` form the user +// must type). +func formatColumnNameList(names []string) string { + seen := make(map[string]bool, len(names)) + parts := make([]string, 0, len(names)) + for _, n := range names { + if seen[n] { + continue + } + seen[n] = true + if n == sanitizeColumnName(n) && n != "" { + parts = append(parts, n) + } else { + parts = append(parts, fmt.Sprintf("%q", n)) + } + } + if len(parts) == 0 { + return "(none)" + } + return strings.Join(parts, ", ") +} + // buildPropKeyMap builds a TypePointer ID -> PropertyKey map. func buildPropKeyMap(widgetDoc bson.D) map[string]string { m := make(map[string]string) diff --git a/mdl/backend/pagemutator/mutator_column_addressing_test.go b/mdl/backend/pagemutator/mutator_column_addressing_test.go new file mode 100644 index 000000000..599b5f018 --- /dev/null +++ b/mdl/backend/pagemutator/mutator_column_addressing_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// --------------------------------------------------------------------------- +// Ledger #78: DataGrid2 column addressing — reject ambiguous ON, and list the +// real (derived) column names on a miss instead of a bare "not found". +// --------------------------------------------------------------------------- + +func idBin(b byte) primitive.Binary { + data := make([]byte, 16) + data[0] = b + return primitive.Binary{Subtype: 0x04, Data: data} +} + +// buildGridWithColumns constructs the minimal DataGrid2 BSON that findBsonColumn +// walks: a Type.ObjectType.PropertyTypes with a `columns` group whose ValueType +// carries `header` + `attribute` sub-property types, and an Object.Properties +// holding the columns list. Column kinds: {"attr":"M.E.Merchant"} → derives +// "Merchant"; {"caption":"Amount"} → derives "Amount". +func buildGridWithColumns(cols []map[string]string) bson.D { + colsID := idBin(0x10) + headerID := idBin(0x11) + attrID := idBin(0x12) + + colObjects := bson.A{int32(2)} + for _, c := range cols { + props := bson.A{int32(2)} + if attr, ok := c["attr"]; ok { + props = append(props, bson.D{ + {Key: "TypePointer", Value: attrID}, + {Key: "Value", Value: bson.D{{Key: "AttributeRef", Value: attr}}}, + }) + } + if caption, ok := c["caption"]; ok { + props = append(props, bson.D{ + {Key: "TypePointer", Value: headerID}, + {Key: "Value", Value: bson.D{ + {Key: "TextTemplate", Value: bson.D{ + {Key: "Template", Value: bson.D{ + {Key: "Items", Value: bson.A{int32(2), bson.D{{Key: "Text", Value: caption}}}}, + }}, + }}, + }}, + }) + } + colObjects = append(colObjects, bson.D{{Key: "Properties", Value: props}}) + } + + return bson.D{ + {Key: "Type", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{ + {Key: "$ID", Value: colsID}, + {Key: "PropertyKey", Value: "columns"}, + {Key: "ValueType", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{{Key: "$ID", Value: headerID}, {Key: "PropertyKey", Value: "header"}}, + bson.D{{Key: "$ID", Value: attrID}, {Key: "PropertyKey", Value: "attribute"}}, + }}, + }}, + }}, + }, + }}, + }}, + }}, + {Key: "Object", Value: bson.D{ + {Key: "Properties", Value: bson.A{ + int32(2), + bson.D{ + {Key: "TypePointer", Value: colsID}, + {Key: "Value", Value: bson.D{{Key: "Objects", Value: colObjects}}}, + }, + }}, + }}, + } +} + +func gridFinder(grid bson.D) widgetFinder { + return func(_ bson.D, name string) *bsonWidgetResult { + if name == "dg" { + return &bsonWidgetResult{widget: grid} + } + return nil + } +} + +func TestFindBsonColumn_UniqueMatch(t *testing.T) { + grid := buildGridWithColumns([]map[string]string{ + {"attr": "M.E.Merchant"}, + {"caption": "Amount"}, + }) + res, err := findBsonColumn(bson.D{}, "dg", "Merchant", gridFinder(grid)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatal("expected a result for the unique 'Merchant' column") + } +} + +func TestFindBsonColumn_AmbiguousIsRejected(t *testing.T) { + // Two dynamic-text columns captioned 'Amount' derive the same name. Before + // the fix, ON "Amount" silently mutated the first; now it must error. + grid := buildGridWithColumns([]map[string]string{ + {"attr": "M.E.Merchant"}, + {"caption": "Amount"}, + {"caption": "Amount"}, + }) + res, err := findBsonColumn(bson.D{}, "dg", "Amount", gridFinder(grid)) + if res != nil { + t.Error("ambiguous match must not resolve to a column") + } + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected an ambiguity error, got %v", err) + } + if !strings.Contains(err.Error(), "caption") { + t.Errorf("ambiguity error should point at captions as the cause, got %q", err.Error()) + } +} + +func TestFindBsonColumn_NotFoundListsAvailable(t *testing.T) { + // The authored MDL name never survives a write; addressing by it must fail + // with an actionable message listing the derived names that DO work. + grid := buildGridWithColumns([]map[string]string{ + {"attr": "M.E.Merchant"}, + {"caption": "Amount"}, + }) + _, err := findBsonColumn(bson.D{}, "dg", "colAuthoredAttr", gridFinder(grid)) + if err == nil { + t.Fatal("expected a not-found error") + } + msg := err.Error() + for _, want := range []string{"not found", "Merchant", "Amount", "available"} { + if !strings.Contains(msg, want) { + t.Errorf("not-found error missing %q; got: %s", want, msg) + } + } +} + +func TestFormatColumnNameList(t *testing.T) { + // Dedup, first-seen order, and quoting of non-identifier names. + got := formatColumnNameList([]string{"Merchant", "Amount", "Amount"}) + if got != "Merchant, Amount" { + t.Errorf("got %q, want %q", got, "Merchant, Amount") + } + if got := formatColumnNameList([]string{"has space"}); !strings.Contains(got, `"has space"`) { + t.Errorf("non-identifier name should be quoted, got %q", got) + } +} + +// TestColumnMatchCount_CrossGrid: two different grids on one page each carry a +// "Merchant" column. A bare `ON Merchant` is ambiguous across grids too — the +// count must be page-wide, not per-grid (ledger #78 follow-up). +func TestColumnMatchCount_CrossGrid(t *testing.T) { + grid1 := buildGridWithColumns([]map[string]string{{"attr": "M.E.Merchant"}, {"caption": "Amount"}}) + grid2 := buildGridWithColumns([]map[string]string{{"attr": "M.E.Merchant"}, {"caption": "Total"}}) + page := bson.D{{Key: "Widgets", Value: bson.A{int32(2), grid1, grid2}}} + m := &Mutator{rawData: page} + + if n := m.columnMatchCount("Merchant"); n != 2 { + t.Errorf("Merchant across two grids: count = %d, want 2 (page-wide)", n) + } + if n := m.columnMatchCount("Amount"); n != 1 { + t.Errorf("Amount (grid1 only): count = %d, want 1", n) + } + if n := m.columnMatchCount("Nonexistent"); n != 0 { + t.Errorf("missing name: count = %d, want 0", n) + } +} diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index d9416ac22..875856e2c 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -183,6 +183,8 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { // buildAndBindActivities builds workflow activities from AST nodes and auto-binds parameters. func buildAndBindActivities(ctx *ExecContext, nodes []ast.WorkflowActivityNode) []workflows.WorkflowActivity { acts := buildWorkflowActivities(nodes) - autoBindActivitiesInFlow(ctx, acts) + // ALTER carries no `parameter $X:` header, so there is no author-declared + // alias to honour — casing normalization only. + autoBindActivitiesInFlow(ctx, acts, contextExprNormalizer{}) return acts } diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index 3529ab522..36dd64b0c 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -615,23 +615,45 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { } // Process loop body statements and connect them with flows. + // pendingCase carries the deferred case value a merge-less split leaves for + // the NEXT flow — e.g. the FALSE branch of `if X then break`, whose split has + // no merge. Mirrors buildFlowGraph; without it a decision inside a loop loses + // its false flow entirely and mx check reports CE0079 (ledger #52). var lastBodyID model.ID + pendingCase := "" for _, stmt := range s.Body { actID := loopBuilder.addStatement(stmt) if actID != "" { loopBuilder.applyPendingAnnotations(actID) if lastBodyID != "" { - loopBuilder.flows = append(loopBuilder.flows, newHorizontalFlow(lastBodyID, actID)) + if pendingCase != "" { + loopBuilder.flows = append(loopBuilder.flows, newHorizontalFlowWithCase(lastBodyID, actID, pendingCase)) + } else { + loopBuilder.flows = append(loopBuilder.flows, newHorizontalFlow(lastBodyID, actID)) + } } + pendingCase = "" // Handle nextConnectionPoint for compound statements (nested IF, etc.) if loopBuilder.nextConnectionPoint != "" { lastBodyID = loopBuilder.nextConnectionPoint loopBuilder.nextConnectionPoint = "" + pendingCase = loopBuilder.nextFlowCase + loopBuilder.nextFlowCase = "" } else { lastBodyID = actID } } } + // A merge-less split as the last body element (e.g. `if X then break`) leaves a + // deferred branch (its FALSE case) with nowhere to go — the loop-body analog of + // falling off the microflow end. Wire it to a Continue event: "didn't + // break/return, so go to the next iteration" — the valid Mendix representation, + // and the missing false flow that made mx check report CE0079 (ledger #52). + if pendingCase != "" && lastBodyID != "" { + loopBuilder.posX += HorizontalSpacing + continueID := loopBuilder.addContinueEvent() + loopBuilder.flows = append(loopBuilder.flows, newHorizontalFlowWithCase(lastBodyID, continueID, pendingCase)) + } // Create LoopedActivity with calculated size // Position is the CENTER point (RelativeMiddlePoint in Mendix) diff --git a/mdl/executor/cmd_microflows_loop_break_test.go b/mdl/executor/cmd_microflows_loop_break_test.go new file mode 100644 index 000000000..51715bd1c --- /dev/null +++ b/mdl/executor/cmd_microflows_loop_break_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// TestBuilder_ConditionalBreakLastInLoop_HasFalseFlow pins ledger #52: a +// `loop { if cond then break }` where the IF is the last statement built an +// ExclusiveSplit with ONLY its `true` outgoing flow (→ BreakEvent); the `false` +// case was deferred to a following statement that never came, so mx check +// reported CE0079 "the 'false' condition value should be configured on an +// outgoing sequence flow". The false branch must now be wired to a ContinueEvent +// ("didn't break → next iteration"). +func TestBuilder_ConditionalBreakLastInLoop_HasFalseFlow(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.LoopStmt{ + ListVariable: "L", + LoopVariable: "R", + Body: []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true}, + ThenBody: []ast.MicroflowStatement{&ast.BreakStmt{}}, + }, + }, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing, varTypes: map[string]string{"L": "List of M.R"}} + col := fb.buildFlowGraph(body, nil) + + // The loop's internal flows are lifted to the top-level collection. + var splitID string + hasBreak, hasContinue := false, false + for _, o := range col.Objects { + switch obj := o.(type) { + case *microflows.LoopedActivity: + for _, inner := range obj.ObjectCollection.Objects { + switch inner.(type) { + case *microflows.ExclusiveSplit: + splitID = string(inner.(*microflows.ExclusiveSplit).ID) + case *microflows.BreakEvent: + hasBreak = true + case *microflows.ContinueEvent: + hasContinue = true + } + } + } + } + // LoopedActivity may store its objects in the inner collection; also scan + // there for events if the split lives inside it. + if splitID == "" { + t.Fatal("no ExclusiveSplit found in the loop body") + } + if !hasBreak { + t.Error("expected a BreakEvent in the loop body") + } + if !hasContinue { + t.Error("expected a synthesized ContinueEvent for the split's false branch (ledger #52)") + } + + trueCount, falseCount := 0, 0 + for _, f := range col.Flows { + if string(f.OriginID) != splitID { + continue + } + switch cv := f.CaseValue.(type) { + case *microflows.ExpressionCase: + if cv.Expression == "true" { + trueCount++ + } else if cv.Expression == "false" { + falseCount++ + } + case microflows.EnumerationCase: + if cv.Value == "true" { + trueCount++ + } else if cv.Value == "false" { + falseCount++ + } + } + } + if trueCount != 1 { + t.Errorf("split should have exactly 1 true flow (→ break), got %d", trueCount) + } + if falseCount != 1 { + t.Errorf("split should have exactly 1 false flow (→ continue), got %d — a decision with no false flow is CE0079", falseCount) + } +} diff --git a/mdl/executor/cmd_pages_builder_crossmodule_test.go b/mdl/executor/cmd_pages_builder_crossmodule_test.go new file mode 100644 index 000000000..4091b6f27 --- /dev/null +++ b/mdl/executor/cmd_pages_builder_crossmodule_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// issuetracker #19: an association whose target lives in ANOTHER module is a +// DomainModels$CrossAssociation and is stored in a separate list, where only the +// local (FROM) end is BY_ID and the remote end is the BY_NAME ChildRef. +// associationEndpoints searched only dm.Associations, so every cross-module hop +// came back unresolvable; a widget bound to `Issue_Assignee/Name` then fell back +// to a flat attribute path and mxbuild failed CE1613 "The selected attribute +// 'IT.Issue.Issue_Assignee/Name' no longer exists". +// +// The reporter framed this as a System-module limitation, but a plain second app +// module reproduces it identically — the trigger is cross-module, not System. +func TestResolveAssociationAttributePath_CrossModule(t *testing.T) { + const ( + modID = model.ID("mod-it") + otherID = model.ID("mod-other") + issueID = model.ID("e-issue") + projID = model.ID("e-project") + personID = model.ID("e-person") + ) + + newPB := func() *pageBuilder { + return &pageBuilder{ + entityContext: "IT.Issue", + execCache: &executorCache{ + hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{ + modID: "IT", + otherID: "Other", + }}, + domainModels: []*domainmodel.DomainModel{ + { + ContainerID: modID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: issueID}, Name: "Issue"}, + {BaseElement: model.BaseElement{ID: projID}, Name: "Project"}, + }, + Associations: []*domainmodel.Association{ + {Name: "Issue_Project", ParentID: issueID, ChildID: projID, Type: domainmodel.AssociationTypeReference}, + }, + CrossAssociations: []*domainmodel.CrossModuleAssociation{ + // Target in another app module. + {Name: "Issue_Person", ParentID: issueID, ChildRef: "Other.Person", Type: domainmodel.AssociationTypeReference}, + // Target in the platform's System module. + {Name: "Issue_Assignee", ParentID: issueID, ChildRef: "System.User", Type: domainmodel.AssociationTypeReference}, + }, + }, + { + ContainerID: otherID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: personID}, Name: "Person"}, + }, + }, + }, + }, + } + } + + tests := []struct { + name string + path string + wantFinal string + wantAssoc string + wantDestEn string + }{ + { + name: "same-module hop (regression guard)", + path: "Issue_Project/Code", + wantFinal: "IT.Project.Code", + wantAssoc: "IT.Issue_Project", + wantDestEn: "IT.Project", + }, + { + name: "cross-module hop into another app module", + path: "Issue_Person/FullName", + wantFinal: "Other.Person.FullName", + wantAssoc: "IT.Issue_Person", + wantDestEn: "Other.Person", + }, + { + name: "cross-module hop into System", + path: "Issue_Assignee/Name", + wantFinal: "System.User.Name", + wantAssoc: "IT.Issue_Assignee", + wantDestEn: "System.User", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + finalQN, steps, ok := newPB().resolveAssociationAttributePath(tc.path) + if !ok { + t.Fatalf("path %q was dropped (ok=false) — the binding falls back to a flat path and mxbuild fails CE1613", tc.path) + } + if finalQN != tc.wantFinal { + t.Errorf("finalQN = %q, want %q", finalQN, tc.wantFinal) + } + if len(steps) != 1 { + t.Fatalf("steps = %+v, want exactly one hop", steps) + } + if steps[0].Association != tc.wantAssoc || steps[0].DestinationEntity != tc.wantDestEn { + t.Errorf("step = %+v, want {Association: %s, DestinationEntity: %s}", + steps[0], tc.wantAssoc, tc.wantDestEn) + } + }) + } + + // The destination resolver used by DATASOURCE bindings reads the same two + // lists and must agree with the step resolver above. + t.Run("datasource destination resolves cross-module", func(t *testing.T) { + pb := newPB() + if got := pb.resolveAssociationDestination("IT.Issue_Assignee", "IT.Issue"); got != "System.User" { + t.Errorf("resolveAssociationDestination = %q, want System.User", got) + } + if got := pb.resolveAssociationDestination("IT.Issue_Person", "IT.Issue"); got != "Other.Person" { + t.Errorf("resolveAssociationDestination = %q, want Other.Person", got) + } + }) +} + +// issuetracker #19 (related quirk): `add attribute CreatedDate: AutoCreatedDate` +// is the spelling mxcli REQUIRES — it rejects any other declared name and tells +// you to use this one — but the member is stored as `createdDate`. Binding a +// widget to the name you just declared failed CE1613 while the undocumented +// lowercase form worked. +func TestStoredSystemMemberName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"CreatedDate", "createdDate"}, + {"createdDate", "createdDate"}, + {"CREATEDDATE", "createdDate"}, + {"ChangedDate", "changedDate"}, + {"ChangedBy", "changedBy"}, + {"Owner", "owner"}, + // Ordinary attributes are untouched — including one that merely contains + // an audit-member name. + {"Title", "Title"}, + {"CreatedDateLocal", "CreatedDateLocal"}, + // Already-qualified paths and association paths are left to their own + // resolvers; rewriting a segment here would corrupt them. + {"IT.Issue.CreatedDate", "IT.Issue.CreatedDate"}, + {"Issue_Assignee/CreatedDate", "Issue_Assignee/CreatedDate"}, + {"$currentObject/CreatedDate", "$currentObject/CreatedDate"}, + {"", ""}, + } + for _, tc := range tests { + if got := storedSystemMemberName(tc.in); got != tc.want { + t.Errorf("storedSystemMemberName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/mdl/executor/cmd_pages_builder_input.go b/mdl/executor/cmd_pages_builder_input.go index 1b3f83ddd..65ecfaef1 100644 --- a/mdl/executor/cmd_pages_builder_input.go +++ b/mdl/executor/cmd_pages_builder_input.go @@ -35,6 +35,7 @@ func (pb *pageBuilder) resolveAttributePath(attr string) string { if attr == "" { return "" } + attr = storedSystemMemberName(attr) // If the attribute already contains a dot, it's already qualified if strings.Contains(attr, ".") { return attr @@ -46,10 +47,47 @@ func (pb *pageBuilder) resolveAttributePath(attr string) string { return attr } +// systemMemberBindingNames maps the name an audit member is DECLARED under to +// the name Mendix actually stores it as. +// +// `alter entity … add attribute CreatedDate: AutoCreatedDate` is the spelling +// mxcli requires (it rejects any other declared name, telling you to use this +// one), but the member is stored as `createdDate` — so binding a widget to +// `CreatedDate`, the same name you just declared, failed the build with CE1613 +// "The selected attribute … no longer exists" while the undocumented lowercase +// form worked. Accept the declared spelling and write the stored one. +// (issuetracker #19) +var systemMemberBindingNames = map[string]string{ + "createddate": "createdDate", + "changeddate": "changedDate", + "changedby": "changedBy", + "owner": "owner", +} + +// storedSystemMemberName maps a bare audit-member name to its stored spelling, +// leaving every other name (and any qualified or association path) untouched. +func storedSystemMemberName(attr string) string { + if strings.ContainsAny(attr, "./$") { + return attr + } + if stored, ok := systemMemberBindingNames[strings.ToLower(attr)]; ok { + return stored + } + return attr +} + // resolveAssociationPath resolves a short association name to a fully qualified name. // Associations are module-level objects, so the path is Module.AssociationName (2-part). // If the name already contains a dot, it's returned as-is. func (pb *pageBuilder) resolveAssociationPath(assocName string) string { + return pb.resolveAssociationPathIn(assocName, pb.entityContext) +} + +// resolveAssociationPathIn is resolveAssociationPath against an explicit entity +// context. Callers that bind a member of a *containing* entity — while +// pb.entityContext already points at their own data source — must pass that +// containing entity, or a bare name is qualified with the wrong module. +func (pb *pageBuilder) resolveAssociationPathIn(assocName, entityContext string) string { if assocName == "" { return "" } @@ -58,8 +96,8 @@ func (pb *pageBuilder) resolveAssociationPath(assocName string) string { return assocName } // Extract module name from entity context (e.g., "PgTest.Order" → "PgTest") - if pb.entityContext != "" { - parts := strings.SplitN(pb.entityContext, ".", 2) + if entityContext != "" { + parts := strings.SplitN(entityContext, ".", 2) if len(parts) >= 1 { return parts[0] + "." + assocName } diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 70aa6d9f7..c09f0f4e5 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -752,8 +752,9 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource ID: model.ID(types.GenerateID()), TypeName: "Forms$MicroflowSource", }, - MicroflowID: mfID, - Microflow: ds.Reference, + MicroflowID: mfID, + Microflow: ds.Reference, + ParameterMappings: flowArgsToParameterMappings(ds.Args), }, entityName, nil case "nanoflow": @@ -771,8 +772,9 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource ID: model.ID(types.GenerateID()), TypeName: "Forms$NanoflowSource", }, - NanoflowID: nfID, - Nanoflow: ds.Reference, + NanoflowID: nfID, + Nanoflow: ds.Reference, + ParameterMappings: flowArgsToParameterMappings(ds.Args), }, entityName, nil case "association": @@ -795,6 +797,20 @@ func (pb *pageBuilder) buildDataSourceV3(ds *ast.DataSourceV3) (pages.DataSource destEntity = pb.resolveAssociationDestination(path, pb.entityContext) } + // An empty DestinationEntity is a by-name reference Mendix resolves to + // null: the loader throws ArgumentNullException setting DestinationEntityId + // and the whole project becomes unopenable — Studio Pro refuses it and + // `mx check` dies before validating anything. Refuse instead of writing a + // structurally invalid unit; the author can name the destination explicitly + // as `Assoc/Module.Entity`. (issuetracker #14) + if destEntity == "" { + return nil, "", mdlerrors.NewValidationf( + "cannot resolve the destination entity of association %q for datasource %q — "+ + "writing it unresolved would produce a project Mendix cannot open; "+ + "name the destination explicitly, e.g. `%s/Module.Entity`", + path, ds.Reference, path) + } + // Return destEntity as the child context so column bindings inside the // widget can resolve short attribute names against it. return &pages.AssociationSource{ @@ -900,7 +916,7 @@ func (pb *pageBuilder) resolveAssociationDestination(assocQN, contextEntity stri } modName, assocName := parts[0], parts[1] - domainModels, err := pb.backend.ListDomainModels() + domainModels, err := pb.getDomainModels() if err != nil { return "" } @@ -913,6 +929,21 @@ func (pb *pageBuilder) resolveAssociationDestination(assocQN, contextEntity stri if pb.moduleNameByID(dm.ContainerID) != modName { continue } + // A cross-module association (target in another module, including + // `System`) lives in a separate list where the remote end is the BY_NAME + // ChildRef rather than a BY_ID pointer — see associationEndpoints + // (issuetracker #19). Resolving it here means the empty-end fallbacks + // below are a last resort, not the normal path for these. + for _, ca := range dm.CrossAssociations { + if ca.Name != assocName { + continue + } + parentEntity := pb.entityQNByID(ca.ParentID) + if contextEntity != "" && contextEntity == ca.ChildRef { + return parentEntity + } + return ca.ChildRef + } for _, a := range dm.Associations { if a.Name != assocName { continue @@ -929,6 +960,20 @@ func (pb *pageBuilder) resolveAssociationDestination(assocQN, contextEntity stri return childEntity } } + // One end may be unresolvable: entityQNByID only sees the project's + // own domain models, so an association ending in a System entity + // (e.g. `from W.Issue to System.Workflow`) yields "" for that side. + // The context then matches neither end and the old code returned the + // empty child — an empty DestinationEntity is a by-name reference + // Mendix resolves to null, which makes the whole .mpr UNLOADABLE + // (issuetracker #14). Prefer whichever end actually resolved and is + // not the context. + if childEntity == "" && parentEntity != "" && parentEntity != contextEntity { + return parentEntity + } + if parentEntity == "" && childEntity != "" && childEntity != contextEntity { + return childEntity + } // No context or mismatch — default to the child (TO) side, which // matches the common FROM=context pattern. return childEntity @@ -943,7 +988,7 @@ func (pb *pageBuilder) entityQNByID(entityID model.ID) string { if entityID == "" { return "" } - domainModels, err := pb.backend.ListDomainModels() + domainModels, err := pb.getDomainModels() if err != nil { return "" } @@ -967,6 +1012,14 @@ func (pb *pageBuilder) moduleNameByID(moduleID model.ID) string { if moduleID == "" { return "" } + // The hierarchy already indexes module names and is the source the sibling + // resolvers (associationEndpoints, entityGeneralizations) read, so consult it + // first — same answer, one less backend round trip. + if h, err := pb.getHierarchy(); err == nil { + if name := h.GetModuleName(moduleID); name != "" { + return name + } + } modules, err := pb.backend.ListModules() if err != nil { return "" @@ -1628,7 +1681,7 @@ func (pb *pageBuilder) resolveAssociationAttributePath(attrRef string) (finalQN current = dest } - return current + "." + attrName, steps, true + return current + "." + storedSystemMemberName(attrName), steps, true } // associationDestination returns the entity reached by navigating assocQN from @@ -1706,6 +1759,16 @@ func (pb *pageBuilder) entityGeneralizations() (map[string]string, error) { // associationEndpoints resolves a qualified association name to its FROM // (ParentID) and TO (ChildID) entity qualified names. +// +// A domain model keeps associations in **two** lists. `Associations` holds the +// intra-module ones, where both ends are BY_ID. An association whose target is +// in another module — including the platform's own `System` module — is a +// `DomainModels$CrossAssociation` and lives in `CrossAssociations`, where only +// the local (FROM) end is BY_ID and the remote end is the BY_NAME `ChildRef`. +// Searching only the first list left every cross-module hop unresolvable, so a +// widget bound to `Issue_Assignee/Name` fell back to a flat attribute path and +// the build failed CE1613 "The selected attribute … no longer exists" +// (issuetracker #19). func (pb *pageBuilder) associationEndpoints(assocQN string) (fromEntity, toEntity string, ok bool) { parts := strings.SplitN(assocQN, ".", 2) if len(parts) != 2 { @@ -1735,15 +1798,24 @@ func (pb *pageBuilder) associationEndpoints(assocQN string) (fromEntity, toEntit if h.GetModuleName(dm.ContainerID) != modName { continue } - a := dm.FindAssociationByName(assocName) - if a == nil { - continue + if a := dm.FindAssociationByName(assocName); a != nil { + from, to := entityQN[a.ParentID], entityQN[a.ChildID] + if from == "" || to == "" { + return "", "", false + } + return from, to, true } - from, to := entityQN[a.ParentID], entityQN[a.ChildID] - if from == "" || to == "" { - return "", "", false + // Cross-module: the remote end is already a qualified name. + for _, ca := range dm.CrossAssociations { + if ca.Name != assocName { + continue + } + from := entityQN[ca.ParentID] + if from == "" || ca.ChildRef == "" { + return "", "", false + } + return from, ca.ChildRef, true } - return from, to, true } return "", "", false } @@ -2141,3 +2213,32 @@ func prefixWidgetNames(widgets []*ast.WidgetV3, prefix string) { prefixWidgetNames(w.Children, prefix) } } + +// flowArgsToParameterMappings converts parsed datasource/action arguments into +// model parameter mappings. A microflow or nanoflow used as a widget datasource +// needs an argument for every parameter exactly as a call action does — Mendix +// reports CE1571 "No argument has been selected for parameter 'X'" otherwise +// (#835). The datasource path previously parsed the arguments and dropped them. +func flowArgsToParameterMappings(args []ast.FlowArgV3) []*pages.MicroflowParameterMapping { + var out []*pages.MicroflowParameterMapping + for _, arg := range args { + mapping := &pages.MicroflowParameterMapping{ + BaseElement: model.BaseElement{ + ID: model.ID(types.GenerateID()), + TypeName: "Forms$MicroflowParameterMapping", + }, + ParameterName: arg.Name, + } + // A leading $ marks a variable reference ($currentObject, a page + // parameter); anything else is an expression. + if strVal, ok := arg.Value.(string); ok { + if strings.HasPrefix(strVal, "$") { + mapping.Variable = strVal + } else { + mapping.Expression = strVal + } + } + out = append(out, mapping) + } + return out +} diff --git a/mdl/executor/cmd_pages_datasource_args_test.go b/mdl/executor/cmd_pages_datasource_args_test.go new file mode 100644 index 000000000..3e8df8c41 --- /dev/null +++ b/mdl/executor/cmd_pages_datasource_args_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestFlowArgsToParameterMappings guards issue #835. +// +// A microflow used as a widget datasource needs an argument for every parameter, +// exactly as a call action does. The grammar parsed `microflow Mod.MF(Name: $x)` +// into DataSourceV3.Args, but the builder never read them and MicroflowSource had +// nowhere to put them, so the binding was silently dropped and mxbuild reported +// +// [error] [CE1571] "No argument has been selected for parameter 'Name' and no +// default is available." at Data grid 2 'dg1' +// +// with mxcli check and exec both reporting success. +func TestFlowArgsToParameterMappings(t *testing.T) { + got := flowArgsToParameterMappings([]ast.FlowArgV3{ + {Name: "Name", Value: "$Filter"}, + {Name: "Limit", Value: "10"}, + {Name: "Ctx", Value: "$currentObject"}, + }) + if len(got) != 3 { + t.Fatalf("got %d mappings, want 3", len(got)) + } + + // A leading $ is a variable reference; anything else is an expression. The + // distinction matters: Mendix binds the two through different BSON fields. + if got[0].ParameterName != "Name" || got[0].Variable != "$Filter" || got[0].Expression != "" { + t.Errorf("$-value mapping = %+v, want Variable=$Filter", got[0]) + } + if got[1].ParameterName != "Limit" || got[1].Expression != "10" || got[1].Variable != "" { + t.Errorf("literal mapping = %+v, want Expression=10", got[1]) + } + if got[2].Variable != "$currentObject" { + t.Errorf("$currentObject mapping = %+v, want Variable=$currentObject", got[2]) + } + for i, m := range got { + if m.ID == "" { + t.Errorf("mapping %d has no ID — every model element needs one", i) + } + } +} + +// No arguments must stay nil rather than an empty slice, so a datasource without +// parameters serializes exactly as it did before this change. +func TestFlowArgsToParameterMappings_Empty(t *testing.T) { + if got := flowArgsToParameterMappings(nil); got != nil { + t.Errorf("no args should yield nil, got %+v", got) + } +} diff --git a/mdl/executor/cmd_rest_clients.go b/mdl/executor/cmd_rest_clients.go index 2d1948d3b..ebc867eb3 100644 --- a/mdl/executor/cmd_rest_clients.go +++ b/mdl/executor/cmd_rest_clients.go @@ -159,7 +159,7 @@ func outputRestOperation(w io.Writer, op *model.RestClientOperation) { if len(op.Parameters) > 0 { var params []string for _, p := range op.Parameters { - params = append(params, fmt.Sprintf("$%s: %s", p.Name, p.DataType)) + params = append(params, fmt.Sprintf("$%s: %s", p.Name, restParamTypeOrDefault(p.DataType))) } fmt.Fprintf(w, " Parameters: (%s),\n", strings.Join(params, ", ")) } @@ -168,7 +168,7 @@ func outputRestOperation(w io.Writer, op *model.RestClientOperation) { if len(op.QueryParameters) > 0 { var params []string for _, q := range op.QueryParameters { - params = append(params, fmt.Sprintf("$%s: %s", q.Name, q.DataType)) + params = append(params, fmt.Sprintf("$%s: %s", q.Name, restParamTypeOrDefault(q.DataType))) } fmt.Fprintf(w, " Query: (%s),\n", strings.Join(params, ", ")) } @@ -238,6 +238,21 @@ func outputRestOperation(w io.Writer, op *model.RestClientOperation) { fmt.Fprintln(w, " }") } +// restParamTypeOrDefault supplies the type describe prints for a REST parameter. +// +// Rest$QueryParameter has no DataType property — Mendix does not model a type +// for query parameters, so the one written in MDL is dropped at write time and +// there is nothing to read back. The MDL grammar still requires a type +// (`$name: Type`), so emitting the empty string produces `$page: `, which does +// not re-parse. String is the honest stand-in: query parameters travel as text +// in the URL, and it is the type this same describe output round-trips to. +func restParamTypeOrDefault(dataType string) string { + if dataType == "" { + return "String" + } + return dataType +} + // writeResponseMappings writes import-direction mappings (JSON → Entity): EntityAttr = jsonField. // Matches the import mapping syntax: CREATE Association/Entity = jsonField { ... }. func writeResponseMappings(w io.Writer, mappings []*model.RestResponseMapping, indent int) { @@ -402,7 +417,10 @@ func createRestClient(ctx *ExecContext, stmt *ast.CreateRestClientStmt) error { // Operations for _, opDef := range stmt.Operations { - op := buildRestClientOperation(opDef) + op, err := buildRestClientOperation(opDef) + if err != nil { + return fmt.Errorf("operation %q: %w", opDef.Name, err) + } svc.Operations = append(svc.Operations, op) } @@ -420,29 +438,40 @@ func createRestClient(ctx *ExecContext, stmt *ast.CreateRestClientStmt) error { } // buildRestClientOperation converts an AST RestOperationDef to a model RestClientOperation. -func buildRestClientOperation(opDef *ast.RestOperationDef) *model.RestClientOperation { +func buildRestClientOperation(opDef *ast.RestOperationDef) (*model.RestClientOperation, error) { + if err := checkInlineMappingBody(opDef); err != nil { + return nil, err + } + // model.RestClientOperation documents BodyType/ResponseType as upper-case + // tokens ("JSON", "EXPORT_MAPPING", "MAPPING", ...) and every consumer + // compares against that spelling — the serializers in both engines, and the + // REST-call microflow builder. The visitor produces the lower-case source + // text, so passing it through unchanged silently disabled all of them: a + // `Response: MAPPING` operation matched no branch and fell back to + // Rest$NoResponseHandling, dropping the mapping without a warning (#843). + // Normalize here, at the one place the AST becomes the semantic model. op := &model.RestClientOperation{ Name: opDef.Name, Documentation: opDef.Documentation, HttpMethod: opDef.Method, Path: opDef.Path, - BodyType: opDef.BodyType, + BodyType: strings.ToUpper(opDef.BodyType), BodyVariable: opDef.BodyVariable, - ResponseType: opDef.ResponseType, + ResponseType: strings.ToUpper(opDef.ResponseType), ResponseVariable: opDef.ResponseVariable, Timeout: opDef.Timeout, } // Convert body mapping (export direction: Left=jsonField, Right=entityAttr) if opDef.BodyMapping != nil { - op.BodyType = "export_mapping" + op.BodyType = "EXPORT_MAPPING" op.BodyVariable = opDef.BodyMapping.Entity.String() op.BodyMappings = convertMappingEntries(opDef.BodyMapping.Entries, false) } // Convert response mapping (import direction: Left=entityAttr, Right=jsonField) if opDef.ResponseMapping != nil { - op.ResponseType = "mapping" + op.ResponseType = "MAPPING" op.ResponseEntity = opDef.ResponseMapping.Entity.String() op.ResponseMappings = convertMappingEntries(opDef.ResponseMapping.Entries, true) } @@ -482,7 +511,45 @@ func buildRestClientOperation(opDef *ast.RestOperationDef) *model.RestClientOper op.Headers = append(op.Headers, header) } - return op + return op, nil +} + +// checkInlineMappingBody rejects `Body:`/`Response: MAPPING X` written without a +// `{ ... }` body. +// +// The clause names an *entity* and the braces list the JSON fields to map onto +// it; Mendix stores the result inline on the operation as +// Rest$ImplicitMappingResponseHandling / Rest$ImplicitMappingBody. It is not a +// reference to a mapping document — a consumed REST operation has nowhere to put +// one, as Rest$RestOperationResponseHandling has exactly two implementations +// (implicit-mapping and none). +// +// So `Response: MAPPING Mod.IMM_Something` parses, names a mapping document +// where an entity belongs, contributes no field mappings, and used to be written +// out as "no response handling" — accepted in silence, and only noticed at +// runtime when nothing was parsed out of the response body (#843). Refuse it +// instead, and say what to write. +func checkInlineMappingBody(opDef *ast.RestOperationDef) error { + for _, m := range []struct { + clause string + def *ast.RestMappingDef + syntax string + }{ + {"Response", opDef.ResponseMapping, "Response: mapping Module.Entity { Attribute = jsonField, ... }"}, + {"Body", opDef.BodyMapping, "Body: mapping Module.Entity { jsonField = Attribute, ... }"}, + } { + if m.def == nil || len(m.def.Entries) > 0 { + continue + } + return fmt.Errorf( + "%s: mapping %s has no mapping body.\n"+ + " A consumed REST operation cannot reference an import/export mapping document;\n"+ + " Mendix stores the mapping inline, so the fields must be listed here.\n"+ + " Name the target entity and its fields:\n"+ + " %s", + m.clause, m.def.Entity.String(), m.syntax) + } + return nil } // convertMappingEntries converts AST RestMappingEntry slices to model RestResponseMapping slices. diff --git a/mdl/executor/cmd_rest_clients_mapping_test.go b/mdl/executor/cmd_rest_clients_mapping_test.go new file mode 100644 index 000000000..7eb4ac448 --- /dev/null +++ b/mdl/executor/cmd_rest_clients_mapping_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestBuildRestClientOperation_NormalizesTypeTokens pins the contract documented +// on model.RestClientOperation: BodyType/ResponseType are upper-case tokens. +// The visitor yields the lower-case source text, and every consumer (both +// serializers, the REST-call microflow builder) compares against the upper-case +// spelling — so passing the AST value through unchanged silently disabled them +// and dropped the mapping (#843). +func TestBuildRestClientOperation_NormalizesTypeTokens(t *testing.T) { + tests := []struct { + name string + def *ast.RestOperationDef + wantBodyType string + wantResponseType string + }{ + { + name: "scalar response", + def: &ast.RestOperationDef{Name: "Get", ResponseType: "json"}, + wantResponseType: "JSON", + }, + { + name: "scalar body", + def: &ast.RestOperationDef{Name: "Post", BodyType: "template", ResponseType: "none"}, + wantBodyType: "TEMPLATE", + wantResponseType: "NONE", + }, + { + name: "response mapping", + def: &ast.RestOperationDef{ + Name: "GetRoute", + ResponseMapping: &ast.RestMappingDef{ + Entity: ast.QualifiedName{Module: "Mod", Name: "Routing"}, + Entries: []ast.RestMappingEntry{{Left: "RoutingCode", Right: "routing_code"}}, + }, + }, + wantResponseType: "MAPPING", + }, + { + name: "body mapping", + def: &ast.RestOperationDef{ + Name: "PostRoute", + BodyMapping: &ast.RestMappingDef{ + Entity: ast.QualifiedName{Module: "Mod", Name: "Routing"}, + Entries: []ast.RestMappingEntry{{Left: "routing_code", Right: "RoutingCode"}}, + }, + }, + wantBodyType: "EXPORT_MAPPING", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op, err := buildRestClientOperation(tt.def) + if err != nil { + t.Fatalf("buildRestClientOperation: %v", err) + } + if op.BodyType != tt.wantBodyType { + t.Errorf("BodyType = %q, want %q", op.BodyType, tt.wantBodyType) + } + if op.ResponseType != tt.wantResponseType { + t.Errorf("ResponseType = %q, want %q", op.ResponseType, tt.wantResponseType) + } + }) + } +} + +// TestBuildRestClientOperation_RejectsMappingWithoutBody covers the syntax the +// reporter actually wrote: `Response: mapping Mod.IMM_R10`, pointing at an +// import mapping *document*. Mendix has no response handler that references one, +// so the clause contributed nothing and the operation was written out as +// Rest$NoResponseHandling — silently. It must be refused, with the inline form +// spelled out. +func TestBuildRestClientOperation_RejectsMappingWithoutBody(t *testing.T) { + tests := []struct { + name string + def *ast.RestOperationDef + wantParts []string + }{ + { + name: "response", + def: &ast.RestOperationDef{ + Name: "SearchRoutes", + ResponseMapping: &ast.RestMappingDef{Entity: ast.QualifiedName{Module: "ZZB", Name: "IMM_R10"}}, + }, + wantParts: []string{"Response", "ZZB.IMM_R10", "Response: mapping Module.Entity {"}, + }, + { + name: "body", + def: &ast.RestOperationDef{ + Name: "PostRoute", + BodyMapping: &ast.RestMappingDef{Entity: ast.QualifiedName{Module: "ZZB", Name: "EXM_R10"}}, + }, + wantParts: []string{"Body", "ZZB.EXM_R10", "Body: mapping Module.Entity {"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op, err := buildRestClientOperation(tt.def) + if err == nil { + t.Fatalf("expected an error, got op %+v", op) + } + for _, want := range tt.wantParts { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err.Error(), want) + } + } + }) + } +} diff --git a/mdl/executor/cmd_security_grant_members_test.go b/mdl/executor/cmd_security_grant_members_test.go new file mode 100644 index 000000000..977405c95 --- /dev/null +++ b/mdl/executor/cmd_security_grant_members_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// grantMembersFixture builds a module with two entities joined by a reference set +// and a GRANT harness over it. `owner` selects Default vs Both. +type grantMembersFixture struct { + ctx *ExecContext + captured *backend.EntityAccessRuleParams +} + +func newGrantMembersFixture(t *testing.T, owner domainmodel.AssociationOwner, audit bool) *grantMembersFixture { + t.Helper() + + const ( + issueID = model.ID("e-issue") + tagID = model.ID("e-tag") + ) + mod := mkModule("IT") + h := mkHierarchy(mod) + + issue := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: issueID}, + Name: "Issue", + Attributes: []*domainmodel.Attribute{{Name: "Title"}}, + HasCreatedDate: audit, + HasChangedDate: audit, + } + tag := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: tagID}, + Name: "Tag", + Attributes: []*domainmodel.Attribute{{Name: "TagName"}}, + } + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: "dm-it"}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{issue, tag}, + Associations: []*domainmodel.Association{{ + Name: "Issue_Tag", + ParentID: issueID, + ChildID: tagID, + Type: domainmodel.AssociationTypeReferenceSet, + Owner: owner, + }}, + } + + f := &grantMembersFixture{} + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{dm}, nil + }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + GetModuleSecurityFunc: func(model.ID) (*security.ModuleSecurity, error) { + return &security.ModuleSecurity{ModuleRoles: []*security.ModuleRole{{Name: "Admin"}}}, nil + }, + AddEntityAccessRuleFunc: func(p backend.EntityAccessRuleParams) error { + cp := p + f.captured = &cp + return nil + }, + ReconcileMemberAccessesFunc: func(model.ID, string) (int, error) { return 0, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + f.ctx = ctx + return f +} + +func grantStmt(entity string, rights ...ast.EntityAccessRight) *ast.GrantEntityAccessStmt { + return &ast.GrantEntityAccessStmt{ + Entity: ast.QualifiedName{Module: "IT", Name: entity}, + Roles: []ast.QualifiedName{{Module: "IT", Name: "Admin"}}, + Rights: rights, + } +} + +func (f *grantMembersFixture) associationRights(assocRef string) (string, bool) { + if f.captured == nil { + return "", false + } + for _, ma := range f.captured.MemberAccesses { + if ma.AssociationRef == assocRef { + return ma.AccessRights, true + } + } + return "", false +} + +// TestGrantEntityAccess_BothOwnerAssociationOnToSide pins issuetracker #20: with +// `OWNER Both` the association is a member of BOTH ends, so the TO entity's rule +// needs a MemberAccess entry too. Emitting it only on the FROM side left the TO +// entity's rule incomplete, which Mendix reports as CE0066 "Entity access is out +// of date" — and made partial coverage worse than none. +func TestGrantEntityAccess_BothOwnerAssociationOnToSide(t *testing.T) { + f := newGrantMembersFixture(t, domainmodel.AssociationOwnerBoth, false) + + // Tag is the TO side of IT.Issue_Tag. + if err := execGrantEntityAccess(f.ctx, grantStmt("Tag", + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll})); err != nil { + t.Fatalf("grant failed: %v", err) + } + rights, ok := f.associationRights("IT.Issue_Tag") + if !ok { + t.Fatalf("no MemberAccess for IT.Issue_Tag on the TO entity — the rule is incomplete (CE0066); got %+v", f.captured.MemberAccesses) + } + if rights != "ReadWrite" { + t.Errorf("association rights = %q, want ReadWrite (the rule default)", rights) + } +} + +// Regression guard: with the default owner the association belongs to the FROM +// side only, and adding it to the TO side is itself a CE0066. +func TestGrantEntityAccess_DefaultOwnerAssociationNotOnToSide(t *testing.T) { + f := newGrantMembersFixture(t, domainmodel.AssociationOwnerDefault, false) + + if err := execGrantEntityAccess(f.ctx, grantStmt("Tag", + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll})); err != nil { + t.Fatalf("grant failed: %v", err) + } + if _, ok := f.associationRights("IT.Issue_Tag"); ok { + t.Errorf("TO entity must not carry the association for an OWNER Default association; got %+v", f.captured.MemberAccesses) + } +} + +// The FROM side keeps its entry under either owner mode. +func TestGrantEntityAccess_AssociationAlwaysOnFromSide(t *testing.T) { + for _, owner := range []domainmodel.AssociationOwner{ + domainmodel.AssociationOwnerDefault, + domainmodel.AssociationOwnerBoth, + } { + f := newGrantMembersFixture(t, owner, false) + if err := execGrantEntityAccess(f.ctx, grantStmt("Issue", + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll})); err != nil { + t.Fatalf("owner %s: grant failed: %v", owner, err) + } + if _, ok := f.associationRights("IT.Issue_Tag"); !ok { + t.Errorf("owner %s: FROM entity lost its association MemberAccess", owner) + } + } +} + +// TestGrantEntityAccess_ToSideAssociationNamedExplicitly: naming the TO-side +// association was rejected as "entity has no member(s) Issue_Tag". It is a +// member, so it must be accepted and its per-member rights honoured. +func TestGrantEntityAccess_ToSideAssociationNamedExplicitly(t *testing.T) { + f := newGrantMembersFixture(t, domainmodel.AssociationOwnerBoth, false) + + err := execGrantEntityAccess(f.ctx, grantStmt("Tag", + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll}, + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{"Issue_Tag"}})) + if err != nil { + t.Fatalf("naming the TO-side association was rejected: %v", err) + } + if rights, _ := f.associationRights("IT.Issue_Tag"); rights != "ReadOnly" { + t.Errorf("association rights = %q, want ReadOnly (the explicit read grant)", rights) + } +} + +// TestGrantEntityAccess_AuditMembers: audit members are entity FLAGS, not +// entries in entity.Attributes, so naming one was rejected as "no member" — +// wrong, Mendix does consider them members. But Mendix stores no MemberAccess +// for them (mxbuild rejects a rule that carries one with CE0066), so their +// access can only come from the rule's default. Naming one is therefore +// accepted; asking for rights that differ from the default is refused rather +// than silently dropped. +func TestGrantEntityAccess_AuditMembers(t *testing.T) { + t.Run("named at the rule default is accepted and emits no entry", func(t *testing.T) { + f := newGrantMembersFixture(t, domainmodel.AssociationOwnerDefault, true) + err := execGrantEntityAccess(f.ctx, grantStmt("Issue", + ast.EntityAccessRight{Type: ast.EntityAccessReadAll}, + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{"createdDate", "changedDate"}})) + if err != nil { + t.Fatalf("naming an audit member was rejected: %v", err) + } + for _, ma := range f.captured.MemberAccesses { + if strings.HasSuffix(ma.AttributeRef, ".createdDate") || strings.HasSuffix(ma.AttributeRef, ".changedDate") { + t.Errorf("emitted a MemberAccess for an audit member (%s) — mxbuild rejects this with CE0066", ma.AttributeRef) + } + } + }) + + t.Run("rights differing from the default are refused with a reason", func(t *testing.T) { + f := newGrantMembersFixture(t, domainmodel.AssociationOwnerDefault, true) + err := execGrantEntityAccess(f.ctx, grantStmt("Issue", + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll}, + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{"createdDate"}})) + if err == nil { + t.Fatal("read-only on an audit member under a ReadWrite default must be refused, not silently dropped") + } + for _, want := range []string{"createdDate", "audit member", "CE0066"} { + assertContainsStr(t, err.Error(), want) + } + // The old message claimed the member did not exist. + if strings.Contains(err.Error(), "has no member") { + t.Errorf("error still claims the audit member does not exist: %s", err.Error()) + } + }) + + t.Run("an unknown member is still an error", func(t *testing.T) { + f := newGrantMembersFixture(t, domainmodel.AssociationOwnerDefault, true) + err := execGrantEntityAccess(f.ctx, grantStmt("Issue", + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{"NoSuchMember"}})) + assertError(t, err) + assertContainsStr(t, err.Error(), "NoSuchMember") + }) +} diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index ffdbb65f8..24409c306 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -12,6 +12,7 @@ import ( mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/security" ) @@ -405,39 +406,74 @@ func execGrantEntityAccess(ctx *ExecContext, s *ast.GrantEntityAccessStmt) error }) } - // Create entries for associations where this entity is the FROM entity. - // In Mendix, ParentID = FROM entity (FK owner). MemberAccess for associations - // is only required on the FROM side; adding it to the TO side triggers CE0066. + // Audit members are stored as flags on the entity, not as entries in + // entity.Attributes, so the member walk above never yields them — naming + // `createdDate` in a GRANT was rejected as "entity has no member(s)", which + // is simply wrong: Mendix does consider them members (issuetracker #20). + // + // What Mendix does NOT have is a MemberAccess for them. An entity storing + // audit members checks clean with no entry, and mxbuild rejects a rule that + // carries one with CE0066 (verified on 11.12.1). Their access therefore comes + // from the rule's default and cannot be set per member — so naming one is + // accepted (it is a real member), but asking for rights that differ from the + // default is refused rather than silently dropped. + for _, sys := range storedAuditMembers(entity) { + grantedMembers[sys] = true + rights, clause := "", "" + if writeMemberSet[sys] { + rights, clause = "ReadWrite", "write" + } else if readMemberSet[sys] { + rights, clause = "ReadOnly", "read" + } + if rights != "" && rights != defaultMemberAccess { + return mdlerrors.NewValidationf( + "%s.%s is a Mendix audit member: its access follows the rule's default and cannot be set per member "+ + "(Mendix stores no member access for it, and a rule that carries one fails the build with CE0066). "+ + "Drop it from the %s (...) list and let `read *` / `write *` cover it, or change the rule's default.", + entityQN, sys, clause) + } + } + + // Create entries for associations this entity owns. ParentID = FROM entity + // (FK owner), and for a Default-owner association MemberAccess belongs only + // on that side — adding it to the TO side triggers CE0066. + // + // `OWNER Both` is the exception: both ends own the association and Mendix + // expects a MemberAccess entry on each. Emitting it only on the FROM side + // left the TO entity's rule incomplete, which is CE0066 "Entity access is + // out of date" — the reported symptom (issuetracker #20). Verified against + // mxbuild 11.12.1: the same script with `OWNER Default` checks clean, so the + // owner mode is the trigger, not the reference set. + addAssociationAccess := func(name, ref string) { + rights := defaultMemberAccess + if writeMemberSet[name] { + rights = "ReadWrite" + } else if readMemberSet[name] { + rights = "ReadOnly" + } + grantedMembers[name] = true + memberAccesses = append(memberAccesses, types.EntityMemberAccess{ + AssociationRef: ref, + AccessRights: rights, + }) + } for _, assoc := range dm.Associations { - if assoc.ParentID == entity.ID { - rights := defaultMemberAccess - if writeMemberSet[assoc.Name] { - rights = "ReadWrite" - } else if readMemberSet[assoc.Name] { - rights = "ReadOnly" - } - grantedMembers[assoc.Name] = true - memberAccesses = append(memberAccesses, types.EntityMemberAccess{ - AssociationRef: module.Name + "." + assoc.Name, - AccessRights: rights, - }) + ownedHere := assoc.ParentID == entity.ID || + (assoc.Owner == domainmodel.AssociationOwnerBoth && assoc.ChildID == entity.ID) + if ownedHere { + addAssociationAccess(assoc.Name, module.Name+"."+assoc.Name) } } for _, ca := range dm.CrossAssociations { if ca.ParentID == entity.ID { - rights := defaultMemberAccess - if writeMemberSet[ca.Name] { - rights = "ReadWrite" - } else if readMemberSet[ca.Name] { - rights = "ReadOnly" - } - grantedMembers[ca.Name] = true - memberAccesses = append(memberAccesses, types.EntityMemberAccess{ - AssociationRef: module.Name + "." + ca.Name, - AccessRights: rights, - }) + addAssociationAccess(ca.Name, module.Name+"."+ca.Name) } } + // A cross-module association owned by both ends is stored in the FROM + // entity's module, so this entity — the TO end — has to look for it there. + for _, other := range otherModuleBothOwnerAssociations(ctx, module.Name, entityQN) { + addAssociationAccess(other.Name, other.Ref) + } // A member named in the GRANT that matched nothing used to be dropped in // silence — the command reported success and the access simply was not there, @@ -1100,6 +1136,61 @@ func execCreateDemoUser(ctx *ExecContext, s *ast.CreateDemoUserStmt) error { } // detectUserEntity finds the entity that generalizes System.User. +// storedAuditMembers returns the audit members the entity actually stores, under +// the names Mendix uses for them. They are entity FLAGS rather than entries in +// entity.Attributes, so a member walk never yields them and a GRANT naming one +// was rejected as "no member" (issuetracker #20). +func storedAuditMembers(e *domainmodel.Entity) []string { + if e == nil { + return nil + } + var out []string + if e.HasCreatedDate { + out = append(out, "createdDate") + } + if e.HasChangedDate { + out = append(out, "changedDate") + } + if e.HasOwner { + out = append(out, "owner") + } + if e.HasChangedBy { + out = append(out, "changedBy") + } + return out +} + +// namedAssociation is an association reachable from an entity, with the +// qualified reference to store in a MemberAccess. +type namedAssociation struct{ Name, Ref string } + +// otherModuleBothOwnerAssociations finds cross-module associations owned by BOTH +// ends whose TO end is entityQN. They are stored in the FROM entity's module, so +// scanning only this entity's own domain model misses them. +func otherModuleBothOwnerAssociations(ctx *ExecContext, thisModule, entityQN string) []namedAssociation { + dms, err := ctx.Backend.ListDomainModels() + if err != nil { + return nil + } + h, err := getHierarchy(ctx) + if err != nil { + return nil + } + var out []namedAssociation + for _, dm := range dms { + modName := h.GetModuleName(h.FindModuleID(dm.ContainerID)) + if modName == "" || modName == thisModule { + continue + } + for _, ca := range dm.CrossAssociations { + if ca.Owner == domainmodel.AssociationOwnerBoth && ca.ChildRef == entityQN { + out = append(out, namedAssociation{Name: ca.Name, Ref: modName + "." + ca.Name}) + } + } + } + return out +} + func detectUserEntity(ctx *ExecContext) (string, error) { modules, err := ctx.Backend.ListModules() if err != nil { diff --git a/mdl/executor/cmd_workflows.go b/mdl/executor/cmd_workflows.go index 0395d4742..7d622e643 100644 --- a/mdl/executor/cmd_workflows.go +++ b/mdl/executor/cmd_workflows.go @@ -310,15 +310,21 @@ func formatWorkflowActivities(flow *workflows.Flow, indent string) []string { if target == "" { target = "?" } - caption := a.Caption - if caption == "" { - caption = a.Name - } if a.Annotation != "" { actLines = append(actLines, formatAnnotation(a.Annotation, indent)) } - escapedCaption := strings.ReplaceAll(caption, "'", "''") - actLines = append(actLines, fmt.Sprintf("%sjump to %s comment '%s'", indent, mdlIdent(target), escapedCaption)) + // Only emit `comment '...'` when it carries information the author + // wrote. buildJumpTo defaults Caption to the target name, so echoing it + // unconditionally rendered a plain `jump to Triage;` as + // `jump to Triage comment 'Triage'` — a phantom comment nobody authored + // (issuetracker #16). Re-applying the shorter form rebuilds the same + // Caption, so dropping it is lossless. + if caption := a.Caption; caption != "" && caption != target && caption != a.Name { + escapedCaption := strings.ReplaceAll(caption, "'", "''") + actLines = append(actLines, fmt.Sprintf("%sjump to %s comment '%s'", indent, mdlIdent(target), escapedCaption)) + } else { + actLines = append(actLines, fmt.Sprintf("%sjump to %s", indent, mdlIdent(target))) + } case *workflows.WaitForTimerActivity: caption := a.Caption if caption == "" { diff --git a/mdl/executor/cmd_workflows_describe_test.go b/mdl/executor/cmd_workflows_describe_test.go index 30d05fe65..23a9aea00 100644 --- a/mdl/executor/cmd_workflows_describe_test.go +++ b/mdl/executor/cmd_workflows_describe_test.go @@ -86,10 +86,23 @@ func TestFormatJumpTo_CaptionCommentFormat(t *testing.T) { want: "jump to target1 comment 'Go Back to Review'", }, { - name: "name fallback when caption empty", + // Was: fell back to the activity name and rendered + // `comment 'jumpAct1'`. That comment was never authored — echoing it + // made a plain `jump to X;` round-trip as `jump to X comment '…'` + // (issuetracker #16). An absent caption must emit no comment clause. + name: "no comment clause when caption empty", caption: "", actName: "jumpAct1", - want: "jump to target1 comment 'jumpAct1'", + want: "jump to target1;", + }, + { + // buildJumpTo defaults Caption to the TARGET name, which is the exact + // shape issuetracker #16 reported. It carries no authored information, + // so it must not be echoed either. + name: "no comment clause when caption is the derived target name", + caption: "target1", + actName: "jumpAct2", + want: "jump to target1;", }, { name: "caption with single quote escaped", @@ -197,3 +210,86 @@ func TestFormatCallWorkflowActivity_CaptionCommentFormat(t *testing.T) { }) } } + +// TestExclusiveSplit_NormalizesWorkflowContextExpression pins issuetracker #17: +// a decision's condition was written verbatim while call-microflow parameter +// mappings were normalized, so the documented `$workflowContext` reached Mendix +// as an undefined variable and the build failed CE0117 "Error(s) in expression". +// mxcli always names the context parameter `WorkflowContext`, and Mendix +// expressions are case-sensitive, so every case variant must normalize to it. +func TestExclusiveSplit_NormalizesWorkflowContextExpression(t *testing.T) { + for _, written := range []string{ + "$workflowContext/Title = 'x'", + "$WORKFLOWCONTEXT/Title = 'x'", + "$WorkflowContext/Title = 'x'", + } { + split := &workflows.ExclusiveSplitActivity{Expression: written} + split.Name = "Decision" + autoBindActivitiesInFlow(nil, []workflows.WorkflowActivity{split}, contextExprNormalizer{}) + if want := "$WorkflowContext/Title = 'x'"; split.Expression != want { + t.Errorf("expression %q normalized to %q, want %q", written, split.Expression, want) + } + } +} + +// TestContextExprNormalizer_AliasesDeclaredParameterName pins the other half of +// issuetracker #17: `create workflow … parameter $Ctx: …` lets the author name +// the context, but mxcli stores the parameter as `WorkflowContext` regardless — +// so `$Ctx` in an expression was an undefined variable (CE0117). The declared +// name must be aliased onto the stored one. +func TestContextExprNormalizer_AliasesDeclaredParameterName(t *testing.T) { + tests := []struct { + name string + declared string + expr string + want string + }{ + { + name: "declared alias rewritten", + declared: "$Ctx", + expr: "$Ctx/Total > 1000", + want: "$WorkflowContext/Total > 1000", + }, + { + name: "declared alias without sigil", + declared: "Request", + expr: "$Request/Status = 'New'", + want: "$WorkflowContext/Status = 'New'", + }, + { + name: "canonical name still normalized when an alias is declared", + declared: "$Ctx", + expr: "$workflowContext/Total > 1000", + want: "$WorkflowContext/Total > 1000", + }, + { + // A variable that merely starts with the declared name must not be + // mangled — the alias is a whole-word match. + name: "longer variable sharing the prefix is untouched", + declared: "$Ctx", + expr: "$CtxItem/Total > $Ctx/Limit", + want: "$CtxItem/Total > $WorkflowContext/Limit", + }, + { + name: "no declared name normalizes casing only", + declared: "", + expr: "$workflowcontext/Total > 1000", + want: "$WorkflowContext/Total > 1000", + }, + { + name: "empty expression stays empty", + declared: "$Ctx", + expr: "", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := newContextExprNormalizer(tc.declared).rewrite(tc.expr) + if got != tc.want { + t.Errorf("rewrite(%q) with declared %q = %q, want %q", tc.expr, tc.declared, got, tc.want) + } + }) + } +} diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 2247c35fe..95222f38e 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -23,6 +23,19 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { return mdlerrors.NewNotConnectedWrite() } + // A standalone `annotation` lands in the workflow's activity flow, which + // Mendix loads by constructing every child with a Flow parent — no annotation + // type takes one, so the written .mpr cannot be LOADED at all (Studio Pro + // won't open the project and `mx check` dies before validating anything). + // Refuse here as well as at check time (MDL-WF04): emitting a structurally + // invalid unit takes down the whole project, not one document. (issuetracker #15) + if hasStandaloneWorkflowAnnotation(s.Activities) { + return mdlerrors.NewUnsupported( + "a standalone `annotation` in a workflow body would produce a model Mendix cannot load " + + "(the annotation is placed in the activity flow, which accepts only flow elements) — " + + "remove it, or keep the note as an MDL comment (`-- ...`) [MDL-WF04]") + } + module, err := findOrCreateModule(ctx, s.Name.Module) if err != nil { return err @@ -100,7 +113,7 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { userActivities := buildWorkflowActivities(s.Activities) // Auto-bind microflow/workflow parameters and sanitize names - autoBindWorkflowParameters(ctx, userActivities) + autoBindWorkflowParameters(ctx, userActivities, s.ParameterVar) // Deduplicate activity names to avoid CE0495 deduplicateActivityNames(userActivities) @@ -617,25 +630,27 @@ func sanitizeActivityName(name string) string { // autoBindWorkflowParameters resolves microflow/workflow parameters and generates // ParameterMappings, default outcomes, and sanitized names for workflow activities. -func autoBindWorkflowParameters(ctx *ExecContext, activities []workflows.WorkflowActivity) { - autoBindActivitiesInFlow(ctx, activities) +// declaredContextVar is the variable name from the workflow header's +// `parameter $X:` clause, used to alias `$X` onto the stored context name. +func autoBindWorkflowParameters(ctx *ExecContext, activities []workflows.WorkflowActivity, declaredContextVar string) { + autoBindActivitiesInFlow(ctx, activities, newContextExprNormalizer(declaredContextVar)) } -func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowActivity) { +func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowActivity, norm contextExprNormalizer) { for _, act := range activities { switch a := act.(type) { case *workflows.CallMicroflowTask: - autoBindCallMicroflow(ctx, a) + autoBindCallMicroflow(ctx, a, norm) // Recurse into outcomes for _, outcome := range a.Outcomes { switch o := outcome.(type) { case *workflows.BooleanConditionOutcome: if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities) + autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) } case *workflows.VoidConditionOutcome: if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities) + autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) } } } @@ -644,9 +659,13 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA case *workflows.UserTask: // Sanitize name a.Name = sanitizeActivityName(a.Name) + a.DueDate = norm.rewrite(a.DueDate) + if xp, ok := a.UserSource.(*workflows.XPathBasedUserSource); ok { + xp.XPath = norm.rewrite(xp.XPath) + } for _, outcome := range a.Outcomes { if outcome.Flow != nil { - autoBindActivitiesInFlow(ctx, outcome.Flow.Activities) + autoBindActivitiesInFlow(ctx, outcome.Flow.Activities, norm) } } case *workflows.ParallelSplitActivity: @@ -654,20 +673,27 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA a.Name = sanitizeActivityName(a.Name) for _, outcome := range a.Outcomes { if outcome.Flow != nil { - autoBindActivitiesInFlow(ctx, outcome.Flow.Activities) + autoBindActivitiesInFlow(ctx, outcome.Flow.Activities, norm) } } case *workflows.ExclusiveSplitActivity: a.Name = sanitizeActivityName(a.Name) + // A decision's condition is an expression over the workflow context, + // and the only in-scope variable is that context. It was left verbatim + // while call-microflow parameter mappings were normalized, so the + // documented `$workflowContext` (and the user's own declared parameter + // name) reached Mendix as undefined variables → CE0117 (issuetracker + // #17). Normalize it the same way. + a.Expression = norm.rewrite(a.Expression) for _, outcome := range a.Outcomes { switch o := outcome.(type) { case *workflows.BooleanConditionOutcome: if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities) + autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) } case *workflows.VoidConditionOutcome: if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities) + autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) } } } @@ -675,6 +701,7 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA a.Name = sanitizeActivityName(a.Name) case *workflows.WaitForTimerActivity: a.Name = sanitizeActivityName(a.Name) + a.DelayExpression = norm.rewrite(a.DelayExpression) case *workflows.JumpToActivity: a.Name = sanitizeActivityName(a.Name) } @@ -687,7 +714,7 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA // configured microflow"). The pre-11.9 CallMicroflowTask tolerated a lone // VoidConditionOutcome regardless of return type, which is why this used to be // hardcoded to Void (FINDINGS #39 regression). -func autoBindCallMicroflow(ctx *ExecContext, task *workflows.CallMicroflowTask) { +func autoBindCallMicroflow(ctx *ExecContext, task *workflows.CallMicroflowTask, norm contextExprNormalizer) { // Sanitize name task.Name = sanitizeActivityName(task.Name) @@ -696,7 +723,7 @@ func autoBindCallMicroflow(ctx *ExecContext, task *workflows.CallMicroflowTask) // case-sensitive on 11.9+, so a user-written `$workflowContext` is an undefined // variable → CE0117 (FINDINGS #39 regression). The pre-11.9 class did not flag it. for _, pm := range task.ParameterMappings { - pm.Expression = normalizeWorkflowContextExpr(pm.Expression) + pm.Expression = norm.rewrite(pm.Expression) } // Look up the target microflow — needed both for return-type-matched outcomes @@ -754,15 +781,52 @@ func defaultCallMicroflowOutcomes(mf *microflows.Microflow) []workflows.Conditio return []workflows.ConditionOutcome{o} } +// workflowContextVar is the name mxcli always gives the workflow context +// parameter. Mendix expressions are case-sensitive, so every reference to the +// context has to match it exactly or the build fails CE0117. +const workflowContextVar = "WorkflowContext" + // normalizeWorkflowContextExpr rewrites a case-insensitive `$workflowContext` // reference to the exact context parameter name `$WorkflowContext`. In a workflow // the only in-scope variable is the context, so this is unambiguous. func normalizeWorkflowContextExpr(expr string) string { - return workflowContextRe.ReplaceAllString(expr, "$$WorkflowContext") + return workflowContextRe.ReplaceAllString(expr, "$$"+workflowContextVar) } var workflowContextRe = regexp.MustCompile(`(?i)\$workflowcontext`) +// contextExprNormalizer makes every way an author can name the workflow context +// resolve to the one name it is actually stored under. +// +// `create workflow … parameter $Ctx: Module.Entity` lets the author pick a +// variable name, but mxcli stores the parameter as `WorkflowContext` regardless, +// so `$Ctx` would reach Mendix as an undefined variable. The declared name is +// therefore aliased onto the real one, and casing is normalized on top +// (issuetracker #17). +type contextExprNormalizer struct{ alias *regexp.Regexp } + +// newContextExprNormalizer builds a normalizer for the variable the author +// declared in the workflow header (with or without the `$` sigil; empty means +// "no alias, normalize casing only"). +func newContextExprNormalizer(declared string) contextExprNormalizer { + name := strings.TrimPrefix(declared, "$") + if name == "" || strings.EqualFold(name, workflowContextVar) { + return contextExprNormalizer{} + } + return contextExprNormalizer{alias: regexp.MustCompile(`(?i)\$` + regexp.QuoteMeta(name) + `\b`)} +} + +// rewrite returns expr with every context reference spelled `$WorkflowContext`. +func (n contextExprNormalizer) rewrite(expr string) string { + if expr == "" { + return expr + } + if n.alias != nil { + expr = n.alias.ReplaceAllString(expr, "$$"+workflowContextVar) + } + return normalizeWorkflowContextExpr(expr) +} + // autoBindCallWorkflow resolves workflow parameters and generates ParameterMappings. func autoBindCallWorkflow(ctx *ExecContext, act *workflows.CallWorkflowActivity) { // Sanitize name @@ -807,3 +871,17 @@ func autoBindCallWorkflow(ctx *ExecContext, act *workflows.CallWorkflowActivity) break } } + +// hasStandaloneWorkflowAnnotation reports whether any activity flow in the +// workflow (including nested outcome / path / boundary-event flows) contains a +// standalone `annotation` statement. See execCreateWorkflow for why it is +// refused rather than written. +func hasStandaloneWorkflowAnnotation(acts []ast.WorkflowActivityNode) bool { + found := false + walkWorkflowActivities(acts, func(a ast.WorkflowActivityNode) { + if _, ok := a.(*ast.WorkflowAnnotationActivityNode); ok { + found = true + } + }) + return found +} diff --git a/mdl/executor/issue619_emitter_quoting_test.go b/mdl/executor/issue619_emitter_quoting_test.go index bc3c1f97b..b2bd2f9d3 100644 --- a/mdl/executor/issue619_emitter_quoting_test.go +++ b/mdl/executor/issue619_emitter_quoting_test.go @@ -22,7 +22,10 @@ func TestWorkflowJumpTo_QuotesReservedTarget(t *testing.T) { output := strings.Join(formatSingleActivity(activity, ""), "\n") - if !strings.Contains(output, `jump to "List" comment`) { + // The trailing `;` pins the whole clause: an unquoted emit would be + // `jump to List;`. (Previously this matched `... comment`, which coupled the + // quoting assertion to the phantom comment removed in issuetracker #16.) + if !strings.Contains(output, `jump to "List";`) { t.Errorf("expected reserved jump-to target to be quoted, got:\n%s", output) } } @@ -33,7 +36,7 @@ func TestWorkflowJumpTo_LeavesPlainTargetUnquoted(t *testing.T) { output := strings.Join(formatSingleActivity(activity, ""), "\n") - if !strings.Contains(output, "jump to Review comment") { + if !strings.Contains(output, "jump to Review;") { t.Errorf("expected non-reserved jump-to target to stay unquoted, got:\n%s", output) } } diff --git a/mdl/executor/roundtrip_workflow_test.go b/mdl/executor/roundtrip_workflow_test.go index 6c6c7d504..4bc2d64a8 100644 --- a/mdl/executor/roundtrip_workflow_test.go +++ b/mdl/executor/roundtrip_workflow_test.go @@ -5,6 +5,7 @@ package executor import ( + "fmt" "strings" "testing" ) @@ -12,7 +13,6 @@ import ( // TestRoundtripWorkflow_Comprehensive tests all workflow MDL syntax in a single roundtrip. // // Activity types covered: -// - ANNOTATION // - USER TASK (PAGE, TARGETING MICROFLOW, DUE DATE, OUTCOMES with nested, BOUNDARY EVENT x2) // - MULTI USER TASK (PAGE, TARGETING MICROFLOW, OUTCOMES) // - CALL MICROFLOW (WITH params, OUTCOMES TRUE/FALSE) @@ -69,8 +69,10 @@ end workflow;`); err != nil { parameter $WorkflowContext: ` + mod + `.WfCtxEntity begin - annotation 'Comprehensive workflow covering all MDL syntax'; - + -- NB: no standalone annotation here. Mendix places it in the activity flow, + -- which accepts only flow elements, and the resulting .mpr cannot be LOADED + -- (issuetracker #15) — mxcli refuses it, so it cannot appear in a round-trip + -- test. See TestCreateWorkflow_StandaloneAnnotationRefused. user task ReviewTask 'Review Request' page ` + mod + `.ReviewPage targeting microflow ` + mod + `.GetSingleReviewer @@ -111,8 +113,6 @@ begin wait for notification; - annotation 'End of flow'; - end workflow;` if err := env.executeMDL(createMDL); err != nil { @@ -130,7 +130,6 @@ end workflow;` label string keyword string }{ - {"annotation activity", "annotation 'Comprehensive workflow"}, {"user task", "user task ReviewTask"}, {"outcome approve", "'Approve'"}, {"outcome reject", "'Reject'"}, @@ -148,7 +147,6 @@ end workflow;` {"path 2", "path 2"}, {"call workflow", "call workflow " + mod + ".SubApprovalFlow"}, {"wait for notification", "wait for notification"}, - {"trailing annotation", "annotation 'End of flow'"}, {"parameter", "parameter $WorkflowContext: " + mod + ".WfCtxEntity"}, } @@ -259,92 +257,63 @@ end workflow;` } } -func TestRoundtripWorkflow_AnnotationActivity(t *testing.T) { +// TestCreateWorkflow_StandaloneAnnotationRefused replaces the two round-trip +// tests that used to assert a standalone `annotation` survives write → read → +// describe → re-execute. +// +// It does survive that loop — mxcli's own reader is tolerant — but the loop +// never loaded the project in Mendix, so it proved nothing about validity. It +// does not: mxcli writes the annotation into the workflow's activity flow, and +// Mendix constructs every child of that list with a Flow parent, which no +// annotation type accepts. The .mpr cannot be LOADED at all — `mx check` dies +// at "Loading the mpr file" with +// +// System.InvalidOperationException: Type Mendix.Modeler.Workflows.Model.Annotation +// does not contain a constructor with a parameter of type +// ...Workflows.Model.Flow +// +// (reproduced on mxbuild 11.12.1 with the guard stubbed out). The old tests +// were pinning that defect in place. mxcli now refuses the construct, so the +// behaviour to lock in is the refusal. (issuetracker #15) +func TestCreateWorkflow_StandaloneAnnotationRefused(t *testing.T) { env := setupTestEnv(t) defer env.teardown() - createMDL := `create workflow ` + testModule + `.WfAnnotation - parameter $WorkflowContext: ` + testModule + `.TestEntityAnnot -begin - annotation 'This is a workflow note'; -end workflow;` - if err := env.executeMDL(`create or modify persistent entity ` + testModule + `.TestEntityAnnot (Name: String(100));`); err != nil { t.Fatalf("Failed to create entity: %v", err) } - if err := env.executeMDL(createMDL); err != nil { - t.Fatalf("Failed to create workflow: %v", err) - } - - output, err := env.describeMDL(`describe workflow ` + testModule + `.WfAnnotation;`) - if err != nil { - t.Fatalf("Failed to describe workflow: %v", err) - } - - if !strings.Contains(output, "annotation 'This is a workflow note'") { - t.Errorf("Expected describe output to contain \"annotation 'This is a workflow note'\", got:\n%s", output) - } - - // Full round-trip: DESCRIBE output must be re-executable (annotation must survive re-create) - describeOutput := output - // Replace WORKFLOW with CREATE OR REPLACE WORKFLOW for round-trip execution - createFromDescribe := strings.Replace(describeOutput, "\nworkflow ", "\ncreate or replace workflow ", 1) - // Strip comment header lines (-- ...) before the CREATE OR REPLACE WORKFLOW - var mdlLines []string - inBody := false - for _, line := range strings.Split(createFromDescribe, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "create or replace workflow") { - inBody = true - } - if inBody { - mdlLines = append(mdlLines, line) - } - } - roundTripMDL := strings.Join(mdlLines, "\n") - if err := env.executeMDL(roundTripMDL); err != nil { - t.Errorf("Round-trip execution failed (describe output is not re-executable): %v\nMDL:\n%s", err, roundTripMDL) - } -} - -// TestRoundtripWorkflow_AnnotationBeforeActivity tests that a workflow activity's -// embedded annotation (BaseWorkflowActivity.Annotation) is preserved in DESCRIBE output -// as a parseable ANNOTATION statement rather than a SQL comment. -func TestRoundtripWorkflow_AnnotationBeforeActivity(t *testing.T) { - env := setupTestEnv(t) - defer env.teardown() - - // Create a workflow with an ANNOTATION before a WAIT FOR TIMER. - // This mimics the pattern from Studio Pro where an annotation is attached to an activity. - createMDL := `create workflow ` + testModule + `.WfAnnotBeforeTimer - parameter $WorkflowContext: ` + testModule + `.TestEntityAnnotTimer + cases := []struct { + name string + body string + }{ + { + name: "annotation alone in the body", + body: " annotation 'This is a workflow note';", + }, + { + name: "annotation preceding an activity", + body: " annotation 'I am a note';\n wait for timer 'addDays([%CurrentDateTime%], 1)' comment 'Timer';", + }, + } + + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + name := fmt.Sprintf("%s.WfAnnotRefused%d", testModule, i) + err := env.executeMDL(`create workflow ` + name + ` + parameter $WorkflowContext: ` + testModule + `.TestEntityAnnot begin - annotation 'I am a note'; - wait for timer 'addDays([%CurrentDateTime%], 1)' comment 'Timer'; -end workflow;` - - if err := env.executeMDL(`create or modify persistent entity ` + testModule + `.TestEntityAnnotTimer (Name: String(100));`); err != nil { - t.Fatalf("Failed to create entity: %v", err) - } - - if err := env.executeMDL(createMDL); err != nil { - t.Fatalf("Failed to create workflow: %v", err) - } - - output, err := env.describeMDL(`describe workflow ` + testModule + `.WfAnnotBeforeTimer;`) - if err != nil { - t.Fatalf("Failed to describe workflow: %v", err) - } - - // The annotation must appear as a parseable ANNOTATION statement, not as a SQL comment. - if !strings.Contains(output, "annotation 'I am a note'") { - t.Errorf("Expected describe to emit annotation statement, got:\n%s", output) - } - if strings.Contains(output, "-- I am a note") { - t.Errorf("describe must not emit annotation as sql comment (not round-trippable), got:\n%s", output) - } - if !strings.Contains(output, "wait for timer") { - t.Errorf("Expected describe to contain wait for timer, got:\n%s", output) +` + tc.body + ` +end workflow;`) + if err == nil { + t.Fatal("standalone annotation was accepted — it writes an .mpr Mendix cannot load") + } + for _, want := range []string{"MDL-WF04", "cannot load"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal should mention %q, got: %v", want, err) + } + } + }) } } diff --git a/mdl/executor/validate_rest_mapping.go b/mdl/executor/validate_rest_mapping.go new file mode 100644 index 000000000..c28d633a8 --- /dev/null +++ b/mdl/executor/validate_rest_mapping.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Check-time (no-project) validation for consumed REST client operations. +// +// `Body:`/`Response: MAPPING X` names an entity and maps JSON fields onto it in +// a `{ ... }` body; Mendix stores that inline on the operation. There is no +// response handling that references an import/export mapping *document* — the +// metamodel offers exactly two (Rest$ImplicitMappingResponseHandling and +// Rest$NoResponseHandling). Written without a body the clause therefore carries +// no mapping at all, and used to be persisted as "no response handling" without +// a word of warning — see issue #843. +// +// The check compares only what is already in the statement, so it needs no +// project. +package executor + +import ( + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// ValidateRestClientMappings reports (MDL-REST01) a REST client operation whose +// body/response mapping clause has no mapping body. +func ValidateRestClientMappings(prog *ast.Program) []linter.Violation { + var out []linter.Violation + for _, stmt := range prog.Statements { + createStmt, ok := stmt.(*ast.CreateRestClientStmt) + if !ok { + continue + } + for _, opDef := range createStmt.Operations { + err := checkInlineMappingBody(opDef) + if err == nil { + continue + } + out = append(out, linter.Violation{ + RuleID: "MDL-REST01", + Severity: linter.SeverityError, + Message: "operation \"" + opDef.Name + "\": " + err.Error(), + Suggestion: "List the JSON fields inline. A consumed REST operation stores its mapping on the " + + "operation itself, so an existing import/export mapping document cannot be referenced here.", + }) + } + } + return out +} diff --git a/mdl/executor/validate_security.go b/mdl/executor/validate_security.go new file mode 100644 index 000000000..6063eb212 --- /dev/null +++ b/mdl/executor/validate_security.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Check-time (no-project) validation for security statements. The executor +// refuses these at write time; without a matching check-time rule the same +// script passes `mxcli check` and only fails once it is run against a project, +// which is exactly the round-trip `check` exists to avoid. +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// auditMemberNames are the members Mendix stores as entity flags rather than +// attributes. They are reserved — mxcli rejects declaring an ordinary attribute +// under these names — so a GRANT naming one always means the audit member, and +// no project is needed to recognise it. +var auditMemberNames = map[string]bool{ + "createddate": true, + "changeddate": true, + "owner": true, + "changedby": true, +} + +// ValidateGrantEntityAccess checks a GRANT for member rights Mendix cannot +// store, without requiring a project connection. +// +// - MDL-SEC01: an audit member (createdDate/changedDate/owner/changedBy) given +// per-member rights that differ from the rule's default. Mendix keeps no +// MemberAccess for these — an entity storing them checks clean with none, +// and a rule that carries one fails the build with CE0066 — so their access +// can only come from the rule's default. (issuetracker #20) +func ValidateGrantEntityAccess(stmt *ast.GrantEntityAccessStmt) []linter.Violation { + if stmt == nil { + return nil + } + loc := linter.Location{ + Module: stmt.Entity.Module, + DocumentType: "entity", + DocumentName: stmt.Entity.Name, + } + + // The rule's default: `write *` makes it ReadWrite, `read *` ReadOnly, + // neither leaves it None. A named member matching that default is a no-op + // and stays legal. + defaultRights := "None" + for _, r := range stmt.Rights { + switch r.Type { + case ast.EntityAccessWriteAll: + defaultRights = "ReadWrite" + case ast.EntityAccessReadAll: + if defaultRights == "None" { + defaultRights = "ReadOnly" + } + } + } + + var out []linter.Violation + flag := func(member, wanted, clause string) { + if wanted == defaultRights { + return + } + out = append(out, linter.Violation{ + RuleID: "MDL-SEC01", + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf( + "grant on %s.%s gives the audit member %s per-member rights — Mendix stores no member access for audit members, and a rule that carries one fails the build with CE0066", + stmt.Entity.Module, stmt.Entity.Name, member), + Suggestion: fmt.Sprintf( + "Drop %s from the %s (...) list and let `read *` / `write *` cover it, or change the rule's default.", + member, clause), + }) + } + + for _, r := range stmt.Rights { + var wanted, clause string + switch r.Type { + case ast.EntityAccessReadMembers: + wanted, clause = "ReadOnly", "read" + case ast.EntityAccessWriteMembers: + wanted, clause = "ReadWrite", "write" + default: + continue + } + for _, m := range r.Members { + if auditMemberNames[strings.ToLower(strings.Trim(m, `"`))] { + flag(m, wanted, clause) + } + } + } + return out +} diff --git a/mdl/executor/validate_security_test.go b/mdl/executor/validate_security_test.go new file mode 100644 index 000000000..39a0c199f --- /dev/null +++ b/mdl/executor/validate_security_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// MDL-SEC01 gives `mxcli check` parity with the executor's refusal. Without it +// a script naming an audit member with non-default rights passed `check` and +// only failed once run against a project — the round-trip `check` exists to +// avoid, and the gap CI caught on issuetracker #20. +func TestValidateGrantEntityAccess_AuditMemberRights(t *testing.T) { + grant := func(rights ...ast.EntityAccessRight) *ast.GrantEntityAccessStmt { + return &ast.GrantEntityAccessStmt{ + Entity: ast.QualifiedName{Module: "IT", Name: "Doc"}, + Roles: []ast.QualifiedName{{Module: "IT", Name: "Admin"}}, + Rights: rights, + } + } + + tests := []struct { + name string + stmt *ast.GrantEntityAccessStmt + wantRule bool + wantNamed string + }{ + { + name: "read-only audit member under a ReadWrite default is rejected", + stmt: grant( + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll}, + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{"createdDate"}}), + wantRule: true, + wantNamed: "createdDate", + }, + { + name: "write on an audit member under a ReadOnly default is rejected", + stmt: grant( + ast.EntityAccessRight{Type: ast.EntityAccessReadAll}, + ast.EntityAccessRight{Type: ast.EntityAccessWriteMembers, Members: []string{"changedDate"}}), + wantRule: true, + wantNamed: "changedDate", + }, + { + name: "quoted name is still recognised", + stmt: grant( + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll}, + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{`"createdDate"`}}), + wantRule: true, + }, + { + // Naming a member at the rule's own default is a no-op, not an error — + // it is how you spell "yes, I know this member exists". + name: "audit member matching the default is allowed", + stmt: grant( + ast.EntityAccessRight{Type: ast.EntityAccessReadAll}, + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{"createdDate", "changedDate"}}), + wantRule: false, + }, + { + name: "ordinary attributes are never flagged", + stmt: grant( + ast.EntityAccessRight{Type: ast.EntityAccessWriteAll}, + ast.EntityAccessRight{Type: ast.EntityAccessReadMembers, Members: []string{"DocTitle", "CreatedDateLocal"}}), + wantRule: false, + }, + { + name: "wildcards alone are fine", + stmt: grant(ast.EntityAccessRight{Type: ast.EntityAccessReadAll}), + wantRule: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ValidateGrantEntityAccess(tc.stmt) + if tc.wantRule { + if len(got) == 0 { + t.Fatal("expected MDL-SEC01, got none — check would pass a script exec refuses") + } + if got[0].RuleID != "MDL-SEC01" { + t.Errorf("RuleID = %q, want MDL-SEC01", got[0].RuleID) + } + if !strings.Contains(got[0].Message, "CE0066") { + t.Errorf("message should name the build error it prevents: %s", got[0].Message) + } + if tc.wantNamed != "" && !strings.Contains(got[0].Message, tc.wantNamed) { + t.Errorf("message should name %q: %s", tc.wantNamed, got[0].Message) + } + } else if len(got) != 0 { + t.Errorf("expected no violation, got %+v", got) + } + }) + } +} diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index 251410dc6..33d211c9c 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -29,6 +29,7 @@ var wfOutcomeIdentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // - MDL-WF02: single-outcome user task containing nested activities (CE1876) // - MDL-WF03: decision / call-microflow outcome that is not a valid // enumeration value identifier +// - MDL-WF04: standalone `annotation` in a workflow body (unloadable model) func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { var out []linter.Violation loc := linter.Location{ @@ -64,6 +65,19 @@ func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { out = append(out, checkWorkflowOutcomeNames(n.Outcomes, "decision", loc)...) case *ast.WorkflowCallMicroflowNode: out = append(out, checkWorkflowOutcomeNames(n.Outcomes, "call microflow", loc)...) + case *ast.WorkflowAnnotationActivityNode: + // MDL-WF04 — a standalone annotation is written into the workflow's + // activity flow, but Mendix constructs every child of that list with a + // Flow parent, and no annotation type takes one. The result is not a + // build error but a project Mendix cannot LOAD, so Studio Pro will not + // open it and `mx check` dies before validating anything. + out = append(out, linter.Violation{ + RuleID: "MDL-WF04", + Severity: linter.SeverityError, + Location: loc, + Message: "a standalone `annotation` in a workflow body produces a model Mendix cannot load (the annotation is placed in the activity flow, which accepts only flow elements) — Studio Pro will not open the project", + Suggestion: "Remove the `annotation` statement. Use an MDL comment (`-- ...`) to keep the note in the script; workflow canvas annotations are not yet writable.", + }) } }) return out diff --git a/mdl/executor/validate_workflow_test.go b/mdl/executor/validate_workflow_test.go index 2ab6c03cb..34a34bb7e 100644 --- a/mdl/executor/validate_workflow_test.go +++ b/mdl/executor/validate_workflow_test.go @@ -146,3 +146,36 @@ end workflow;` t.Fatalf("boolean decision should not trigger MDL-WF03, got %v", vs) } } + +// MDL-WF04 — a standalone `annotation` in a workflow body is refused. +// +// mxcli placed the Annotation in the workflow's activity flow, where Mendix +// constructs every child with a Flow parent. The result is a project Mendix +// cannot LOAD ("Type ...Workflows.Model.Annotation does not contain a +// constructor with a parameter of type ...Flow") — Studio Pro will not open it +// and `mx check` dies before validating anything. Until the correct container +// is known, refusing beats emitting an unopenable model. (issuetracker #15) +func TestValidateWorkflow_StandaloneAnnotationRefused(t *testing.T) { + src := wfPreamble + `create workflow WF.Flow + parameter $Context: WF.Ctx +begin + annotation 'Escalation path per policy 4.2'; + user task T 'Do it' page WF.TaskPage outcomes 'Approve' { } 'Reject' { }; +end workflow;` + vs := workflowViolations(t, src) + if !hasRule(vs, "MDL-WF04") { + t.Fatalf("expected MDL-WF04 for a standalone workflow annotation, got %v", vs) + } +} + +// A workflow without a standalone annotation must not trip MDL-WF04. +func TestValidateWorkflow_NoAnnotationNoWF04(t *testing.T) { + src := wfPreamble + `create workflow WF.Flow + parameter $Context: WF.Ctx +begin + user task T 'Do it' page WF.TaskPage outcomes 'Approve' { } 'Reject' { }; +end workflow;` + if vs := workflowViolations(t, src); hasRule(vs, "MDL-WF04") { + t.Errorf("MDL-WF04 must not fire without an annotation: %v", vs) + } +} diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index bfd877c39..c69c302c8 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -227,6 +227,14 @@ type BuildContext struct { type PluggableWidgetEngine struct { backend backend.WidgetBuilderBackend pageBuilder *pageBuilder + + // outerEntityContext is the entity context as it stood when the current + // widget's Build started, before any of its own DataSource mappings moved + // pageBuilder.entityContext to the widget's own data (e.g. a ComboBox's + // option list). A property that names something on the *containing* data — + // an association the widget binds — must be qualified against this, not + // against the widget's own datasource entity. (issuetracker #19) + outerEntityContext string } // NewPluggableWidgetEngine creates a new engine with the given backend and page builder. @@ -243,6 +251,12 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* oldEntityContext := e.pageBuilder.entityContext defer func() { e.pageBuilder.entityContext = oldEntityContext }() + // Remember the containing context for properties that name members of it + // rather than of this widget's own data. Saved/restored for nested widgets. + oldOuterEntityContext := e.outerEntityContext + e.outerEntityContext = oldEntityContext + defer func() { e.outerEntityContext = oldOuterEntityContext }() + // 1. Load template via backend builder, err := e.backend.LoadWidgetTemplate(def.WidgetID, e.pageBuilder.getProjectPath()) if err != nil { @@ -706,7 +720,18 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W attr = w.GetAttribute() } if attr != "" { - ctx.AssocPath = e.pageBuilder.resolveAssociationPath(attr) + // The association belongs to the CONTAINING entity, not to this + // widget's option list. A `DataSource:` mapping listed before this + // one has already moved entityContext to the option entity, so + // qualifying a bare name against it produced a reference into the + // wrong module — `System.Issue_Assignee` for a ComboBox over + // `System.User`, which fails CE1613 "The selected association … no + // longer exists". (issuetracker #19) + outer := e.outerEntityContext + if outer == "" { + outer = e.pageBuilder.entityContext + } + ctx.AssocPath = e.pageBuilder.resolveAssociationPathIn(attr, outer) } ctx.EntityName = e.pageBuilder.entityContext if ctx.AssocPath != "" && ctx.EntityName == "" { diff --git a/mdl/executor/widget_engine_test.go b/mdl/executor/widget_engine_test.go index 49967d8f4..7ac227b39 100644 --- a/mdl/executor/widget_engine_test.go +++ b/mdl/executor/widget_engine_test.go @@ -479,3 +479,44 @@ func TestResolveMapping_Association(t *testing.T) { t.Errorf("expected EntityName='Module.Order', got %q", ctx.EntityName) } } + +// TestResolveMapping_Association_QualifiesAgainstOuterContext pins issuetracker +// #19's ComboBox half. A ComboBox's `DataSource:` mapping is applied before its +// `Association:` mapping and moves pageBuilder.entityContext to the OPTION LIST +// entity. A bare association name was then qualified with that module — for a +// ComboBox over System.User it became `System.Issue_Assignee`, and mxbuild +// failed CE1613 "The selected association … no longer exists". The association +// belongs to the containing entity, so it must be qualified against the context +// as it stood when the widget's Build started. +func TestResolveMapping_Association_QualifiesAgainstOuterContext(t *testing.T) { + pb := &pageBuilder{ + // Already moved to the option list by the DataSource mapping. + entityContext: "System.User", + paramEntityNames: map[string]string{}, + widgetScope: map[string]model.ID{}, + } + engine := &PluggableWidgetEngine{ + pageBuilder: pb, + // The dataview the ComboBox sits in. + outerEntityContext: "IssueTracker.Issue", + } + + mapping := PropertyMapping{ + PropertyKey: "attributeAssociation", + Source: "Association", + Operation: "association", + } + w := &ast.WidgetV3{Properties: map[string]any{"Association": "Issue_Assignee"}} + + ctx, err := engine.resolveMapping(mapping, w) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := "IssueTracker.Issue_Assignee"; ctx.AssocPath != want { + t.Errorf("AssocPath = %q, want %q (qualified with the option list's module is CE1613)", ctx.AssocPath, want) + } + // DestinationEntity still comes from the widget's own data source. + if ctx.EntityName != "System.User" { + t.Errorf("EntityName = %q, want System.User", ctx.EntityName) + } +} diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index 82132ba22..cc931826f 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -105,6 +105,7 @@ xpathComparisonExpr xpathValueExpr : xpathFunctionCall + | MINUS xpathValueExpr | xpathPath | LPAREN xpathExpr RPAREN ; @@ -130,13 +131,19 @@ xpathQualifiedName : xpathWord (DOT xpathWord)* ; -/** Any single-word token that can appear as part of a name in XPath. */ +/** Any single-word token that can appear as part of a name in XPath. + * + * MINUS is excluded: a hyphen inside a name is already lexed as a single + * HYPHENATED_ID (`starts-with`), so a standalone `-` is never part of a name. + * While it was admissible here, `[Amount > -7]` parsed the sign as a name word + * and left the digits stranded — reported as "negative literals truncate to -" + * (issuetracker finding #18). It is a unary operator; see xpathValueExpr. */ xpathWord : ~( DOT | SLASH | LBRACKET | RBRACKET | LPAREN | RPAREN | COMMA | EQUALS | NOT_EQUALS | LESS_THAN | LESS_THAN_OR_EQUAL | GREATER_THAN | GREATER_THAN_OR_EQUAL | AND | OR | NOT - | SEMICOLON + | SEMICOLON | MINUS | STRING_LITERAL | NUMBER_LITERAL | VARIABLE | MENDIX_TOKEN | DOLLAR_STRING ) ; diff --git a/mdl/visitor/visitor_import_export_mapping.go b/mdl/visitor/visitor_import_export_mapping.go index 63fc92383..54d9c1648 100644 --- a/mdl/visitor/visitor_import_export_mapping.go +++ b/mdl/visitor/visitor_import_export_mapping.go @@ -54,7 +54,7 @@ func buildImportRootElement(ctx *parser.ImportMappingRootElementContext) *ast.Im // Entity name if ctx.QualifiedName() != nil { - elem.Entity = ctx.QualifiedName().GetText() + elem.Entity = buildQualifiedName(ctx.QualifiedName()).String() } // Children @@ -83,8 +83,8 @@ func buildImportChild(ctx *parser.ImportMappingChildContext) *ast.ImportMappingE // Association path: qualifiedName SLASH qualifiedName allQN := ctx.AllQualifiedName() if len(allQN) >= 2 { - elem.Association = allQN[0].GetText() - elem.Entity = allQN[1].GetText() + elem.Association = buildQualifiedName(allQN[0]).String() + elem.Entity = buildQualifiedName(allQN[1]).String() } // JSON key after EQUALS @@ -106,7 +106,7 @@ func buildImportChild(ctx *parser.ImportMappingChildContext) *ast.ImportMappingE } allQN := ctx.AllQualifiedName() if len(allQN) >= 1 { - elem.Converter = allQN[0].GetText() + elem.Converter = buildQualifiedName(allQN[0]).String() } if len(allIdent) >= 2 { elem.ConverterParam = identifierOrKeywordText(allIdent[1]) @@ -174,7 +174,7 @@ func buildExportRootElement(ctx *parser.ExportMappingRootElementContext) *ast.Ex elem := &ast.ExportMappingElementDef{} if ctx.QualifiedName() != nil { - elem.Entity = ctx.QualifiedName().GetText() + elem.Entity = buildQualifiedName(ctx.QualifiedName()).String() } for _, childCtx := range ctx.AllExportMappingChild() { @@ -197,8 +197,8 @@ func buildExportChild(ctx *parser.ExportMappingChildContext) *ast.ExportMappingE if len(allQN) >= 2 { // Object mapping: Assoc/Entity AS jsonKey - elem.Association = allQN[0].GetText() - elem.Entity = allQN[1].GetText() + elem.Association = buildQualifiedName(allQN[0]).String() + elem.Entity = buildQualifiedName(allQN[1]).String() // JSON key after AS allIdent := ctx.AllIdentifierOrKeyword() diff --git a/mdl/visitor/visitor_mapping_quoted_test.go b/mdl/visitor/visitor_mapping_quoted_test.go new file mode 100644 index 000000000..874e03669 --- /dev/null +++ b/mdl/visitor/visitor_mapping_quoted_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestImportMappingBody_StripsQuotedIdentifiers guards issue #842. +// +// Quoting identifiers is the documented way to avoid MDL keyword collisions, and +// it is stripped generically everywhere else. Inside a mapping body the entity +// and association names were read with ctx.QualifiedName().GetText(), which +// returns the raw parse text — quotes included — so the reference was stored as +// `ZZB."Routing"` and Mendix reported, on an otherwise clean project: +// +// [error] [CE1613] "The selected entity 'ZZB."Routing"' no longer exists." +// [error] [CE1613] "The selected attribute 'ZZB."Routing".RouteId' no longer exists." +// +// The attribute half already came through unquoted (identifierOrKeywordText), +// which is what made the stored name a mix of stripped and unstripped parts. +func TestImportMappingBody_StripsQuotedIdentifiers(t *testing.T) { + prog, errs := Build(` +create import mapping ZZB."IMM_Route" + with json structure ZZB."JSON_Route" +{ + create ZZB."Routing" { + "RouteId" = id, + "RouteName" = name + } +};`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt, ok := prog.Statements[0].(*ast.CreateImportMappingStmt) + if !ok { + t.Fatalf("statement type = %T, want *ast.CreateImportMappingStmt", prog.Statements[0]) + } + if got, want := stmt.RootElement.Entity, "ZZB.Routing"; got != want { + t.Errorf("root entity = %q, want %q — quotes must be stripped like everywhere else", got, want) + } + for _, child := range stmt.RootElement.Children { + if child.Attribute == `"RouteId"` || child.Attribute == `"RouteName"` { + t.Errorf("attribute %q kept its quotes", child.Attribute) + } + } +} + +// A nested object element carries both an association and an entity; both are +// read from the same raw-text path and both must be stripped. +func TestImportMappingNestedObject_StripsQuotedIdentifiers(t *testing.T) { + prog, errs := Build(` +create import mapping ZZB."IMM_Order" + with json structure ZZB."JSON_Order" +{ + create ZZB."Order" { + "OrderId" = orderId, + create ZZB."Order_Line"/ZZB."Line" = items { + "Sku" = sku + } + } +};`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt := prog.Statements[0].(*ast.CreateImportMappingStmt) + var found bool + for _, child := range stmt.RootElement.Children { + if child.Association == "" { + continue + } + found = true + if child.Association != "ZZB.Order_Line" { + t.Errorf("association = %q, want ZZB.Order_Line", child.Association) + } + if child.Entity != "ZZB.Line" { + t.Errorf("nested entity = %q, want ZZB.Line", child.Entity) + } + } + if !found { + t.Fatal("no nested object element found") + } +} + +// Export mapping bodies read the same way and have the same defect. +func TestExportMappingBody_StripsQuotedIdentifiers(t *testing.T) { + prog, errs := Build(` +create export mapping ZZB."EMM_Route" + with json structure ZZB."JSON_Route" +{ + ZZB."Routing" { + id = "RouteId", + name = "RouteName" + } +};`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + stmt, ok := prog.Statements[0].(*ast.CreateExportMappingStmt) + if !ok { + t.Fatalf("statement type = %T, want *ast.CreateExportMappingStmt", prog.Statements[0]) + } + if got, want := stmt.RootElement.Entity, "ZZB.Routing"; got != want { + t.Errorf("export root entity = %q, want %q", got, want) + } +} diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index d0df006e2..1c8cd910a 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -1653,6 +1653,10 @@ func xpathExprToString(expr ast.Expression) string { } return "not(" + operand + ")" } + // Unary minus binds to its operand: `-7`, not `- 7`. + if op == "-" { + return "-" + operand + } return op + " " + operand case *ast.XPathPathExpr: return xpathPathToString(e) diff --git a/mdl/visitor/visitor_xpath.go b/mdl/visitor/visitor_xpath.go index 12b6e1103..c31972ea2 100644 --- a/mdl/visitor/visitor_xpath.go +++ b/mdl/visitor/visitor_xpath.go @@ -122,6 +122,17 @@ func buildXPathValueExpr(ctx parser.IXpathValueExprContext) ast.Expression { return buildXPathFunctionCall(fc) } + // Unary minus: -7, -12.5, -(expr). The grammar admits this so that a + // negative numeric literal can be written unquoted inside a constraint + // (issuetracker finding #18); without a case here the operand is dropped + // and `[Amount > -7]` serializes to `[Amount > ]`. + if valCtx.MINUS() != nil { + return &ast.UnaryExpr{ + Operator: "-", + Operand: buildXPathValueExpr(valCtx.XpathValueExpr()), + } + } + // Path: step/step/step if path := valCtx.XpathPath(); path != nil { return buildXPathPath(path) diff --git a/mdl/visitor/visitor_xpath_test.go b/mdl/visitor/visitor_xpath_test.go index dfbedb802..8fd3d8676 100644 --- a/mdl/visitor/visitor_xpath_test.go +++ b/mdl/visitor/visitor_xpath_test.go @@ -429,3 +429,57 @@ func TestXPath_ASTTypes(t *testing.T) { } }) } + +// TestXPath_NegativeNumericLiteral pins issuetracker finding #18. +// +// `-` was admissible as an xpathWord, so `[Amount > -7]` parsed the sign as a +// name and left the digits stranded — `extraneous input '7'`. The report called +// this "negative literals truncate to -", which is what it looks like from the +// outside. Adding unary minus to the grammar without a matching visitor case +// would have been worse than the parse error: the constraint would parse and +// silently serialize to `[Amount > ]`. +func TestXPath_NegativeNumericLiteral(t *testing.T) { + t.Run("round-trips with the sign intact", func(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"negative integer", "[Amount > -7]", "[Amount > -7]"}, + {"negative decimal", "[Amount <= -12.5]", "[Amount <= -12.5]"}, + {"negative on the left", "[-7 < Amount]", "[-7 < Amount]"}, + {"compound with and", "[Amount > -7 and Code = 'A']", "[Amount > -7 and Code = 'A']"}, + {"function argument", "[contains(Code, 'A') and Amount > -1]", "[contains(Code, 'A') and Amount > -1]"}, + // Regression: MINUS was removed from xpathWord, so hyphenated XPath + // function names must still resolve (they lex as one HYPHENATED_ID). + {"hyphenated function still parses", "[starts-with(Code, 'AB')]", "[starts-with(Code, 'AB')]"}, + // Regression: positive literals unaffected. + {"positive integer", "[Amount > 7]", "[Amount > 7]"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := roundTripXPath(tc.input); got != tc.want { + t.Errorf("roundTripXPath(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } + }) + + t.Run("builds a UnaryExpr, not a dropped operand", func(t *testing.T) { + expr := parseXPathConstraint("[Amount > -7]") + bin, ok := expr.(*ast.BinaryExpr) + if !ok { + t.Fatalf("expected BinaryExpr, got %T", expr) + } + unary, ok := bin.Right.(*ast.UnaryExpr) + if !ok { + t.Fatalf("right operand = %T, want *ast.UnaryExpr — a nil operand serializes to an empty string", bin.Right) + } + if unary.Operator != "-" { + t.Errorf("operator = %q, want -", unary.Operator) + } + if unary.Operand == nil { + t.Error("operand is nil — the literal was dropped") + } + }) +} diff --git a/modelsdk/mpr/serialize_web_services.go b/modelsdk/mpr/serialize_web_services.go index a94ab8f27..16194202b 100644 --- a/modelsdk/mpr/serialize_web_services.go +++ b/modelsdk/mpr/serialize_web_services.go @@ -426,7 +426,9 @@ func serWebRestOperation(op *model.RestClientOperation) bson.D { } doc = append(doc, bson.E{Key: "QueryParameters", Value: queryParams}) - if op.ResponseType == "MAPPING" && op.ResponseEntity != "" && len(op.ResponseMappings) > 0 { + // Case-insensitive for the same reason as the codec writer: the else-branch + // silently downgrades to Rest$NoResponseHandling (#843). + if strings.EqualFold(op.ResponseType, "MAPPING") && op.ResponseEntity != "" && len(op.ResponseMappings) > 0 { doc = append(doc, bson.E{Key: "ResponseHandling", Value: serWebRestImplicitMappingResponse(op.ResponseEntity, op.ResponseMappings)}) } else { doc = append(doc, bson.E{Key: "ResponseHandling", Value: serWebRestResponseHandling(op.ResponseType)}) @@ -444,7 +446,7 @@ func serWebRestMethod(op *model.RestClientOperation) bson.D { {Key: "$Type", Value: "Rest$RestOperationMethodWithBody"}, {Key: "HttpMethod", Value: httpMethod}, } - if op.BodyType == "EXPORT_MAPPING" && len(op.BodyMappings) > 0 { + if strings.EqualFold(op.BodyType, "EXPORT_MAPPING") && len(op.BodyMappings) > 0 { bodyDoc = append(bodyDoc, bson.E{Key: "Body", Value: serWebRestImplicitMappingBody(op.BodyVariable, op.BodyMappings)}) } else { bodyDoc = append(bodyDoc, bson.E{Key: "Body", Value: serWebRestBody(op.BodyType, op.BodyVariable)}) diff --git a/sdk/mpr/writer_javascriptactions.go b/sdk/mpr/writer_javascriptactions.go index de350f3b1..6ff951abc 100644 --- a/sdk/mpr/writer_javascriptactions.go +++ b/sdk/mpr/writer_javascriptactions.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/javaactions" @@ -119,10 +120,19 @@ func (w *Writer) serializeJavaScriptAction(jsa *JavaScriptAction) ([]byte, error return marshalUnitIDFirst(doc) } -// jsActionSourceDir returns javascriptsource//actions, using the original -// module-name casing Studio Pro writes (unlike javasource, which is lowercased). +// jsActionSourceDir returns javascriptsource//actions with the module +// name LOWERCASED, which is where Mendix looks: a blank Mendix 11 app ships +// javascriptsource/nanoflowcommons/, /datawidgets/ and /webactions/ for modules +// named NanoflowCommons, DataWidgets and WebActions. +// +// Writing the original casing instead is silent and total: mxbuild finds no +// source at the path it reads, generates a stub whose body throws +// "JavaScript action was not implemented", and bundles that. The action parses, +// passes `mxcli check` and builds cleanly, then throws when a user clicks it. +// Only reproduces on a case-sensitive filesystem — on macOS and Windows the two +// spellings are the same directory, which is why it went unnoticed. func (w *Writer) jsActionSourceDir(moduleName string) string { - return filepath.Join(filepath.Dir(w.reader.path), "javascriptsource", moduleName, "actions") + return filepath.Join(filepath.Dir(w.reader.path), "javascriptsource", strings.ToLower(moduleName), "actions") } // WriteJavaScriptSourceFile writes javascriptsource//actions/.js. diff --git a/sdk/pages/pages_datasources.go b/sdk/pages/pages_datasources.go index ee02ce9e9..45e02e1b2 100644 --- a/sdk/pages/pages_datasources.go +++ b/sdk/pages/pages_datasources.go @@ -52,6 +52,10 @@ type MicroflowSource struct { model.BaseElement MicroflowID model.ID `json:"microflowId"` Microflow string `json:"microflow"` // Qualified name (e.g., "Module.MicroflowName") + // Argument bindings for a parameterized source microflow. Mendix requires an + // argument for every parameter; dropping these builds to CE1571 "No argument + // has been selected for parameter 'X'" (#835). + ParameterMappings []*MicroflowParameterMapping `json:"parameterMappings,omitempty"` } func (MicroflowSource) isDataSource() {} @@ -61,6 +65,9 @@ type NanoflowSource struct { model.BaseElement NanoflowID model.ID `json:"nanoflowId"` Nanoflow string `json:"nanoflow"` // Qualified name (e.g., "Module.NanoflowName") + // Argument bindings for a parameterized source nanoflow — same rule as + // MicroflowSource above (#835). + ParameterMappings []*MicroflowParameterMapping `json:"parameterMappings,omitempty"` } func (NanoflowSource) isDataSource() {}