# Build with an AI assistant (https://light.magerex.nl/md/developer/ai-assistants.md) > The Markdown of every page, prompts that work, and an AGENTS.md for your repository. An assistant writes a good Light module when it has read the right pages and knows the rules. This site gives it both: every page as Markdown, the whole guide in one file, and a rules file for your repository. ## Give your assistant the guide | What | Where | Use it for | |---|---|---| | Copy Markdown | The button under each page title | Paste one page into a chat | | View as Markdown | The link next to it | Read the page as a model gets it | | Open | The menu next to them: Open in ChatGPT, Claude, Cursor or Scira AI. A copy of the site on localhost doesn't show it, because those services can't read it | Start a chat that reads this page | | One page | `https://light.magerex.nl/md/developer/.md`, for example [https://light.magerex.nl/md/developer/recipes/nav-counter.md](https://light.magerex.nl/md/developer/recipes/nav-counter.md) | Point an agent at the page it needs | | Every page, listed | [https://light.magerex.nl/llms.txt](https://light.magerex.nl/llms.txt) | Let an agent pick the pages itself | | Every page, in full | [https://light.magerex.nl/llms-full.txt](https://light.magerex.nl/llms-full.txt) | Give a long-context model the whole guide | The Markdown is the page as written: a code block keeps the path of the file it quotes in its title, and a diagram stays a `mermaid` block that a model can read. Its links point at the full address of the Markdown they mean, so an assistant can follow them. ## Put the rules in your repository Save these two files in the root of your module's repository. Coding agents such as Claude Code, Cursor and Codex read them before they change anything. ```md title="docs/site/public/agents/AGENTS.md" # Working on a Light module This module extends Light, the simple admin for Mage-OS and Magento Open Source. The Light developer guide is the authority: https://light.magerex.nl/llms-full.txt holds all of it as Markdown, and https://light.magerex.nl/md/developer/.md holds one page. ``` The whole file is on this site at [/agents/AGENTS.md](https://light.magerex.nl/agents/AGENTS.md). ```md title="docs/site/public/agents/CLAUDE.md" @AGENTS.md ``` Claude Code reads `CLAUDE.md` and follows the import, so both tools share one set of rules. ## Prompts that work Each prompt names the pages to read first, the job, and the check to run at the end. Swap in your own module and names. ```text Read https://light.magerex.nl/md/developer/recipes/nav-counter.md and https://light.magerex.nl/md/developer/ux-guidelines.md first. In my module Vendor_Returns, add a Light nav item "Returns to handle" under Orders that opens light/vendorreturns/index, and a counter of the returns with the status "new". Follow the recipe: a class that implements BadgeSourceInterface, returns null at 0 and uses the tone attention, registered in the BadgePool argument providers in etc/adminhtml/di.xml, and BadgeCacheInterface::invalidate() wherever the status changes. Gate every item on Vendor_Returns. Then run bin/magento mrx:light:doctor --module=Vendor_Returns and fix what it reports. ``` ```text Read https://light.magerex.nl/md/developer/getting-started.md and https://light.magerex.nl/md/developer/case-study-request-an-account.md first. Write a bridge Vendor_ReviewsLight for the module Vendor_Reviews, which I can't change. Start with bin/magento mrx:light:app Vendor_ReviewsLight --for=Vendor_Reviews --with=nav,route-map --parent=products. Keep every call into Vendor_Reviews under Model/Backend/, and add a unit test that fails on any other reference. Use the module's own ACL resources. Then tick the "Before you ship" list of the getting started page, and run the doctor on Vendor_ReviewsLight. ``` ```text Read https://light.magerex.nl/md/developer/recipes/form-card.md first. Add a card "Delivery" with one field, a delivery note of at most 200 characters, to the Light product editor for my module Vendor_Delivery. Keep the value in a product attribute, set in apply(); save() returns null. Check the length in validate() and in a browser hook. Put the card in mrx.product.form.main.bottom and give it the ACL resource Vendor_Delivery::manage. Then run the doctor, and copy test 4 of tests/playwright/light-proof.spec.ts for a browser test. ``` ```text Read https://light.magerex.nl/md/developer/recipes/settings-page.md and https://light.magerex.nl/md/developer/ux-guidelines.md first. My module Vendor_Feed has the section vendor_feed in etc/adminhtml/system.xml. Add a Light settings page for it that shows only general/enabled and general/title in simple mode, with warm labels and notes, and send the stock section to it. Name each field one by one. Then run the doctor and fix any schema_field finding. ``` ```text Read https://light.magerex.nl/md/developer/ux-guidelines.md and the "Before you ship" list of https://light.magerex.nl/md/developer/getting-started.md. Go through my module Vendor_Module and list every place it breaks one of the six rules or misses a box of the list, with the file and the line. Don't change anything yet. ``` ```text Read https://light.magerex.nl/md/developer/troubleshooting.md first. Here is the output of bin/magento mrx:light:doctor --module=Vendor_Module --format=json: Explain each finding, fix it in the module, and run the doctor again until it reports no errors. ``` ```text Read https://light.magerex.nl/md/developer/upgrading.md, https://light.magerex.nl/md/developer/changelog.md and https://light.magerex.nl/md/developer/versioning.md first. Move my module Vendor_Module to the newest Light API version the changelog lists. Change the range in extra.mrx-light-api, in the range guard of registration.php and in any require on mrx/module-light, and replace every deprecated use the doctor reports with deprecated_use. ``` ## Check what it wrote An assistant can be sure of itself and still be wrong. Before you merge its work: - `bin/magento mrx:light:doctor --module=` reports no errors, `private_api` included. - The module's browser test passes, and `tests/playwright/users-and-permissions.spec.ts` still does. - A person reads every label and every Dutch translation: warm copy is [Built for Light, rule 6](https://light.magerex.nl/md/developer/ux-guidelines.md#6-warm-copy). # How Light fits together (https://light.magerex.nl/md/developer/architecture.md) > The parts of Light, a request to a Light page, the three ways to plug in, and the public API, as diagrams. Light is a set of Magento modules. Your module talks to it the Magento way: DI items in pools, blocks in named containers, and a handful of `@api` interfaces. This page draws how those parts connect. The recipes show how to use each one. ## The parts ```mermaid flowchart TB subgraph admin["The admin"] simple["Simple mode: the Light shell
handle mrx_simple"] advanced["Advanced mode: the stock admin
handle mrx_advanced"] end subgraph core["Light core: 14 Mrx_* modules"] shell["Mrx_Light
shell, nav, lists, forms, palette"] pages["Page modules
Home, Orders, Customers, Catalog,
Content, Discounts, Returns, Locations"] more["Mrx_Apps, Mrx_Settings,
Mrx_Themes, Mrx_Ai, Mrx_Documents"] end api["Extension API
@api types and ExtensionApi::VERSION"] catalogue["Catalogue
pools, containers, handles,
forms, JS modules, events"] gate["Compatibility gate
CompatibilityMap and ModuleGate"] subgraph packs["Packs (packages/)"] nl["Mrx_CountryNl
Light for the Netherlands"] mollie["Mrx_PaymentsMollie"] paynl["Mrx_PaymentsPaynl"] end subgraph yours["Your code"] bridge["Pattern A: a bridge
Vendor_NameLight"] b1["Pattern B1: XML glue
in your module"] b2["Pattern B2: PHP glue
Vendor_NameLightCompat"] end looks["Brand packages and
admin themes"] hyva["-hyva modules
the Hyvä storefront"] tools["Doctor, surface snapshot,
guards, docs check"] nl -- "country_rules, carriers, legal page and page drafts,
cards in the taxes containers" --> catalogue mollie -- "payment_providers, accountant_fee_columns,
panels in mrx.settings.payments.online" --> catalogue paynl -- "payment_providers,
panels in mrx.settings.payments.online" --> catalogue bridge --> catalogue b1 --> catalogue b2 --> catalogue bridge --> api b2 --> api looks --> catalogue catalogue --> gate gate --> core core --> simple core -- "route map, advanced view" --> advanced core --> hyva tools -. "check" .-> packs tools -. "check" .-> yours tools -. "check" .-> catalogue ``` - **Light core** is the 14 modules of `Surface::CORE_MODULES`. They ship together with one API version. - **Packs** (`packages/`) are modules outside core that fill core's pools and containers: `Mrx_CountryNl` holds what is Dutch, `Mrx_PaymentsMollie` and `Mrx_PaymentsPaynl` connect a payment provider. Core names none of them, so a shop installs the packs it needs. The doctor and the compatibility gate check a pack like any module ([Ship it as a pack](https://light.magerex.nl/md/developer/recipes/settings-page.md#ship-it-as-a-pack)). - **The catalogue** lists every extension point: the pools your `di.xml` adds items to, the layout containers and handles, the seven editor forms, the public JS modules and the events. [Extension targets](https://light.magerex.nl/md/developer/reference/extension-targets.md) prints it. - **The compatibility gate** reads the Light API range each module declares and hides what an out-of-range module adds, before any pool is read ([Versioning](https://light.magerex.nl/md/developer/versioning.md#compatibility-gate)). - **The `-hyva` modules** hold the Hyvä storefront code of core modules, so Light itself never needs Hyvä ([Hyvä code in a -hyva module](https://light.magerex.nl/md/developer/recipes/storefront-hyva.md)). - **The checks** keep all of it honest: the doctor reads what every module registers, the surface snapshot keeps the API stable, and the guards prove a module landed without a change to core ([Testing](https://light.magerex.nl/md/developer/testing.md)). ## A request to a Light page Every Light page is a controller on the admin front name `light`. Nothing of it answers before a complete sign-in: signed in and, where Magento_TwoFactorAuth is on, past the second factor (`Model\Auth\AdminAccess`). Where admins sign in with an Adobe ID (Magento_AdminAdobeIms), Magento skips its own second factor and Light counts that sign-in as complete. The gate answers 404 for a controller whose module is off or hidden, and the blocks read the pools through `ModuleGate`, so an item of a hidden module never renders: ```mermaid sequenceDiagram autonumber actor Admin participant Router as admin router, front name light participant Check as Plugin\Request\ValidateCompleteSignIn participant Auth as Plugin\Controller\RequireCompleteSignIn participant Gate as Plugin\Controller\PageModuleGate participant Page as your AbstractPage controller participant Modes as Observer\AddModeHandles participant Blocks as layout and blocks participant Pools as the pools, through ModuleGate Admin->>Router: GET light/{controller}/{action} Router->>Check: afterValidate(), once Magento's request validators (form and secret key, method) passed, before Magento_TwoFactorAuth's redirect Check-->>Admin: 401 JSON to a script while the second factor is open Router->>Auth: aroundDispatch(), after Magento's own sign-in check Auth-->>Admin: 401 JSON or the two-factor screen until the sign-in is complete Router->>Gate: beforeDispatch() Gate-->>Admin: 404 when its module is off or hidden Router->>Page: execute() Modes->>Blocks: layout_load_before adds mrx_base, and mrx_simple or mrx_advanced (complete sign-in only, never on the sign-in layout) Page->>Blocks: createPage() with the handle mrx_page Blocks->>Pools: nav items, header actions, cards, and counters from the cache Blocks-->>Admin: the Light page ``` Light asks Magento_TwoFactorAuth itself whether the second factor is done, so a module that skips Magento's two-factor check without granting the session keeps Light closed while stock pages open. [Light keeps sending you to the two-factor screen](https://light.magerex.nl/md/developer/troubleshooting.md#light-keeps-sending-you-to-the-two-factor-screen) has the fix. The same check decides whether Light reads anything of the admin: their own theme, their pinned apps and their counts wait for the complete sign-in too. ## Emails and documents `Mrx_Documents` gives every customer email one look from one brand kit: the store's logo, the owner's accent colour, the footer text and the company details, in one of the formats of the `formats` pool. A module writes only the body; the stock header and footer includes lead into Light's shell: ```mermaid flowchart LR body["A body template"] --> texts["ApplyEmailTexts"] texts --> include["header include"] include --> map["MapShellIds"] map --> header["Light's header"] header --> css["Css\Processor + SwapCssTokens"] css --> emo["Emogrifier"] emo --> plain["AddPlainTextPart
at send"] remember["RememberEmailContext
on TransportBuilder"] -.-> keep plain --> keep["KeepSentCopy
after sendMessage()"] keep --> table[("mrx_documents_sent_email")] table --> timeline["Orders Timeline
View email"] ``` `ApplyEmailTexts` puts the subject, opening text and button text the owner saved in Settings > Customer emails into a body of the `emails` pool as it loads, `MapShellIds` maps the stock header and footer ids to Light's, `SwapCssTokens` fills the `__MRX_*__` tokens of the email CSS with the brand kit of the mail's store, and Emogrifier inlines the result. When the mail is sent, `AddPlainTextPart` adds a plain-text part made from the HTML. `RememberEmailContext` notes the template id and what the vars say about the order while the mail is built, and `KeepSentCopy` stores an exact copy of a customer mail about an order in `mrx_documents_sent_email` once it went out. The order page's timeline reads those copies and links each mail's line to it ([Copies of sent mails](https://light.magerex.nl/md/developer/recipes/email-and-documents.md#copies-of-sent-mails)). [Your module's email and document](https://light.magerex.nl/md/developer/recipes/email-and-documents.md) is the contract. The same brand kit prints the PDFs. Every stock caller of an invoice, credit memo or packing slip PDF (the print buttons, the mass actions with "Print All", Light's "Print invoices", the accountant ZIP) calls `getPdf()`, and an `around` plugin answers it without the stock drawing code: ```mermaid flowchart LR caller["A getPdf() caller"] --> hook["RenderWithDompdf
(stock PDF when classic is on)"] hook --> types["documents pool
order, invoice, creditmemo, yours;
packing slips and picklist from Mrx_Orders;
return slip from Mrx_Returns"] download["light/documents/download,
DocumentRendererInterface"] --> types mail["AttachingSenderBuilder
order, invoice, shipment
and refund emails"] --> types print["ServePdfOnPrint
storefront Print invoice,
Print refund"] --> types rma["Returns' EmailSender
the approval mail"] --> types pick["pick_locations pool"] -.-> types types --> engine["Engine
by store view, 25 to a run"] engine --> html["HtmlBuilder
frontend area, store emulation"] sections["document_sections pool"] -.-> html html --> dompdf["PdfRendererInterface
dompdf"] dompdf --> zend["DocumentPdf for a getPdf() caller,
the file for a download"] ``` A document type in the `documents` pool supplies the data and the body template, the `item_lines` pool the rows of the items table, and the `document_sections` pool blocks from other modules after the parties, items or totals. The order confirmation has no stock PDF, so only the download route and `DocumentRendererInterface` reach it; the picklist reads bin locations from the `pick_locations` pool. `Engine` renders each store view's documents in that store's language, at most 25 to a dompdf run, and merges the runs. `DocumentPdf` hands the bytes back as a `\Zend_Pdf`, whose `render()` returns them as they are and whose pages "Print All" merges with the packing slips, which are dompdf documents too. The same PDFs reach the customer two more ways. `AttachingSenderBuilder` replaces the `SenderBuilder` of Magento's seven sales senders and adds the invoice, credit memo or order confirmation PDF to the email. One setting, `mrx_documents/invoice/send_with`, decides which email carries the invoice: the shipping confirmation (the default), "Payment received" or none, and the refund email carries the credit memo. `mrx_documents_invoice_mailed` remembers which invoices went out, so each travels once. Returns' own `EmailSender` puts the return slip on the customer's approval mail with `MessageAttacher`, the class the builder attaches with. In My Account and the guest order view, `ServePdfOnPrint` answers "Print invoice" and "Print refund" with the PDF once the stock controller has checked the order ([PDFs on customer emails](https://light.magerex.nl/md/developer/recipes/email-and-documents.md#pdfs-on-customer-emails)). ## Customer text in two forms Dutch and German address a customer two ways, and the merchant picks one per language of a channel. Magento builds the dictionary of a store in layers, and the variant files take the pack's slot: ```mermaid flowchart LR modules["Module CSVs
i18n/nl_NL.csv"] --> pack["Language pack
(Dictionary::getDictionary)"] pack --> prefer["PreferModulePhrases
afterGetDictionary"] modules --> prefer setting["mrx_light/locale/form_of_address
per store view"] --> forStore["FormOfAddressInterface
forStore()"] forStore --> rows["FormOfAddressRows
afterGetDictionary"] variants["Every module's
i18n/nl_NL.formal.csv"] --> rows prefer --> rows rows --> theme["Theme CSVs"] theme --> inline["Inline edits
of the merchant"] inline --> page["Storefront page,
email, PDF"] rows -.-> neutral["KeepJsDictionaryNeutral
js-translation.json keeps the base rows"] ``` `PreferModulePhrases` runs first: for the phrases in the pool `prefer_phrases` it writes the named module's row over the language pack's, where a pack gets a phrase wrong for Light (German "Returns" as "Details anzeigen"). `FormOfAddressRows` is an `afterGetDictionary` plugin on `Magento\Framework\App\Language\Dictionary`. In a customer area (storefront, REST, GraphQL) and in store emulation, it merges the variant file for the store's form from every enabled module into the pack's rows. Every mail and PDF renders in store emulation, so they follow the form too. The admin keeps its own dictionary. A theme's CSV and the merchant's inline edits load later and win. `js-translation.json` is one static file per theme and locale, so `KeepJsDictionaryNeutral` builds it from the base rows only ([Channels and store views](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md#4-two-forms-of-address)). ## Countries Core knows three things about a country: the home country in Business details, the One Stop Shop for an EU home country, and the identifiers a country module supplies through the pool `country_rules`. The Taxes page runs on Magento's own tax tables, so a shop anywhere can add its rates, switch "Prices include tax" and say it doesn't charge tax. A country module adds what is specific to its country in three places: an item in `country_rules` for the business registration number, the tax ID and the postcode; cards in the containers `mrx.settings.taxes.preset`, `mrx.settings.taxes.cross_border`, `mrx.settings.business.registration` and `mrx.settings.payments.providers`; and block arguments that change the wording of a core card. Carriers and legal page drafts are pools too (`carriers`, `legal_page_templates` and `legal_page_types`). The Dutch parts live in the pack `Mrx_CountryNl` (`packages/module-country-nl`): the Dutch VAT setup, the KvK number and Dutch VAT number checks, the postcode, the address order, PostNL and the Dutch tracking links, and the legal page drafts. A shop in another country runs core on its neutral defaults, and the doctor rule `country_pack` warns when a known pack for the shop's country isn't enabled. [A settings page from your system.xml](https://light.magerex.nl/md/developer/recipes/settings-page.md#country-rules) shows each place. ## Locations and pickup A location is a source of Magento's Multi-Source Inventory (MSI). `Mrx_Locations` adds Settings > Locations and reads the sources for the rest of Light; every write goes through MSI's own services, so a module that reads MSI sees what Light did: ```mermaid flowchart LR subgraph light["Light"] page["Settings > Locations
LocationManager"] read["LocationsInterface
(@api, read only)"] qty["Catalog: LocationQuantities"] ship["Orders: ShipFrom, ShipItems"] pickup["Orders: PickupOrders
Ready for pickup, Picked up"] card["Settings > Shipping
PickupProviderInterface"] own[("mrx_location
mrx_location_stock
mrx_order_pickup")] end subgraph msi["MSI, Magento's own"] sources["Sources
one per location"] lightStock["The Light stock
(shared by the channels)"] stock1["Stock 1 and the default source
(simple mode; emptied by the switch)"] channels["Channels (websites)"] instore["In-Store Pickup
carriers/instore"] end page -- "LocationWriter, StockReconciler,
Conversion" --> sources page --> lightStock page --> own read --> sources qty -- "source items" --> sources ship -- "source_code on the shipment" --> sources pickup -- "NotifyOrdersAreReadyForPickup" --> instore pickup --> own card -- "carriers/instore/*" --> instore lightStock -- "links, in drag order" --> sources channels -- "sell from" --> lightStock stock1 -. "before the first Add location" .-> channels ``` - **Simple mode.** With MSI off, or MSI on and one enabled source, a shop has one location: the business address. Every screen and every write is the single-quantity one, and pickup is Light's own carrier at the business address (`carriers/mrxpickup`). - **Locations mode.** The first "Add location" runs the conversion: the business address becomes a source, the stock of the `default` source is copied to it, a stock Light makes (the Light stock) links both, the open orders' reservations and every channel move to it, and `default` is set to 0. From then on stock is kept per location, a shipment carries the location it ships from, and pickup is Magento's In-Store Pickup at the locations that offer it (with Magento's in-store pickup modules off, Light's own carrier stays at the business address). - **Module graph.** `Mrx_Light` ← `Mrx_Settings` ← `Mrx_Locations` ← `Mrx_Catalog`, `Mrx_Orders`, `Mrx_Home`. Settings declares the internal `PickupProviderInterface` and its default; Locations replaces the preference, so Settings never names Locations. Documents and Returns read where a parcel shipped from through Orders. [Locations and pickup](https://light.magerex.nl/md/developer/recipes/locations-and-pickup.md) has the contract and the plugins Light adds on MSI. ## Three ways to plug in ```mermaid flowchart LR subgraph A["Pattern A: a bridge"] vendorA["Disrex_RequestAnAccount
the module, untouched"] bridgeA["Disrex_RequestAnAccountLight
range guard in registration.php"] bridgeA -- "Model/Backend only" --> vendorA end subgraph B["Pattern B: built in, one package"] b1["B1: Acme_Hello in src/
scalar DI items and layout"] b2["B2: Acme_HelloLightCompat in LightCompat/
PHP glue and range guard"] b2 --> b1 end pools[("Light pools
and containers")] bridgeA --> pools b1 --> pools b2 --> pools ``` - **Pattern A** keeps the module you bridge untouched. Every call into it sits under `Model/Backend/`, and a unit test holds that line. - **Pattern B1** is XML only: scalar DI items on Light classes and layout on Light handles. Without Light, Magento ignores them, so the module installs on any shop. - **Pattern B2** is the PHP that needs Light types. Its range guard leaves it unregistered on a shop without Light, or with a Light outside its range. [Getting started](https://light.magerex.nl/md/developer/getting-started.md) builds both in about 15 minutes. ## The public API Everything your PHP may name is `@api`. The diagrams group the types by the job they do; [API reference](https://light.magerex.nl/md/developer/reference/api.md) lists every type with every member, and the doctor reports `private_api` for anything else. ```mermaid classDiagram direction LR class ItemProviderInterface { <> +getItems() array } class BadgeSourceInterface { <> +getBadgeValue() Badge } class BadgeProviderInterface { <> +getBadge() string } class Badge { +int count +string tone +string label +getDisplay() string } class BadgeCacheInterface { <> +invalidate(itemKeys) void } class BadgeReaderInterface { <> +get(itemKey) Badge } class ProviderInterface { <> +getAclResource() string +getColumns() array +getTabs() array +getBulkActions() array +getEmptyState() array +getRows(Query query) array } class FilterableProviderInterface { <> +getFilterKeys() array } class ChannelFilterAwareInterface { <> } class ProviderExtensionInterface { <> +getColumns() array +getBulkActions() array +decorateRows(rows, Query query) array } class RowActionsInterface { <> +getRowActions() array } class Query { +tab +search +sort +page +filters } BadgeSourceInterface ..> Badge : returns BadgeReaderInterface ..> Badge : returns FilterableProviderInterface --|> ProviderInterface ChannelFilterAwareInterface --|> ProviderInterface ProviderInterface ..> Query : reads ProviderExtensionInterface ..> Query : reads ``` ```mermaid classDiagram direction LR class FormExtensionInterface { <> +load(FormContextInterface context) array +validate(FormContextInterface context, input) array +save(FormContextInterface context, input) array } class DeclaresFieldsInterface { <> +getFields(FormContextInterface context) array } class AppliesToEntityInterface { <> +apply(FormContextInterface context, input, entity) void } class FormContextInterface { <> +getFormCode() string +getEntityId() int +getEntity() object +getStoreId() int } class CurrentFormInterface { <> +get() FormContextInterface } class PageInterface { <> +getAclResources() array +getCacheTypes() array +save(data) string } class ChannelScopedInterface { <> } class SettingsValidatorInterface { <> +validate(values, websiteId) array } class ScopedConfigWriterInterface { <> +save(values, websiteId, storeId) array } class ValidationException { +getErrors() array } class NormalizesInputInterface { <> +getNormalizedValues() array } class ReloadsAfterSaveInterface { <> +shouldReload() bool } class PaymentProviderStateInterface { <> +isOffered() bool } class ChannelPaymentProviderStateInterface { <> +isOfferedIn(channelId) bool } class PaymentProviderCardInterface { <> +getStatus(channelId) string +getAccountName(channelId) string +getMethods(channelId) array +getAdvancedRows(channelId) array } class PaymentMethodsInterface { <> +hasOtherCheckoutMethod(exceptPrefix, channelId) bool } class Fields { <> +field(name, label, value, options) string +select(name, label, choices, value, options) string +multiselect(name, label, choices, values, options) string +toggle(name, label, checked, options) string +icon(name, class) string +idFor(name) string } class AbstractPage { <> +execute() ResultInterface } class AbstractSettingsPage { <> +execute() ResultInterface } FormExtensionInterface ..> FormContextInterface DeclaresFieldsInterface ..> FormContextInterface AppliesToEntityInterface ..> FormContextInterface CurrentFormInterface ..> FormContextInterface SettingsValidatorInterface ..> ValidationException PaymentProviderStateInterface <|-- ChannelPaymentProviderStateInterface AbstractSettingsPage --|> AbstractPage ``` ```mermaid classDiagram direction LR class OrderActionConditionInterface { <> +isAvailable(OrderInterface order) bool } class OrderPageContextInterface { <> +getOrder() OrderInterface } class OrdersTimeline["Orders TimelineProviderInterface"] { <> +getEvents(OrderInterface order) array } class TotalsProviderInterface { <> +getRows(OrderInterface order) array } class CustomerPageContextInterface { <> +getCustomer() CustomerInterface } class CustomersTimeline["Customers TimelineProviderInterface"] { <> +getEvents(CustomerInterface customer) array } class ProductPageContextInterface { <> +getProduct() ProductInterface } class CounterInterface { <> +getCount() int } class ChannelAwareCounterInterface { <> +getCount(Channel channel) int } class SetupCheckInterface { <> +isComplete() bool } class SetupDescriptionInterface { <> +getDescription() string } class PaymentMode { <> +INFO_KEY +TEST +LIVE } class RefundReasonInterface { <> +set(reason) void +take() string } ChannelAwareCounterInterface --|> CounterInterface ``` ```mermaid classDiagram direction LR class ExtensionApi { <> +VERSION +satisfies(constraint)$ bool } class ModeInterface { <> +get() string +isSimple() bool +isStockView() bool +showsLight() bool } class AdvancedUrlInterface { <> +PARAM +get(route, params) string } class BackLinkProviderInterface { <> +getBackLink(fullActionName) array } class ConditionInterface { <> +shouldRedirect(RequestInterface request) bool } class ParamsResolverInterface { <> +resolve(RequestInterface request) array } class HasParam { +shouldRedirect(RequestInterface request) bool } class ActionConditionInterface { <> +isAvailable(RequestInterface request) bool } class GroupInterface { <> +getLabel() string +getAclResource() string +getSortOrder() int +search(query, limit) array } class BrandInterface { <> +get() Brand } class Brand { +label +shortName +logoOnLight +defaultTheme } class RequiresModule { <> +modules } class ProvidesNavItems { <> +keys } class FormOfAddressInterface { <> +INFORMAL +FORMAL +XML_PATH +forStore(storeId) string } HasParam ..|> ConditionInterface BrandInterface ..> Brand : returns ``` A `?` in front of a return type in the code means the method may return null: `getBadgeValue(): ?Badge` answers null at 0. The diagrams leave the `?` out. # Case study: disrex/module-request-an-account (https://light.magerex.nl/md/developer/case-study-request-an-account.md) > How the first real module joined Light with no change to Light core or to the module. `disrex/module-request-an-account` was the first real module plugged into Light on the finished backbone. Its bridge, `Disrex_RequestAnAccountLight`, was built with no change to Light core and none to the module itself. This page walks through it, from install to sign-off. ## Why this module went first - It had to prove pattern A, a bridge, on a module Light must not change. Pattern B has its own proof, the `Acme_Hello` example. - The Light API and the module release on their own schedules, and the bridge pins both. - It is exactly what the zero-core-edit guard has to prove: every line of glue sits outside `app/code/Mrx`, and the module stays untouched. ## The module A B2B lead form. A business customer fills in the storefront form; the module stores a row in `disrex_registration_request` with the status `new` and emails the shop. The answers sit in `form_fields` as a JSON list of `{field_label, field_value, field_type}`, where `field_value` can be a list for checkboxes. There is no approval flow, so Light offers "Mark handled" and "Reopen", not approve and reject. It comes as `disrex/module-request-an-account` 1.6.0 from `packages.disrex.nl` and needs `disrex/module-core`. In the stock admin its grid and settings hang under the Disrex menu, which no Light section or role area covers: Apps would file it under "Other", and staff would never see it. ## Prerequisites - The package, required with Composer from `packages.disrex.nl`, and `bin/magento setup:upgrade --keep-generated`. Its plugins on `Customer\Model\Url` and, on the storefront, on the reCAPTCHA check need `generated/metadata/staticcache/*.php` and those interceptors cleared after install ([Troubleshooting](https://light.magerex.nl/md/developer/troubleshooting.md#a-new-plugin-does-nothing)). - A Hyvä store view: its storefront templates are built for Hyvä. A store view qualifies when its theme is `Hyva/default` or inherits from it; here every store view does through `MageRex/studio`. Light itself needs no Hyvä. ## The bridge, surface by surface | Surface | Bridge files | Extension point | |---|---|---| | Nav item and counter | `etc/adminhtml/di.xml`, `Model/PendingRequests.php` | nav `items`, `BadgePool` `providers` | | Home to-do, pin and palette count | `etc/adminhtml/di.xml` | a `TodoList` item with `badge` | | Light list with row and bulk actions | `Controller/Adminhtml/Accountrequests/Index.php`, `Model/IndexTable/RequestsProvider.php`, layout | `ProviderPool`, `RowActionsInterface` | | Detail page | `Controller/Adminhtml/Accountrequests/View.php`, `ViewModel/RequestView.php`, template | `AbstractPage` | | Status change and delete | `Controller/Adminhtml/Accountrequests/MassStatus.php`, `MassDelete.php` | bulk action routes | | Stock grid to Light list | `etc/adminhtml/di.xml` | `Redirect\Map` | | Settings page | `etc/adminhtml/di.xml` | `Page\Pool`, `ConfigSections`, a Settings card | | App | `etc/adminhtml/di.xml` | `Registry` `apps`, `claims`, `hidden_modules` | | Palette | `etc/adminhtml/di.xml` | `Actions`, one `Destinations` item | | Customer card | `view/adminhtml/layout/light_customers_view.xml` | `mrx.customer.aside.bottom` | | Role access | `etc/di.xml` | `RoleAreas` | How the parts connect: ```mermaid flowchart LR module["Disrex_RequestAnAccount 1.6.0
the module, untouched"] backend["Model/Backend/Requests
the only class that calls it"] subgraph bridge["Disrex_RequestAnAccountLight"] counter["PendingRequests
the counter"] list["RequestsProvider
the Light list"] pages["Index, View, MassStatus, MassDelete
on light/accountrequests"] card["CustomerRequestCard
the customer card"] end subgraph light["Light pools and containers"] nav["nav_items and nav_badge_providers"] home["todos"] tables["table_providers"] routes["redirects"] settings["settings_pages, config_sections, settings_cards"] apps["apps, app_claims, app_hidden"] palette["palette_actions, palette_destinations"] staff["role_areas"] aside["mrx.customer.aside.bottom"] end counter --> backend list --> backend pages --> backend card --> backend backend --> module counter --> nav counter --> home list --> tables pages --> routes card --> aside bridge --> settings bridge --> apps bridge --> palette bridge --> staff ``` ### The skeleton `registration.php` carries the range guard, because the bridge has Light PHP; `composer.json` declares the same range in `extra.mrx-light-api` and on `mrx/module-light`, and requires the module: ```json title="app/code/Disrex/RequestAnAccountLight/composer.json" "disrex/module-request-an-account": "^1.6", ``` A unit test keeps every reference to the module's classes under `Model/Backend/`: ```php title="app/code/Disrex/RequestAnAccountLight/Test/Unit/BoundaryTest.php" self::assertSame([], $offenders, 'Move these references under Model/Backend/'); ``` The backend service, `Model/Backend/Requests.php`, is the only class that talks to the module: it counts, reads, changes and deletes requests through the module's own resource model, so the module's model events still fire. ### Nav item and counter "Account requests" under Customers, with a counter of the requests to review that rolls up into Customers while its section is closed ([A nav item with a counter](https://light.magerex.nl/md/developer/recipes/nav-counter.md)): ```xml title="app/code/Disrex/RequestAnAccountLight/etc/adminhtml/di.xml" true ``` The merchant sees "Customers 5" while Customers is closed, "Account requests 5" inside it. ### Home to-do, pin and palette A Home to-do reads the same count ([Home to-dos, setup steps and widgets](https://light.magerex.nl/md/developer/recipes/home.md)). A pinned app and the "Go to" entry show it without any code. The merchant sees "5 account requests to review" on Home, linking to the list's "To review" tab. ### Light list with row and bulk actions `light/accountrequests/index` lists the requests with the tabs "To review", "Handled" and "All", search on name, email and company, and "Mark handled", "Reopen" and "Delete" as bulk and row actions ([Light lists](https://light.magerex.nl/md/developer/recipes/index-table.md)). ### Detail page A request opens with its contact details, the answers (a checkbox answer reads "Groothandel, Webshop"), the comment, and "Open customer" when a customer with that email exists ([Your own list and detail pages](https://light.magerex.nl/md/developer/recipes/own-pages.md)). ### Status change and delete `MassStatus` and `MassDelete` answer the list and the detail page, and drop the nav count, so the nav, the pin, the palette and Home update on the next page. ### Stock grid to Light list The route map sends the stock grid `requestanaccount/request/index` to the Light list in simple mode; with `mrx_stock=1` it stays, and the banner says "Back to Account requests" ([The advanced view, the route map and header actions](https://light.magerex.nl/md/developer/recipes/advanced-and-route-map.md)). ### Settings page A settings page from the module's own `system.xml`, with the everyday fields named one by one, warm labels and notes, one input per language for the welcome message, and an Advanced section that shows the form page address, the thank-you page address, the terms link and the form builder switch, read-only ([A settings page from your system.xml](https://light.magerex.nl/md/developer/recipes/settings-page.md)). A Settings card under Selling points at it. ### App The Apps card "Account requests" sits under Customers, opens the Light list and links the settings page; the stock "Configuration" screen is hidden, and `Disrex_Core` is not an app ([An app, its category and pins](https://light.magerex.nl/md/developer/recipes/app-and-pins.md)). ### Palette "Review account requests" under Actions, and "Account request settings" under Go to ([The command palette](https://light.magerex.nl/md/developer/recipes/palette.md)). ### Customer card The customer page shows "Account request" with the date, the status and "View request" when that customer's email sent one ([Order and customer pages](https://light.magerex.nl/md/developer/recipes/order-and-customer-pages.md)). ### Role access The module's resources join the Customers area, so the Staff preset and custom roles with Customers reach the list, and existing Staff roles catch up on the next `setup:upgrade` ([Users and permissions](https://light.magerex.nl/md/developer/recipes/users-and-permissions.md)). ### What the admin sees ![The navigation with its counters: Orders counts the orders to ship, and Customers the account requests to review, rolled up while the section is closed](https://light.magerex.nl/screenshots/recipes/nav-counter/nav-counters.webp) ![The pilot's Light list: tabs, search, and the row menu with Mark handled](https://light.magerex.nl/screenshots/recipes/index-table/requests-list.webp) ![A request's detail page: the contact details, the answers and the comment, with Mark handled in the header](https://light.magerex.nl/screenshots/recipes/own-pages/request-detail.webp) ![A customer page with the pilot's Account request card in the side column](https://light.magerex.nl/screenshots/recipes/order-and-customer-pages/customer-card.webp) ![The palette after typing account: the list under Go to with its count, the settings page, and Review account requests under Actions](https://light.magerex.nl/screenshots/recipes/palette/palette-account.webp) ## What stays in the advanced view The form builder (a serialized list of fields edited in a modal), the badge, benefits, quote, contact and success steps, the colours, the "type for" setting and the SEO keywords and canonical URL stay in the stock section, behind the hint. They are set up once, by whoever builds the form, not in daily work. ## The checks - **The storefront.** On the Hyvä store view, the request form renders with the Hyvä layout, a submitted request lands on the "To review" tab, and the nav count goes up by one. - **The browser proof.** `tests/playwright/pilot-request-an-account.spec.ts` checks the counts everywhere, the list, the detail page, the route map, the settings page, Apps, the palette, the customer card, role access for an existing Staff role, and the storefront form. - **The doctor.** The bridge uses only the public API: ```console $ bin/magento mrx:light:doctor --module=Disrex_RequestAnAccountLight --format=json ``` It reports `"errors": []`: no `private_api`, `unknown_resource`, `unknown_container` or `unknown_reference`. - **The guards.** `npm run guard:zero-core-edit` exits 0: nothing under `app/code/Mrx` changed. `composer show disrex/module-request-an-account` still reports 1.6.0, and `diff -r` against a fresh copy of the 1.6.0 dist finds no change under `vendor/disrex/module-request-an-account`. - **The gate.** With `extra.mrx-light-api` and the `require` on `mrx/module-light` set to `^9.0` for a moment (the range guard stays `^0.4`, so the bridge stays registered), the doctor reports two `api_constraint` errors, Home shows the red banner in developer mode, and the account requests disappear from Customers, Home, the customer page and the list route, which answers 404. ## What the pilot taught The pilot found no gap in the Light API: every surface above uses only `@api` types and catalogued pools, and the guard base never moved for it. It did show three edges: - A request sent from the storefront reaches the count within its `ttl`, 60 seconds, not at once: the module saves through its own resource model without an event prefix, so nothing calls `BadgeCacheInterface::invalidate()` for it. The bridge's own status changes and deletes do. - The doctor's `acl_unreachable` counts a module as reached once any of its resources is. The bridge's "Go to" entry for its settings sits in the Settings area, so the rule stayed quiet before the Customers-area row existed; the bridge's unit and browser tests check that row instead. - `disrex/module-core` 0.7.4 names a stylesheet it doesn't ship, so every admin page logged a 404. The bridge removes it from the admin head in `view/adminhtml/layout/default.xml` until module-core is fixed. ## The pilot against the "Built for Light" rules - [Place, don't add](https://light.magerex.nl/md/developer/ux-guidelines.md#1-place-dont-add): the list sits under Customers as "Account requests", named after the job; no new top-level item, no new category. - [Use the smallest surface](https://light.magerex.nl/md/developer/ux-guidelines.md#2-use-the-smallest-surface): a Home to-do instead of a widget, and a customer card that renders nothing without a request. - [Counts mean work](https://light.magerex.nl/md/developer/ux-guidelines.md#3-counts-mean-work): the counter counts only requests to review, is null at 0, and uses `attention`. - [Simple mode shows everyday settings](https://light.magerex.nl/md/developer/ux-guidelines.md#4-simple-mode-shows-everyday-settings): nine fields named one by one; the form builder and styling stay in the advanced view. - [Pins belong to the merchant](https://light.magerex.nl/md/developer/ux-guidelines.md#5-pins-belong-to-the-merchant): no default pin. - [Warm copy](https://light.magerex.nl/md/developer/ux-guidelines.md#6-warm-copy): in Dutch "Markeren als afgehandeld" for the action and "Afgehandeld" for the status, and an empty state that says what will appear. ## Moving to pattern B The module is Disrex's own, so its Light glue could later move into the module itself. That release keeps every identifier and route path of the bridge ([Versioning](https://light.magerex.nl/md/developer/versioning.md#your-modules-own-identifiers)) and declares: ```text "conflict": {"disrex/module-request-an-account-light": "*"} ``` `light_collision` flags a bridge left installed next to it. ## Outside Light These go to Disrex and didn't block the pilot: - The module uses `public const string`, which needs PHP 8.3, while its `composer.json` allows PHP 8.1. - `exit;` in `Observer/RedirectToCustomRegistration.php`. - Its `module.xml` has no sequence on `Disrex_Core`. # Changelog (https://light.magerex.nl/md/developer/changelog.md) > What each Light API release added, changed and deprecated. ## 0.4.10 (beta) Fixes only; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. ### Fixed - Magento 2.4.8: the nav counters' cache clean (`BadgeInvalidator`) and the test-payment count in Orders named `Magento\Framework\Cache\CacheConstants`, a class Magento 2.4.9 added. On 2.4.8 the first order, invoice or other change that refreshes a counter stopped with a 500 "Class "Magento\Framework\Cache\CacheConstants" not found". Both pass the mode's value now, which works on 2.4.8, 2.4.9 and Mage-OS 3.x. ## 0.4.9 (beta) No `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. Run `setup:upgrade` right after it. ### Changed - `magerex/distribution-nl` and `magerex/distribution-nl-hyva` no longer install `magerex/light-branding`: Light from a metapackage is white-label and shows the platform's own logo and default theme. The MageRex brand is an add-on that `magerex/distribution-nl` suggests. A shop that keeps the brand requires `magerex/light-branding` `^0.4` itself, in the same command as the update ([From 0.4.8 to 0.4.9](https://light.magerex.nl/md/developer/upgrading.md#from-048-to-049)). ### Added - The guide: [Packages and compatibility](https://light.magerex.nl/md/developer/reference/packages.md), with every package, which ones install on their own, what a shop may leave off and the bridges to other modules. The published guide at https://light.magerex.nl/ gives assistants full links: `llms.txt`, `llms-full.txt` and the Markdown of each page point at the full address of every page they link to. ## 0.4.8 (beta) One package, `magerex/module-flex-bridge`; the other packages stay at 0.4.7. No `@api` change. ### Fixed - `magerex/module-flex-bridge`: `bin/magento mrx:light:doctor` reported `private_api` because the page editor template named Mrx_Content's internal `ContentFormInterface` in a docblock; it now names no internal type. ## 0.4.7 (beta) Fixes and small screen changes; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. Run `setup:upgrade` right after it. ### Added - `magerex/module-flex-bridge`: a Flex Editor card in the category editor and in the product editor, with "Edit in Flex Editor" in a new tab. A category opens on a store view whose menu holds it, a product on a store view of a channel that sells it and where it is enabled, each at its own storefront URL. The product card says "This product has its own Flex blocks." when it has them; the category card says "Your category pages have Flex blocks." when the blocks every category shares exist, because Flex keeps one set for all categories. No card for a root or hidden category, a category no store view's menu holds, a disabled product, one that isn't visible on its own or that no store view sells, without Flex, or without the editor permission. The package now requires `mrx/module-catalog` `^0.4`. - `magerex/module-flex-bridge`: `light/flexbridge/open` takes `type` `category` or `product` with `id` and an optional `store`; `cms`, the default, works as before. A category or product that is gone, or that no store view shows, goes back to its list with a message, and an unknown type is a 404. ### Fixed ## 0.4.6 (beta) Fixes and small screen changes; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. Run `setup:upgrade` right after it. ### Fixed - Users and permissions (security): every role Light makes or completes, and a role saved in Magento's own role editor with Light ticked, gets the user's own second factor (`Magento_TwoFactorAuth::tfa`) without its parents `Magento_Backend::system` and `Magento_User::acl`. Magento allows the second factor under a denied parent, so sign-in and the two-factor set-up work as before, while `Magento_Backend::system` no longer opens `admin/system/*`, the Varnish VCL export and the media storage sync for staff. Magento's role tree posts the parents of every ticked resource, so a save in Magento's own role editor drops `Magento_Backend::system` and `Magento_User::acl` again unless another ticked resource sits below them (Settings' cache screen sits below System). A role saved before 0.4.6 keeps the parents it holds until it is saved in Magento's own role editor with Light ticked, or until you untick System in the advanced view ([Users and permissions](https://light.magerex.nl/md/developer/recipes/users-and-permissions.md)). ### Changed ## 0.4.5 (beta) Fixes and small screen changes; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. Run `setup:upgrade` right after it. ### Fixed - Settings: on Light's own settings pages a save writes only the switches the merchant turned (Shipping's "Ship from the business address" always stores `1` or `0`, as a stored `0` means an own ship-from). A switch posted back off where nothing is stored (no row and no `config.xml` default, such as Magento's `sales/minimum_order/active`) no longer writes a `0` at All channels, and in a channel no longer becomes the channel's own value. `ScopedConfigWriterInterface::save()` compares a PHP `bool` the same way; pass the string `'0'` when the row itself must exist ([Saving per scope](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md)). - Users and permissions: an admin whose role lacks `Magento_Config::config` who types the stock configuration URL without a section (`admin/system_config/index` or `.../edit`) gets Magento's "Sorry, you need permissions to view this content." page (403) instead of a 500 "Undefined array key 0" from Magento_Paypal's structure plugin. A URL with a section, and a role that may open the first section, keep Magento's behaviour. ### Changed - `ScopedConfigWriterInterface::save()` treats a PHP `bool` as a switch: `false` equals `'0'`, `''` and a path with no value, so it writes no row for a switch left off. Pass the string `'0'` when the row itself must exist. No signature change. ## 0.4.4 (beta) Fixes and small screen changes; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. Run `setup:upgrade` right after it. ### Fixed - Users and permissions (security): every Light role holds `Magento_TwoFactorAuth::tfa`, which in Magento also opens the web API routes that read or change another admin's second factor (`GET` and `PUT /V1/tfa/user-providers/:userId`, `GET /V1/tfa/providers-to-activate/:userId`, `GET` and `PUT /V1/tfa/default-provider-code/:userId`, also through `/async` and SOAP). With an admin token those routes now work on another user only when the role also holds user management (`Magento_User::acl_users`), else they answer 401 "The consumer isn't authorized to access Magento_User::acl_users."; the token's own user, full access and integrations keep Magento's behaviour ([Users and permissions](https://light.magerex.nl/md/developer/recipes/users-and-permissions.md)). - Users and permissions (security): that guard refuses a guarded two-factor method that has no `userId` parameter instead of letting the call through, so a rename in Magento cannot switch it off silently. An integration that holds `Magento_TwoFactorAuth::tfa` can still read and change any admin's second factor, as in stock Magento; give it that resource only when it should. ### Changed - Home: for developers, `?mrx_kit_prelaunch=1` shows Home's before-the-first-sale layout and Recent orders' empty state on a shop that has orders, while the kit fixtures are on (developer mode and `dev/mrx_light/kit_fixtures`); it never changes an order and does nothing in production mode. ## 0.4.3 (beta) Fixes and small screen changes; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. Run `setup:upgrade` right after it. ### Fixed - Flex bridge: the Flex Editor banner on Home loads its stylesheet only when it shows, so a shop without Flex, or an admin without the editor permission, loads no `MageRex_FlexBridge` file on Home. - Users and permissions: a role saved in Magento's own role editor (the advanced view) with Light (`Mrx_Light::light`) ticked now gets the user's own second factor (`Magento_TwoFactorAuth::tfa`) with its parents `Magento_Backend::system` and `Magento_User::acl`, as roles of Light's role editor do, so its users can finish signing in on a shop with two-factor on. Nothing else is added: "All resources", roles without Light and roles that have it already are saved as ticked, and integrations are never touched ([Users and permissions](https://light.magerex.nl/md/developer/recipes/users-and-permissions.md)). - Upgrade note for 0.1.2: the first `setup:upgrade` on 0.1.2 or later saves each role that holds `Mrx_Light::light` and lacks the second factor once more through Magento's `Rules::saveRel()` (the Staff and Shipping staff presets also when an area misses screens). That save writes the role again from the ACL tree of that moment, so a resource of a module that was disabled then is gone from the role and doesn't come back when the module is enabled again; dump the database first, and check the roles named in `var/log/system.log` ("Light: roles ... got the screens") ([Upgrading](https://light.magerex.nl/md/developer/upgrading.md#from-011-to-012)). ### Changed - `magerex/module-flex-bridge`: the Flex Editor banner on Home stands out like FlexCore's own launcher: a gradient from the theme's inverse surface into its primary colour with a soft glow, a "Content" eyebrow and a light pill button, so it follows every theme. ## 0.4.2 (beta) - Rich-text editor: text is 16px on touch screens and narrow windows, so iOS no longer zooms in when you tap into it. - Analytics: a chart without sales in the period keeps its frame like Home's "Sales over time" (gridlines, a labelled zero, date ticks, the previous period's dashed line when it sold) with a quiet "No sales in this period yet", and "Create an order" for admins who may. - Categories: a language's own "Visible" or "Show in menu" is now only a store-view value that differs from the default. The editor no longer lists a language whose store-view value equals the default, and a change of the switch doesn't ask about it: that row takes the new value and stays (multi-scope audit D9, decision F7). Changing a switch therefore never asks any more; "Show in menu" and "Hide from menu" in the list still ask when they would replace a language's own value. - Orders: the order list no longer builds every payment method on each load to decide on "Incomplete payments" and "Capture payments"; the answers are cached until a payment setting changes, so the list stops reopening the admin session (Mollie and PayPal start it) and answers sooner. - Settings: in a channel, a value the channel's default language has of its own under a text with Translations inputs (payment names and instructions, the pickup name, opening hours, the welcome text, the documents footer) is named under the field with "Remove the Nederlands value", as on schema pages ([Store-view values from the advanced view](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md#store-view-values-from-the-advanced-view)). "Changed for English in its translation" is now only said when that language has a translation input under All channels; otherwise the note says "in the advanced view". ## 0.4.1 (beta) Fixes and small screen changes; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.4`. Run `setup:upgrade` right after it. ### Changed - Orders: "Print picklist" drops canceled, shipped and other-location orders first and then prints the first 250 of the rest, so "Only the first 250 of N orders are printed" counts orders that can be printed. The check reads at most 2000 selected orders and says "of more than 2000" beyond that. Packing slips and order confirmations still cut at 250 before they filter. - Apps: a config section `mageplaza` is no longer dropped as an upsell entry, so `Mageplaza_Core` shows as "Mageplaza (general settings)". `Magefan_AdminUserGuide` folds into the developer's "(general settings)" row, like the `*_Core`, `*_Base` and `*_Community` modules ([What detection does](https://light.magerex.nl/md/developer/recipes/app-and-pins.md#what-detection-does)). - Settings > Legal pages: the dialog says when it refills the draft. - Home: the dashboard shows from the first day. Before the first sale the setup guide sits on top and the toolbar, KPIs, chart, to-dos, `mrx.home.widgets` and the lists follow it, where they used to be hidden; `mrx.home.top` stays right under the guide, above the toolbar. The channel filter applies before the first sale too, and hiding the guide no longer reloads the page. - Home: designed zero states. A KPI card without sales shows the formatted zero (`€0.00`, `0`, `0%`) with a short helper in place of the comparison and a muted flat sparkline; Analytics' series cards show the same. "Sales over time" keeps its gridlines, `€0` baseline, date ticks and the previous period's dashed line, with a centred "No sales in this period yet" and "View store" and "Create an order". Recent orders and Top products show an icon, one line and "Create an order" or "View products" ("Add a product" while the catalog is empty). Each link shows only when its page is installed and the admin may open it ([Home to-dos, setup steps and widgets](https://light.magerex.nl/md/developer/recipes/home.md)). - `magerex/module-flex-bridge`: on a page built with the Flex Editor, the page editor's Content field shows a Flex banner with "Edit in Flex Editor" instead of Light's Page Builder notice and the old content; the side card shows only on pages that aren't built. Every other page keeps Light's editor. ### Fixed - Settings > Channels: Magento's own "Default Category" is never removed with an empty, unused menu. - Support: the local copy of tickets moves to another shop id in a safer order, and a sync that overlaps a `connect` no longer leaves the new shop with the old shop's resume point. - Cards: a card that is only a header (a title, a line and an action, such as "Keep stock in more places?" on Settings > Locations) keeps the same space below as above (`.mrx-card__header:last-child`). - Settings > Locations: the pickup line says "Customers can pick up here." or "Customers can't pick up here yet." with "Change in Shipping and pickup", instead of "Customers can pick up here: Off" next to a loose link. - Sign-in: the reCAPTCHA v2 checkbox stays inside the card down to a 344px wide screen, and `etc/csp_whitelist.xml` adds `connect-src https://www.google.com/recaptcha/`, which the stock reCAPTCHA modules leave out. ## 0.4.0 (beta) One new layout container on Home, `mrx.home.top`. A new container is an addition, but a 0.x minor never matches the range of the one before it, so every range moves to `^0.4` ([Upgrading from 0.3.x to 0.4.0](https://light.magerex.nl/md/developer/upgrading.md#from-03x-to-040)). Move the ranges with `composer require` (a `composer update` stays inside `^0.3`) and run `setup:upgrade` right after it. ### Added - Home: the container `mrx.home.top` (handle `light_home_index`, ACL `Magento_Backend::dashboard`) for full-width banners right under the setup guide, above the date range and the figures, and in the same place before the first sale. Its blocks are `.mrx-card` blocks that render nothing when they have nothing to say; Home adds no wrapper around them, so with nothing in it Home looks as before. `?mrx_slots=1` outlines it in developer mode, like `mrx.home.widgets` ([Home to-dos, setup steps and widgets](https://light.magerex.nl/md/developer/recipes/home.md#3-a-widget)). ### Changed - `magerex/module-flex-bridge`: the Flex Editor card on Home is a banner under the setup guide (in `mrx.home.top`, no longer in `mrx.home.widgets`), with "Open Flex Editor" and "Choose a page" on the right. - Every range moves to `^0.4`: `extra.mrx-light-api`, the range guard in `registration.php` and the requires on `mrx/module-*` and `magerex/*`. `ExtensionApi::VERSION` and the surface snapshot say 0.4.0. ## 0.3.2 (beta) Fixes and small screen changes; no `@api` change, so `composer update 'mrx/*' 'magerex/*' -W` stays inside `^0.3`. Run `setup:upgrade` right after it. ### Changed - Documents and Orders: the filenames of PDFs with several documents (`order-confirmations-*`, `invoices-*`, `creditmemos-*`, `packing-slips-*`, `picklist-*`) use the shop's local time; before, all but the picklist used UTC. - Orders: "Print picklist" on more than 250 orders prints the first 250 and says "Only the first 250 of N orders are printed", like packing slips and order confirmations. - The notification bell: the inbox section is called "Updates and news", so the panel no longer says "Notifications" twice. - Settings: the legal pages section is called "Legal pages"; Mrx_Returns names it "Returns and legal pages" while it is enabled. - Orders > Create order: the customer search results push "Create a new customer" down instead of covering it. - The two-factor screen: "Logout" sits below the card instead of above its heading. - Dutch pack, Settings > Taxes: the Dutch VAT card names the first four countries without a rate and "and N more", with the full list behind "Show all countries". When the ship-from address is outside the Netherlands it says which channel's address that is, links to the ship-from field on Shipping (in that channel) and names OSS or the regular VAT settings as the way to go. ### Fixed - Documents: the order, invoice and credit memo PDFs left the fixed product tax (FPT, such as a disposal fee) in the subtotal and printed it again as its own row. The subtotal now leaves it out, as the order page does; the document's own amounts are unchanged. - Documents: "View email" on the order timeline looks the stored copy up within its order, and a copy that can't be unpacked answers "This email copy can't be opened." (404) instead of a server error. - Demo data (`MageRex_DemoData`) no longer fails on a shop without Magento_Bundle or Magento_ConfigurableProduct. - Support: the ticket detail, refresh and reply routes declare their ACL resource (`MageRex_Support::tickets`) themselves. - White-label: core READMEs, composer descriptions, CSS, PHP comments and table comments no longer say "MageRex Light". A `setup:upgrade` updates the table comments. ## 0.3.1 (beta) One new package, `magerex/module-flex-bridge`; the other packages stay at 0.3.0. No `@api` change. ### Added - `magerex/module-flex-bridge` (`MageRex_FlexBridge`), new and optional. On a shop with Disrex Flex (`Disrex_FlexCore`) it adds a Flex Editor card on Home, "Edit in Flex Editor" on Content > Pages (row action and More actions) and in the page editor, a "Flex" column that says "Built with Flex", a palette action and an Apps row. It opens the editor in a new tab on the store view the page shows in, and the Content area of Settings > Users and permissions includes it. It shows nothing without FlexCore. It is in neither metapackage, and it changes no `@api`: it uses the `target` key and the row-only bulk action of 0.3.0. Checked against FlexCore 2.0.26. Custom roles that held Content before the install need one save in Settings > Users and permissions ([Flex shops](https://light.magerex.nl/md/developer/install.md#flex-shops-an-add-on)). ## 0.3.0 (beta) Two new routes, notification dismissal and legal page visibility, plus smaller additions to lists, the page header, the palette and Settings > Payments. A new route is an addition, but a 0.x minor never matches the range of the one before it, so every range moves to `^0.3` ([Upgrading from 0.2.x to 0.3.0](https://light.magerex.nl/md/developer/upgrading.md#from-02x-to-030)). Move the ranges with `composer require` (a `composer update` stays inside `^0.2`) and run `setup:upgrade` right after it. ### Added - Page header and palette: a `target` key on `header_actions` and `palette_actions` items. `_blank` opens the link in a new tab: a pool action in More actions renders `target="_blank" rel="noopener"`, and a palette action opens its page in a new tab by click and by Enter and hands the focus back. Any other value is ignored. A palette search group's result may carry `target` `_blank` too (`GroupInterface::search()`); a command never gets one ([The advanced view, the route map and header actions](https://light.magerex.nl/md/developer/recipes/advanced-and-route-map.md#6-header-actions-on-other-modules-pages), [The command palette](https://light.magerex.nl/md/developer/recipes/palette.md#2-an-action)). - Lists: a bulk action with `row_only` `true` is left out of the bulk bar and runs only through a row action that names it, on that one row. The list config carries it under `rowBulkActions`; a list whose bulk actions are all row-only gets no checkboxes ([Lists](https://light.magerex.nl/md/developer/recipes/index-table.md#3-bulk-and-row-actions)). - `Mrx\Settings\Api\ChannelPaymentProviderStateInterface` (`@api`), which extends `PaymentProviderStateInterface` with `isOfferedIn(?int $channelId): bool`. Settings > Payments asks it when it saves one channel, so a provider connected or switched on for that channel only counts there, and one switched off there doesn't (multi-scope audit 4.3). `null` means every channel, as `isOffered()`. A state with only the old interface is asked `isOffered()` as before. The Mollie and Pay. packs implement it ([A settings page from your system.xml](https://light.magerex.nl/md/developer/recipes/settings-page.md#payment-providers)). - Settings > Legal pages: a page a legal page links has a "Make visible" button (and "Hide" once it is visible) next to its badge, for admins who may save pages (`Magento_Cms::save`). It asks "Have you had the text checked? Customers can read it once it's visible.", then switches the page's visible state through the CMS repository (`light/settings/legalPageVisibility`, posts `page_id` and `visible`) and reloads the screen; a page no legal page links is refused. A visible page also says that customers find it in the footer. Each card has an anchor (`#privacy`, `#terms`, ...). - The notification bell: a finished bulk task (Magento_AsynchronousOperations, such as "Update attributes" or "Rule processing") has a Dismiss button, and the System messages section has "Dismiss all finished" while it lists a task, as the stock message bar's Dismiss and "Dismiss All Completed Tasks" do. Both post to `light/notifications/dismissTasks` (`uuid[]` or `all=1`, ACL `Magento_Logging::system_magento_logging_bulk_operations`), which acknowledges only the signed-in admin's own finished tasks through Magento's `BulkNotificationManagement::acknowledgeBulks()`; another admin's task or one still waiting is refused. A task that hasn't run yet says it waits for the store's background jobs (cron) instead. ### Changed - Every range moves to `^0.3`: `extra.mrx-light-api`, the range guard in `registration.php` and the requires on `mrx/module-*` and `magerex/*`. `ExtensionApi::VERSION` and the surface snapshot say 0.3.0. - Phones: Safari no longer zooms into a field when it gets the focus. Under `@media (pointer: coarse), (max-width: 640px)` every text field (inputs of a text type, selects, textareas; Light's and the stock ones, in pages, modals, the bulk bar and the palette) has 16px text, one `!important` floor in `light.css`; `login.css` does the same on the sign-in, two-factor and password-reset cards. A field that sets a smaller size for phones loses it there. Desktop sizes are unchanged ([Design system](https://light.magerex.nl/md/ui-kit.md)). - Phones: the sign-in, two-factor and password-reset card starts near the top of the screen (24px) instead of in the middle, and the page scrolls; up to 640px wide. - Phones and touch tablets: the search palette is a full-screen panel anchored to the top instead of a bottom sheet. It follows `window.visualViewport`, so the search row stays put when the on-screen keyboard opens or closes and only the results list, which scrolls inside the panel, changes height. It has no grab handle and no slide; Close, Escape and a result close it at once. The palette no longer carries `.mrx-sheet-handle`, `.is-closing` or the `--mrx-palette-viewport` property; `Mrx_Light/js/sheet` is the modals' alone ([Design system](https://light.magerex.nl/md/ui-kit.md)). - Apps: an open row leaves room between its header and the description, settings and screens below it. - Settings > Shipping: the country picker has a "Clear all" button next to "Add EU countries" (disabled while nothing is selected). The 0% tax warning under it names the first four countries and "and N more", with a "Show all" / "Show fewer" toggle, so a shop shipping everywhere no longer gets a page-long list. - Home: while a privacy policy draft exists that no one has made visible, the setup step "Review your legal pages" says the draft is ready and needs a check and "Make visible", its button reads "Open legal pages" and opens Settings > Legal pages at `#privacy`. The step is still done only when the privacy policy is visible. - Analytics: cards in one row are as tall as the row, so a short card next to a tall one leaves no empty gap, and an empty state sits in the middle of its card. The reading order and the phone layout are unchanged. ### Fixed - The Mollie and Pay. pages' own switch-off and disconnect guards ask whether another way to pay is on in the channel being saved (`PaymentMethods::hasOtherCheckoutMethod()` passes the channel to the provider state), so a provider connected for one channel only no longer makes them refuse in another. ## 0.2.3 (beta) A patch: multi-scope round 2 (a second store group is a menu of its channel, category visibility asks before it resets languages, legal pages reach every store view of their language, a channel's default language gets a translation field) and the order-number fix of 0.2.2 for every package. No `@api` change. Run `setup:upgrade` after `composer update 'mrx/*' 'magerex/*' -w`. ### Added - Lists: a bulk action's route may answer 409 with `confirm` (`title`, `message`, `label`, `param`) to ask the admin first; on yes the list posts the same `ids[]` again with `param` (default `confirmed`) set to `1` ([Lists](https://light.magerex.nl/md/developer/recipes/index-table.md)). - `mrx:light:doctor` has a new rule, [`legal_page_hidden`](https://light.magerex.nl/md/developer/troubleshooting.md#legal_page_hidden-warning): a warning per legal page link that a store view of its language can't open (the page isn't visible there, or no longer exists), naming the page, the language and each store view. Such a store view's footer leaves the page out while Settings > Legal pages shows it as linked (multi-scope audit D7). ### Changed - A second store group in a website is a second menu of that website's channel, not a channel (multi-scope audit D5). `ChannelProvider::getChannelsByRootCategory()` also returns the channel for the root category of its other store groups; the channel's `rootCategoryId` and default language stay its default group's. Products > Categories lists such a root under its channel and channel filter, the product editor's category picker groups it as "Studio Noord · Kids", and a category below it offers only that group's languages (the channel's own menu leaves them out). Content > Menu has a tab per menu (`light/menu/index/channel//root/`, saves post `root`). Settings > Channels lists each menu's languages under the channel and offers only the channel's own menu's languages as its default language. Two store groups of one website on the same root category are one menu, named after the channel once ([Channels and store views](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md#two-store-groups-in-one-website)). - Category visibility still applies to every language, but when a language has its own value (set in the advanced view) that differs from the new one, the category editor, the list's "Show in menu" and "Hide from menu" and the menu editor name those languages and ask first; nothing is written before the answer (409 with `confirm`, then the same request with `confirmed=1`). An own value equal to the new one stays. The menu editor asks before its first step, so a "no" leaves no new category behind. The category editor lists the languages with an own value under each switch ("Hidden for Deutsch (Outlet), set in the advanced view"), and `light/categories/save` answers with `ownValueNotes`, so the list follows a confirmed change. Before, any save that changed a switch removed every language's own value of both switches (multi-scope audit D9). - Category editor: a category whose main-language texts differ from its default values (written in the advanced view) shows the main language as a language of its own next to "Default (all languages)", as the product editor does, and a save reaches those texts. Main-language rows equal to the default values keep it merged (multi-scope audit D9). - Settings > Legal pages keeps one page per language for every channel, and a save now makes each linked page visible in every store view of its language: a Dutch page shown in the main channel's Dutch store view is added to the outlet's Dutch one too. This runs for every link on the screen, changed or not, so a store view added later gets the page with the next save. A page of another language is still refused, and a page's own visible or hidden switch is never changed. Only an admin who may save pages (`Magento_Cms::save`) makes this change; for others the link saves as before. When a store view can't be added (another page uses the address there), a changed link is refused with the reason ([Channels and store views](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md#legal-pages-across-channels)). - The translations under a settings field (`i18n[][...]`) have an input under All channels for a channel's default language when it isn't the shop's main language, such as an English channel on a Dutch shop. Before, that channel showed the main-language text unless every text got a channel value. In the channel itself the main input is its own language and shows the channel's value, while that translation wins on the storefront: on a schema page the field there names it as "Changed for English in its translation" with "Remove the English value", like a value from the advanced view. Under All channels and for the channel's other languages a translation is never named as a change. The texts Light keeps per language itself (payment and carrier titles, opening hours, the welcome text, the documents footer) show no such note yet (multi-scope audit D8). ## 0.2.2 (beta) A hotfix for `mrx/module-settings` only; the other packages stay at 0.2.1. No `@api` change. Run `setup:upgrade` after `composer update 'mrx/module-settings' -w`. ### Fixed - `setup:upgrade` no longer hangs in `Mrx_Settings`' data patch `UseChannelOrderNumbers` on a shop whose channel has a second store view with its own order numbers. The patch dropped that store view's sequence table inside its own transaction, and the DROP waited for the transaction's metadata lock. Order numbers of a channel still share one sequence; the store view's old table now stays until Magento removes the store view. A run that was killed left the store view's order numbering pointing at a dropped table, so orders in that store view failed: the next `setup:upgrade` with 0.2.2 repairs it. ## 0.2.1 (beta) A patch: Locations and pickup follow-ups, the single-channel fold of multi-scope D1, search and returns fixes, the install guide for 0.2.0. No `@api` change. Run `setup:upgrade` after `composer update 'mrx/*' 'magerex/*' -w`. ### Docs - [Install Light](https://light.magerex.nl/md/developer/install.md) is current for 0.2: `magerex/distribution-nl` installs 18 Light modules and `-hyva` 21, with `Mrx_Locations` coming in through Catalog, Orders and Home; the rate table pack is in neither metapackage and no metapackage suggests it; the first install names the modules in a `module:enable` line before `setup:upgrade`; new sections on stock and locations (simple mode with one source, locations mode from the second) and on two-factor sign-in; where the doctor fits. - A shop that required Light at `^0.1` doesn't reach 0.2.0 with `composer update 'mrx/*' 'magerex/*' -w`, which the 0.2.0 notes below suggest: a caret on 0.x stops at the next minor. Move the range with `composer require 'magerex/distribution-nl:^0.2' -w` (and Returns, the rate table pack or `-hyva` where the shop has them), then `setup:upgrade` ([Updating](https://light.magerex.nl/md/developer/install.md#updating)). ### Changed - A shop with one channel (one website) and settings kept at that website, as many existing stores have them: "All channels" now shows the website's value, which is what the storefront uses, and a save writes default scope and removes the website's row for each path it saves (multi-scope audit D1). This holds for `ScopedConfigWriterInterface::save()` with `$websiteId = null` too, so a module page on such a shop writes one scope; with two or more channels nothing changes ([Channels and store views](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md)). ### Fixed - Discounts: the product and category picker stays closed after Escape. A search still waiting for the typing pause, or on its way back, opened the list again over the fields below it. - On a shop with one channel, Settings > Payments shows that channel's own IBAN and Settings > Taxes its own "I don't charge tax" value, as the save now writes them (multi-scope D1). Before, the page showed the all-channels value, and a save could have replaced the channel's own one with it. - On a shop with one channel, saving Settings > Shipping with zones also removes that channel's own `carriers/flatrate/active` row, so the zones' flat rate is on at checkout as the page says. - Palette: the Categories group and the ranking of exact results fold the query like "Go to" and "Actions" (`StaticMatcher::normalize()`), so "e-mail" and "email" find the same categories, and "E-mail templates" ranks a result named "Email templates" first. - Apps: a composer package's hyphens split its parts into words, so "email" no longer finds an app through `vendor/module-mailer` ("modulemailer"). "module mailer" or the package pasted whole still finds it; the page and the palette search alike. - Settings > Returns: a new reason named like one renamed in the same save is added as a reason of its own; before, it was merged into the renamed one. - Dutch VAT card: the basis the button sets ("VAT of the customer's EU country") counts a Dutch rule only when OSS can copy it, as Settings > Taxes does: OSS's own rules, which may still charge a Dutch rate after the business moved to the Netherlands, no longer count. - Settings > Checkout: the one terms agreement is active while any channel asks for terms. A channel switching its terms off, or following All channels that has them off, now switches the agreement off when no other channel asks; All channels switching off keeps it for a channel that has terms on of its own. - Taxes: a home move (Business details, or the advanced view) updates the OSS rates on that save: the new home's rate leaves the OSS rules at once, where it waited for the next shipping-country or OSS save. - Doctor: `store_override` on a setting of a payment provider's own section (Mollie, Pay.) names that provider's settings on Settings > Payments and the advanced view, where it said that no Light settings page shows the setting. - Locations: the first "Add location" carries Local pickup over at All channels. It writes `carriers/instore/active` for all channels and a channel row only where that channel's pickup differed, and removes the channels' `carriers/mrxpickup/active` rows. Before, it wrote a row for every channel, so switching pickup for All channels on Settings > Shipping later changed nothing at checkout. The channel rows the conversion writes are Light's (multi-scope D11): an All channels save that makes one equal removes it, so that channel follows All channels again, and a save at the channel itself makes it the merchant's own. A shop converted with 0.2.0 keeps its row per channel: choose "Use the all-channels value" under the pickup switch in each channel, or remove the rows in the advanced view. - Settings > Shipping in locations mode: an empty translation of the pickup name is filled with Light's pickup name in that language ("Abholung im Geschäft"), as it already was for Local pickup at the business address. Magento's own names for pickup at a location count as Light's English name. ## 0.2.0 (beta) Locations and pickup at a location, plus the fixes found on the 0.1.5 shop re-checks. Run `setup:upgrade` after `composer update 'mrx/*' 'magerex/*' -w`: it creates Light's location and pickup tables. A new core module, new `@api` types and new routes are additions, so the minor goes up and every range moves to `^0.2` ([Upgrading from 0.1.x to 0.2.0](https://light.magerex.nl/md/developer/upgrading.md#from-01x-to-020)). Run `setup:upgrade` after `composer update`: it creates `mrx_location`, `mrx_location_stock` and `mrx_order_pickup`. ### Added - `Mrx_Locations` (`mrx/module-locations`), the 14th core module: Settings > Locations (`light/settings/locations`, the `settings_pages` item `locations`), shown only while Magento's inventory modules are on, to a role with Settings and Magento's `Magento_InventoryApi::source` (the palette leaves it out for a role without Settings). A shop with one location sees the business address and "Add location"; the first add makes the business address the first location, copies its stock there and keeps every channel selling the same quantities, in steps that resume after a stop (in the cron job `mrx_locations_conversion` above 3,000 products). Each location has a page (`light/locations/edit`) with its details, "Sell online from this location", "Sells to" with more than one channel, and "Customers can pick up here" with the name at checkout, opening hours, pickup instructions and "Usually ready in". Drag the list to set which location ships first; turn a location off once its stock has moved ("Move stock to another location"). Routes `light/locations/{edit,save,add,order,disable,movestock,moveoldstock,progress,resume}` ([Locations and pickup](https://light.magerex.nl/md/developer/recipes/locations-and-pickup.md)). - `Mrx\Locations\Api\LocationsInterface` and `Mrx\Locations\Api\Data\LocationInterface` (`@api`, read only): whether the inventory modules are on, whether the shop is in locations mode, the locations in shipping order, the main location and the locations a channel sells from. In simple mode they return the business address as the one location, code `''`. - Stock per location: the product's Stock card shows a row per location with "Stocked here" and "On hand", and "Available to sell" per channel; the variants table, the Stock page and "Adjust stock" get a location select ("All locations" shows read-only sums, "Not stocked here" offers "Stock here"); a new product or variant is stocked at 0 at every location that sells online; Duplicate copies the quantities per location. Changing them needs `Magento_InventoryApi::stock_source_item_assign`. - Ship from a location: "Ship items" says where it ships from, preset to Magento's priority suggestion, and caps each line at what that location holds; shipments and the timeline say "Shipped from" and the location, the packing slip prints it, the picklist takes a location, and the refund dialogs say where restocked items go back to. Bulk "Mark as shipped" ships each order from one location that holds all of it and skips an order that needs two. - Pickup at a location, on Magento's In-Store Pickup: Settings > Shipping's "Local pickup" card switches it per channel, names it per language and lists the pickup locations. Pickup orders show "Pickup ·" and the location and get "Ready for pickup" (routes `light/orders/readyforpickup`: ships from the pickup location and sends the email `mrx_orders_ready_for_pickup`, with the location, opening hours and instructions from the layout handle `mrx_orders_email_pickup`) and "Picked up" (`light/orders/pickedup`, with "Also mark as paid" for an unpaid offline payment). Local pickup at the business address gets the same two steps. The order list filters on pickup orders. - Home to-dos: pickup orders waiting, stock left at the old location, and "Your checkout doesn't offer pickup at locations yet." while a location offers pickup that the checkout can't show (`Mrx_SettingsHyva` names Hyvä Checkout for it). - The pool `checkouts_without_pickup` (`Mrx\Home\Model\Todo\Counter\PickupCheckoutMissing::checkoutsWithoutPickup`, adminhtml): module names of storefront checkouts without a step to pick up at a location; while one of them is on and a location offers pickup, Home warns. `Mrx_SettingsHyva` adds `Hyva_Checkout`. - Three plugins on Magento: a new channel sells from the Light stock, the checkout offers a pickup location only when it holds every product in the cart, and without a Google key a typed postcode or city lists the pickup locations. ### Changed - The several-locations guards are gone. With MSI and more than one enabled source, the product's Stock card, the variants, the Stock page and bulk "Adjust stock" edit stock per location instead of leaving it to the advanced view, and "Ship items" and "Mark as shipped" ship from a location instead of answering 422. `Mrx\Catalog\Model\Inventory\StockLocations`, `BulkResult::MULTI_SOURCE`, `Mrx\Orders\Model\Service\MsiSourceMode` and `ShipItems::shipsFromSeveralLocations()` went with them ([Upgrading from 0.1.x to 0.2.0](https://light.magerex.nl/md/developer/upgrading.md#from-01x-to-020)). - The products list's quantity on hand, its CSV export and the draft order's product picker sum what the enabled locations hold; "Sold out" and "Low stock" still follow what the channel can sell. - The picklist's storage column is headed "Bin": a location is now a place that holds stock. - "Location" and Magento's checkout label "Select Store" show as "Locatie" / "Standort" and "Kies een afhaallocatie" / "Abholort wählen" in Dutch and German: `Mrx_Locations` lists them in `prefer_phrases`, where the language packs said "Plaats" / "Ort" and "Selecteer winkel" / "Store wählen". - Every range moves to `^0.2`: `extra.mrx-light-api`, the range guard in `registration.php` and the requires on `mrx/module-*`. ### Settings - Business details' branding card names a store view's own logo, browser icon or email logo, set under Content > Design in the advanced view, with "Changed for Deutsch in the advanced view" and "Remove the Deutsch value" under that file, like any field with a `path`. Customers of that language saw the store view's file while the card showed the channel's. - `mrx:light:doctor`'s [`store_override`](https://light.magerex.nl/md/developer/troubleshooting.md#store_override-warning) names where to remove the value: the settings page that shows the setting ("under the field on Settings > Checkout"), or, for a setting no Light page shows (a theme's setting, the compare link, the wishlist), only the advanced view (Stores > Configuration at that store view; Content > Design > Configuration for a logo). Before, every finding pointed to a settings page that most of these settings don't have. The message still starts with the path and the store view. Schema pages count with their fields; a module in `app/code/Mrx` declares the paths its own page notes in the `pages` argument of `Mrx\Settings\Model\Config\ShownPaths` (no `@api` type, so other modules' rows point to the advanced view). - Magento's own default VAT basis, the delivery address, no longer counts as "OSS chosen". While no row stores `tax/calculation/based_on` for a channel or for all channels and no rule charges a rate of another EU country, the shop is on home VAT for Light: Settings > Taxes selects the home choice, `OssInterface::isUsed()` is false and `addRates()` adds nothing. A stored delivery or billing address, or rates of other EU countries (Light's OSS rules or the merchant's own), still mean OSS. Saving the Taxes page while no all-channels row exists stores the basis All channels shows, also when it equals Magento's default: under All channels the chosen basis, in a channel the one All channels showed before. OSS chosen for one channel no longer moves All channels to OSS through the shared rates. ### Dutch pack (`mrx/module-country-nl`) - The Dutch VAT card reads the basis the same way: on a shop that never chose a basis its checklist shows "Dutch VAT on every order" as missing, and "Check Dutch VAT setup" writes `origin` and adds no OSS rates. - The card's checklist names only what differs. While the settings match, one line names the prices and the VAT the button keeps (with OSS now "Prices and shipping include VAT, VAT of the customer's EU country"). While they don't, each setting that differs is a line of its own (`data-mrx-tax-check=""`), so a shop whose prices are fine no longer reads "Prices and shipping include VAT: Missing". The separate "Not set yet" list is gone; its lines are the checklist's. ### Shell and palette - The palette's "Go to" and "Actions" groups fold punctuation the way the Apps group and the Apps page do since 0.1.5, so "e-mail" finds Customer emails, Sender addresses and Emails & documents, as "email" does. The fold is `Mrx\Light\Model\Search\Group\StaticMatcher::normalize()` (lower case, diacritics stripped, hyphens and apostrophes joined, other punctuation a space); `Mrx\Apps\Model\Search\AppMatcher::normalize()` now calls it. Groups that search the database (orders, products, customers, pages, blocks and the rest) still match the text as typed. - Opening a Light page ends the advanced view also when Magento refuses the address's secret key (one copied earlier in the session, after a cache flush). Magento then sends the admin to the startup page before any controller runs, and that page opened as Magento's dashboard in the advanced view; it now opens Light Home. A `before` plugin on `Magento\Framework\App\Request\CompositeValidator` (`Plugin\Request\LeaveAdvancedViewForLightPage`) ends the advanced view for a `light_*` page request (GET, not a script call) before the key checks. ### Hyvä storefront - No "Broken reference" lines for `mrx.settings.legal_page.links` and `mrx.content.footer.menu` any more. Both sat in Luma's `footer_links`, which Hyvä doesn't have. `Mrx_ContentHyva` and `Mrx_SettingsHyva` declare them again on `hyva_default` in Hyvä's `footer-content`, which moves them before Magento builds the page. The footer menu block is `mrx.content.footer.menu` on Hyvä too (it was `mrx.content.footer.menu.hyva`), and without `Mrx_Content` the legal page links now show in Hyvä's footer (`mrx.settings.legal_page.links.hyva`, template `Mrx_SettingsHyva::footer/legal-links.phtml`, through the new `Mrx\Settings\Block\LegalPageLinks::getLinks()`). Luma is unchanged ([Hyvä storefront parts](https://light.magerex.nl/md/developer/recipes/storefront-hyva.md)). ## 0.1.5 (beta) A patch. Run `setup:upgrade` after `composer update 'mrx/*' 'magerex/*' -w`: a data patch takes from preset roles what their areas exclude. No `@api` type, pool key, route or token changed, so the API surface is the same as in 0.1.4. Two settings behaviours that modules build on changed, and preset roles lose what their areas exclude ([Upgrading](https://light.magerex.nl/md/developer/upgrading.md#from-014-to-015)). ### Settings - A channel keeps a value of its own also when it equals the all-channels value. `ScopedConfigWriterInterface::save()` for a website, and every channel-scoped settings page, no longer remove the channel's value when it matches all channels: a channel that switched guest checkout on while all channels had it on keeps it on after all channels switch it off. Only `null` removes the channel's own value, which is what "Use the all-channels value" posts (`mrx_use_default[]`). A value equal to what the channel reads now is still not written, so a field the merchant left alone keeps following all channels ([Saving per scope](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md)). Values Light works out for a channel itself (its invoice address, its ship-from taken from its business address, a sender name that follows a rename) still follow all channels when they match it. - A save posted for a channel that no longer exists answers 422 "This channel no longer exists. Reload the page." on every page that saves through `light/settings/save/page/`, the packs' pages and schema pages included, before the page's `save()` runs. Before, most pages read the removed channel as "All channels" and wrote the tab's values for every channel. It also applies on a shop that has one channel left. - Settings > Payments' "Keep at least one payment method on" looks at the channel being saved. In a channel, switching every manual method off is refused unless a payment provider is on for that channel; a provider on for all channels but off for the outlet no longer lets the outlet lose every way to pay, and one on for the outlet only no longer blocks it. Under all channels, every channel needs a provider on for it or a manual method it keeps on with its own value. - A value a store view has of its own for a channel setting, set in the advanced view, is no longer invisible. It wins on the storefront for that language, so a field with a `path` (`Fields` and core's pages) now shows "Changed for Deutsch in the advanced view" under it, with "Remove the Deutsch value": the save posts `mrx_store_reset[]=:` and `light/settings/save` removes that store-view row after the page saved. In a channel the note lists that channel's languages, under All channels every language with the channel's name, and it shows on a shop with one channel too. Light still has no store-view level in its settings and never writes such a value. Texts Light keeps per language itself (titles, names, instructions, opening hours, the locale, email templates, legal page links, system pages, the documents footer) don't count, nor do the fields a schema page translates, nor a language's own web address. Removing one needs the ACL resource of that setting's section (for a logo `Magento_Config::config_design`), and a secret such as an API key is never removed this way ([Store-view values from the advanced view](https://light.magerex.nl/md/developer/recipes/channels-and-store-views.md#store-view-values-from-the-advanced-view)). - Taxes' "Prices include tax" writes every tax display setting, so its note names a store view's own value of any of them (a German store view that shows prices without tax while the switch says they include it). One note per language; "Remove the Deutsch value" removes all of that language's rows. Such a button lists its values space-separated in `data-mrx-store-reset`. - `mrx:light:doctor` has a new rule, [`store_override`](https://light.magerex.nl/md/developer/troubleshooting.md#store_override-warning): one warning per such store-view value, naming the path and the store view (code, id, language and channel). A warning, so the doctor still exits 0. - Switching OSS on (`EuVat::ensureRates()`, also run by the `AddOssRates` observer) unlinks the home country's whole-country rate from an OSS rule when a rule of the home already charges that class. A shop that had OSS on, moved from the Netherlands to Belgium and then added a Belgian rule charged Belgian customers 42%: the Belgian rule and the old OSS rate "BE 21%". The rate itself stays. An OSS rule whose last rate goes that way (the merchant's own rules already tax every other country) is removed instead of saved empty, which Magento refuses. - Checkout in a channel: "Use the all-channels value" on "Set a minimum order amount", while all channels has no minimum, also removes the channel's own `sales/minimum_order/amount` and `sales/minimum_order/tax_including`. The amount field hides with the switch, so it had no reset to press and the channel kept its amount rows. ### Returns - Settings > Returns treats a new reason named like a reason the shop has (the reason's default label, ignoring case) as that reason. The same "Add reason" row posted twice before the page reloaded, by a double click or a retry, made a second reason with the code `_2`; now the second post changes nothing. A merchant who adds a reason by the name of a switched-off one switches that one on again with the new position and labels. ### Discounts - Saving a discount writes the titles of store 0 and of the languages the editor shows, and leaves every other store view's title alone. A title set in the advanced view for the default store view or for an inactive store view was emptied on every save in Light. ### Home - The payments step of the setup guide no longer counts a token method as a way to pay. PayPal's billing agreement, which Magento ships switched on and which saving the PayPal section stores as on, only charges an agreement a customer made before, so a shop with it and "free" as its only active methods had the step marked done. `Mrx\Settings\Model\Payments\OfferedMethodList`, the method list Home's check reads when Settings is installed, now also leaves out what `ProviderDetector::takesPayment()` rejects: admin-only methods and saved cards (Magento_Vault) as well. ### Users and roles - The first `setup:upgrade` on this release takes from the Staff and Shipping staff roles Light made what their areas exclude: "Clean balanced reservations" and "Mass Delete Reservations", which 0.1.4 took out of the Products area while a role made before kept them. The data patch `RemoveExcludedFromPresetRoles` runs once, touches only roles named like a preset that hold `Mrx_Light::light` and lack full access, and logs each role with what it took. Saving such a role in Light's role editor takes the excluded resources of the areas it keeps away as well; any other role keeps what it has outside its areas ([Upgrading](https://light.magerex.nl/md/developer/upgrading.md#from-014-to-015)). ### Rate table pack (`mrx/module-shipping-matrixrate`) - After "Rate table saved" the Advanced block shows "Numeric postcode ranges: Yes" and the warning that they are off goes, without a reload. A save always turns them on; the block kept saying "No" until the page was reloaded. ### Dutch pack (`mrx/module-country-nl`) - The Dutch VAT card counts a price display of "Including and excluding tax" as including VAT, and its button keeps it. A shop that shows prices including VAT and the subtotal both ways on invoices (`tax/sales_display/subtotal` = 3) no longer sees "Prices and shipping include VAT" as missing, and "Check Dutch VAT setup" no longer sets that display back to including only. - The card names the VAT its button sets: "Dutch VAT on every order", or "VAT of the customer's EU country" when OSS stays. A fresh shop stores no VAT basis, so Magento's delivery address read as OSS and the card said "VAT of the customer's EU country", while the button, finding no Dutch rule for OSS to add rates to, charged Dutch VAT on every order. The OSS radio and the lines about countries without a rate still follow the shop's current mode. ### Apps - Every tab of the Apps page says when it lists no app: "No apps in the menu", "No apps with screens", "No apps with only settings" and "No hidden apps", each with a line about what would show there. Before, the tab showed an empty panel under the toolbar. All keeps "All apps are hidden", and a search without hits still says "No apps found". - Search on the Apps page and in the palette folds punctuation: hyphens and apostrophes join and any other punctuation counts as a space, so "e-mail" finds "Email", "email" finds "E-mail templates" and "mage-os" finds what "mageos" finds. The page and the palette fold alike (`Mrx\Apps\Model\Search\AppMatcher` and the page's finder, with the shared cases in `Test/Unit/_files/matcher-vectors.json`). Because terms join too, a package such as `vendor/module-mailer` also holds "email"; such an app ranks last, found by its terms only. ### Logs - In developer mode the sign-in page and every two-factor screen no longer log about 27 INFO "Broken reference" lines per render. The `default` handle fills containers the `admin-login` page layout lacks (`header`, `page.menu`, `main.top`, `before.body.end` and the rest); Light's `admin_login.xml` now declares them inside a block nothing renders, so their blocks stay off the page as before and nothing else on the sign-in card changes. - Light pages no longer log "Broken reference: the 'notification.messages' tries to reorder itself towards 'user'" on Magento 2.4.9, whose module order schedules Magento_AdminNotification's toolbar before the user menu. `mrx_base.xml` removes that toolbar, and now also moves it without ordering it, which replaces the backend theme's "after user" move. ## 0.1.4 (beta) A patch for the modules real shops run. No `@api` type, pool key, route or token changed, so the API surface is the same as in 0.1.3. Light now works with Multi-Source Inventory on with one source, PayPal, fixed product tax (Weee), gift messages, Meta robots tag and the PCI DSS 4 rules; Apps finds every app at scale; the rate table pack (`mrx/module-shipping-matrixrate`) is new and optional. Run `setup:upgrade` after `composer update` ([Upgrading from 0.1.3 to 0.1.4](https://light.magerex.nl/md/developer/upgrading.md#from-013-to-014)). ### Shell - Simple mode hides Magento's `header` container (`