ordng

Teaching Agents Prussian Virtues

Table of Contents

ordng

ordng

Raw source: README.md

ordng is an opinionated pattern library and conceptual foundation for reactive, composable Haskell/IHP/HTMX applications, written for human developers working alongside coding agents. Its underlying principles are broader than this stack, but this repository works them out in a canonical server-rendered, HTMX-oriented form. It takes an axiomatic approach to implementation patterns.

Haskell, IHP

HTMX

ordng

Your Application

Your Coding Agent

provides patternsbuilds application

ordng focuses on the layer above raw IHP and HTMX: reusable patterns, abstractions, and conventions for server-rendered, reactive web applications. Cross-cutting concerns such as accessibility are handled inside the relevant pattern topics and orchestrated by recipes. No need for a JavaScript frontend framework.

ordng is intentionally canonical in tone. If its design choices fit your work, use the patterns and improve them here (= contribute!). If they don't, fork and make it your own canonical system. The pattern library is still under active extraction, though, and not yet complete enough to serve as a full push-button canon for arbitrary IHP application builds.

  • Foundations — conceptual substrate: what ordng believes and why.
  • Patterns — reusable building blocks: how to implement specific mechanisms.
  • Recipes — feature-level orchestration: which patterns to apply, in what order.

What ordng Covers

ordng is built around a few core ideas:

  • Literate Patterns — patterns are written as literate Haskell (.lhs): prose and compilable code live in the same file, so the pattern is explained and executable at once.
  • Agentic Programming — patterns are executable specifications. An agent reads a .lhs pattern, reuses or adapts its compilable parts where appropriate, binds names to the local domain, and verifies with the compiler.
  • Cross-Cutting Topics — patterns are grouped by what they help build, not by MVC destination. A single pattern can span controller, helper, and view code.
  • Domain Modeling — data-structure principles for modeling reusable, recombinable units, typed relationships, and taxonomies.
  • Foundational Concepts — composability, abstraction, and the underlying theory from which the patterns are derived.
  • Extractable Structure — topics are designed to move independently and the whole system is structured so it can be extracted from a host project into a standalone repository.

Choose an Entry Point

Reading Options

  • Human readers — the formatted book version lives at https://ordng.org.
  • Agentic readers — clone the repository. The pattern library is mostly .lhs plus topic .md files; Foundations and Recipes are Markdown. The surrounding IHP host app and tooling live in normal Haskell, shell, CSS, and asset files.
  • Diagrams — inline D2 blocks render automatically on ordng.org. For local viewing, install the D2 CLI and use a D2-capable editor extension.

Changelog

For notable repository changes, see CHANGELOG.md.

Foundations

Foundations

Raw source: Foundations/README.md

The reusable patterns in Patterns/ rest on a conceptual foundation that is elaborated in the following sections.

Table of Contents

  • 01-Composability/ – a brief reminder on composability and abstraction as system-shaping principles in the design of computer programs
  • 02-CodeAbstraction/ – application to the code layer: reusable helpers, interfaces, lightly coupled application structure, and recurring project bootstrap baselines
  • 03-DomainAbstraction/ – application to the data layer: atomistic and polymorphic domain/data modeling, typed relations, structures that reconcile free association with structured taxonomy
  • 04-AgenticDevelopment/ – building maintainable systems with AI agents
  • Literature/ – external sources, ordng-adjacent written works

Composability

Composability

Raw source: Foundations/01-Composability/README.md

As in Structure and Interpretation of Computer Programs (SICP), the starting point here is that the power of software does not lie primarily in isolated procedures or data structures, but in the ways they can be combined. Complex systems become manageable when they are built from parts that can be understood locally and composed into larger wholes. Composability is therefore not an ornament of good design, but one of the main conditions under which software remains intelligible as it grows.

Abstraction is the companion principle. It allows us to work with stable interfaces while temporarily ignoring lower-level detail, and it allows us to construct larger systems without having to hold all of their internals in mind at once. In this sense, composability and abstraction are not separate topics: abstraction makes composition tractable, and composition gives abstraction something to organize.

This is also part of why ordng is developed in a Haskell/IHP/HTMX setting. Haskell gives composability unusually strong support at the level of functions, types, and explicit interfaces, while HTMX favors interfaces built from small server-rendered interactions rather than a large client-side control system. Neither technology guarantees good structure by itself, but both make compositional design more natural and more visible.

The following sections apply this general principle more specifically. In 02-CodeAbstraction/, the question is how code can be shaped into reusable helpers, interfaces, and lightly coupled structures. In 03-DomainAbstraction/, the question is how the same concern appears on the side of the modeled world: atomic units, typed relations, taxonomies, and polymorphic roles. The purpose of this first section is simply to keep the general principle in view before those more specific applications begin.

Code Abstraction

Code Abstraction

Raw source: Foundations/02-CodeAbstraction/README.md

The question is not only how to write correct code, but how to shape code so that generic concerns acquire stable names and interfaces, while concrete application code remains readable at the call site. Helpers, components, controllers, views, and scripts should not each solve the same general problem for themselves. Reusable structure should be extracted once, so that what remains local can stay genuinely local.

A good abstraction keeps generic implementation detail out of application code and places it instead into explicit helper functions, interfaces, and support modules. The scope reaches from idiomatic use of Haskell and HTMX, through recurring framework-level structures above raw IHP conventions, up to component-based and hierarchically organized design systems. The user interface is only the most visible case of this. The same compositional logic can be applied across the application, so that a substantial part of the system is shaped by explicit layers of reusable structure rather than by isolated one-off code. But extraction is not an end in itself. The decisive question is always whether a piece of code has become reusable enough, stable enough, and legible enough to justify abstraction. Reuse is therefore not just a convenience gained afterward, but one of the tests of whether the abstraction is real.

Once such abstractions have become clear enough to formulate canonically, they enter the pattern library in Patterns/. There they can be documented as reusable coding patterns above raw IHP and HTMX conventions, while concrete applications keep their own local realizations at the edge. This includes not only the patterns themselves, but also further architectural documentation of how such principles are realized, for example in Patterns/ARCHITECTURE.md, where the structure of pattern modules and the boundary between reusable helper infrastructure and concrete use are made explicit. The same applies to recurring project bootstrap work after generators such as ihp-new: once the post-generator baseline has stabilized, it belongs in Patterns/Bootstrap/ as a reusable setup pattern rather than as oral tradition. That bootstrap layer is part of the fully opinionated ordng path, while the other pattern topics remain usable independently of it. The aim is lightly coupled application code whose moving parts remain understandable, but whose recurring structures do not have to be reinvented in every file.

Domain Abstraction

Domain Abstraction

Raw source: Foundations/03-DomainAbstraction/README.md

03-DomainAbstraction/ carries the concerns of abstraction and composability over from code into the modeled world. It asks what the primary units are, how they relate, how they are classified, and how structure can become more explicit without collapsing into separate silos.

At the center is a simple claim: a good system should not force a choice between free association and structured classification. The same unit should be able to participate in both. A note, block, item, or entry should be linkable as a node in a graph, classifiable in one or more taxonomies, and enrichable with typed structure, without being rewritten into a different kind of thing each time.

The core modeling move is to treat content as atomic enough to be reusable, but not so rigidly typed that every new use case requires a new silo. At least at first, this means ordinary-language textual content: names, titles, descriptions, comments, and similar material that is meaningful to a human reader but not already structured for the program like enums, calculable numbers, or parseable URLs. Precisely because such content is not exhaustively understood by the program itself, it becomes valuable to model it as small reusable units that can exist meaningfully in several contexts at once.

That does not mean every text field must immediately become a node. The important distinction is whether the text should stay purely local to one entity or whether it should become reusable, addressable, or independently relatable across contexts.

Typical relation types in this area might include:

  • description — a title or name node has a more detailed explanatory child
  • definition_of_done — a project or task node points to criteria text
  • outline_parent / outline_child — ordinary outliner hierarchy between text nodes
  • comment — one text node comments on another
  • example — a concept node points to an example node
  • citation or reference — one node points to a source-like node
  • revises / supersedes — one text node replaces or updates another
  • supports / contradicts — argument-like relations between statements

This means small addressable units instead of monolithic documents only, typed relationships instead of mere unstructured linkage, graph navigation for association, discovery, and serendipity, taxonomy for reliable placement, maintenance, and traversal, and optional overlays, frames, or roles rather than one fixed essence per atom.

Many systems force one-sided choices. Classic document tools privilege linear structure. Outliners privilege explicit hierarchy. Graph tools privilege free linking but often neglect stable classification. Database-like tools privilege predefined schemas and rigid containers. Low-code systems often model structure well, but leave associative knowledge work impoverished. ordng is interested in a more general substrate where these are different views on the same underlying material.

A content system built in this spirit should be able to refer to small units directly, not only to whole pages or documents. These atoms may later appear as knowledge nodes, authoring blocks, tasks, definitions, sources, arguments, or records in a more operational system. The point is not that everything must look identical in the UI, but that the underlying model should permit reuse and recombination.

A recurring idea in the notes is that the same atom should be findable in at least two ways: via associative links, similarity, or relevance, and via explicit hierarchical or taxonomic placement. This is not an either-or. Graph-style association helps with discovery, cross-domain connections, non-linear thought, serendipitous retrieval, and low-friction capture. Taxonomic structure helps with reliable placement, maintainable classification, shared orientation, programmatic traversal, and explicit constraints and expectations. The model therefore needs both.

There is also no reason to assume one universal hierarchy. The same atom may belong simultaneously to several taxonomic structures, for example topic hierarchies, organizational hierarchies, onboarding or tutorial structures, workflow or process classifications, or required-knowledge trees. This matters because taxonomies serve different purposes. They are not merely alternate labels for the same tree.

This already implies that relationships cannot remain anonymous. Different relations behave differently: some are symmetric, some directional, some hierarchical, some temporal, some restrictive in cardinality. The system therefore needs explicit relation semantics.

Relationship Type Properties

Each relationship type can be understood as having its own characteristics:

Property Options Purpose
Symmetry symmetric / asymmetric Symmetric: link = backlink. Asymmetric: direction matters
Cardinality 1:1, 1:n, n:m One definition per term? Many arguments per thesis?
Display priority Integer Which relations show first in backlink lists
Inverse label Text supports ↔︎ supported-by, broader ↔︎ narrower

This matters because a reference is symmetric, while a supports is not, and a defines relation may be constrained much more tightly than a loose cross-reference.

The question is not whether every domain relation must be lifted into an explicit relation-type system. Some relation semantics can already be read from ordinary relational structure, while others cannot.

Field on explicit relation type Can be derived from a traditional relation? How
source_min / target_min partly NOT NULL gives a lower bound of 1 on the referencing side; nullable foreign keys give 0
source_max / target_max partly plain foreign key suggests many-to-one; UNIQUE tightens one side to 1; junction tables express many-to-many
is_hierarchical partly often inferable when the relation is explicitly modeled as child → parent or owner → owned
direction yes the foreign key direction or junction-table role names provide it
is_symmetric x not determined by ordinary schema alone
inverse_name x not determined by ordinary schema alone
display or traversal semantics x not determined by ordinary schema alone

In the atomic model, fields such as inverse_name with a foreign-key constraint to relation_types.name are still ordinary text columns; the constraint only adds referential integrity.

Multi-Dimensional Relationship Graphs

The same atom should be able to participate in several kinds of relationship structure at once:

  • Free links: associative, similarity-based, graph-like
  • Taxonomic hierarchy: broader/narrower, kind-of, part-of, and related classificatory structures

A useful first distinction is this:

Characteristic Examples Behavior
Non-hierarchical (symmetric) reference, related-to, see-also Link = backlink, bidirectional by nature
Hierarchical (asymmetric) supports, contradicts, defines, broader/narrower Direction matters, enables taxonomies
Temporal supersedes, revises Version chains

Once this is made explicit, one can build not only generic graphs, but several kinds of navigable structure on the same atoms:

  • Knowledge graphs: Terms → definitions → examples → counter-examples
  • Argumentation maps: Thesis → supporting arguments → counter-arguments → rebuttals
  • Citation networks: Block cites Block, visible in both directions
  • Concept taxonomies: Broader/narrower term relationships
  • Multiple parallel taxonomies: Topics, departments, document types — same block belongs to several

Taxonomy only becomes practically useful if the system helps with placement. That suggests features such as breadcrumb display, suggestions of existing parent concepts before near-duplicates are created, and directional rather than undifferentiated display for hierarchical relations. Backlinks remain useful, but they are only one retrieval mechanism among several. They do not by themselves create order.

Graph traversal also matters because it enables serendipity: accidental but meaningful discovery is not merely a UX flourish here, but one of the reasons to prefer a graph-capable model in the first place.

Polymorphic Atoms

One of the strongest ideas in the older notes is that a node should not have to stop being itself when it acquires more structure. The point is not that every concern should be collapsed into one uniform graph table. It is that reusable semantic units should not be trapped inside one local entity each time the domain becomes more structured.

In traditional systems, this is often the painful point. One decides too early whether something is a Task or a Note, and migration becomes expensive. The alternative explored here is to keep reusable nodes comparatively naked, then let explicit domain entities and typed relations carry the richer structure around them.

Block"Implement user authentication"Task framestatus = in-progressassignee = Fritzdue = 2026-02Topic framebroader = Securitybroader = MVP

Frames are optional metadata overlays. They do not replace the atom beneath them. This sketch remains useful as a view model: it shows how one node can appear with multiple contextual frames in presentation and navigation. AtomicDomainModel.md addresses the persistence model instead.

At least in the first patterns we are currently exploring, these atoms or nodes are still text-bearing units. That is not a trivial detail. It is one of the reasons polymorphism is useful here in the first place: titles, names, descriptions, topic entries, and similar pieces of ordinary-language content are different in role, but alike enough in substance that a shared content-bearing micro-entity becomes valuable.

Even there, a minimal content-type distinction may still be useful. A first node model may therefore keep a simple closed distinction such as ordinary-language content versus code, without immediately turning node typing into a large taxonomy of content kinds.

The richer fields then belong on explicit domain entities or, when the relation itself carries domain structure, on explicit domain-relation tables. This keeps the node layer reusable while still allowing the surrounding model to stay strict.

This keeps migration cheap and lets a system evolve from loose capture toward stronger structure. It also shows why the relevant notion of a type system here is not only a programming-language concern. The content model itself may need to express which kinds of relations exist, which constraints hold on them, and where richer domain structure belongs instead of falling back to untyped property bags.

Attributes, Relations, and Aliases

A useful observation from the Podio comparison is that structured systems are not fundamentally alien here. Podio-like systems can be understood as a special case where relations are more predefined, some are mandatory, and entries also carry typed attributes. Roam-like systems can be understood as the opposite special case where relations are much freer, explicit attributes are minimal or absent, and text carries much of the burden. The interesting modeling task is therefore not choosing one camp, but finding an abstraction that can host both.

If atoms are to be first-class, they also need stable and human-usable ways to be addressed. That points toward aliases, generated short handles, searchable summaries, and link targets that do not depend on long raw text.

The same logic also applies to internal content structure. If a task title and a task description are both text-bearing semantic units, the model may benefit from treating them as related nodes inside one content structure rather than as unrelated scalar fields spread across separate tables. In that case, the difference between title and description is carried not by the bare existence of two different text columns, but by the typed relation that links one content node to another.

Alias Source Example
Manual User sets [[auth]] as alias for authentication block
Auto-generated First N words, project codes, semantic summary
From content If block contains #define Wissenschaftsfreiheit, extract as alias

Aliases make atomic structure usable in practice. They support search autocomplete, quick linking, and the treatment of the same atom as page-like when it becomes a stable reference point.

Adjacent Pressures from Application Design

Some of the motivating notes are not pure foundation, but they reveal pressures that shape the model.

There is an unresolved but productive tension between page-based systems and endless-document systems. The deeper issue is not the exact UI choice, but whether the model privileges page containers or smaller atoms as the primary unit.

The notes on journals versus curated pages point to a related question: how rough captures relate to stabilized knowledge, how a system distinguishes inbox material from integrated material, and how backlinks should behave when rough and processed notes coexist. These are workflow questions, but they put pressure on the underlying model.

Another recurring theme is whether external content such as tweets, blog posts, or code should be copied in, referenced, indexed, or authored directly inside the system. This matters because the atomic model may need to host both native and imported material.

The notes also hint at a third top-level category beyond trees and nodes: queries. Once relations, types, and taxonomies become explicit, derived views and computed collections stop being incidental UI features and become part of the conceptual system.

Relationship to Patterns/

This strand stays on the conceptual side. It is concerned with questions such as what kind of thing the primary unit is, how graph relations and taxonomies coexist, what makes a node polymorphic, and which distinctions belong in the model itself.

When the ideas here become explicit reusable implementation guidance, they belong in Patterns/, especially in future domain/data-oriented pattern topics. This may include canonical worked examples or overtragbare structure patterns, but the point here is the principle rather than a ready-made schema to copy unchanged.

For adjacent model traditions and prior art, see AdjacentModels.md. For neighboring software products, see AdjacentProducts.md.

Adjacent Models and Prior Art

Raw source: Foundations/03-DomainAbstraction/AdjacentModels.md

This note collects nearby model traditions for the ideas discussed in README.md. The point is not to collapse ordng into one of them, but to make the neighborhood explicit.

Short Conclusion

The general idea is not new. Several established traditions already combine some of these elements:

  • graph-shaped association,
  • typed relationships,
  • schema-bearing or schema-bound graph models,
  • flexible polymorphic content or entity structures,
  • and hybrid approaches that keep relational domain entities while exposing graph structure.

What seems more specific in the current ordng line of thought is the particular combination:

  1. explicit operational domain entities remain intact,
  2. only the semantic content layer is polymorphized into reusable nodes,
  3. relation types are modeled explicitly as reusable semantic objects,
  4. the same node layer supports both internal content structure and wider knowledge-graph relations.

In this sense, ordng should not simply adopt the standard property-graph vocabulary wholesale. node is a good fit, but relation remains more precise than edge, because the important point is not merely that two nodes are connected, but what semantically determined relation holds between them.

Property Graphs

The closest broad family is the property graph model.

Property graphs treat nodes and edges as first-class. Nodes and edges can carry properties. In the common labeled property graph style, nodes may also carry labels, while edges typically carry a single label naming the kind of connection. That already covers a large part of the desire for free association plus explicit relation semantics.

NodeEdgeLabel 1PersonLabel 2FriendPropertyPropertySingle LabelKNOWSPropertyPropertynameAlicestatusactivesince2024-01-01strength0.8

These properties are ordinary node or edge attributes, not vector embeddings. A property graph is therefore a logical graph model, not a commitment to a vector database, nor even necessarily to a native graph database. The same model can also be implemented, or projected, over relational tables.

At the same time, these labels and key-value properties are comparatively untyped. In practice, the same keys have to be reused consistently, whether programmatically or manually, if later queries, rendering, or application logic are supposed to treat them as the same property. The same applies to labels: if one writes a different string, whether intentionally or by typo, one has in effect created a different label.

That edge label is still weaker than an explicitly modeled relation type. It is closer to tagging a connection than to representing the relation type itself as a reusable semantic object with its own properties. The stronger move in the current ordng direction is therefore not merely to name a connection, but to make the relation type itself a reusable semantic object with explicit properties.

Distinction from Atomic Domain Modeling

In ordng, nodes are not primarily enriched by open-ended property bags, but by explicit assignment into one or more richer domain entities or overlays. That yields stricter and more expressive structure than free key-value extension alone.

Relations follow a similar logic. One could always introduce a domain-specific junction table when a particular relation needs extra fields. The stronger move here, however, is the introduction of a generalized relation type that is assigned rather than written as a free string. This makes reusable tree and graph structures programmable in a stricter and more reliable way, because the model depends on explicit assignment rather than repeated string conventions.

What property graphs do not by themselves settle is how strongly the graph remains bound to an explicit application domain model, or how much of the domain should remain relational and operational rather than being flattened into one generic graph substrate.

Reference:

SQL/PGQ and Graph Views Over Relational Tables

The hybrid thought, relational domain entities plus graph structure, is now explicit in the SQL/PGQ direction.

SQL/PGQ and related PGQL work allow property graphs to be defined over existing relational tables. This means one does not have to choose between a relational model and a graph model in an all-or-nothing way. Instead, graph structure can be projected from or layered over a relational base.

This is especially relevant as neighboring prior art because it confirms that the combination of explicit domain tables and graph traversal is not an eccentric idea, but an emerging standard direction.

References:

The Entity-Attribute-Value family is another clear neighbor.

EAV is an older response to schema rigidity. It makes attributes more flexible and metadata-driven, often with explicit attribute-definition tables and dynamic validation metadata. Extensions such as EAV/CR and EAV/CSG add classes, relations, sets, and graph structure.

This is relevant because it shows that people have long tried to avoid premature commitment to rigid entity silos.

At the same time, ordng is not simply an EAV system. The current direction does not primarily start from sparse attributes or arbitrary user-defined fields. It starts from reusable semantic content units and typed relations between them, while still keeping explicit operational entities where that remains useful.

A useful contrast to property-graph thinking is that what those models often treat generically as node or edge properties gets split more strictly here: global schema-level fields stay ordinary columns, domain-specific fields stay attached to explicit domain entities or overlays, and only genuinely ad-hoc per-node or per-relation metadata would motivate a later property-like extension.

References:

Schema-Bound Typed Graph Models

Another close neighbor is work that criticizes schema-light property graphs and argues for stronger type discipline.

Fritz Laux's "The Typed Graph Model" is directly relevant here. It proposes a schema-bound typed graph model with properties and labels, explicitly arguing that graph flexibility should not require giving up data quality or structural guarantees.

This matters because it lands close to one of the central ordng intuitions: graph freedom is useful, but not sufficient. Relation semantics, admissible structures, and explicit types still matter.

Reference:

Haskell Ecosystem

Within the Haskell world, the nearest neighbors seem to fall into two separate camps rather than one combined model tradition.

On the graph side, libraries such as algebraic-graphs and fgl show serious work on graph representation, graph algebra, and labeled edges. They are relevant because they confirm that Haskell has a strong tradition of thinking carefully about graph structure. What they do not appear to provide is the particular synthesis explored here: explicit domain entities, a reusable node layer, explicit relation-type entities, and one shared semantic enforcement layer across both graph-like and domain-like relations.

On the relational side, libraries such as beam and persistent are strong exactly where one would expect Haskell to be strong: typed schemas, typed fields, typed joins, typed query construction, and generally much stricter correspondence between program structure and database structure than is common in dynamic frameworks. That is highly relevant to ordng, because it shows that Haskell already offers a mature culture of type-driven relational modeling. What seems missing there is not strictness, but the particular move of lifting relation semantics themselves into explicit reusable domain objects that bridge freer graph structure and traditional domain entities.

So the Haskell ecosystem does contain strong ingredients for this direction, but mostly in separate traditions: graph libraries on one side, strongly typed SQL and schema libraries on the other. The current ordng model still looks more like a synthesis across those traditions than like a straightforward reuse of one existing Hackage pattern.

Topic Maps

Topic Maps are older and come from a somewhat different tradition, but conceptually they are surprisingly close.

Their core vocabulary is topics, associations, and occurrences. All of these can be typed. Topic Maps also distinguish between the semantic subject layer and the concrete resources in which those subjects occur.

That makes them interesting adjacent prior art for at least three reasons:

  • they treat relationships as explicitly typed,
  • they separate semantic subjects from concrete resource occurrences,
  • and they support a knowledge model that is not reducible to one simple hierarchy.

This is not the same as the current ordng pattern direction, but it is useful evidence that typed associations plus resource anchoring have deep precedent.

References:

Working Distinction for ordng

A useful working distinction is therefore:

  • Property graphs show that typed edges and rich relationship semantics are established.
  • SQL/PGQ shows that relational and graph views can be deliberately combined.
  • EAV-family models show that extensible, metadata-driven structure has a long history.
  • Typed graph models show that graph flexibility can remain schema-bound.
  • Topic Maps show that typed semantic associations and resource anchoring are older than the current graph-database wave.

The question for ordng is therefore not whether this territory is untouched. It is which synthesis is worth making explicit, and which boundaries matter so that the result does not dissolve into either a generic EAV system, a schema-light property graph, or a pure ontology stack.

Adjacent Products

Raw source: Foundations/03-DomainAbstraction/AdjacentProducts.md

This note collects software products that sit near the model territory discussed in README.md. The point is not to claim that they implement the same architecture as ordng, but to identify useful neighboring product directions.

Short Conclusion

No widely known personal knowledge management product appears to implement the exact combination currently explored in ordng:

  • explicit operational domain entities,
  • a reusable semantic node layer,
  • generalized relation types as explicit semantic objects,
  • and graph or tree structures built by assignment rather than by repeated free strings.

Still, several adjacent products illuminate parts of the space.

Anytype

Anytype is probably the closest broadly known product in this neighborhood.

It treats everything as an object, supports types, supports relations between objects, and exposes graph-like views over those connections. This already places it much closer to typed semantic structure than page-and-link tools such as Roam or Obsidian.

At the same time, it still appears to operate more as an object-property-relation system than as the particular synthesis explored in ordng. The current ordng direction keeps asking more explicitly how free semantic nodes, explicit operational entities, and generalized relation types can coexist in one stricter model.

References:

Capacities

Capacities is also close, especially on the object-type side.

Its core unit is the object. Objects have types, types have properties, and object properties can point to other objects. This gives Capacities a stronger structural model than most wiki- or outliner-style PKM tools.

What seems less explicit there, compared with the current ordng direction, is the idea of a generalized relation-type layer that is itself modeled as a reusable semantic object rather than remaining mostly inside the object-property system.

References:

Tana

Tana is another nearby product direction.

Its supertags and structured node model clearly move beyond plain free-form notes. A Tana node can remain freely linkable while also carrying a more explicit schema through its supertag and fields.

This makes Tana relevant as an example of how graph-like note systems and stronger structure can coexist. What is less clear, in comparison to ordng, is whether relation types themselves are elevated into an explicit reusable semantic layer, rather than remaining mostly implicit in fields, references, or application conventions.

Reference:

Logseq and Roam

Logseq and Roam remain important neighboring tools, but mainly as examples of the other side of the spectrum.

Both are strong in free linking, graph navigation, and flexible capture. Both also have some notion of attributes or properties. But in common usage they still lean much more toward free-form graph structure plus conventions than toward a strict semantic model with explicit reusable relation types.

They therefore help clarify what ordng is trying not to stop at.

References:

Working Distinction for ordng

A useful practical distinction is this:

  • Anytype, Capacities, and Tana show that users do want freer note-like units and stronger structure in the same product.
  • Logseq and Roam show the power of free graph navigation and low-friction capture.
  • ordng is trying to make the semantic and domain-model side more explicit than these tools usually do, especially around node reuse, relation typing, and assignment into stricter modeled structures.

Atomic Domain Model

Raw source: Foundations/03-DomainAbstraction/AtomicDomainModel.md

This document describes the atomic domain model as a generic mental model for atomic domain abstraction.

The point of the model is not to dissolve the domain into one undifferentiated graph. Explicit domain entities remain. What changes is that reusable nodes and typed relations form a shared semantic layer around them.

Atomic semanticlayerExplicit domainentitiesTyped domainrelationsEnumsnodesiduuidPKcontenttextnot_nullcontent_typenot_nullaliastextUNQrelation_typesnametextPKinverse_nametextis_symmetricbooleannot_nullis_hierarchicalbooleannot_nullsource_mincardinality_boundnot_nullsource_maxcardinality_boundnot_nulltarget_mincardinality_boundnot_nulltarget_maxcardinality_boundnot_nullnode_relationssource_node_iduuidnot_nulltarget_node_iduuidnot_nullrelation_typetextnot_nullpksource + target + typePKdomain_entity_aiduuidPKnode_iduuidFKdomain_field_1textdomain_field_2datedomain_entity_biduuidPKnode_iduuidFKdomain_field_1textdomain_field_2datea_b_relationssource_entity_a_iduuidnot_nulltarget_entity_b_iduuidnot_nullrelation_typetextnot_nulldomain_relation_field_1textpksource + target + typePKcontent_typeordinary_languageenum_valuecodeenum_valuecardinality_boundzeroenum_valueoneenum_valuemanyenum_value
Diagram Reading

The diagram above shows three structural containers.

  • Explicit domain entities - domain_entity_a and domain_entity_b keep their own fields; the model does not dissolve the domain into generic property bags.
  • Typed domain relations - a_b_relations connects explicit domain entities while still naming a shared relation_type.
  • Atomic semantic layer - nodes, relation_types, and node_relations form the reusable semantic substrate. enums (content_type, cardinality_bound) live inside this layer as supporting value sets.

Edge colors:

  • Red - connections to nodes (reusable node references)
  • Green - connections to relation_types (typed relation semantics)
  • Blue - connections between explicit domain entities
  • Grey — connections to enums (value-set references)
Nodes

nodes are the reusable semantic units. They hold addressable content such as names, descriptions, topic entries, and similar small units. The model keeps a minimal content_type distinction so that ordinary-language content and code do not collapse into one undifferentiated text bucket.

They intentionally stay comparatively naked. Once richer structured fields are needed, the model should normally move into explicit domain entities or explicit domain-relation tables rather than reintroducing generic key-value bags at node level.

A useful decision rule is this:

  • keep a field on nodes only if it is intrinsic to the node and should remain the same across all uses of that node,
  • model something as an additional node plus typed relation when it is itself semantic content,
  • keep workflow or view-specific state outside the node layer, typically on domain entities or other context-specific structures.

This is why content_type clearly belongs on the node, while things like comments are better modeled as nodes linked by a relation type such as comment, and draft or review state is usually better treated as context-dependent rather than intrinsic to the reusable node itself.

Domain Entities

domain_entity_a and domain_entity_b stand for explicit domain objects. They keep their own domain fields. This is important: the model does not replace ordinary domain structure with generic property bags.

What changes is that each domain entity points to a node_id. The reusable node carries the semantic unit that can later also participate in cross-cutting structures beyond the one local entity table.

Relation Types

relation_types make relation semantics explicit. A relation type is not just a string written onto a connection. It is a modeled object with its own identity and its own semantic properties, such as symmetry, hierarchy, and inverse relation.

This is the crucial step beyond loose graph labeling.

Relations

The model uses two relation families: node_relations for node-to-node structure and a_b_relations for relations between explicit domain entities. Both point to the same relation_types table.

This distinction matters. The purpose of separate domain-relation tables is not only to avoid polymorphic source and target fields. It is also to preserve rich domain-specific relation structure where the domain needs it. a_b_relations can therefore carry its own domain relation fields without collapsing back into generic key-value properties.

That shared relation_type field is the key to central enforcement logic. Because every relation must name a relation type, one general compliance layer can enforce symmetry rules, inverse relations, and cardinality bounds across the whole model instead of re-implementing them table by table. In that sense, the extra abstraction does not remove strictness. It relocates it into one reusable enforcement point.

If this typed relation substrate becomes central enough, a later data-layer step may also want reusable graph operations over it rather than only bespoke query code. For an adjacent implementation reference in that direction, see ../Literature/Sources/InductiveGraphsAndFunctionalGraphAlgorithms/.

Process modeling is a concrete candidate for this pattern. A task or activity should remain an explicit process-domain entity with fields such as implementation hook, version, expected inputs, output events, subject, or UI behavior. Its name or description can still point to reusable nodes. The edges and control-flow connectors of a process model, however, are naturally typed domain relations: sequence flow, conditional transition, message flow, subprocess boundary, and branch/join/loop structure all carry semantics that should be inspectable and enforceable. Some of these semantics may still be represented by explicit gateway or event entities rather than by relation rows alone. In either case, the right shape is usually not a bare generic node_relations row, but a process-specific relation table that still names a shared relation_type and can add fields such as condition, ordering, external export ID, gateway role, or projection metadata. For a related process-engine source, see ../Literature/Sources/ReferenziellTransparenteBusinessProzesse/.

In this model, typed relation tables use composite primary keys rather than surrogate id columns. The rule is strict: when a table is a relation table and carries the mandatory relation_type, its identity is the source-target-type triple itself.

Reading the Model

The intended reading order is:

  1. domain entities stay explicit,
  2. reusable nodes carry the semantic units that should not be trapped in one table,
  3. typed relations connect nodes and domain entities in a way that remains semantically inspectable and programmatically reliable.

That is the core modeling move.

Agentic Development

Agentic Development

Raw source: Foundations/04-AgenticDevelopment/README.md

This strand is about implementing ordng patterns with AI agents without giving up the structural standards by which good software is usually judged. The central claim is simple: agents are most valuable in the mechanical part of software work, but only after the semantic decisions have been made and the success criteria have been made explicit. Tests are the machine-checkable subset of those criteria. In that sense, ordng is interested not merely in testable pattern rollout, but in success-criteria-driven rollout with as much of the verification as possible made executable.

For ordng, this is not a matter of prompt folklore in the abstract. The focus is narrower and more architectural: how code can serve simultaneously as implementation, specification, and documentation; how context systems, skills, and other forms of explicit guidance can reinforce that role; and how agent behavior can be tuned against such artifacts rather than against vague intentions alone. The point is to give both humans and agents clearer boundaries, more stable abstractions, and more reliable feedback loops.

This is also why agentic development cannot be separated cleanly from the rest of the repository. It depends on the same concern for composability, code abstraction, and explicit domain structure that appears in the earlier strands. AI assistance becomes useful when those structures are visible and pressure-tested, not when they are replaced by ad-hoc output. In this sense, agentic development is less a rival to ordinary software design than a stress test for it.

Armin Ronacher's MiniJinja port is a good concrete example of the stance taken here. The human decided the architecture and defended the semantics. The agent did the large volume of mechanical, test-backed work. The point was not to remove judgment, but to move repetitive rollout and verification effort onto the machine once the meaning of the work was already fixed. The mechanical part was not merely checked afterward; it was carried out against explicit criteria from the start.

For ordng, that means reusable patterns should usually be discovered and criteria-driven in a real target system first, not written in isolation. The target system is where friction, duplication, and unclear boundaries become visible; where a living example can be made to work end to end; and where the line between local behavior and reusable shape becomes easier to see. ordng is then the place where that insight is extracted, clarified, and stabilized as a shared pattern. After that, the refined pattern should be re-applied in the target system so that the shared abstraction and the living example do not drift apart.

A practical consequence is that agentic development benefits from repository shapes that reduce ambiguity before rollout starts. The human should decide the stable library/product boundary, keep the semantic core small, push volatile complexity into outer layers, and prefer names and module boundaries that make existing capabilities easy to discover. The agent should then be steered toward those surfaces rather than toward ad-hoc reinvention.

This is also the right place for negative constraints, but only at the layer where they actually belong. Rules such as avoiding bare catch-alls, raw SQL, raw UI inputs, or dynamic imports are not universal prompt advice; they are local repository policies that should sit next to the relevant pattern or subsystem. When possible, they should be enforced by typed wrappers, component boundaries, linting, or other executable checks rather than by prose alone.

In that sense, the job of the human is not just to prompt well, but to prepare a codebase that gives the agent good rails: explicit patterns, narrow interfaces, discoverable names, and machine-checkable constraints. The less the agent has to infer from vague intent, the less it falls back to generic internet priors.

This can happen in three increasingly structured execution modes:

  1. manually, by copying, adapting, and checking the pattern without agent support,
  2. interactively with an agent, step by step, whether through normal prompting, a plan file, or plan mode,
  3. durably, when the mechanical rollout itself becomes checkpointed, testable, resumable, and approval-gated.

DurableExecution.md is about this third mode. It does not define the foundation itself, but one opinionated implementation path for the most structured form of pattern rollout.

A further consequence for ordng is that patterns should ideally expose their own rollout verification criteria. If a pattern can only be described but not checked, then the mechanical part of applying it still depends too much on human interpretation. If a pattern states its success criteria clearly, then the machine-checkable subset can be copied directly into an agentic rollout plan while the remaining criteria stay available for human judgment.

When these ideas become explicit reusable techniques, helper structures, promptable workflows, or application patterns, they belong in Patterns/. For downstream operational guidance on how to run this refinement loop with an agent, see Patterns/AGENTS_TEMPLATE.md.

Durable Execution for Pattern-Driven Agentic Development

Raw source: Foundations/04-AgenticDevelopment/DurableExecution.md

The Problem

Coding agents are good at mechanical work: apply a known pattern, fix tests one by one, migrate files to a new structure. But sessions are fragile. A crash, rate limit, or timeout at step 7 of 20 destroys all progress. The human restarts, re-explains, re-does steps 1–6.

Plan Mode in current agents makes it worse: the plan is a conversation artifact, not an execution artifact. "Step successful" means the agent says so. There's no checkpoint, no verifiable criterion, no resume.

The Solution: Absurd

Absurd is a Postgres-native durable execution system by Armin Ronacher (Flask, Rye/uv) and Earendil Inc. All runtime logic lives as stored procedures in one SQL file. No separate server, no broker. Apache 2.0.

A task is broken into steps. Each completed step is checkpointed in Postgres. Crash at step 7 → restart → steps 1–6 loaded from DB, continue at 7. Steps can await events (for human approval gates) or sleep (for scheduled work).

SDKs: TypeScript (~1,400 LOC), Python (~1,900 LOC), Go (experimental). CLI: absurdctl (Python, installed via uv/uvx). Dashboard: Habitat. Ships a Pi agent skill.

Absurd vs. Alternatives

Absurd is not Temporal, Inngest, or a message broker. The distinctions matter:

  • vs. Kafka/RabbitMQ: Brokers transport messages. Absurd executes work — with checkpoints, retry, and state. What happens after delivery is their consumer's problem; it's Absurd's core job.
  • vs. Temporal/Inngest: Full workflow platforms with deterministic replay, HTTP push, and large SDKs (Temporal's Python SDK: 170k LOC). Absurd is intentionally minimal: explicit step boundaries, pull-based workers, thin SDKs. Easier to explain, easier to self-host, fewer guarantees.
  • vs. PGMQ: Pure queue, stops at message delivery. Absurd starts where PGMQ leaves off — tasks, checkpoints, events, sleep.

See Absurd's own comparison page for details.

Why Ronacher Built It (and What It's Not For)

Ronacher is explicitly anti-slop. His "Agent Psychosis" is a sharp critique of unattended agent loops that produce garbage. His position (shared by Pi creator Mario Zechner): autonomy for the mechanical parts, human judgment for the decisions.

The case study: porting MiniJinja from Rust to Go. 45 min human, 10h agent (7h unsupervised overnight), 2.2M tokens, $60. The agent ground through 400+ snapshot tests. Ronacher steered architecture and pushed back on shortcuts (agent wanted to skip error-message tests entirely instead of fuzzy-matching; agent tried to regress HTML escaping semantics and iterator behavior). The unsupervised phase started only after the semantic decisions were made. What remained was mechanical.

At Earendil, Absurd runs in production (5-month retrospective):

  • Durable agent turns. Each LLM call is a checkpoint. Documented pattern for Pi SDK (patterns/pi-ai-agent), using @mariozechner/pi-agent-core. Each message_end event is persisted as a durable step; on retry, the agent loop rebuilds context from the step log and resumes with runAgentLoopContinue.
  • Dedup'd cron jobs. Idempotency keys derived from task name + cron expression + time slot ensure each slot runs exactly once, even with multiple scheduler replicas.
  • Deploy-safe background processing. Anything needing retry-and-resume.
  • Child-task pipelines. Parent spawns children, awaits results. Added post-launch based on real need.

Production learnings: core design held up, hardened claim handling, watchdogs for broken workers, deadlock prevention, lease management edge cases. Decomposed steps (beginStep/completeStep) added for "before call / after call" patterns where you need to inspect before committing.

Ronacher on "The Final Bottleneck"

From The Final Bottleneck: the human is always the bottleneck — that's correct, because the human carries accountability. The machine speeds up the mechanical parts; it doesn't remove the need for understanding what's being shipped. Zechner's Pi embodies the same stance: 4 tools (Read, Write, Edit, Bash), shortest system prompt of any agent, no MCP, auto-closes PRs from untrusted contributors.

Durable Test-Driven Plan Mode

Today's Plan Mode is ephemeral. Absurd could make it an execution artifact for a test-driven plan:

  1. Plan with success criteria. Each step defines an action and its pass condition. The machine-checkable subset can then run autonomously, for example tests green, file exists, grep matches, compiler errors = 0.
  2. Steps become checkpoints. Completed steps are never re-executed. Crash → resume at the failed step.
  3. Events as approval gates. A step can awaitEvent("human-approved-step-5") — staged autonomy: N steps autonomous, pause for review, continue.
  4. Human reviews the plan, not the execution. Semantic decisions happen upfront. Grinding runs checkpoint-protected.

This is Ronacher's MiniJinja pattern generalized: the human defines the what and the success criteria, the agent grinds through the how, and Absurd ensures nothing is lost. In the best case, the human defines those criteria upfront and only re-enters the loop when a decision point or approval gate is actually needed.

The hard part isn't the tooling, it is writing good success criteria. This works best for tasks decomposable into criteria-driven steps with a substantial machine-checkable subset. For exploratory work, the human stays in the loop.

What Would Need to Be Built

Absurd has the primitives. The bridge between "plan in markdown" and "durable task" doesn't exist yet:

Piece Status Effort
Plan format with per-step success criteria Convention to define ~1 h
Plan → Absurd task runner To build (parse plan, execute steps, check criteria) 4–8 h
Pi integration (extension + skill) Absurd skill exists; execution bridge missing 2–4 h
Why ordng Is a Natural Fit

Most projects can't use Durable Plan Mode effectively because their "patterns" are informal conventions — no machine can verify "did you apply the pattern correctly?" ordng solves this by design:

1. Patterns Are Typecheckable Specifications

Literate Haskell (.lhs) unifies prose, specification, and compilable code in one file. The success criterion for "apply this pattern" exists by construction:

ghci -fno-code Patterns/Htmx/Composed/LiveSearch.lhs  # exit 0 = correct

This gives Absurd something to checkpoint against. Not "the agent says done" but "the compiler says correct."

A stronger version of this would be for patterns to expose explicit rollout checks as part of the pattern itself. Then an execution system would not need a human to invent the verification step each time. It could copy the check from the pattern and run it mechanically.

2. Patterns Have Defined Application Order

The ordng Roadmap and extraction order define which patterns exist and how they relate. An agent rolling out patterns to a target system doesn't improvise a sequence — the sequence is documented.

3. The Refinement Loop Maps to Absurd Steps

ordng's development model (from this strand's README):

Reusable patterns should usually be discovered and pressure-tested in a real target system first [...] ordng is then the place where that insight is extracted, clarified, and stabilized [...] After that, the refined pattern should be re-applied in the target system.

Each phase is an Absurd step with a testable outcome:

Phase Success criterion
Discover in target Tests pass in target system
Extract to ordng ghci -fno-code on .lhs pattern — exit 0
Stabilize Human approval gate (awaitEvent)
Re-apply to target Target typechecks + tests green
Concrete Rollout Scenario

"Apply the HTMX Composed pattern LiveSearch to three search pages in the target system."

Step Action Pass criterion
1 Inventory existing controllers rollout-inventory.md exists
2 Apply pattern to SearchA Typecheck — exit 0
3 Run SearchA tests Test suite green
4 Human review gate awaitEvent("approved-searchA")
5–8 SearchB, SearchC (same structure) Same gates
9 Cleanup + docs Full project typecheck + zero TODO markers

Each step is checkpointed. Crash at SearchB → SearchA stays done. Human reviews at approval gates, not at every compiler run.

Using ordng without Absurd

Everything in ordng works without Absurd. Patterns are patterns — use them manually, apply them with any agent, iterate in whatever workflow.

What Absurd adds is the last mile: once you know which pattern to apply where, the mechanical rollout becomes durable, testable, and resumable. For a single pattern, the difference is small. For rolling out patterns across a codebase, it's the difference between a frustrating afternoon and a reliable overnight run.

Practical Setup

Absurd needs only Postgres and one SQL file. If you're developing with IHP, you already have Postgres. Setup in the Pi Nix devShell is documented in <paths.repos.pi>/docs/agentic-os/absurd.md.

For the full technical reference (primitives, SDK details, comparison), see the Absurd docs.

Handover Notes from Initial Evaluation Session

The following points came up during the evaluation session and should inform next steps. Not all are resolved.

TigerFS (GitHub: timescale/tigerfs) is a PostgreSQL-backed filesystem from the same ecosystem (Tiger Data = Timescale rebranded). It solves shared state (ACID file I/O, version history via .history/, instant cross-machine visibility), while Absurd solves durable execution. Both bet on "Postgres is enough." They share a Postgres instance, and adopting one lowers the barrier for the other. Evaluated separately in <paths.repos.pi>/docs/agentic-os/tigerfs.md.

Open Design Questions for Durable Plan Mode
  • Plan format: Three obvious candidates exist: prose plans in Markdown, agent scripts in TypeScript, and, for the IHP-specific case, Haskell scripts. Needs a concrete proposal for which layer is canonical.
  • Granularity: How fine-grained should steps be? Too coarse (one step = "apply pattern to entire module") loses crash-safety. Too fine (one step = "edit line 42") adds overhead and makes plans unreadable.
  • Approval gate UX: awaitEvent is the primitive. A Pi-facing /approve command looks like the most natural first candidate, but the exact interaction still needs to be designed.
  • Agent execution context: The likely model is a dedicated Pi session for the Absurd worker, so the worker keeps the normal tool environment instead of spawning a fresh agent context per step.
  • Failure semantics: Retry should be reserved for transient external failures such as network errors, rate limits, or worker crashes. Failures of the success criteria themselves should normally pause for human intervention rather than loop blindly.
Nix Integration (Not Done Yet)

absurdctl is a Python CLI, installable via uv tool install absurdctl or uvx absurdctl. absurd-sdk is an npm package for the TypeScript SDK. Postgres runs in the Pi Nix devShell (shell-level, not system-level — explicit activation model). Tracked with concrete install plan in the Pi operational doc (<paths.repos.pi>/docs/agentic-os/absurd.md).

Effort Estimates (from Evaluation)
What Effort
Absurd schema + absurdctl setup 0.5 h
PoC: one durable workflow as Absurd task 2–3 h
Pi integration (dispatch + events) 3–5 h
Plan format definition ~1 h
Plan → Absurd runner (the bridge) 4–8 h
Total for full Durable Plan Mode pilot 11–18 h
Sources

Patterns

Patterns

Raw source: Patterns/README.md

Patterns/ is the pattern library inside ordng. It contains reusable building blocks — compilable patterns grouped by topic.

If you are here to refactor a cross-cutting mechanism, browse topics, or extract new patterns, continue below. If you are building or refactoring an entire feature, see ../Recipes/README.md instead.

See ARCHITECTURE.md for system-level structure, ROADMAP.md for extraction order, and each topic README.md for the local catalog. For downstream projects, AGENTS_TEMPLATE.md provides a starting point for local agent instructions.

Patterns enter this library through extraction from real host-project code. The maintainer workflow for that process lives in ../docs/ordng/pattern-extraction-workflow.md.

Patterns/**/*.lhs are intended to be canonical, compile-checked pattern modules, not just reference notes.

TODO: integrate Patterns/ into the normal project cradle/build so this intent is enforced by the regular project tooling instead of the standalone check below.

Topics

All topics live directly under Patterns/. Some are concern-oriented, others capture organizational patterns.

Topic Directory Scope
HTMX Htmx/ HTMX/IHP interaction patterns
Views Views/ View composition, templates, and visual building blocks
Forms Forms/ Form lifecycle and reusable form structure
Actions Actions/ Server-side request handling patterns: authorization guards (pure policy, actor-target, scoped role registry, last-owner protection), CRUD flows, response strategies, error handling
Domain & Data Domain/ Domain modeling and data patterns
Composition Composition/ Organizational patterns for component-driven structure, presentation boundaries, and code placement
Bootstrap Bootstrap/ Organizational patterns for post-ihp-new project setup and baseline configuration
Haskell Haskell/ Haskell language idioms and structural conventions

Read the topic README.md for the local glossary, catalog, and structure.

Accessibility across topics

Accessibility is a cross-cutting constraint inside existing topics, not a standalone Patterns/Accessibility/ topic.

Topic Accessibility scope Current entry points
Views Semantic page structure, landmarks, heading outline, semantic color, native element choice, accessible control names, keyboard operability Views/AccessibleControlNames.lhs, Views/AccessibilityHeadingOutline.lhs, Views/AccessibilityLandmarks.lhs, Views/AccessibilitySemanticColor.lhs, Views/KeyboardOperability.lhs, Views/SectionedContainer.lhs
Forms Labels, grouped fields, help text, validation errors, focus after validation Forms/AccessibilityFieldStructure.lhs, Forms/AccessibilityValidationFeedback.lhs
HTMX Focus continuity, keyboard-safe dynamic controls, selected-state semantics, live/asynchronous feedback Htmx/Primitives/AccessibilityFocusAndLiveFeedback.lhs, Htmx/Composed/InlineDropdown.lhs, Htmx/Composed/PermissionAwareEnumControl.lhs, Htmx/Primitives/NestedControlClickIsolation.lhs, Htmx/Primitives/SemanticValueAxes.lhs
Actions Only response contracts that directly constrain accessible rendered flow Add only when a server-side accessibility boundary emerges

For cross-topic application order, use ../Recipes/AccessibilityHardening.md. Keep generic WCAG mechanics out of ordng unless an IHP, HSX, or HTMX pattern boundary is involved.

Environment

Pattern files live outside the active HLS cradle. Use the explicit GHC check as authoritative:

direnv exec . ghci -v0 -ignore-dot-ghci -fno-code \
  Patterns/Haskell/*.lhs \
  Patterns/Htmx/Shared/*.lhs Patterns/Htmx/Primitives/*.lhs Patterns/Htmx/Composed/*.lhs Patterns/Htmx/Migration/*.lhs \
  Patterns/Forms/Submission/*.lhs \
  Patterns/Views/*.lhs \
  Patterns/Bootstrap/*.lhs \
  Patterns/Actions/*.lhs \
  Patterns/Domain/02-Data/*.lhs \
  Patterns/Composition/*.lhs

HLS may fail to resolve cross-imports between pattern modules even when this check succeeds.

Website Exposure and Structure

All Patterns/**/*.lhs files are exposed in two ways by the surrounding IHP application:

  • Rendered inline on the main page (WelcomeAction), as a flat long-scroll document in the style of Diehl's WIWIKWLH. Pre-rendered HTML fragments (generated via scripts/render-book-fragments) are loaded at request time from static/generated/.
  • Raw source via the PatternSource endpoint, which serves the .lhs file as plain text. The URL is also linked directly from each pattern's heading on the main page, making patterns accessible to both human readers and agents via web-fetch.

The page structure is generated automatically from the directory layout under Patterns/. Each top-level subdirectory becomes a topic chapter; each subdirectory inside becomes a group.

Maps

Maps are D2 diagrams inside Patterns/. They visualize navigation within the pattern library: "Where am I and where should I go?" Maps have branches but no numbered steps.

  • The top-level map.md is system-level navigation for the pattern library itself (browse, refine, maintain, extract).
  • Topic-local maps (e.g., Bootstrap/map.md) visualize navigation within one topic.

Maps do not cross topics and do not contain step-by-step instructions. For feature-level orchestration, use a recipe in ../Recipes/README.md.

Recipes

Recipes/ contains feature-level orchestration guides that link patterns in application order. Recipes answer "What must I do in what order?" — they have numbered steps, not branches.

Patterns are the building blocks; recipes are the assembly instructions.

See ../Recipes/AuthAndPermissions.md for a cross-topic recipe example.

Map

Raw source: Patterns/map.md

This file is the canonical high-level map for approaching the ordng pattern system. It is a visual map, not step-by-step instructions.

Maps navigate. They answer "Where am I and where should I go?" with branches, not numbered steps. Recipes instruct. They answer "What must I do in what order?" with numbered steps, not branches.

For feature-level recipes that orchestrate patterns, see ../Recipes/.

The process structure is the inline D2 block; rendered images are derived artifacts only.

Diagram

Startpattern-systemworkReadREADME.mdandPatterns/README.mdChooseobjectiveBuildnew projectRefactorexistingprojectBrowsepatternsUnderstandconceptsRefinepatternshereForkor adaptFollowBootstrapmapInspectexistingproject stateReadrelevanttopic docsReadFoundations/README.mdReadARCHITECTURE.mdandROADMAP.mdExtract,generalize,backfill new projectexisting projectbrowseconceptsmaintain patternsadapt elsewhere

Pattern System Architecture

Raw source: Patterns/ARCHITECTURE.md

This document covers what one needs in order to use, read, extend, and situate the pattern library in Patterns/. For the broader conceptual background of abstraction and modeling, see ../Foundations/01-Composability/README.md, ../Foundations/02-CodeAbstraction/README.md, and ../Foundations/03-DomainAbstraction/README.md.

Table of Contents

Library Shape

Patterns/ is a curated library of reusable coding patterns above raw framework conventions. It is the operational core of ordng: the place where the repository's opinionated abstraction layer becomes concrete and reusable, not only for the Haskell/IHP/HTMX-oriented canon developed here.

Patterns are grouped by what they help build, not by where code lands in the MVC tree. A single pattern may span controller, view, and helper code. The point of the library is therefore not to mirror deployment directories, but to capture recurring structures in the form in which they are most intelligible and reusable.

Taxonomy of artifacts

The repository uses four kinds of guidance artifacts with distinct roles:

Artifact Role Format Example
Pattern Reusable building block with compilable core .lhs NarrowPublicFillBoundary.lhs
Recipe Stable orchestration of patterns for a feature or goal .md Recipes/AuthAndPermissions.md
Map D2 diagram: "Where am I and where should I go?" .md with D2 Patterns/map.md
Plan Temporary working coordination for extraction or rollout .md in docs/plans/ docs/plans/plan-auth.md

Patterns answer "how do I implement X?" Recipes answer "what must I do in what order to build or refactor feature Y?" Maps answer "where am I and where should I go?" — they navigate to the right entry point (recipe or topic) but do not carry out the work. Plans are working documents that become obsolete once patterns or recipes are canonical.

Maps have branches but no numbered steps. Recipes have numbered steps but no branches. A map may be system-level or topic-local; either way it is a navigation aid, not an instruction set.

Recipes live outside Patterns/ in Recipes/ because they cross topics. Patterns live inside Patterns/ because they are topic-local building blocks.

Why .lhs

Patterns are documented as literate Haskell (.lhs) because the prose is part of the artifact, not a separate wrapper around it. .lhs files keep explanation and compilable code in one place, so that the pattern can be read as documentation while its Haskell sections remain typecheckable by the pattern checks.

This applies to patterns only. Application code stays .hs with Haddock and comments. If a module needs more explanation than Haddock can reasonably carry, the explainable part likely belongs in Patterns/ as a .lhs pattern.

Pattern modules are not production application modules. Runtime IHP app code must not import Patterns.*; when the app needs reusable code discovered through a pattern, copy or adapt the implementation into normal application modules such as Application/Helper/..., Web/..., or another production source directory. Patterns/ remains the canonical pattern library and compile-checked documentation, not an app dependency.

Pattern Topics

Topics are pragmatic clusters of related patterns. Some are concern-oriented, others capture organizational patterns such as Composition or Bootstrap. Their concrete material is organized in dedicated directories with topic README.md files, pattern modules, and optional supporting assets such as diagrams, CSS, SQL, or other non-Haskell material. Shared conventions such as the pattern skeleton and .lhs formatting are defined here at the system level.

Topics:

Topic Scope Internal structure
HTMX Reactive server-driven interaction: swap, OOB, indicators, modal, typeahead… Foundation primitives → composed interactions → migration references
Views View composition, data projection, templates, and visual building blocks, usually expressed in HSX Primitives → composed views → templates
Forms Form lifecycle in HSX: validation, dynamic fields, multi-model forms, file upload By lifecycle stage: field primitives → composed forms → validation → submission handling
Actions Server-side request handling: CRUD flows, auth, response strategies, error handling By concern: single-entity CRUD, bulk operations, auth guards, response strategies, error handling
Domain & Data Domain types, query shapes, preloading, aggregation, transactional updates By data concern: domain types → query patterns → derived data → transactional workflows
Composition Organizational patterns for component boundaries, presentation-layer seams, and code placement By organizational seam: component-driven flows, presentation boundaries, and code placement
Bootstrap Organizational patterns for bringing a freshly generated project onto the ordng baseline By project bootstrap: post-ihp-new setup, local baseline config, and references to companion repos used by the fully opinionated path
Topic Relationships

Relationships between the concern-oriented topics:

HTMX
Reactive interaction patterns

HSX

Actions
Request handling

Domain & Data
Domain types, queries, transactions

Views
Templates, partials, components

Forms
Form lifecycle

embedded intriggerssubmits torenders / updatesprojected intoedited throughqueried / mutated in

This diagram covers the concern-oriented topics only. Organizational topics such as Composition and Bootstrap cut across several of these areas rather than adding another runtime concern beside them. The HSX box marks implementation substrate, not an extra directory level.

Pattern File Structure

Pattern Skeleton

Every .lhs pattern file follows a fixed seven-section structure:

  1. Pattern intent and mechanism — what the pattern does and how
  2. Project-specific notes and rollout guidance — host-project context
  3. Supporting snippets — non-Haskell artifacts the pattern depends on (CSS, SQL, JS, etc.)
  4. Core reusable pattern infrastructure — the portable abstraction
  5. Helper implementation examples — concrete implementations using the infrastructure
  6. Usage examples — call-site examples
  7. Standalone checks — optional; skip when 4/5/6 already compile standalone

Sections 1–2 are prose, section 3 is mixed, sections 4–7 are compilable Haskell.

This gives every pattern the same scan shape and makes the boundary between generic infrastructure (section 4) and project-specific application (sections 2, 5, 6) explicit. Both humans and LLMs can navigate patterns by position without reading everything.

Layered Application Flow

The diagram below visualizes how a pattern file maps onto a target system. The feedback loop (target → pattern) captures that real-world usage refines patterns over time.

Pattern Application Flow

Pattern LibraryLegendTarget SystemSomePattern.lhsSomeOtherPattern.lhsProse (1, 2)Supporting context snippets (3)Compilable pattern code (4, 5, 6)Scaffolding Haskell (7)Support layer(data, styling, assets, …)Abstraction layer(Helper module(s))Local usage layer(Local call sites)1) Pattern intent and mechanism2) Project-specific notes and rollout guidance3) Supporting snippets4) Core reusable pattern infrastructure5) Helper implementation examples6) Usage examples7) Standalone checks(skip when 4/5/6 already compile standalone) implementimplementimplement apply provideprovide refine

Conventions

.lhs Formatting
Haskell Code (Sections 4–7)

Use \begin{code} / \end{code} blocks. No bird tracks (> ...). Keep delimiters on their own lines.

One example per \begin{code} block, introduced by short prose. Do not merge unrelated examples into one block.

Non-Haskell Snippets (Section 3, Inline)

Markdown fenced code blocks with a full language specifier. This works in both .md and .lhs files because the book renderer uses Pandoc Markdown with literate-Haskell support. Use fences only for illustrative snippets; compilable Haskell examples still belong in \begin{code} / \end{code} blocks.

Prefer canonical language names over short aliases so Pandoc/Skylighting can apply syntax highlighting consistently:

Use Avoid
haskell hs
typescript ts
javascript js
bash sh
sql
lua
text
d2
Prose

Setext headings for H1/H2. Avoid ATX (#, ##). For deeper structure, use bold labels.

Two blank lines before major section headings, one before minor subheadings.

Pattern Identity in Application Code

When a pattern is rolled out into application code, keep its identity visible through stable, descriptive names. Prefer helper functions, components, modules, selectors, and similar local abstractions whose names preserve the pattern reference, instead of dissolving the pattern into anonymous ad-hoc code.

This is a grepability and migration convention, not a formal registry. The goal is that when a pattern changes, application code can be searched for its named local realizations and affected call sites can be assessed without rebuilding that mapping from scratch.

Generic Pattern Names versus Domain-Specific Local Names

Patterns carry generic names that describe what the mechanism is. Local application code carries domain-specific names that describe what the entity is.

Layer Name shape Example
Pattern (ordng) generic, mechanism-describing ensureRemainingGuardians, InlineDropdown, canActAs
Local wrapper / config domain + pattern reference ensureRemainingAdmin, projectMemberRoleDropdown, canAssignProjectRole

The boundary rule:

  • The pattern file (.lhs in ordng) never contains project-specific vocabulary. It speaks in actor, guardian, entity, target, role — never in concrete product or organization names.
  • The local wrapper or config in application code binds the generic pattern to the domain. This is where Admin, Member, Editor, Project, Task appear.
  • The local name should still be grepable back to the pattern. A good local name contains both domain context and pattern reference: projectMemberRoleDropdown tells you it is a Dropdown for the projectMemberRole domain concern.

Why this boundary matters:

  1. Pattern evolution: When InlineDropdown gains a new feature, grep for InlineDropdown in the host project and find all call sites — the local names surface them.
  2. Multiple instantiations: The same pattern can be used for different domain concerns in the same project (projectMemberRoleDropdown, documentVisibilityDropdown). Generic names prevent collision; local names prevent confusion.
  3. Agent guidance: An agent applying a pattern should generate the local wrapper name from domain vocabulary plus pattern vocabulary, not invent a new generic name or silently inline the pattern without naming it.

Anti-patterns:

  • Inlining a pattern without naming it — the code looks right but is not grepable.
  • Using a purely generic name locally — renderInlineDropdown for a project-member dropdown hides the domain concern and collides with other dropdowns.
  • Using a purely domain name without pattern reference — renderAdminSelector hides the fact that it is an InlineDropdown pattern, making updates hard.
Process and Diagram Source Files

For workflows and process diagrams, keep D2 inline in the Markdown document as a fenced d2 block. The Markdown document is the canonical artifact: it carries both the diagram source and the prose needed to interpret it. Do not create companion source/wrapper pairs for normal workflow diagrams.

Inline D2 blocks should:

  • state the process scope in nearby prose,
  • use stable technical keys and concise human labels,
  • pin the layout engine to elk,
  • orient process flow from left to right with direction: right,
  • keep long explanation outside the diagram,
  • avoid maintaining a prose transcript that restates the graph,
  • use prose only for scope, rationale, caveats, and annotations that would make the diagram too noisy,
  • use rendered images only as derived publication artifacts.

Standalone .d2 files are allowed only when a diagram is too large for readable Markdown, reused across multiple documents, or required by an export pipeline. If standalone D2 is used, explicitly mark which artifact is canonical.

Schema patterns remain governed by the Domain topic conventions. Do not treat their source/prose pairing as the default model for workflow diagrams.

Product Name Formatting

The repository and library name ordng is written in bold (**ordng**) when it appears as a product name in prose, including headings.

Do not nest Markdown strong emphasis. If a sentence or list label containing the product name would otherwise be bold, keep the surrounding phrase unbolded and bold only ordng.

This applies everywhere except:

  • URLs and file paths (docs/ordng/, codeberg.org/fritzfeger/ordng)
  • Code blocks and inline code spans
  • Link anchor text that must match a heading slug ([What ordng Covers](#what-ordng-covers))
  • D2 diagram labels and other diagram source

Do not use backticks (`ordng`) for the product name in prose. Backticks are reserved for file names, commands, and code identifiers.

Extractability and Extension

The directory is structured so that:

  • each topic can move independently,
  • system-level files (ARCHITECTURE.md, README.md, AGENTS.md) travel with the system,
  • topic READMEs are self-contained,
  • host-project coupling is limited to project-specific sections and the GHC check command.

To add a new pattern topic:

  1. Create Patterns/<topic>/ with its own README.md.
  2. Add .lhs files following the pattern skeleton.
  3. Register the topic in the top-level README.md.
  4. Use the same .lhs conventions.

Library-level extraction priority lives in ROADMAP.md. Active working plans live under ../docs/plans/ so Patterns/ stays the canonical pattern library rather than a workspace for extraction notes.

Outlook

Fork and Adapt

This system is designed to be forked. A team can clone the repo, keep the generic patterns, and build its own project-specific layer on top. The skeleton and topic structure provide a shared shape; the content is where teams diverge.

A longer-term possibility is a separation between a shared base of IHP best practices and a configurable layer where teams fix their own conventions — pattern dialects that share infrastructure but differ in choices. The structural separation between the shared repository and project-local Patterns/ already points in that direction.

Code Generation: Deterministic, LLM, or Hybrid

Traditional web frameworks use deterministic generators to scaffold code from patterns. Custom generators built from these patterns would be a natural next step.

At the same time, pattern application to a specific context is increasingly something LLMs can assist with well: choosing a pattern, adapting helper signatures, and composing multiple patterns against existing code. The likely sweet spot is hybrid: deterministic generators for mechanical scaffolding, LLMs for context-sensitive pattern application. The pattern skeleton already serves both audiences: sections 4–5 are structured enough for generators, while sections 1–2 provide context for LLM-guided adaptation.

Pattern System Roadmap

Raw source: Patterns/ROADMAP.md

This document tracks the planned growth of the Patterns/ library. For the stable system design, see ARCHITECTURE.md.

Table of Contents

  1. Pattern identification
  2. Extraction process
  3. Topic order

Pattern Identification

A pattern is code you would want to reuse across projects. Signs that something in the codebase is a pattern candidate:

  • You find yourself copying similar structures across controllers or views.
  • You need to explain an approach to a new team member or an LLM.
  • A helper in Application/Helper/ solves a problem that is not project-specific.
  • You catch yourself thinking "how did we solve this last time?"

Extraction Process

The HTMX topic was extracted from prose documentation (docs). For the remaining topics, the source is usually running code in a host project.

The operational extraction workflow lives in ../docs/ordng/pattern-extraction-workflow.md. This roadmap tracks identification signals and topic order; it does not duplicate the step-by-step extraction procedure.

Topic Order

  1. Bootstrap — extract the recurring post-ihp-new project baseline while the local setup deltas are current and easy to inspect.
  2. HTMX — complete the existing topic, including gaps identified in docs/plans/plan-htmx-topic-completion.md.
  3. Domain & Data — make domain types, query patterns, and transactional shapes explicit before extracting higher-level application patterns.
  4. Views — first pattern extracted (SectionedContainer); remaining view-building blocks referenced by HTMX patterns still to document.
  5. Forms — high practical value; form handling in IHP is where the most improvisation happens.
  6. Actions — repetitive and useful, but lower urgency than the topics above.
  7. Haskell — extract language idioms and structural conventions that recur across topics (config records, explicit bindings, permission grouping).
  8. Composition — extract organizational patterns for component-driven flows, presentation-layer boundaries, and code placement once the surrounding concern-oriented topics are stable enough to factor them cleanly.

Recipe Coverage

Recipes are feature-level orchestration guides that span multiple topics. The current greenfield sequence and the status of each recipe:

Order Recipe Primary topics Status
1 Bootstrap Bootstrap Complete
2 Domain Modeling Domain & Data Thin — needs query/transaction patterns
3 Page Layout & Composition Views, Composition Thin — needs template primitives and composed view patterns
4 Auth and Permissions Actions, Haskell Complete
5 Forms & Validation Forms, Actions Thin — needs field primitives, validation, multi-model forms
6 Reactive UI HTMX, Actions, Views Substantial
7 Accessibility Hardening Views, Forms, HTMX Thin — core view, form, and HTMX accessibility patterns extracted; narrower host-driven follow-ups only
8 External Provider Integration Actions, Composition, HTMX Complete

A recipe is complete when its steps can be executed end-to-end with extracted patterns. It is thin when it relies on planned patterns that have not been extracted yet. It is substantial when most steps have pattern coverage but gaps remain.

AGENTS Template for Projects Using ordng Patterns

Raw source: Patterns/AGENTS_TEMPLATE.md

This file is a starting point for projects that adopt ordng patterns. Adjust it to local project reality, keep it thin, and keep canonical project knowledge in normal repository documents rather than in agent-only files.

Treat ordng as the shared source for cross-project pattern guidance, and keep this file focused on project-specific workflow and conventions.

If this project uses Pi or a compatible setup, keep AGENTS.md as the canonical instruction file and use CLAUDE.md only as a compatibility shim. If the harness expects CLAUDE.md as the primary file, adapt the naming, but keep a single source of truth.

When setting up a new IHP project, use the latest IHP version. If ihp-new creates a CLAUDE.md, move any genuinely useful project guidance into AGENTS.md or normal repository docs, then reduce CLAUDE.md to a compatibility shim.

Session Start

Load skills and commands by task. If your setup uses different names, adapt this section to your harness.

Typical Pi-oriented choices:

  • /skill:haskell-workflow for feature planning, pattern selection, and compiler-guided rollout
  • /skill:haskell-implementation for Haskell/IHP implementation rules
  • /skill:htmx for HTMX-facing work
  • /skill:literate-haskell when editing .lhs pattern files
  • /skill:nix for environment and Nix-related tasks
  • /skill:d2 for architecture and flow diagrams
  • /skill:typescript for TypeScript scripts and tooling
  • /skill:git for git operations
  • /skill:commit for commit creation
  • /review before commits or when a formal review pass is needed

If required skills are not active, ask the user to load them. ## Dev Environment

Describe the real local workflow here. For example:

  • whether the project uses devenv/direnv
  • the normal dev server entrypoint
  • preferred verification commands
  • commands the agent should avoid

Prefer project-native verification over broad builds when the project workflow calls for it.

Project Docs and Pattern Lookup

The project should have one canonical document for local code decisions. Name it explicitly here and update it when new local conventions emerge.

If the project keeps local ordng-usage docs under docs/ordng/ at the project root, read at minimum:

If ordng is being introduced or rolled out and docs/ordng/ does not exist yet, read:

  • <paths.repos.ordng>/Patterns/map.md
  • <paths.repos.ordng>/Patterns/Composition/usage-documentation-structure.md

Then create the local docs/ordng/ structure at the project root.

Do not maintain a local pattern system that overlaps ambiguously with ordng. Keep ordng as the shared baseline, and add local patterns only where the project needs additional or deliberately different guidance. If the project intentionally overrides ordng in specific areas, document those exceptions explicitly so the boundary stays clear.

When implementing locally:

  • check ordng first for an applicable reusable pattern,
  • read the relevant ordng documentation chain from the topic entrypoint down to the directory that contains the pattern before applying it, at minimum Patterns/README.md, the topic README.md, and any topic-local docs referenced there,
  • if ordng has a fitting pattern, instantiate or adapt it,
  • keep the applied pattern grepable in local code through stable, descriptive names for helpers, components, modules, selectors, or similar abstractions,
  • if the project needs a bespoke solution, note whether that solution should later refine or extend an ordng pattern.

For ordng itself, canonical shared knowledge belongs in normal repo docs such as README.md, Patterns/README.md, Patterns/ARCHITECTURE.md, and Patterns/ROADMAP.md.

Target-System-First Refinement Loop

When a project both applies and helps refine ordng patterns, start in the target system by default.

Why:

  • the real usage usually spans many local files there,
  • the target system is where a living example can be checked end to end,
  • ordng refinement is usually the smaller follow-up step once the reusable shape is visible.

Default workflow:

  1. inspect the relevant code in the target system first,
  2. make or sharpen one living example there,
  3. switch to ordng when the reusable shape is clear enough,
  4. refine the shared pattern in ordng,
  5. re-apply the refined shape in the target system where useful.

Default session model:

  • Prefer one session with explicit repo phases over two disconnected sessions.
  • Split into two sessions only when the pattern distillation needs its own conceptual pass.
  • Keep reviews and commits separate per repo.

Haskell API and Pattern Discovery

  1. Hoogle first for API discovery, functions, types, constructors, and package docs.
  2. ordng next for a reusable shared pattern.
  3. The project's own code-conventions document for local decisions.
hoogle search "<name-or-type>"

If the project has additional discovery tools, document them here.

Git Workflow

Define the project's actual git workflow here. Examples:

  • direct commits to the main branch
  • branch-based work
  • PR-required workflow

Be explicit so the agent does not assume the wrong collaboration model.

Deployment Workflow

If the project follows the fully opinionated ordng deployment path, tell the agent to read the canonical companion docs directly:

  • <paths.repos.ihp-deployment-manager>/AGENTS.md
  • <paths.repos.ihp-deployment-manager>/DEPLOYMENT_README.md

Under Pi, <paths...> is resolved via ~/.pi/agent/paths.json. If the project uses a different harness, adapt this to its path mechanism.

Keep only genuinely local deployment facts in the project AGENTS.md, for example:

  • machine names and host mapping
  • project-specific deploy entrypoints or app names
  • explicit confirmation rules if they differ locally

Projects that bootstrap or deploy differently can omit this section and still use the rest of ordng without adopting the opinionated deployment path.

Project-Specific Sections

Add only what is truly local, for example:

  • deployment workflow
  • local data formats or import/export rules
  • domain-specific validation rules
  • review or release process

Actions

Action Patterns

Raw source: Patterns/Actions/README.md

Index and reference for action patterns in this directory.

This topic covers server-side request handling patterns: CRUD flows, authorization guards, response strategies, bulk operations, and error handling.

Planned Structure

Organize this topic by concern rather than by framework directory shape. Clusters include:

  • single-entity CRUD flows
  • bulk operations
  • auth guards
  • response strategies
  • error handling

Catalog

Pattern File Scope
PurePolicyControllerGate PurePolicyControllerGate.lhs Separate pure policy predicates from framework-specific controller gates
ActorTargetAuthGate ActorTargetAuthGate.lhs Derive policy from actor's own record, check ownership against target
ScopedRolePermissionRegistry ScopedRolePermissionRegistry.lhs Pure, scope-aware permission table for multiple domain actions sharing one contextual role
NarrowPublicFillBoundary NarrowPublicFillBoundary.lhs Split fill allowlists by trust boundary so public paths cannot write server-controlled fields
IntegrationResultBoundary IntegrationResultBoundary.lhs Preserve the difference between successful empty external-provider results and provider unavailability, including disabled, timed-out, and failed operations
ExternalSearchQueryPolicy ExternalSearchQueryPolicy.lhs Normalize free-text search before external provider auth or HTTP; short queries become successful no-op searches
WreqJsonProviderRequest WreqJsonProviderRequest.lhs Build wreq JSON GET and form POST requests inside the provider boundary and decode responses through IntegrationResult
ExternalProviderEndpointGate ExternalProviderEndpointGate.lhs Gate method and actor presence before an endpoint can spend external provider resources
ExpiringUserTokenFlow ExpiringUserTokenFlow.lhs Generate, deliver, verify, and invalidate short-lived out-of-band tokens (password reset, confirmation, recovery)
LastGuardianProtection LastGuardianProtection.lhs Prevent removing/demoting the last owner/admin/lead
CustomSessionLockout CustomSessionLockout.lhs Failed-attempt tracking and time-based lockout for custom login actions
CaseInsensitiveIdentity CaseInsensitiveIdentity.lhs Case-insensitive login with case-insensitive signup uniqueness; safe legacy duplicate handling
TransactionalParentDelete TransactionalParentDelete.lhs Delete parent after removing cleanup children, with explicit transaction boundaries

Pattern Shape

.lhs files in this topic follow the seven-section pattern skeleton from ../ARCHITECTURE.md.

Actor-Target Authorization Gate

Raw source: Patterns/Actions/ActorTargetAuthGate.lhs

Pattern intent and mechanism

Separate the actor's policy record from the target resource. Load the actor's own record to compute policy; check ownership against the target resource.

This pattern prevents IDOR-style mistakes: the actor's authority comes from who they are and what they own, never from the target resource's properties.

Use this pattern when:

  • a controller action operates on a resource that belongs to a different user,
  • the actor's authority depends on their own relationship to the system (their subscription, their role, their membership),
  • the target resource is identified by a user-supplied ID that could be swapped.

Critical rule: never derive actor authority from the target resource.

If you derive policy from the target, an attacker changes the target ID and inherits the target's authority. The actor's policy must come from the actor's own context alone.

Role-based decisions that are scoped to a single context (for example, "the actor's role in this project") are orthogonal to ownership. Centralize those role decisions in ScopedRolePermissionRegistry and compose them with the ownership check from this pattern.

Project-specific notes and rollout guidance

Load the actor's own record before the gate call. In IHP, this is typically a database query in the controller action. The gate receives both the actor's own record (for policy) and the target resource (for ownership).

When the actor has no own record (not subscribed, not a member), pass Nothing to the policy computation. The policy then falls back to the default (usually read-only or denied).

Keep the gate call at the top of the controller action, before any writes or side effects.

Supporting snippets

Actor-target gate:

ensureActorCanActOnTarget
    :: (?context :: ControllerContext, ...)
    => User -> Maybe OwnRecord -> TargetRecord -> Action -> IO ()
ensureActorCanActOnTarget user maybeOwnRecord target action =
    let actorCtx = mkActorContext user maybeOwnRecord
        policy = computePolicy actorCtx
        permitted = canDoActionWithOwnership actorCtx target policy action
    in accessDeniedUnless permitted

Controller usage:

action UpdateTargetAction { targetId } = do
    target <- fetch targetId
    currentUser <- getCurrentUser
    ownSubscription <- fetchOwnSubscription currentUser
    ensureActorCanActOnTarget currentUser ownSubscription target UpdateAction
    -- ... proceed with update
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Actions.ActorTargetAuthGate where

import Prelude
import IHP.ViewPrelude

-- | Re-export the pure policy vocabulary.
import Patterns.Actions.PurePolicyControllerGate
    ( ActorContext (..)
    , Policy (..)
    , Action (..)
    , canDoAction
    , canDoActionWithOwnership
    , Ownable (..)
    )
Helper implementation examples
-- | Actor-target authorization check (pure, no side effects).
-- | The caller supplies the already-computed policy (from the actor's own record).
-- | The helper checks ownership against the target resource.
-- | Returns True if permitted, False if denied.
actorCanActOnTarget
    :: Ownable target
    => ActorContext ownRole -> Policy -> target -> Action -> Bool
actorCanActOnTarget actorCtx policy target action =
    canDoActionWithOwnership actorCtx target policy action
Usage examples

A task system where the actor's authority comes from their membership in the task's parent project, not from direct ownership of the task.

data Task = Task
    { taskId :: Int
    , taskOwnerId :: Int
    , taskTitle :: Text
    }
    deriving (Eq, Show)

instance Ownable Task where
    ownerId = taskOwnerId

targetTask :: Task
targetTask = Task
    { taskId = 99
    , taskOwnerId = 7
    , taskTitle = "Target"
    }

The actor is a project member. Their FullAccess policy is derived from their membership in the task's parent project, not from owning the task itself.

projectMemberActor :: ActorContext Text
projectMemberActor = ActorContext
    { actorId = 42
    , actorIsAdmin = False
    , actorOwnedRole = Just "Member"
    }

-- | True: project member has FullAccess policy, can update any task.
memberCanUpdate :: Bool
memberCanUpdate = actorCanActOnTarget projectMemberActor FullAccess targetTask UpdateAction

The actor has no project role and cannot act on others' tasks.

outsiderActor :: ActorContext Text
outsiderActor = ActorContext
    { actorId = 99
    , actorIsAdmin = False
    , actorOwnedRole = Nothing
    }

-- | False: outsider gets ReadOnly policy, cannot update.
outsiderCannotUpdate :: Bool
outsiderCannotUpdate = actorCanActOnTarget outsiderActor ReadOnly targetTask UpdateAction
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Case-Insensitive Account Identity

Raw source: Patterns/Actions/CaseInsensitiveIdentity.lhs

Pattern intent and mechanism

Handle email-based identity with case-insensitive lookup and case-insensitive uniqueness enforcement. This prevents new duplicates while tolerating legacy ones (created before the pattern was applied).

The mechanism:

  1. Signup: Reject case-variant duplicates at the boundary using fetchCount with filterWhereCaseInsensitive. Note: this is race-prone under concurrency; a database unique index is required as backstop.
  2. Login: Query case-insensitively; expect 0, 1, or N matches.
  3. Safety: Use fetchCount first when legacy duplicates may exist; only proceed with single-match logic when count == 1.
  4. Production backstop: Database-level case-insensitive unique index required for race-safe uniqueness; apply after deduplicating legacy data.
Project-specific notes and rollout guidance

Schema assumptions. This pattern assumes an email :: Text field on the user table. Case-insensitive lookup relies on database collation or explicit lower-casing in the query.

IHP specific. Use filterWhereCaseInsensitive for queries; for uniqueness validation use validateFieldIO with a custom IO validator.

Race safety. The fetchCount check at application level prevents duplicates in normal request flow but is not atomic. Under concurrent requests, two signups with the same case-variant email can both pass the count check before either inserts. The database unique index is the required backstop; it will reject the second insert with a constraint violation. Handle this exception in the controller (generic failure or retry) to prevent leakage of the duplicate existence.

Legacy handling. If duplicates exist:

  • Signup rejects new variants (best effort without DB constraint; guaranteed once case-insensitive unique index is active).
  • Login uses fetchCount to detect ambiguity and can fail safely or use oldest-match strategy rather than crashing.

Rollback safety. Adding case-insensitive unique index on existing data with duplicates will fail; deduplicate first or use partial index (WHERE created_at > migration_date).

Supporting snippets

Signup uniqueness check:

isEmailUniqueCaseInsensitive :: (?modelContext :: ModelContext) => Text -> IO ValidatorResult
isEmailUniqueCaseInsensitive email = do
    count <- query @User
        |> filterWhereCaseInsensitive (#email, email)
        |> fetchCount
    pure $ if count > 0
        then Failure "This email address is already in use"
        else Success

Edit uniqueness check (excludes current user):

-- | For edit actions: exclude the current user so keeping one's own email
-- does not falsely trigger a collision.
isEmailUniqueCaseInsensitiveExcludingUser
    :: (?modelContext :: ModelContext)
    => Id User -> Text -> IO ValidatorResult
isEmailUniqueCaseInsensitiveExcludingUser currentUserId email = do
    count <- query @User
        |> filterWhereCaseInsensitive (#email, email)
        |> filterWhereNot (#id, currentUserId)
        |> fetchCount
    pure $ if count > 0
        then Failure "This email address is already in use"
        else Success

Login lookup with safety:

-- | Safe login lookup when legacy duplicates may exist.
-- Returns Nothing for 0 matches, Just user for exactly 1, or handles
-- ambiguity (e.g., fail or use oldest) for N > 1.
findUserForLogin :: (?modelContext :: ModelContext) => Text -> IO (Maybe User)
findUserForLogin email = do
    count <- query @User
        |> filterWhereCaseInsensitive (#email, email)
        |> fetchCount
    case count of
        0 -> pure Nothing
        1 -> query @User
            |> filterWhereCaseInsensitive (#email, email)
            |> fetchOneOrNothing
        _ -> do
            -- Ambiguity: multiple users with case-variant emails.
            -- Options: fail, use oldest created, or require exact match.
            -- Use static marker only; do not log user-controlled input (email).
            Log.warn ("Login ambiguity: case-variant collision detected" :: Text)
            pure Nothing  -- or implement disambiguation strategy
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
module Patterns.Actions.CaseInsensitiveIdentity where

import Prelude
import Data.Text (Text)
import Data.Time.Clock (UTCTime)

-- | Result type for uniqueness validation.
data ValidatorResult = Success | Failure Text

-- | Stub for logging.
warn :: Text -> IO ()
warn _ = pure ()
-- | Safe user lookup with ambiguity handling.
-- Demonstrates the fetchCount-first pattern for legacy safety.
findUserForLogin
    :: Text          -- ^ Email from login form
    -> (Text -> IO Int)   -- ^ Count function (inject for testing)
    -> (Text -> IO (Maybe User))  -- ^ Fetch-one function
    -> IO (Maybe User)
findUserForLogin email countFn fetchFn = do
    n <- countFn email
    case n of
        0 -> pure Nothing
        1 -> fetchFn email
        _ -> do
            warn "Login ambiguity: multiple users for case-variant email"
            pure Nothing  -- Conservative: treat as not found
Helper implementation examples
-- Example User type for compilation.
data User = User
    { userId :: Int
    , userEmail :: Text
    , createdAt :: UTCTime
    }

-- Example validation integration.
validateEmailUniqueness
    :: (Text -> IO Int)   -- ^ Case-insensitive count
    -> Text
    -> IO ValidatorResult
validateEmailUniqueness countFn email = do
    n <- countFn email
    pure $ if n > 0
        then Failure "This email address is already in use"
        else Success
Usage examples

Signup controller integration (IHP):

action CreateUserAction = do
    let user = newRecord @User
    user
        |> fill @'["email", "password"]
        -- Format check first (fast, no DB round-trip), then uniqueness
        |> validateField #email isEmail
        |> validateFieldIO #email isEmailUniqueCaseInsensitive
        |> validateField #password isNonEmpty
        >>= ifValid \case
            Left user -> render NewView { user }
            Right user -> do
                hashed <- hashPassword (get #password user)
                user <- user
                    |> set #passwordHash hashed
                    |> createRecord
                redirectTo UsersAction

Edit controller integration (IHP):

action UpdateUserAction { userId } = do
    -- State-changing action: enforce POST-only
    when (requestMethod request /= "POST") do
        redirectTo EditUserAction { userId }
    user <- fetch userId
    user
        |> fill @'["email"]
        |> validateField #email isEmail
        |> validateFieldIO #email (isEmailUniqueCaseInsensitiveExcludingUser userId)
        >>= ifValid \case
            Left user -> render EditView { user }
            Right user -> do
                user <- updateRecord user
                redirectTo UsersAction

Login controller integration (IHP) with legacy-safe lookup:

action CreateSessionAction = do
    -- POST-only guard (state-changing action)
    when (requestMethod request /= "POST") do
        redirectTo NewSessionAction

    email <- param @Text "email"
    password <- param @Text "password"

    -- Safe lookup: handles 0, 1, or N matches
    mUser <- findUserForLogin email

    case mUser of
        Nothing -> do
            Log.info ("Login failed: no unique user found" :: Text)
            genericLoginFailure
        Just user -> do
            if verifyPassword user password
                then login user
                else do
                    Log.info ("Login failed: wrong password" :: Text)
                    genericLoginFailure

Database migration (PostgreSQL) — safe case-insensitive unique index:

Use a DO block that pre-checks for duplicates and fails with a clear error message instead of an opaque constraint failure:

DO $$
DECLARE
    duplicate_emails TEXT;
BEGIN
    -- Pre-check: find duplicates (case-insensitive)
    SELECT string_agg(DISTINCT LOWER(email), ', ')
    INTO duplicate_emails
    FROM users
    GROUP BY LOWER(email)
    HAVING count(*) > 1;

    IF duplicate_emails IS NOT NULL THEN
        RAISE EXCEPTION 'Migration blocked: duplicate emails exist: %',
            duplicate_emails;
    END IF;

    -- Safe to create index
    CREATE UNIQUE INDEX idx_users_email_lower ON users (LOWER(email));
END $$;

Alternative simple form (fails with constraint error if duplicates exist):

-- Required: guarantees uniqueness under concurrent signups.
-- Only safe to create if no duplicates exist; deduplicate first if needed.
CREATE UNIQUE INDEX idx_users_email_lower ON users(LOWER(email));

-- Alternative: functional unique index with COALESCE for NULL safety
CREATE UNIQUE INDEX idx_users_email_lower
    ON users(COALESCE(LOWER(email), ''));
Standalone checks

GHC type-check (no runtime):

direnv exec . ghci -v0 -ignore-dot-ghci -fno-code \
  Patterns/Actions/CaseInsensitiveIdentity.lhs

Expected: clean type-check, no warnings.

Custom Session Lockout

Raw source: Patterns/Actions/CustomSessionLockout.lhs

Pattern intent and mechanism

IHP provides standard lockout when using the built-in auth schema and controller configuration. Custom login actions must enforce equivalent behavior themselves.

This pattern adds failed-attempt tracking and time-based lockout to a custom session controller. The mechanism:

  1. Check lockout status before password verification.
  2. Return generic failure responses for all error cases (unknown email, wrong password, locked account) to prevent enumeration.
  3. Increment failed attempts via atomic SQL update to avoid races on concurrent login attempts.
  4. Set lockedAt timestamp when threshold reached.
  5. Reset failed attempts and lock after successful login (see note on concurrent increments in usage example).
Project-specific notes and rollout guidance

Schema fields. Add to your users table (or equivalent):

failed_login_attempts INT DEFAULT 0,
locked_at TIMESTAMP NULL

Integration points:

  • Call isUserLockedOut early in CreateSessionAction, before password verify.
  • Call recordFailedLogin after failed login for known users (wrong password).
  • For unknown emails, use a global or email-based throttle (optional but recommended; per-user counter unavailable).
  • Call resetLoginAttempts after successful login.
  • Keep failure messages generic; log specifics server-side only.

Unlock strategies. This pattern implements time-based auto-unlock (lockoutDurationMinutes). Administrators can manually clear locked_at and failed_login_attempts via admin interface or database if needed.

Supporting snippets

Configuration:

maxFailedLoginAttempts :: Int
maxFailedLoginAttempts = 5

lockoutDurationMinutes :: NominalDiffTime
lockoutDurationMinutes = 30 * 60  -- 30 minutes

Lockout check (requires lockedAt field on User record):

isUserLockedOut :: User -> UTCTime -> Bool
isUserLockedOut user now =
    case get #lockedAt user of
        Just lockedAt -> diffUTCTime now lockedAt < lockoutDurationMinutes
        Nothing       -> False

Atomic failed-login recording (raw SQL to avoid races):

recordFailedLogin :: (?modelContext :: ModelContext) => Id User -> IO ()
recordFailedLogin userId = do
    now <- getCurrentTime
    sqlExecDiscardResult
        "UPDATE users SET failed_login_attempts = failed_login_attempts + 1, \
\        locked_at = CASE WHEN failed_login_attempts + 1 >= ? THEN ? ELSE locked_at END \
\        WHERE id = ?"
        (maxFailedLoginAttempts, now, userId)

Note: Some implementations pass User instead of Id User and extract the ID inside; either shape works, but the pattern uses explicit Id User for clarity.

Reset after successful login:

resetLoginAttempts :: (?modelContext :: ModelContext) => Id User -> IO ()
resetLoginAttempts userId =
    sqlExecDiscardResult
        "UPDATE users SET failed_login_attempts = 0, locked_at = NULL WHERE id = ?"
        (Only userId)

Note: Some implementations return the updated User record; this pattern shows IO () for simplicity. Adjust to IO User if you need the refreshed record.

Generic failure response (prevents enumeration; must halt control flow):

genericLoginFailure :: (?context :: ControllerContext) => IO ()
genericLoginFailure = do
    setErrorMessage "Invalid credentials."
    redirectTo NewSessionAction  -- redirect halts this action
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
module Patterns.Actions.CustomSessionLockout where

import Prelude
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)

-- | Configuration: lock after this many failed attempts.
maxFailedLoginAttempts :: Int
maxFailedLoginAttempts = 5

-- | Configuration: lockout window in seconds (here: 30 minutes).
lockoutDurationMinutes :: NominalDiffTime
lockoutDurationMinutes = 30 * 60
-- | Check if a user is currently locked out.
-- Requires a record with a `lockedAt :: Maybe UTCTime` field.
isUserLockedOut
    :: (HasLockedAt user)  -- ^ Constraint for field access
    => user
    -> UTCTime             -- ^ Current time
    -> Bool
isUserLockedOut user now =
    case lockedAtField user of
        Just lockedAt -> diffUTCTime now lockedAt < lockoutDurationMinutes
        Nothing       -> False

-- Minimal type class for demonstration (IHP provides actual field accessors).
class HasLockedAt a where
    lockedAtField :: a -> Maybe UTCTime
Helper implementation examples
-- Example User type with HasLockedAt instance for compilation.
data User = User
    { userId :: Int
    , lockedAt :: Maybe UTCTime
    }

instance HasLockedAt User where
    lockedAtField = lockedAt

-- Example: check lockout for a user.
exampleCheck :: User -> UTCTime -> String
exampleCheck user now =
    if isUserLockedOut user now
        then "Account locked"
        else "Account active"
Usage examples

Schema migration (PostgreSQL):

ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INT DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_at TIMESTAMP NULL;

-- Optional: index for admin queries on locked users
CREATE INDEX idx_users_locked_at ON users(locked_at) WHERE locked_at IS NOT NULL;

Controller integration (IHP) — preferred single-case structure:

action CreateSessionAction = do
    -- State-changing action: enforce POST-only before any reads or mutations.
    when (requestMethod request /= "POST") do
        redirectTo NewSessionAction

    email <- param @Text "email"
    password <- param @Text "password"

    user <- query @User
        |> filterWhereCaseInsensitive (#email, email)
        |> fetchOneOrNothing

    now <- getCurrentTime

    -- Single case: handle all branches without risk of falling through
    case (user, password) of
        (Nothing, _) -> do
            -- Unknown email: log internally, return generic failure.
            -- Optional but recommended: global/email-based throttle here.
            Log.info ("Login failed: user not found" :: Text)
            genericLoginFailure
        (Just u, _) | isUserLockedOut u now -> do
            Log.info ("Login rejected: account locked" :: Text)
            genericLoginFailure
        (Just u, pwd) | verifyPassword u pwd -> do
            -- Re-fetch to observe any concurrent lockout set during verify
            u' <- fetch (get #id u)
            now' <- getCurrentTime
            if isUserLockedOut u' now'
                then do
                    Log.info ("Login rejected: account locked (race)" :: Text)
                    genericLoginFailure
                else do
                    -- Warning: concurrent failed-login increments between
                    -- verify and reset are lost. Use conditional UPDATE for
                    -- strict accounting (only reset if locked_at IS NULL).
                    _ <- resetLoginAttempts (get #id u')
                    login u'
        (Just u, _) -> do
            _ <- recordFailedLogin (get #id u)
            Log.info ("Login failed: wrong password" :: Text)
            genericLoginFailure

Note: genericLoginFailure must halt control flow (via redirectTo or respondHtml). All branches end in this helper; no fall-through possible.

Alternative two-case structure (early check + later handling) exists but requires careful control-flow management to prevent execution continuing past the early locked branch.

Standalone checks

GHC type-check (no runtime):

direnv exec . ghci -v0 -ignore-dot-ghci -fno-code \
  Patterns/Actions/CustomSessionLockout.lhs

Expected: clean type-check, no warnings.

Expiring User Token Flow

Raw source: Patterns/Actions/ExpiringUserTokenFlow.lhs

Pattern intent and mechanism

Password reset, email confirmation, account recovery, and invite acceptance share the same lifecycle:

  1. Generate a random token.
  2. Store token state with a timestamp.
  3. Deliver the token out-of-band (usually by email).
  4. Verify token and expiry when the user presents it.
  5. Perform the guarded operation.
  6. Invalidate the token immediately after success.

A recovery token is not a password, not a session, and not a permission. It is a short-lived proof delivered out-of-band and consumed exactly once. Treating it like any of those three things is a category error that leads to predictable security mistakes.

The pattern identity in host code is the named token type and the lifecycle order enforced in controller actions. Each step is visible and grepable; skipping a step (for example, forgetting to invalidate after success) should stand out in review.

Use this pattern when:

  • a user must prove control of an email address or account without being logged in,
  • the proof must expire and become unusable after a short time,
  • the operation guarded by the token is state-changing and must happen at most once per token.
Project-specific notes and rollout guidance

Storage schema.

Choose one schema and do not carry both a separate token table and user-record fields forward. The IHP Authentication Guide stores password_reset_token :: Maybe Text and password_reset_token_created_at :: Maybe UTCTime directly on the users record. This is the guide-close default. A separate table is acceptable only if it is a deliberate design with valid foreign keys, atomic invalidation, and no competing migrations.

Token comparison.

Compare the raw token after fetching the user by userId. The link carries userId plus raw token; the handler fetches the user by userId, then compares the stored token and checks expiry.

Do not use hashPassword for token lookup or storage comparison. hashPassword is salted and non-deterministic. A second hash of the same token will not match the stored hash. If raw token storage is unacceptable, use a deterministic digest or HMAC, not hashPassword.

Verification happens twice.

Re-check token and expiry in both the edit action (renders the form) and the update action (accepts the new password). A user might leave the form open past expiry, or an attacker might replay a consumed token. Both checks must reject invalid or expired tokens.

Generic responses.

The reset-request action must return the same response for unknown email addresses and known ones. Do not disclose whether an account exists. Log internally for debugging, but keep the user-facing message generic.

POST-only state changes.

The reset request and the password update are POST actions. The link click that renders the reset form is GET and read-only. This aligns with IHP's SameSite=Lax CSRF model.

Invalidate after success.

After hashing the new password and updating the record, clear the token and timestamp fields. A token that survives a successful reset is a replay hazard.

Passwords are POST body fields.

Read the new password and confirmation with param @Text inside the update action. Do not put them into action constructors or generated URLs.

Supporting snippets

IHP route shape for reset flow:

data SessionsController
    = ...
    | ForgotPasswordAction          -- ^ Show request form
    | RetrievePasswordAction        -- ^ POST: generate token, send mail
    | ResetPasswordAction
        { userId :: !(Id User)
        , token  :: !Text
        }                           -- ^ GET: show new-password form
    | UpdatePasswordAction
        { userId :: !(Id User)
        , token  :: !Text
        }                           -- ^ POST: validate token, update password

Opaque token newtype at the boundary:

newtype PasswordResetToken = PasswordResetToken
    { unwrapPasswordResetToken :: Text }
    deriving (Eq)

Explicit export list with a boundary smart constructor:

module Web.Types
    ( PasswordResetToken
    , mkPasswordResetToken   -- controllers wrap raw route text here
    , unwrapPasswordResetToken
    -- raw constructor NOT exported
    ) where

-- | Wrap raw text from the route or URL into the token type.
-- Controllers at the route boundary use this; do not construct
-- tokens from arbitrary text in other contexts.
mkPasswordResetToken :: Text -> PasswordResetToken
mkPasswordResetToken = PasswordResetToken

Request action (POST-only, generic response):

action RetrievePasswordAction = do
    unless (requestMethod request == "POST") do
        redirectTo NewSessionAction

    case paramOrNothing @Text "email" of
        Nothing -> pure ()  -- generic response below
        Just email -> do
            user <- query @User
                |> filterWhereCaseInsensitive (#email, email)
                |> fetchOneOrNothing
            case user of
                Nothing -> pure ()  -- generic response below
                Just u  -> do
                    rawToken <- generateAuthenticationToken
                    now      <- getCurrentTime
                    userWithToken <- u
                        |> set #passwordResetToken (Just rawToken)
                        |> set #passwordResetTokenCreatedAt (Just now)
                        |> updateRecord
                    mailResult <- try @SomeException (sendMail (ResetPasswordMail
                        { user = userWithToken
                        , token = mkPasswordResetToken rawToken
                        }))
                    case mailResult of
                        Right () -> pure ()
                        Left _e  -> do
                            -- Mail delivery failed: clear the token so the
                            -- user cannot reach a broken reset form.
                            -- Log internally for operations; keep the user-
                            -- facing response generic to avoid enumeration.
                            Log.error ("Password reset mail failed: " <> tshow _e)
                            _ <- clearPasswordResetToken userWithToken
                            pure ()

    -- Same message whether the email exists or not.
    setSuccessMessage iPasswordResetRequestReceived
    redirectTo ForgotPasswordAction

Edit action (verify before rendering form):

action ResetPasswordAction { userId, token } = do
    maybeUser <- query @User
        |> filterWhere (#id, userId)
        |> fetchOneOrNothing
    case maybeUser of
        Nothing -> redirectWithError
        Just user -> do
            now <- getCurrentTime
            let resetToken = mkPasswordResetToken token
            if passwordResetTokenIsValid user resetToken now
                then render ResetPasswordView { userId, token = resetToken }
                else redirectWithError

Update action (verify again, then hash, then invalidate):

action UpdatePasswordAction { userId, token } = do
    when (requestMethod request /= "POST") do
        redirectTo NewSessionAction

    maybeUser <- query @User
        |> filterWhere (#id, userId)
        |> fetchOneOrNothing
    case maybeUser of
        Nothing -> redirectWithError
        Just user -> do
            now <- getCurrentTime
            let resetToken = mkPasswordResetToken token
            unless (passwordResetTokenIsValid user resetToken now) do
                redirectWithError

            let password     = param @Text "password"
            let confirmation = param @Text "passwordConfirmation"
            unless (password == confirmation) do
                setErrorMessage "Passwords do not match."
                redirectTo ResetPasswordAction { userId, token }

            hashed <- hashPassword password
            _ <- user
                |> set #passwordHash hashed
                |> set #passwordResetToken Nothing
                |> set #passwordResetTokenCreatedAt Nothing
                |> updateRecord

            setSuccessMessage "Password updated."
            redirectTo NewSessionAction
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Actions.ExpiringUserTokenFlow where

import Prelude
import Data.Text (Text)
import Data.Time.Clock
    ( UTCTime (..)
    , NominalDiffTime
    , diffUTCTime
    )
import Data.Time (fromGregorian)

-- | A boundary-wrapped token delivered out-of-band. The raw value only
-- exists at the route boundary; controllers wrap it immediately.
-- In application code, do not export the constructor; keep construction
-- inside the module that owns the route parser.
newtype ResetToken = ResetToken { unwrapResetToken :: Text }
    deriving (Eq)

-- | Generic account record with token lifecycle fields.
-- In IHP this is the generated database record.
data Account = Account
    { accountId :: Int
    , email :: Text
    , passwordHash :: Text
    , resetToken :: Maybe Text
    , resetTokenCreatedAt :: Maybe UTCTime
    }
    deriving (Eq)
Helper implementation examples

Maximum token age and validation logic.

-- | Tokens expire after this many seconds.
tokenMaxAge :: NominalDiffTime
tokenMaxAge = 3600  -- 1 hour

-- | Check whether a presented token matches the stored token and has
-- not expired. Returns 'False' when no token is stored.
tokenIsValid :: Account -> ResetToken -> UTCTime -> Bool
tokenIsValid account (ResetToken presented) now =
    case (resetToken account, resetTokenCreatedAt account) of
        (Just stored, Just createdAt) ->
            let age = diffUTCTime now createdAt
            in stored == presented && age >= 0 && age < tokenMaxAge
        _ -> False

Token invalidation after successful use.

-- | Clear the reset token and timestamp so the token cannot be reused.
-- Call this immediately after the guarded operation succeeds.
clearResetToken :: Account -> Account
clearResetToken account = account
    { resetToken = Nothing
    , resetTokenCreatedAt = Nothing
    }

Simulated password hashing for the compilable example.

-- | Simulated password hash. In IHP this is 'hashPassword' from
-- 'IHP.AuthSupport.Authentication'.
hashPassword :: Text -> Text
hashPassword pw = "hashed:" <> pw
Usage examples

A concrete token and account for demonstration.

-- | Example account with an active reset token.
exampleAccount :: Account
exampleAccount = Account
    { accountId = 1
    , email = "ada@example.com"
    , passwordHash = "oldhash"
    , resetToken = Just "abc123"
    , resetTokenCreatedAt = Just exampleTokenTime
    }

-- | A token that matches the stored value.
exampleValidToken :: ResetToken
exampleValidToken = ResetToken "abc123"

-- | A token that does not match.
exampleInvalidToken :: ResetToken
exampleInvalidToken = ResetToken "wrong"

-- | Fixed point in time for the example checks.
exampleNow :: UTCTime
exampleNow = UTCTime (fromGregorian 2024 1 1) 0

-- | The token was created five minutes before 'exampleNow'.
exampleTokenTime :: UTCTime
exampleTokenTime = UTCTime (fromGregorian 2024 1 1) (-300)

Token validation checks.

-- | True: the token matches and is within the expiry window.
validTokenCheck :: Bool
validTokenCheck = tokenIsValid exampleAccount exampleValidToken exampleNow

-- | False: the token value does not match.
invalidTokenCheck :: Bool
invalidTokenCheck = tokenIsValid exampleAccount exampleInvalidToken exampleNow

Password update with hash-and-invalidate.

-- | Simulated successful reset: hash the new password and clear the token.
examplePasswordUpdate :: Account -> Text -> Account
examplePasswordUpdate account newPassword =
    let hashed = hashPassword newPassword
    in (clearResetToken account) { passwordHash = hashed }

-- | The account after a successful reset. Token is gone; password is hashed.
resetAccount :: Account
resetAccount = examplePasswordUpdate exampleAccount "newsecret"

-- | True: the token was cleared after use.
tokenIsCleared :: Bool
tokenIsCleared = resetToken resetAccount == Nothing

-- | True: the new password was hashed.
passwordWasHashed :: Bool
passwordWasHashed = passwordHash resetAccount == hashPassword "newsecret"
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. The Account record and hashPassword simulation are stand-ins for IHP's generated record and framework hashing; the lifecycle order — generate, store, verify, consume, invalidate — is the invariant that survives across frameworks.

External Provider Endpoint Gate

Raw source: Patterns/Actions/ExternalProviderEndpointGate.lhs

Pattern intent and mechanism

Gate controller endpoints that can spend external provider resources before any provider operation is attempted. A route that triggers provider auth, quota, latency, or cost is not an ordinary read-only component route, even when it does not mutate the local database.

The gate is a request-boundary/resource guard:

  1. check the HTTP method first,
  2. check whether an actor is required and present,
  3. check CSRF, same-origin, or equivalent request-forgery protection for browser-submitted requests,
  4. fail with an explicit response before token, auth, search, or fetch calls,
  5. continue only when the endpoint is allowed to spend provider resources.

This is not a domain permission pattern. It answers whether this request may use the application as a proxy to an external provider. Domain role questions such as who may import, persist, or edit provider-derived data belong to a later application action.

Use this pattern when:

  • a public route can trigger provider auth or search,
  • an anonymous caller could spend provider quota or request time,
  • a read-like HTMX endpoint has provider side effects outside the local app,
  • a controller action should fail before constructing provider requests.

Search aliases: endpoint hardening, abuse protection, quota protection, POST-only, login required, CSRF, same-origin.

Project-specific notes and rollout guidance

Call the gate at the top of the controller action. No token lookup, provider search, database write, or expensive local work should happen before it.

Prefer POST for HTMX endpoints that trigger provider work, even when the local action is read-like. The method is a resource-use boundary here, not merely a local database mutation signal. POST is not sufficient on its own: browser requests must also pass the framework's CSRF, same-origin, or equivalent request-forgery protection before provider resources are spent.

Keep actor presence separate from domain authorization. A logged-in-only gate can prevent anonymous quota abuse without saying that the actor may import, modify, or own the provider result. It also does not replace request-forgery protection: a logged-in browser can still be induced to send a POST unless the framework or the endpoint rejects forged requests.

In IHP, a helper that calls respondAndExit needs the implicit ?respond :: Respond constraint. If you give the helper an explicit signature, include it:

ensureExternalProviderAutocompleteAllowed
    :: ( ?context :: ControllerContext
       , ?request :: Wai.Request
       , ?respond :: Respond
       )
    => IO ()

A tiny local helper can also omit the explicit signature during exploration, but canonical application code should make the boundary grepable once it settles.

Supporting snippets

IHP controller shape:

action AutocompleteExternalProviderComponentAction = do
    ensureExternalProviderAutocompleteAllowed
    frozenContext <- freeze ?context
    let ?context = frozenContext in renderExternalProviderComponent (param "query")

IHP gate shape:

ensureExternalProviderAutocompleteAllowed
    :: ( ?context :: ControllerContext
       , ?request :: Wai.Request
       , ?respond :: Respond
       )
    => IO ()
ensureExternalProviderAutocompleteAllowed = do
    unless (Wai.requestMethod request == "POST") do
        respondAndExit
            (responseLBS status405
                [("Allow", "POST"), ("Content-Type", "text/plain; charset=utf-8")]
                "Method Not Allowed")
    case currentUserOrNothing of
        Just _ -> pure ()
        Nothing -> respondAndExit
            (responseLBS status401
                [("Content-Type", "text/plain; charset=utf-8")]
                "Login required")
    -- Verify the framework CSRF/same-origin boundary here when it is not
    -- guaranteed before the action runs. The provider call below must only run
    -- after that request-forgery boundary has accepted the request.

The provider call belongs after the gate:

searchExternalProviderComponent query = do
    result <- liftIO (searchExternalProvider query)
    respondHtml (renderExternalProviderResult result)
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Actions.ExternalProviderEndpointGate where

import Prelude
import Data.ByteString (ByteString)

-- | Configuration for an endpoint that can spend external provider resources.
data ExternalProviderEndpointGate = ExternalProviderEndpointGate
    { externalProviderAllowedMethod :: ByteString
    , externalProviderActorRequirement :: ExternalProviderActorRequirement
    , externalProviderRequestForgeryRequirement :: ExternalProviderRequestForgeryRequirement
    , externalProviderMethodNotAllowedBody :: ByteString
    , externalProviderLoginRequiredBody :: ByteString
    , externalProviderRequestRejectedBody :: ByteString
    }
    deriving (Show, Eq)

-- | Whether the endpoint may be used anonymously or requires an actor.
data ExternalProviderActorRequirement
    = ExternalProviderPublicEndpoint
    | ExternalProviderRequiresActor
    deriving (Show, Eq)

-- | Whether request-forgery protection must have accepted the request.
data ExternalProviderRequestForgeryRequirement
    = ExternalProviderRequestForgeryNotRequired
    | ExternalProviderRequiresRequestForgeryProtection
    deriving (Show, Eq)

-- | Result of the framework's CSRF, same-origin, or equivalent boundary.
data ExternalProviderRequestForgeryState
    = ExternalProviderRequestForgeryAccepted
    | ExternalProviderRequestForgeryRejected
    deriving (Show, Eq)

-- | Minimal request shape needed by the reusable gate decision.
data ExternalProviderRequest = ExternalProviderRequest
    { externalProviderRequestMethod :: ByteString
    , externalProviderRequestForgeryState :: ExternalProviderRequestForgeryState
    }
    deriving (Show, Eq)

-- | Gate failure before any provider operation is attempted.
data ExternalProviderGateFailure
    = ExternalProviderMethodNotAllowed ByteString ByteString
    | ExternalProviderLoginRequired ByteString
    | ExternalProviderRequestRejected ByteString
    deriving (Show, Eq)

The gate decision is pure. Framework-specific controller code maps failures to respondAndExit, redirects, JSON, CLI output, or another boundary response.

checkExternalProviderEndpointGate
    :: ExternalProviderEndpointGate
    -> ExternalProviderRequest
    -> Maybe actor
    -> Either ExternalProviderGateFailure ()
checkExternalProviderEndpointGate
        ExternalProviderEndpointGate
            { externalProviderAllowedMethod = allowedMethod
            , externalProviderActorRequirement = actorRequirement
            , externalProviderRequestForgeryRequirement = requestForgeryRequirement
            , externalProviderMethodNotAllowedBody = methodNotAllowedBody
            , externalProviderLoginRequiredBody = loginRequiredBody
            , externalProviderRequestRejectedBody = requestRejectedBody
            }
        ExternalProviderRequest
            { externalProviderRequestMethod = requestMethod
            , externalProviderRequestForgeryState = requestForgeryState
            }
        maybeActor
    | requestMethod /= allowedMethod =
        Left (ExternalProviderMethodNotAllowed allowedMethod methodNotAllowedBody)
    | actorRequirement == ExternalProviderRequiresActor && actorMissing =
        Left (ExternalProviderLoginRequired loginRequiredBody)
    | requestForgeryRequirement == ExternalProviderRequiresRequestForgeryProtection
        && requestForgeryState == ExternalProviderRequestForgeryRejected =
        Left (ExternalProviderRequestRejected requestRejectedBody)
    | otherwise = Right ()
  where
    actorMissing = case maybeActor of
        Nothing -> True
        Just _ -> False
Helper implementation examples

A typical external autocomplete gate accepts only POST, requires a logged-in actor, and requires request-forgery protection to have accepted the request.

externalAutocompleteGate :: ExternalProviderEndpointGate
externalAutocompleteGate = ExternalProviderEndpointGate
    { externalProviderAllowedMethod = "POST"
    , externalProviderActorRequirement = ExternalProviderRequiresActor
    , externalProviderRequestForgeryRequirement = ExternalProviderRequiresRequestForgeryProtection
    , externalProviderMethodNotAllowedBody = "Method Not Allowed"
    , externalProviderLoginRequiredBody = "Login required"
    , externalProviderRequestRejectedBody = "Request rejected"
    }

A small response representation keeps the standalone example independent of IHP while preserving HTTP semantics.

data GateResponse = GateResponse
    { gateResponseStatus :: Int
    , gateResponseHeaders :: [(ByteString, ByteString)]
    , gateResponseBody :: ByteString
    }
    deriving (Show, Eq)

renderGateFailure :: ExternalProviderGateFailure -> GateResponse
renderGateFailure (ExternalProviderMethodNotAllowed allowedMethod body) =
    GateResponse
        { gateResponseStatus = 405
        , gateResponseHeaders =
            [ ("Allow", allowedMethod)
            , ("Content-Type", "text/plain; charset=utf-8")
            ]
        , gateResponseBody = body
        }
renderGateFailure (ExternalProviderLoginRequired body) =
    GateResponse
        { gateResponseStatus = 401
        , gateResponseHeaders =
            [("Content-Type", "text/plain; charset=utf-8")]
        , gateResponseBody = body
        }
renderGateFailure (ExternalProviderRequestRejected body) =
    GateResponse
        { gateResponseStatus = 403
        , gateResponseHeaders =
            [("Content-Type", "text/plain; charset=utf-8")]
        , gateResponseBody = body
        }
Usage examples

Wrong methods fail before actor checks and before provider calls.

wrongMethodResponse :: Either GateResponse ()
wrongMethodResponse =
    case checkExternalProviderEndpointGate externalAutocompleteGate getRequest (Just Actor) of
        Left failure -> Left (renderGateFailure failure)
        Right () -> Right ()

Anonymous direct calls fail after the method is accepted.

anonymousPostResponse :: Either GateResponse ()
anonymousPostResponse =
    case checkExternalProviderEndpointGate externalAutocompleteGate postRequest (Nothing :: Maybe Actor) of
        Left failure -> Left (renderGateFailure failure)
        Right () -> Right ()

A logged-in forged POST still fails before provider work.

forgedPostResponse :: Either GateResponse ()
forgedPostResponse =
    case checkExternalProviderEndpointGate externalAutocompleteGate forgedPostRequest (Just Actor) of
        Left failure -> Left (renderGateFailure failure)
        Right () -> Right ()

A logged-in POST whose request-forgery boundary has accepted the request can continue to provider work.

allowedPostResponse :: Either GateResponse ()
allowedPostResponse =
    case checkExternalProviderEndpointGate externalAutocompleteGate postRequest (Just Actor) of
        Left failure -> Left (renderGateFailure failure)
        Right () -> Right ()

Example request and actor values:

data Actor = Actor
    deriving (Show, Eq)

getRequest :: ExternalProviderRequest
getRequest = ExternalProviderRequest
    { externalProviderRequestMethod = "GET"
    , externalProviderRequestForgeryState = ExternalProviderRequestForgeryRejected
    }

forgedPostRequest :: ExternalProviderRequest
forgedPostRequest = ExternalProviderRequest
    { externalProviderRequestMethod = "POST"
    , externalProviderRequestForgeryState = ExternalProviderRequestForgeryRejected
    }

postRequest :: ExternalProviderRequest
postRequest = ExternalProviderRequest
    { externalProviderRequestMethod = "POST"
    , externalProviderRequestForgeryState = ExternalProviderRequestForgeryAccepted
    }
Standalone checks

Not applicable in this pattern; sections 4, 5, and 6 compile standalone.

External Search Query Policy

Raw source: Patterns/Actions/ExternalSearchQueryPolicy.lhs

Pattern intent and mechanism

Normalize user-supplied free-text search before an external provider call. A local autocomplete input may emit empty, whitespace-only, very short, or very long values. Sending those values to a provider wastes request time and quota, and can produce broad low-value searches.

This pattern applies a small query policy at the provider boundary:

  1. strip surrounding whitespace,
  2. reject blank values and values shorter than the configured minimum,
  3. cap values at the configured maximum,
  4. call the provider only when a normalized query remains.

A rejected blank or short query is not invalid user input. For autocomplete-style external search it is a successful no-op: the caller should usually return an empty successful result and keep the UI quiet.

Use this pattern when:

  • a user-typed free-text query can trigger an external search,
  • auth or token retrieval has latency or quota impact,
  • short broad searches have low value,
  • the provider should not receive oversized query strings.

Search aliases: query normalization, input sanitization, autocomplete, typeahead, min length, max length, empty query.

Do not use this pattern unchanged for exact-ID lookups, URL lookups, barcode lookups, or other provider calls where a short or blank value may be meaningful.

Project-specific notes and rollout guidance

Run query normalization before provider auth, token retrieval, or HTTP search. If auth itself has latency or quota cost, a one-character query should not spend that cost.

Keep the policy generic and provider values local. The reusable helper should know only about minimum and maximum length; the provider module chooses concrete numbers for its provider surface and UI context.

Treat Nothing as a named policy outcome, not vague failure. The function name and policy type make the meaning explicit: no external search should be attempted for this input.

The direct record constructor is fine for static provider constants in trusted source code. If policy values come from configuration, an admin UI, or another runtime source, validate them with a smart constructor before use.

Preserve this boundary when composing with IntegrationResultBoundary: a blank or short query maps to IntegrationSucceeded [], while provider failures map to IntegrationUnavailable ....

Supporting snippets

Provider-local policy constant:

providerSearchQueryPolicy :: ExternalSearchQueryPolicy
providerSearchQueryPolicy = ExternalSearchQueryPolicy
    { externalSearchMinLength = 3
    , externalSearchMaxLength = 200
    }

Provider search shape:

searchExternalProvider rawQuery =
    case normaliseExternalSearchQuery providerSearchQueryPolicy rawQuery of
        Nothing -> pure (IntegrationSucceeded [])
        Just query -> callExternalProvider query
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Actions.ExternalSearchQueryPolicy where

import Prelude
import Data.Text (Text)
import qualified Data.Text as T
import Patterns.Actions.IntegrationResultBoundary

-- | Query-shaping policy for external free-text search endpoints.
--
-- Invariants for direct construction:
--
-- - 'externalSearchMinLength' must be >= 1.
-- - 'externalSearchMaxLength' must be >= 'externalSearchMinLength'.
--
-- Use 'mkExternalSearchQueryPolicy' when values come from runtime
-- configuration or other untrusted sources.
data ExternalSearchQueryPolicy = ExternalSearchQueryPolicy
    { externalSearchMinLength :: Int
    , externalSearchMaxLength :: Int
    } deriving (Show, Eq)

The smart constructor rejects impossible policies. Static trusted constants may use the record constructor directly; runtime configuration should use this function.

mkExternalSearchQueryPolicy :: Int -> Int -> Maybe ExternalSearchQueryPolicy
mkExternalSearchQueryPolicy minLength maxLength
    | minLength < 1 = Nothing
    | maxLength < minLength = Nothing
    | otherwise = Just ExternalSearchQueryPolicy
        { externalSearchMinLength = minLength
        , externalSearchMaxLength = maxLength
        }

The normalizer strips whitespace, treats blank and short queries as no-op decisions, and caps long values before they leave the application.

normaliseExternalSearchQuery :: ExternalSearchQueryPolicy -> Text -> Maybe Text
normaliseExternalSearchQuery ExternalSearchQueryPolicy
        { externalSearchMinLength = minLength
        , externalSearchMaxLength = maxLength
        } rawQuery
    | T.null strippedQuery = Nothing
    | T.length strippedQuery < minLength = Nothing
    | otherwise = Just (T.take maxLength strippedQuery)
  where
    strippedQuery = T.strip rawQuery
Helper implementation examples

A provider module supplies concrete values for its own search endpoint.

providerSearchQueryPolicy :: ExternalSearchQueryPolicy
providerSearchQueryPolicy = ExternalSearchQueryPolicy
    { externalSearchMinLength = 3
    , externalSearchMaxLength = 200
    }

Provider search applies normalization before any external operation.

searchProvider :: Text -> IO (IntegrationResult [Text])
searchProvider rawQuery =
    case normaliseExternalSearchQuery providerSearchQueryPolicy rawQuery of
        Nothing -> pure (IntegrationSucceeded [])
        Just query -> callProviderSearch query

The provider call shown here is a placeholder for auth, HTTP, and decode logic. It receives only normalized text.

callProviderSearch :: Text -> IO (IntegrationResult [Text])
callProviderSearch query =
    pure (IntegrationSucceeded ["searched: " <> query])
Usage examples

Short and whitespace-only queries are deliberate no-op searches.

blankQueryResult :: Maybe Text
blankQueryResult = normaliseExternalSearchQuery providerSearchQueryPolicy "   "

shortQueryResult :: Maybe Text
shortQueryResult = normaliseExternalSearchQuery providerSearchQueryPolicy "ab"

Long queries are capped, not rejected.

smallPolicy :: ExternalSearchQueryPolicy
smallPolicy = ExternalSearchQueryPolicy
    { externalSearchMinLength = 1
    , externalSearchMaxLength = 5
    }

cappedQueryResult :: Maybe Text
cappedQueryResult = normaliseExternalSearchQuery smallPolicy "abcdefghi"

Runtime policies should be parsed through the smart constructor.

validRuntimePolicy :: Maybe ExternalSearchQueryPolicy
validRuntimePolicy = mkExternalSearchQueryPolicy 3 200

invalidRuntimePolicy :: Maybe ExternalSearchQueryPolicy
invalidRuntimePolicy = mkExternalSearchQueryPolicy 0 200

invalidRuntimePolicyOrder :: Maybe ExternalSearchQueryPolicy
invalidRuntimePolicyOrder = mkExternalSearchQueryPolicy 10 3
Standalone checks

Not applicable in this pattern; sections 4, 5, and 6 compile standalone.

Integration Result Boundary

Raw source: Patterns/Actions/IntegrationResultBoundary.lhs

Pattern intent and mechanism

External provider calls fail in ways that ordinary in-process helpers do not: credentials may be absent, the provider may be slow, the network may fail, or response decoding may reveal schema drift. A caller still needs to distinguish those operational states from a successful provider response with an empty payload.

This pattern makes that distinction explicit:

  • IntegrationSucceeded payload means the provider operation completed. An empty list inside this constructor is a successful no-hit result.
  • IntegrationUnavailable reason means the provider was disabled, timed out, or failed operationally. It is not a successful empty result.

The boundary belongs around provider IO: auth requests, HTTP calls, response parsing, or equivalent third-party operations. Do not wrap an entire controller or domain workflow just to turn every exception into an integration result. Cancellation and shutdown exceptions are not provider failures and must be re-thrown.

Use this pattern when:

  • an optional external provider enriches a local flow,
  • provider absence is acceptable in local development or optional deployments,
  • a UI, job, or CLI caller must preserve the fact that the provider failed,
  • a slow provider must not hold a request/response handler indefinitely.

Search aliases: error handling, failure handling, timeout, unavailable provider, empty result vs failed request.

Project-specific notes and rollout guidance

Introduce this result type at the provider helper boundary. Controllers, components, jobs, and scripts should consume IntegrationResult; raw HTTP, auth, and decode exceptions should not leak past the provider module as ordinary empty payloads.

Map disabled providers deliberately. Local development may render nothing for a missing optional provider, but that choice belongs at the presentation boundary; the provider helper still returns IntegrationUnavailable (IntegrationDisabled provider).

Map runtime failures visibly enough for the current surface. A UI may show a small unavailable block, a job may log and continue, and a CLI may exit with a non-zero status. In every case the failure fact remains available. Do not render raw exception text directly to end users; use details for logs or diagnostic channels.

Use the timeout wrapper for provider operations made during request/response UI flows. Pick a conservative provider-specific budget and tune it from production evidence.

If the provider performs query shaping before auth or HTTP, return IntegrationSucceeded [] for a deliberate no-op search. That is a successful policy decision, not provider unavailability.

Supporting snippets

Controller or component response mapping:

case tracksResult of
    IntegrationSucceeded [] -> respondHtml mempty
    IntegrationSucceeded tracks -> respondHtml (renderTracks tracks)
    IntegrationUnavailable (IntegrationDisabled _) -> respondHtml mempty
    IntegrationUnavailable err -> respondHtml (renderProviderUnavailable err)

Provider helper boundary:

searchProvider rawQuery =
    case normaliseExternalSearchQuery providerQueryPolicy rawQuery of
        Nothing -> pure (IntegrationSucceeded [])
        Just query -> integrationTryWithTimeout providerName providerTimeout do
            token <- fetchProviderToken
            response <- fetchProviderSearchResults token query
            decodeProviderResults response
Core reusable pattern infrastructure
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Patterns.Actions.IntegrationResultBoundary where

import Prelude
import Control.Exception
    ( SomeAsyncException
    , SomeException
    , displayException
    , fromException
    , throwIO
    , try
    )
import Data.Text (Text)
import qualified Data.Text as T
import System.Timeout (timeout)

-- | Operational failure of an external integration.
--
-- 'IntegrationDisabled' is configuration-driven and often acceptable in local
-- development. 'IntegrationTimedOut' prevents a slow provider from tying up a
-- request handler indefinitely. 'IntegrationFailed' means the provider was
-- configured but a request, response decode, or other operational step failed.
data IntegrationError
    = IntegrationDisabled Text
    | IntegrationTimedOut Text Int
    | IntegrationFailed Text Text
    deriving (Show, Eq)

-- | Result of an external integration call.
--
-- Empty successful payloads stay inside 'IntegrationSucceeded'. Operational
-- failures use 'IntegrationUnavailable' and must not be collapsed into empty
-- payloads.
data IntegrationResult a
    = IntegrationSucceeded a
    | IntegrationUnavailable IntegrationError
    deriving (Show, Eq)

The plain wrapper catches synchronous exceptions at the provider boundary and preserves the provider name with a diagnostic message. Asynchronous exceptions such as cancellation and shutdown are re-thrown.

integrationTry :: Text -> IO a -> IO (IntegrationResult a)
integrationTry provider action = do
    result <- try action
    case result of
        Right value -> pure (IntegrationSucceeded value)
        Left (exception :: SomeException) -> integrationException provider exception

The timeout wrapper adds a provider-specific request budget and keeps timeout as a distinct failure constructor.

integrationTryWithTimeout :: Text -> Int -> IO a -> IO (IntegrationResult a)
integrationTryWithTimeout provider timeoutMicros action = do
    result <- try (timeout timeoutMicros action)
    case result of
        Right (Just value) -> pure (IntegrationSucceeded value)
        Right Nothing -> pure (IntegrationUnavailable (IntegrationTimedOut provider timeoutMicros))
        Left (exception :: SomeException) -> integrationException provider exception

integrationException :: Text -> SomeException -> IO (IntegrationResult a)
integrationException provider exception
    | Just (_ :: SomeAsyncException) <- fromException exception = throwIO exception
    | otherwise = pure (IntegrationUnavailable failure)
  where
    failure = IntegrationFailed provider (T.pack (displayException exception))
Helper implementation examples

A provider may be disabled by configuration before any HTTP request is built. Fully absent optional credentials are disabled; partial configuration is a visible failure.

loadProviderCredentials :: Maybe Text -> Maybe Text -> IntegrationResult (Text, Text)
loadProviderCredentials Nothing Nothing =
    IntegrationUnavailable (IntegrationDisabled "ExampleProvider")
loadProviderCredentials (Just clientId) (Just clientSecret) =
    IntegrationSucceeded (clientId, clientSecret)
loadProviderCredentials _ _ =
    IntegrationUnavailable (IntegrationFailed "ExampleProvider" "partial credentials")

A provider search returns a successful empty payload for deliberate no-op queries and wraps only the external operation.

searchExampleProvider :: Text -> IO (IntegrationResult [Text])
searchExampleProvider rawQuery
    | T.length (T.strip rawQuery) < 3 = pure (IntegrationSucceeded [])
    | otherwise = integrationTryWithTimeout "ExampleProvider" 5000000 do
        pure ["external result"]
Usage examples

Presentation code distinguishes empty success from provider unavailability.

renderSearchOutcome :: IntegrationResult [Text] -> Text
renderSearchOutcome (IntegrationSucceeded []) =
    "no provider results"
renderSearchOutcome (IntegrationSucceeded results) =
    "provider results: " <> T.intercalate ", " results
renderSearchOutcome (IntegrationUnavailable (IntegrationDisabled _provider)) =
    ""
renderSearchOutcome (IntegrationUnavailable (IntegrationTimedOut provider _timeoutMicros)) =
    "provider timed out: " <> provider
renderSearchOutcome (IntegrationUnavailable (IntegrationFailed provider _details)) =
    "provider unavailable: " <> provider

A job or CLI can keep the same boundary but choose a stricter mapping.

integrationSucceededOrMessage :: IntegrationResult a -> Either Text a
integrationSucceededOrMessage (IntegrationSucceeded value) =
    Right value
integrationSucceededOrMessage (IntegrationUnavailable (IntegrationDisabled provider)) =
    Left ("integration disabled: " <> provider)
integrationSucceededOrMessage (IntegrationUnavailable (IntegrationTimedOut provider _timeoutMicros)) =
    Left ("integration timed out: " <> provider)
integrationSucceededOrMessage (IntegrationUnavailable (IntegrationFailed provider _details)) =
    Left ("integration failed: " <> provider)
Standalone checks

Not applicable in this pattern; sections 4, 5, and 6 compile standalone.

Last-Guardian Protection

Raw source: Patterns/Actions/LastGuardianProtection.lhs

Pattern intent and mechanism

Prevent the removal or demotion of the last owner, admin, or lead of a group, project, or organization. Before the destructive action, count how many other guardians remain. If the count is zero, deny the action.

This is UX/app-level protection, not a hard invariant. The check is read-before-write without locking. Two parallel demotes can both see the same remaining guardian and proceed, leaving none.

Use this pattern when:

  • a resource must retain at least one member with elevated privileges,
  • the action removes or demotes the current guardian,
  • a silent orphan (no owner) would break downstream workflows.

For hard invariants, combine this pattern with a database trigger or transactional SELECT ... FOR UPDATE. The app-level guard catches the common case; the trigger or lock catches the race.

Project-specific notes and rollout guidance

Call the guard after the actor-target authorization gate but before any database writes. The gate stack should be: policy check → ownership check → guardian check → write.

Keep the guard specific to one guardian role. Do not try to model a generic "any role with any privilege" check; the query becomes complex and slow. If multiple roles count as guardians (owner and admin), write separate guards or use a single query that counts both.

Document the race caveat in the pattern prose and in the host code. A comment like "read-before-write without locking — see LastGuardianProtection pattern" is enough.

For hard invariants, use a trigger that counts remaining guardians before the delete or update commits and raises an exception if the count would drop to zero. PostgreSQL CHECK constraints cannot contain subqueries, so a plain constraint is not sufficient.

Alternatively, use application-level locking (SELECT ... FOR UPDATE) in the guard query to serialize concurrent demotes. This prevents the race at the cost of holding a lock for the duration of the transaction.

Supporting snippets

IHP query that counts remaining guardians:

remaining <- query @Subscription
    |> filterWhere (#groupId, current.groupId)
    |> filterWhere (#role, Lead)
    |> filterWhereNot (#userId, current.userId)
    |> fetchCount

Deny if no guardians remain:

accessDeniedUnless (remaining > 0)
Core reusable pattern infrastructure
{-# LANGUAGE ImplicitParams #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Actions.LastGuardianProtection where

import Prelude
import IHP.ControllerPrelude

-- | Configuration for a last-guardian check.
-- | The caller supplies the count query. The deny action is either a throw
-- | (for IHP controller gates) or an explicit result (for UX decisions).
data LastGuardianConfig entity = LastGuardianConfig
    { remainingGuardians :: entity -> IO Int
    }

-- | Explicit result for callers that decide how to handle denial.
-- | Use this for UX paths (show a modal, redirect with a flash message)
-- | instead of a hard 403.
data GuardianResult = GuardiansRemain | LastGuardianDenied Text
    deriving (Eq, Show)
Helper implementation examples
-- | Throwing gate. Use this inside IHP controller actions, consistent with
-- | other 'ensure*' gates in 'PurePolicyControllerGate' and
-- | 'ActorTargetAuthGate'.
ensureRemainingGuardians
    :: (?context :: ControllerContext)
    => LastGuardianConfig entity -> entity -> IO ()
ensureRemainingGuardians LastGuardianConfig { remainingGuardians } entity = do
    count <- remainingGuardians entity
    accessDeniedUnless (count > 0)

-- | Non-throwing variant. Use this when the caller needs the result for
-- | conditional UI (e.g. disable the demote button with a tooltip).
checkRemainingGuardians :: LastGuardianConfig entity -> entity -> IO GuardianResult
checkRemainingGuardians LastGuardianConfig { remainingGuardians } entity = do
    count <- remainingGuardians entity
    if count > 0
        then pure GuardiansRemain
        else pure (LastGuardianDenied "last guardian cannot be removed")
Usage examples

A project membership system where each project needs at least one Lead.

data Membership = Membership
    { membershipId :: Int
    , membershipProjectId :: Int
    , membershipUserId :: Int
    , membershipRole :: Text
    }
    deriving (Eq, Show)

-- | Count remaining leads for this project, excluding the current member.
countRemainingLeads :: Membership -> IO Int
countRemainingLeads membership = do
    -- Simplified: real implementation uses IHP's query DSL
    pure 1  -- placeholder

lastLeadConfig :: LastGuardianConfig Membership
lastLeadConfig = LastGuardianConfig
    { remainingGuardians = countRemainingLeads
    }

The throwing gate is used inside controller actions.

-- | Before demoting a member from Lead to Contributor, throw 403 if
-- | this is the last lead. Called inside an IHP controller action.
canDemoteMember :: (?context :: ControllerContext) => Membership -> IO ()
canDemoteMember = ensureRemainingGuardians lastLeadConfig

The non-throwing variant is used for conditional UI.

-- | Check if demoting is allowed; return a result for UI handling.
-- | The view can show a disabled button with a tooltip when denied.
canDemoteMemberUI :: Membership -> IO GuardianResult
canDemoteMemberUI = checkRemainingGuardians lastLeadConfig

Race caveat: two parallel demotes of the only two Leads both see one remaining and proceed, leaving none. A trigger or SELECT ... FOR UPDATE prevents this.

Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Narrow Public Fill Boundary

Raw source: Patterns/Actions/NarrowPublicFillBoundary.lhs

Pattern intent and mechanism

IHP's fill @'[...] is an explicit field allowlist, but a single allowlist used by every controller action is a security boundary leak. If the public signup path can fill fields that belong to admin privilege changes or to server-controlled counters, the explicit list is correct in syntax and wrong in authority.

This pattern splits one monolithic builder into named, path-specific builders. Each builder carries only the fields that belong to its boundary:

  1. Public construction path — fields supplied by an unauthenticated or weakly authenticated request (for example, signup).
  2. Self-edit profile path — fields a normal authenticated user may change about themselves.
  3. Privileged/admin path — fields only a guarded action may change.

Server-controlled fields (role, counters, lock state, media references, confirmation flags) are never in a public fill list. They are set explicitly, outside fill, after validation or inside the privileged path.

The pattern identity in host code is the named builder function: buildSignupUser, buildEditUser, buildAdminUserUpdate, or local domain equivalents. A grep for buildSignup should reveal the entire public boundary.

Use this pattern when:

  • a record has fields that mix public input with internal server state,
  • the same record is created or updated from more than one trust boundary,
  • generated records make every column available and only a subset belongs to public input.

This is an action pattern because the security boundary lives in the request-to-domain construction path inside controller actions. The mechanism is server-side; it does not depend on view-level hiding or on form field omission.

Project-specific notes and rollout guidance

Inventory every fill call that touches user-facing create or update actions. For each call, classify the fields:

  • Public — the unauthenticated user may supply this (name, email, password).
  • Self-editable — the authenticated owner may change this (nickname, preferred language).
  • Privileged — only an admin or guarded action may touch this (role, account state).
  • Server-controlled — the application sets this and the user never does (counters, timestamps, lock flags, foreign keys to internal records).

Create one builder per boundary. Do not reuse a broad builder for a narrow path "because the form does not expose those fields." The server boundary is the authority; the form is only one client.

Set server-controlled fields after the narrow fill, never inside it:

user
    |> buildSignupUser
    |> set #privilege NormalUser
    |> set #failedLoginAttempts 0

Give each builder an explicit type signature. Use _ for the constraint when IHP's generated field names or implicit request context make a fully written signature fragile:

buildSignupUser :: _ => User -> User

The return type (User -> User) is never inferred loosely; keeping it explicit makes the boundary grepable and prevents silent type-driven changes.

Name builders after the boundary, not after the record. buildSignupUser signals the trust level; buildUser does not.

When an admin action needs the same public fields plus privileged ones, duplicate the public list rather than calling the public builder and then overwriting. The duplicated list is explicit; the call-and-overwrite pattern hides which fields are actually being written.

POST-only for state-changing actions.

IHP's CSRF protection relies on SameSite=Lax session cookies, not CSRF tokens. Every state-changing action must allow only the HTTP method(s) it expects. Form-backed create and update actions typically accept only POST; logout may accept POST or DELETE. Reject everything else before any database read or write.

action UpdateUserAction { userId } = do
    unless (Wai.requestMethod request == "POST") do
        redirectTo (EditUserAction userId)
    -- ... proceed with update

A GET (or HEAD, OPTIONS, or any unexpected method) that reaches fill or updateRecord is a CSRF risk. Actions that only render a form (EditUserAction) remain GET-safe.

Persisted secret fields never appear in rendered HTML.

Persisted secret fields such as passwordHash must never be prefilled from records into rendered HTML. Recovery or invite tokens may appear only at their intentional delivery boundary — for example an emailed link or a route parameter — and must not be copied from stored record fields into generic forms or hidden inputs.

IHP's HSX escapes interpolated values by default, which prevents XSS injection, but escaping does not make a secret safe to display. A persisted secret in HTML is a confidentiality leak even when it cannot execute scripts.

Audit every form that pre-fills values from a database record. Whitelist the fields explicitly and exclude secrets:

-- WRONG: passwordHash leaks into the HTML response
<input type="hidden" name="passwordHash" value={user.passwordHash} />

-- CORRECT: the form only carries fields the user may edit
[hsx|
    <input type="text" name="firstName" value={user.firstName} />
    <input type="text" name="email" value={user.email} />
|]

A one-line audit rule: if a field name contains "password", "token", "secret", or "hash" and it comes from a persisted record, verify it does not flow into HTML output.

Supporting snippets

IHP signup builder with narrow public allowlist and server-controlled fields set outside fill:

-- | Builder for public signup. Only fields the user may set during signup.
-- The password field here carries the raw input from the form; it must be
-- hashed with 'hashPassword' before persistence.
buildSignupUser :: _ => User -> User
buildSignupUser user = user
    |> fill @'[ "firstName", "lastName", "nickname", "email"
              , "passwordHash", "language"
              ]

-- Usage in controller, after validation:
--   let built = user |> buildSignupUser
--   hashed <- hashPassword (get #passwordHash built)
--   built
--       |> set #passwordHash hashed
--       |> set #privilege NormalUser
--       |> set #failedLoginAttempts 0
--       |> set #lockedAt Nothing
--       |> createRecord

Self-edit builder excluding privilege and server-controlled state:

-- | Builder for user profile edit. Allows editable profile fields only.
-- Privilege changes remain admin-only via a separate privileged action.
buildEditUser :: _ => User -> User
buildEditUser user = user
    |> fill @'[ "firstName", "lastName", "nickname", "email"
              , "language"
              ]

Admin update builder that includes privileged fields:

-- | Builder for admin-only user update. Includes fields that public or
-- self-edit paths must never touch.
buildAdminUserUpdate :: _ => User -> User
buildAdminUserUpdate user = user
    |> fill @'[ "firstName", "lastName", "nickname", "email"
              , "language", "privilege"
              ]

Explicit type signatures with inferred constraints. Use this shape when generated field or enum names collide with implicit request context:

buildSignupUser :: _ => User -> User
buildEditUser   :: _ => User -> User
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Actions.NarrowPublicFillBoundary where

import Prelude
import Data.Text (Text)

-- | Forward pipe for readable record-update chains.
(|>) :: a -> (a -> b) -> b
(|>) x f = f x
infixl 0 |>

-- | Generic privilege level for examples.
data Privilege = NormalUser | Admin
    deriving (Eq, Show)

-- | Generic user record with mixed-sensitivity fields.
-- In IHP this is the generated database record; here it is reproduced
-- explicitly so the boundary examples compile without framework imports.
data User = User
    { firstName :: Text
    , lastName :: Text
    , nickname :: Maybe Text
    , email :: Text
    , passwordHash :: Text
    , language :: Text
    , privilege :: Privilege
    , failedLoginAttempts :: Int
    , lockedAt :: Maybe Text
    , profilePicId :: Maybe Int
    }
    deriving (Eq, Show)

-- | A fresh user with only server-controlled defaults.
-- Application code uses the framework's @newRecord@ instead.
newUser :: User
newUser = User
    { firstName = ""
    , lastName = ""
    , nickname = Nothing
    , email = ""
    , passwordHash = ""
    , language = "en"
    , privilege = NormalUser
    , failedLoginAttempts = 0
    , lockedAt = Nothing
    , profilePicId = Nothing
    }
Helper implementation examples

Public construction path. Accepts only fields that belong to signup.

-- | Public signup boundary.
-- The caller supplies public fields; server-controlled fields are
-- applied afterwards by 'applyServerControlledDefaults'.
--
-- The @rawPassword@ parameter is the plaintext input from the form.
-- The controller must hash it before writing to the database.
buildSignupUser :: Text -> Text -> Maybe Text -> Text -> Text -> Text -> User -> User
buildSignupUser fn ln nick em rawPassword lang user = user
    { firstName = fn
    , lastName = ln
    , nickname = nick
    , email = em
    , passwordHash = rawPassword
    , language = lang
    }

Self-edit profile path. Same public shape, but without password so that password changes travel through a dedicated, guarded action.

-- | Self-edit profile boundary.
-- Excludes password, privilege, and all server-controlled fields.
buildEditUser :: Text -> Text -> Maybe Text -> Text -> Text -> User -> User
buildEditUser fn ln nick em lang user = user
    { firstName = fn
    , lastName = ln
    , nickname = nick
    , email = em
    , language = lang
    }

Privileged/admin path. Includes fields that public and self-edit paths must never touch.

-- | Admin-only update boundary.
-- Includes privileged fields in addition to the editable profile set.
buildAdminUserUpdate :: Text -> Text -> Maybe Text -> Text -> Text -> Privilege -> User -> User
buildAdminUserUpdate fn ln nick em lang priv user = user
    { firstName = fn
    , lastName = ln
    , nickname = nick
    , email = em
    , language = lang
    , privilege = priv
    }

Server-controlled defaults applied after the narrow boundary.

-- | Server-controlled fields set explicitly outside any public fill path.
-- This is the safety net: even if a public builder is called incorrectly,
-- the controller still overwrites these fields with safe defaults.
applyServerControlledDefaults :: User -> User
applyServerControlledDefaults user = user
    { privilege = NormalUser
    , failedLoginAttempts = 0
    , lockedAt = Nothing
    , profilePicId = Nothing
    }
Usage examples

Signup action boundary. The controller validates first, then calls the public builder, then applies server-controlled defaults before persistence.

-- | Simulated signup flow. In IHP this lives in a controller action.
-- The raw password is hashed before it reaches the database.
exampleSignupUser :: Text -> Text -> Maybe Text -> Text -> Text -> Text -> User
exampleSignupUser fn ln nick em rawPassword lang =
    let user = newUser
            |> buildSignupUser fn ln nick em rawPassword lang
            |> applyServerControlledDefaults
        -- In IHP: hashed <- hashPassword (get #passwordHash user)
        -- Here we simulate the hashing step with a type-level marker.
    in user { passwordHash = "hashed:" <> passwordHash user }

Self-edit boundary. The authenticated user updates only their profile fields.

exampleEditUser :: User -> Text -> Text -> Maybe Text -> Text -> Text -> User
exampleEditUser currentUser fn ln nick em lang =
    currentUser
        |> buildEditUser fn ln nick em lang

Admin boundary. A privileged action updates both public and privileged fields.

exampleAdminUpdate :: User -> Text -> Text -> Maybe Text -> Text -> Text -> Privilege -> User
exampleAdminUpdate targetUser fn ln nick em lang priv =
    targetUser
        |> buildAdminUserUpdate fn ln nick em lang priv

A user record before and after a public signup call.

-- | The user after public signup. Privilege and counters are safe defaults.
signedUpUser :: User
signedUpUser = exampleSignupUser "Ada" "Lovelace" Nothing "ada@x" "secret" "en"

-- | True: the server-controlled fields were not left at uninitialized values.
privilegeIsNormal :: Bool
privilegeIsNormal = privilege signedUpUser == NormalUser

failedAttemptsAreZero :: Bool
failedAttemptsAreZero = failedLoginAttempts signedUpUser == 0
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. The record-update style (user { field = value }) is a stand-in for IHP's fill/set pipeline; the boundary principle is the same: named builders, narrow field lists, and server-controlled fields applied outside the public path.

Pure Policy plus Controller Gate

Raw source: Patterns/Actions/PurePolicyControllerGate.lhs

Pattern intent and mechanism

Separate domain policy (who may do what) from framework-specific enforcement (when and how to deny access). The pure layer decides; the gate layer enforces.

The pattern has three layers:

  1. Actor context — who the current user is and what they already own.
  2. Policy — a capability summary derived from the actor context.
  3. Permission — a boolean decision from policy + requested action.

Layer 1 is extracted once per request. Layer 2 is computed from Layer 1. Layer 3 is checked at every guarded entry point. Layer 2 and 3 are pure; Layer 1 may read the database.

Ownership is an additional axis orthogonal to policy. An actor can have a weak policy but still act on their own resource because they own it. The ownership check is layered on top of the policy check, not folded into the policy type.

This pattern is not a global RBAC or permission-matrix system. It models one actor's capability for one concrete resource type. Each resource type (document, project, subscription) gets its own policy type, action type, and gate. Shared infrastructure (user roles, admin flags) live in the actor context.

When several domain actions in the same scope share the same contextual role (for example, "the actor's subscription role in this project"), use ScopedRolePermissionRegistry instead. It centralizes the permission table for multiple actions under one scope-aware role context.

Use this pattern when:

  • a controller action must check whether the current user may perform an operation,
  • the decision depends on who the user is, what they own, and what they are trying to do,
  • the same decision logic is reused in views (to show/hide controls) and in controllers (to enforce the decision).
Project-specific notes and rollout guidance

Derive the policy from the actor's context, never from the target resource. Deriving authority from the target invites IDOR-style mistakes: an attacker changes the target ID and inherits the target's authority.

Keep the policy type small. Three to five constructors is usually enough. If you need more, consider whether you are modeling multiple concerns in one type.

Keep the action type exhaustive. Every operation that needs a gate should be a constructor. Pattern-match exhaustively in canDoAction; the compiler will warn when a new action is added but the permission table is not updated.

Call the gate at the top of the controller action, before any database writes or side effects. The gate should be the first thing that can fail.

Do not call the gate inside a view helper. Views read the pure predicate to decide what to render; only controllers call the throwing gate. This keeps views fast and side-effect-free.

Distinguish two cases in views:

  • Global controls (create new, list all) — use canDoAction with the policy alone. The resource is not known yet.
  • Target-specific controls (edit this, delete that) — use canDoActionWithOwnership with the concrete resource. The user may own this specific resource even when their policy is weak.

Global privileges (for example a site-wide admin flag) and contextual membership roles are combined in the actor context, never in the target. An admin does not need a fake membership on the target resource in order to edit it; the elevated privilege lives in the actor-context layer. Only contextual permissions (for example “member of this project”) are derived from relationships that involve the target. Keep the two sources separate: actor context for who the user is globally, target context for what relationships they have locally.

Supporting snippets

IHP gate with accessDeniedUnless:

ensureCanDoAction :: (?context :: ControllerContext, ...)
                  => User -> Maybe Resource -> ResourceAction -> IO ()
ensureCanDoAction user maybeResource action =
    let policy = computePolicy user maybeResource
        permitted = case maybeResource of
            Just resource -> canDoActionWithOwnership user resource policy action
            Nothing       -> canDoAction policy action
    in accessDeniedUnless permitted

Controller usage:

action UpdateDocumentAction { documentId } = do
    document <- fetch documentId
    currentUser <- getCurrentUser
    ensureCanDoAction currentUser (Just document) UpdateDocument
    -- ... proceed with update
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Actions.PurePolicyControllerGate where

import Prelude
import Data.Text (Text)
import IHP.ViewPrelude

-- | Actor context: who the current user is and what they already own.
-- | This is the input to policy computation.
data ActorContext owned = ActorContext
    { actorId :: Int
    , actorIsAdmin :: Bool
    , actorOwnedRole :: Maybe owned
    }
    deriving (Eq, Show)

-- | Generic policy type for a resource with three capability levels.
-- | Real applications may rename constructors to match their domain.
data Policy
    = ReadOnly       -- ^ may view, may not modify
    | Editor         -- ^ may view and modify own content
    | FullAccess     -- ^ may do anything, including admin operations
    deriving (Eq, Show)

-- | Generic action type for a resource.
-- | Every operation that needs a gate is a constructor.
data Action
    = ViewAction
    | CreateAction
    | UpdateAction
    | DeleteAction
    deriving (Eq, Show)
Helper implementation examples

Pure policy computation from actor context.

-- | Compute policy from actor context.
-- | Admin always gets full access.
-- | An actor who owns a resource with the 'Owner' role gets editor access.
-- | Everyone else gets read-only.
computePolicy :: (Eq owned, Show owned) => ActorContext owned -> owned -> Policy
computePolicy actor ownerRole
    | actorIsAdmin actor = FullAccess
    | Just ownerRole == actorOwnedRole actor = Editor
    | otherwise = ReadOnly

Pure permission table. Exhaustive pattern match; the compiler warns when a new action is added.

-- | Check if an action is permitted by the policy alone.
-- | Does not consider ownership.
canDoAction :: Policy -> Action -> Bool
canDoAction FullAccess _ = True
canDoAction Editor ViewAction = True
canDoAction Editor UpdateAction = True
canDoAction Editor CreateAction = True
canDoAction Editor DeleteAction = False
canDoAction ReadOnly ViewAction = True
canDoAction ReadOnly _ = False

Ownership-aware permission check.

-- | A type class for resources that have an owner id.
-- | Keeps the permission check generic over resource types.
class Ownable resource where
    ownerId :: resource -> Int

-- | Ownership-aware permission check.
-- | The actor can delete their own resource even with a weak policy.
-- | Admins bypass ownership for all targets.
canDoActionWithOwnership
    :: Ownable resource
    => ActorContext owned -> resource -> Policy -> Action -> Bool
canDoActionWithOwnership actor resource policy action =
    let isOwner = actorId actor == ownerId resource
    in case action of
        DeleteAction -> isOwner || canDoAction policy action
        UpdateAction -> (isOwner || policy == FullAccess) && canDoAction policy action
        _            -> canDoAction policy action
Usage examples

A domain-specific role and resource.

data ProjectRole = Observer | Contributor | Lead
    deriving (Eq, Show)

data Project = Project
    { projectId :: Int
    , projectOwnerId :: Int
    , projectTitle :: Text
    }
    deriving (Eq, Show)

instance Ownable Project where
    ownerId = projectOwnerId

The actor context for a concrete user.

actor :: ActorContext ProjectRole
actor = ActorContext
    { actorId = 42
    , actorIsAdmin = False
    , actorOwnedRole = Just Contributor
    }

Policy computation and permission checks.

examplePolicy :: Policy
examplePolicy = computePolicy actor Lead

-- | True: Contributor policy allows viewing.
canView :: Bool
canView = canDoAction examplePolicy ViewAction

-- | False: Contributor policy does not allow deleting.
canDelete :: Bool
canDelete = canDoAction examplePolicy DeleteAction

Ownership overrides a weak policy.

ownedProject :: Project
ownedProject = Project
    { projectId = 1
    , projectOwnerId = 42
    , projectTitle = "Example"
    }

notOwnedProject :: Project
notOwnedProject = Project
    { projectId = 2
    , projectOwnerId = 99
    , projectTitle = "Other"
    }

-- | True: owner can delete their own project even with ReadOnly policy.
ownerCanDelete :: Bool
ownerCanDelete = canDoActionWithOwnership actor ownedProject ReadOnly DeleteAction

-- | False: non-owner cannot delete with ReadOnly policy.
strangerCannotDelete :: Bool
strangerCannotDelete = canDoActionWithOwnership actor notOwnedProject ReadOnly DeleteAction
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. The Ownable type class keeps the permission check generic and reusable across resource types.

Scoped Role Permission Registry

Raw source: Patterns/Actions/ScopedRolePermissionRegistry.lhs

Pattern intent and mechanism

Centralize pure role-based decisions for a single authorization scope.

Multiple domain actions share the same contextual role — for example, "the actor's subscription role in this project". The permission table is pure, exhaustive, and scope-aware, but it is not a global RBAC matrix.

This pattern sits between two extremes:

  • It is not PurePolicyControllerGate, which models one concrete resource type per policy type. ScopedRolePermissionRegistry covers multiple domain actions (create, manage, edit membership, write status) that are governed by the same contextual role.
  • It is not ActorTargetAuthGate, which derives authority from the actor's own record and checks ownership against a target. Ownership, target-specific invariants, and last-owner protection stay outside.
  • It is not PermissionAwareEnumControl, which renders UI controls. The registry produces booleans; views may feed them into controls or disabled action buttons.

The pattern has two layers:

  1. Role context — the actor, the value-level scope key, and the actor's role in that scope.
  2. Permission table — an exhaustive action ADT with a pure can function.

Layer 1 is extracted once per request, typically via a database query at the controller boundary that loads the actor's role for the current scope. Layer 2 — the permission table — is pure. The context value plus the permission table together form the pure decision core.

Use this pattern when:

  • several actions in the same scope depend on the same contextual role,
  • the role is local to the scope (not a global site-wide role),
  • ownership and target-specific checks are handled separately.
Project-specific notes and rollout guidance

Keep the permission ADT exhaustive. Add a constructor for every role-governed action. Pattern-match exhaustively in can; the compiler will warn when a new action is added but the permission table is not updated.

Name constructors precisely. When ownership provides a separate path for the same domain operation, distinguish them: EditRecordByRole for the role-based path, so that callers do not confuse it with an ownership-based EditOwnRecord gate. Local wrappers may keep a shorter domain name (EditRecord) if they compose both checks.

Keep the scope explicit. The simple variant uses a plain RolePermissionContext with a comment. The strong variant adds a phantom type parameter scope that prevents passing a RoleIn ProjectScope to a function expecting RoleIn TeamScope.

Important limitation: the phantom type disambiguates scope kinds, not scope instances. A RoleIn ProjectScope built for project A can still be reused for project B without a type error unless the context also carries the concrete scope key. The strong variant below carries a phantom-typed ScopeId scope; use canInScopeFor at authorization sites so the context scope is compared with the target scope.

Do not fold ownership into the registry. An actor may edit their own resource even when their role is weak. That check belongs in a separate ActorTargetAuthGate layer or in a local controller gate that composes the role decision with an ownership decision.

Do not fold target-specific invariants into the registry. Deleting the last admin, uniqueness checks, or child-count guards belong in their own gates. The registry answers "what can this role do?" not "what is safe to do to this target?"

Call the registry at the top of the controller action, before any database writes. Compose it with ownership checks at the same level:

accessDeniedUnless (canInScopeFor ctx targetScopeId EditRecordByRole || isOwnRecord actor target)

In views, use the pure predicate to decide what to render. Do not call throwing gates inside view helpers. If a view needs both role and ownership information, pass both booleans or a small view-model record.

Keep the hasRole helper general (accept a list of roles) so the permission table stays compact and readable:

can ctx CreateItem = isAdmin ctx || hasRole ctx [Lead, Member]

Presentation helpers.

Adjacent helpers such as isEngagedSubscriber ("does this user have any meaningful role in this scope?") are useful for UI salience decisions (badge styling, section visibility), but they are not permission checks. Keep them in the same module for locality, mark them with a comment, and do not confuse them with authority functions.

Name the local wrapper after the domain and the pattern: projectMemberCan, domainRolePermission, not just can.

Rollout guidance.

Migrate controller gates first, then view models.

  1. Add mkScopedRoleContext or mkScopedRoleContextIO and switch the first controller gate to canInScopeFor.
  2. Verify the gate still rejects cross-scope role reuse.
  3. Add deprecated pragmas to the old non-scope gates.
  4. Only after all controller gates use the scope-bound context, compute permission booleans in show/list controllers and pass them to views.
  5. Remove policy function calls from views once the controller passes precomputed decisions.

Changing views before controllers is risky: a view that receives a raw Maybe SubscriptionRole may still call the old permission function with the wrong scope.

Supporting snippets

Controller gate composing role registry with ownership:

ensureCanEditTargetRecord
    :: (?context :: ControllerContext, ...)
    => ProjectRolePermissionContext -> ScopeId ProjectScope -> User -> TargetRecord -> IO ()
ensureCanEditTargetRecord ctx targetScopeId user membership =
    let hasRolePermission = canInScopeFor ctx targetScopeId EditRecordByRole
        isOwner = get #userId membership == get #id user
    in accessDeniedUnless (hasRolePermission || isOwner)

Application authorization module: explicit export list and context builder. The raw constructor is not exported; callers build values through the smart constructor that validates the role belongs to the user and scope.

module Web.Authorization.Permissions
    ( Permission(..)
    , RolePermissionContext(..)
    , can
      -- Scope-bound context (constructor NOT exported)
    , ScopeId(..)
    , ScopedRolePermissionContext
    , canInScopeFor
    , mkScopedRoleContext
      -- Convenience wrappers
    , canCreateItemAsUser
    , canManageItemAsUser
      -- Presentation helpers (not authority)
    , isEngagedSubscriber
    ) where

Pure smart constructor that validates the subscription belongs to the actor and the target scope. Returns Nothing if the caller passed the wrong subscription record. This prevents pairing a role from scope A with scope B.

mkScopedRoleContext
    :: User -> ScopeId ProjectScope -> Maybe Subscription
    -> Maybe (ScopedRolePermissionContext ProjectScope)
mkScopedRoleContext user scopeId maybeSub =
    case maybeSub of
        Nothing ->
            Just ScopedRolePermissionContext
                { scopedActorUser = user
                , scopedScopeId = scopeId
                , scopedRoleInScope = RoleIn Nothing
                }
        Just sub ->
            let userMatches = get #userId sub == get #id user
                scopeMatches = get #scopeId sub == scopeId
            in if userMatches && scopeMatches
               then Just ScopedRolePermissionContext
                   { scopedActorUser = user
                   , scopedScopeId = scopeId
                   , scopedRoleInScope = RoleIn (Just (get #subscriptionRole sub))
                   }
               else Nothing

IO wrapper that loads the actor's subscription and delegates to the pure validating builder. The wrapper fetches the full subscription record so mkScopedRoleContext can verify user-id and scope-id match. Controllers import this helper, not the raw constructor.

mkProjectRoleContextFromIO
    :: (User -> ScopeId ProjectScope -> IO (Maybe Subscription))
    -> User -> ScopeId ProjectScope -> IO (Maybe ProjectRolePermissionContext)
mkProjectRoleContextFromIO fetchSub user scopeId = do
    maybeSub <- fetchSub user scopeId
    pure (mkScopedRoleContext user scopeId maybeSub)

-- Usage in controller:
--   maybeCtx <- mkProjectRoleContextFromIO fetchSubscription currentUser (ScopeId projectId)
--   case maybeCtx of
--       Just ctx  -> accessDeniedUnless (canInScopeFor ctx scopeId CreateEntity)
--       Nothing   -> accessDeniedUnless False

View helper using the pure predicate. Compose role permission with ownership; do not call throwing gates inside views.

renderEditButton :: ProjectRolePermissionContext -> ScopeId ProjectScope -> TargetRecord -> User -> Html
renderEditButton ctx targetScopeId membership currentUser =
    let permitted = canInScopeFor ctx targetScopeId EditRecordByRole
                 || get #targetUserId membership == get #id currentUser
    in if permitted
       then [hsx|<a href={...}>Edit</a>|]
       else [hsx|<span class="text-muted">Edit</span>|]

Presentation helper for UI salience. Not a permission check.

-- | Is this user engaged with the scope? Used for badge styling and
-- section visibility, not for authorization.
isEngagedSubscriber :: Maybe ScopedRole -> Bool
isEngagedSubscriber (Just Viewer) = False
isEngagedSubscriber Nothing       = False
isEngagedSubscriber _             = True
Core reusable pattern infrastructure

Simple variant: plain context with a comment denoting scope.

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Actions.ScopedRolePermissionRegistry
    ( User(..)
    , ScopedPermission(..)
    , ScopedRole(..)
    , RolePermissionContext(..)
    , ScopeId(..)
    , RoleIn
    , ScopedRolePermissionContext
    , actorIsAdmin
    , hasRole
    , can
    , actorIsAdminScoped
    , canInScope
    , canInScopeFor
    , hasRoleInScope
    , scopeMatches
    , hasAnyRoleInScope
    , isEngagedSubscriber
    , canCreateEntityAsUser
    , canManageEntityAsUser
    , canManageScopeAsUser
    , canWriteStatusAsUser
    , canEditRecordByRoleAsUser
    , ProjectScope
    , ProjectRolePermissionContext
    , mkProjectRoleContextFromMap
    , projectCtx
    , canCreate
    , cannotManageScope
    , adminCtx
    , adminCanManageScope
    , TargetRecord(..)
    , localCanEditTargetRecord
    ) where

import Prelude
import Data.Text (Text)
import IHP.ViewPrelude

-- | Example actor record. In real code this is the IHP-generated User record.
data User = User
    { userId :: Int
    , userIsAdmin :: Bool
    }
    deriving (Eq, Show)

-- | A permission action in a scoped role registry.
-- | Add one constructor per role-governed operation.
-- | Keep names precise: 'EditRecordByRole' not 'EditTargetRecord'
-- | when ownership provides a separate path.
data ScopedPermission
    = CreateEntity
    | ManageEntity
    | ManageScope
    | EditRecordByRole
    | WriteStatus
    deriving (Eq, Show)

-- | Generic role type for the simple variant.
-- | In application code this is usually a domain enum.
data ScopedRole = Lead | Member | Viewer
    deriving (Eq, Show)

-- | Simple role context. The scope is implied by call-site convention.
-- | Use this variant when there is only one scope in the codebase or
-- | when cross-scope confusion is unlikely.
data RolePermissionContext = RolePermissionContext
    { actorUser :: User
    , actorScopedRole :: Maybe ScopedRole
    }

Strong variant: phantom type parameter prevents mixing roles from different scopes.

-- | Value-level scope key tagged by scope kind. This lets call sites
-- | compare the context's concrete scope with the target scope without
-- | mixing different scope kinds.
newtype ScopeId scope = ScopeId Int
    deriving (Eq, Show)

-- | A role tied to a specific scope kind. The role is carried together
-- | with a value-level 'ScopeId' in 'ScopedRolePermissionContext'.
newtype RoleIn scope = RoleIn (Maybe ScopedRole)
    deriving (Eq, Show)

-- | Role context tagged by scope kind. The constructor is not exported;
-- | application code builds values through a smart constructor that
-- | loads the role from the correct scope and stores that concrete
-- | scope key.
data ScopedRolePermissionContext scope = ScopedRolePermissionContext
    { scopedActorUser :: User
    , scopedScopeId :: ScopeId scope
    , scopedRoleInScope :: RoleIn scope
    }
Helper implementation examples

Simple variant helpers.

-- | Admin bypass: always true regardless of subscription state.
-- | In real code this inspects a privilege field on the user record.
actorIsAdmin :: RolePermissionContext -> Bool
actorIsAdmin ctx = userIsAdmin (actorUser ctx)

-- | Check if the user's scoped role is any of the given roles.
hasRole :: RolePermissionContext -> [ScopedRole] -> Bool
hasRole ctx roles = maybe False (`elem` roles) (actorScopedRole ctx)

-- | Pure permission table for the simple variant.
-- | Exhaustive match; the compiler warns when a new constructor is added.
can :: RolePermissionContext -> ScopedPermission -> Bool
can ctx CreateEntity       = actorIsAdmin ctx || hasRole ctx [Lead, Member]
can ctx ManageEntity       = actorIsAdmin ctx || hasRole ctx [Lead, Member]
can ctx ManageScope        = actorIsAdmin ctx || hasRole ctx [Lead]
can ctx EditRecordByRole = actorIsAdmin ctx || hasRole ctx [Lead, Member]
can ctx WriteStatus        = actorIsAdmin ctx || hasRole ctx [Lead, Member]

Strong variant helpers.

-- | Admin bypass for the strong variant.
actorIsAdminScoped :: ScopedRolePermissionContext scope -> Bool
actorIsAdminScoped ctx = userIsAdmin (scopedActorUser ctx)

-- | Permission table for the strong variant.
-- | The scope type parameter ensures the role and the table belong to
-- | the same scope.
canInScope :: ScopedRolePermissionContext scope -> ScopedPermission -> Bool
canInScope ctx CreateEntity       = actorIsAdminScoped ctx || hasRoleInScope ctx [Lead, Member]
canInScope ctx ManageEntity       = actorIsAdminScoped ctx || hasRoleInScope ctx [Lead, Member]
canInScope ctx ManageScope        = actorIsAdminScoped ctx || hasRoleInScope ctx [Lead]
canInScope ctx EditRecordByRole = actorIsAdminScoped ctx || hasRoleInScope ctx [Lead, Member]
canInScope ctx WriteStatus        = actorIsAdminScoped ctx || hasRoleInScope ctx [Lead, Member]

-- | Check whether the context belongs to the target scope instance.
scopeMatches :: ScopedRolePermissionContext scope -> ScopeId scope -> Bool
scopeMatches ctx targetScopeId = scopedScopeId ctx == targetScopeId

-- | Permission check that also verifies the value-level target scope.
-- | Use this at authorization sites where the target scope is known.
canInScopeFor :: ScopedRolePermissionContext scope -> ScopeId scope -> ScopedPermission -> Bool
canInScopeFor ctx targetScopeId permission =
    scopeMatches ctx targetScopeId && canInScope ctx permission

hasRoleInScope :: ScopedRolePermissionContext scope -> [ScopedRole] -> Bool
hasRoleInScope ctx roles =
    let RoleIn maybeRole = scopedRoleInScope ctx
    in maybe False (`elem` roles) maybeRole

Convenience helper for view predicates.

-- | True when the actor holds any role in the scope, including admin.
-- | Useful for UI salience decisions (show section only to members).
-- | This is a presentation helper, not a permission check.
hasAnyRoleInScope :: ScopedRolePermissionContext scope -> Bool
hasAnyRoleInScope ctx =
    actorIsAdminScoped ctx
    || case scopedRoleInScope ctx of
        RoleIn (Just _) -> True
        RoleIn Nothing  -> False

Presentation helper: distinguish engaged from passive membership.

-- | True for members who are actively engaged, false for passive
-- observers. Used for badge styling and section visibility, not for
-- authorization decisions.
--
-- Keep this in the same module as the permission registry for locality,
-- but mark it as a view helper, not an authority function.
isEngagedSubscriber :: Maybe ScopedRole -> Bool
isEngagedSubscriber (Just Viewer) = False
isEngagedSubscriber Nothing       = False
isEngagedSubscriber _             = True

Convenience wrappers for common call patterns.

-- | Build a context and check 'CreateEntity' in one call.
-- | These wrappers are optional; they reduce boilerplate at call sites
-- | where the context is used once and discarded.
canCreateEntityAsUser :: User -> Maybe ScopedRole -> Bool
canCreateEntityAsUser user role = can (RolePermissionContext user role) CreateEntity

canManageEntityAsUser :: User -> Maybe ScopedRole -> Bool
canManageEntityAsUser user role = can (RolePermissionContext user role) ManageEntity

canManageScopeAsUser :: User -> Maybe ScopedRole -> Bool
canManageScopeAsUser user role = can (RolePermissionContext user role) ManageScope

canWriteStatusAsUser :: User -> Maybe ScopedRole -> Bool
canWriteStatusAsUser user role = can (RolePermissionContext user role) WriteStatus

canEditRecordByRoleAsUser :: User -> Maybe ScopedRole -> Bool
canEditRecordByRoleAsUser user role =
    can (RolePermissionContext user role) EditRecordByRole
Usage examples

Application-specific scope tag.

-- | Concrete scope declared in application code.
data ProjectScope

type ProjectRolePermissionContext = ScopedRolePermissionContext ProjectScope

Building a context in a controller.

-- | Pure constructor for pre-loaded maps, cached reads, or test
-- | fixtures. NOT for wrapping an already-loaded 'Maybe ScopedRole'
-- | in a lambda — that would recreate the detached-role hazard.
mkProjectRoleContextFromMap
    :: (User -> ScopeId ProjectScope -> Maybe ScopedRole)  -- ^ pure map lookup
    -> User
    -> ScopeId ProjectScope                                -- ^ scopeId
    -> ProjectRolePermissionContext
mkProjectRoleContextFromMap lookupRole user scopeId =
    ScopedRolePermissionContext
        { scopedActorUser = user
        , scopedScopeId = scopeId
        , scopedRoleInScope = RoleIn (lookupRole user scopeId)
        }

-- | Detached-role constructor (not exported). Used internally for
-- | examples where the role and scope key are already paired.
mkProjectRoleContext :: User -> ScopeId ProjectScope -> Maybe ScopedRole -> ProjectRolePermissionContext
mkProjectRoleContext user scopeId maybeRole = ScopedRolePermissionContext
    { scopedActorUser = user
    , scopedScopeId = scopeId
    , scopedRoleInScope = RoleIn maybeRole
    }

Permission checks.

-- | Example lookup for demonstration. In real code this queries the
-- | database or a membership table.
lookupProjectRole :: User -> ScopeId ProjectScope -> Maybe ScopedRole
lookupProjectRole (User 42 _) (ScopeId 7) = Just Member
lookupProjectRole (User 99 _) _           = Nothing
lookupProjectRole _           _           = Nothing

projectCtx :: ProjectRolePermissionContext
projectCtx = mkProjectRoleContextFromMap lookupProjectRole (User 42 False) (ScopeId 7)

-- | True: Member may create entities.
canCreate :: Bool
canCreate = canInScopeFor projectCtx (ScopeId 7) CreateEntity

-- | False: Member may not manage the scope itself.
cannotManageScope :: Bool
cannotManageScope = not (canInScopeFor projectCtx (ScopeId 7) ManageScope)

Admin bypass.

adminCtx :: ProjectRolePermissionContext
adminCtx = mkProjectRoleContextFromMap lookupProjectRole (User 99 True) (ScopeId 99)

-- | True: admin bypasses all role checks.
adminCanManageScope :: Bool
adminCanManageScope = canInScopeFor adminCtx (ScopeId 99) ManageScope

Composing with ownership in a local gate.

data TargetRecord = TargetRecord
    { targetUserId :: Int
    , targetStatus :: Text
    }
    deriving (Eq, Show)

localCanEditTargetRecord
    :: ProjectRolePermissionContext
    -> ScopeId ProjectScope
    -> User
    -> TargetRecord
    -> Bool
localCanEditTargetRecord ctx targetScopeId user membership =
    canInScopeFor ctx targetScopeId EditRecordByRole
    || targetUserId membership == userId user

Transactional Parent Delete

Raw source: Patterns/Actions/TransactionalParentDelete.lhs

Pattern intent and mechanism

Delete a parent entity after first removing child records that are owned by and meaningless without the parent. Keep the child cleanup and parent delete inside one explicit transaction so either the whole delete succeeds or no delete is committed.

This pattern is for dependent records that cannot exist meaningfully without their parent: metadata, cache rows, audit fragments, generated join rows, or parent-owned relations like subscriptions. These records may have their own unsubscribe/detach actions in normal operation, but after the parent is gone they have no purpose and can be cleaned up automatically.

The mechanism:

  1. Authorize the parent delete before the transaction.
  2. Reject blocking ACTION children that are fachlich independent from the parent or require separate user confirmation.
  3. Begin transaction for the cleanup and parent delete.
  4. Delete cleanup children in dependency order: start with leaves and work toward the parent. Each delete must succeed.
  5. Delete the parent only after all cleanup children are removed.
  6. Commit on success / rollback on failure. Any error in steps 4-5 aborts the whole operation. Success logging happens after the transaction returns.
Project-specific notes and rollout guidance

ACTION vs cleanup children. - Blocking ACTION children: Records that are fachlich independent from the parent or require separate user confirmation/authorization. Examples: Tasks belonging to a Project, Events belonging to a Seshn. These must be deleted by the user through their own workflow before the parent can be removed. - Cleanup children: Parent-owned dependent rows that have no meaningful existence after the parent is deleted, even if they have their own unsubscribe/detach actions in normal operation. Examples: Subscriptions, metadata caches, audit fragments, generated join rows. These are removed transactionally with the parent.

Ordering. Delete children in dependency order (leaves first). If child A has a foreign key to child B (A references B), delete A first, then B. Deleting B first causes foreign-key violations.

Idempotency. Cleanup inside the transaction should be safe to retry: if some children are already gone, continue with the remaining children. The normal controller path fetches the parent before the transaction for authorization; if you need true idempotent retry after the parent is already gone, wrap that fetch in optional handling and return a deliberate no-op success.

Method guards. This pattern is for POST or DELETE only. Never allow deletion via GET. Verify the method before fetching records, checking actionability, or opening the transaction.

Error handling. Return a generic failure message to the user; log specifics server-side. Do not expose which child cleanup failed.

Supporting snippets

Controller action with ACTION children that block deletion:

action DeleteProjectAction { projectId = projectId } = do
    unless (requestMethod request == "POST") do
        redirectTo ShowProjectAction { projectId = projectId }

    project <- fetch projectId
    accessDeniedUnless (canDeleteProject currentUser project)

    openTaskCount <- query @Task
        |> filterWhere (#projectId, projectId)
        |> fetchCount
    when (openTaskCount > 0) do
        setErrorMessage "Cannot delete: tasks are still attached"
        redirectTo ShowProjectAction { projectId = projectId }

    let transaction = withTransaction $ do
            deleteRecord project
    result <- try transaction :: IO (Either SomeException ())

    case result of
        Right () -> do
            Log.info $ "Deleted project " <> tshow projectId
            setSuccessMessage "Project deleted"
            redirectTo ProjectsAction
        Left err -> do
            Log.error $ "Failed to delete project " <> tshow projectId <> ": " <> tshow err
            setErrorMessage "Delete failed; no changes were made"
            redirectTo ShowProjectAction { projectId = projectId }

Controller action with cleanup children removed transactionally:

action DeleteProjectAction { projectId = projectId } = do
    unless (requestMethod request == "POST") do
        redirectTo ShowProjectAction { projectId = projectId }

    project <- fetch projectId
    accessDeniedUnless (canDeleteProject currentUser project)

    let transaction = withTransaction $ do
            -- Cleanup children: dependent rows meaningless without parent.
            -- Note: Individual deleteRecord calls are N+1 queries. For simple
            -- leaf rows without application hooks, prefer batched SQL
            -- (DELETE FROM ... WHERE project_id = ?) to reduce lock time.
            auditEntries <- query @ProjectAuditEntry
                |> filterWhere (#projectId, projectId)
                |> fetch
            forEach auditEntries deleteRecord

            cacheEntries <- query @ProjectCacheEntry
                |> filterWhere (#projectId, projectId)
                |> fetch
            forEach cacheEntries deleteRecord

            -- Finally delete the parent record fetched before the transaction.
            deleteRecord project
    result <- try transaction :: IO (Either SomeException ())

    case result of
        Right () -> do
            Log.info $ "Deleted project " <> tshow projectId
            setSuccessMessage "Project deleted"
            redirectTo ProjectsAction
        Left err -> do
            Log.error $ "Failed to delete project " <> tshow projectId <> ": " <> tshow err
            setErrorMessage "Delete failed; no changes were made"
            redirectTo ShowProjectAction { projectId = projectId }

Helper function for reusable cleanup:

deleteProjectWithCleanupChildren :: (?modelContext :: ModelContext) => Project -> IO ()
deleteProjectWithCleanupChildren project = withTransaction $ do
    let projectId = get #id project

    auditEntries <- query @ProjectAuditEntry
        |> filterWhere (#projectId, projectId)
        |> fetch
    forEach auditEntries deleteRecord

    cacheEntries <- query @ProjectCacheEntry
        |> filterWhere (#projectId, projectId)
        |> fetch
    forEach cacheEntries deleteRecord

    deleteRecord project
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
module Patterns.Actions.TransactionalParentDelete where

import Prelude
import Control.Exception (SomeException, try)
import Data.Text (Text)
import qualified Data.Text as Text
-- | Transaction boundary (stub for compilation).
withTransaction :: IO a -> IO a
withTransaction = id

-- | Logger stub.
logError :: Text -> IO ()
logError _ = pure ()
-- | Result type for transactional delete. Failure carries no details;
-- log specifics server-side before returning. This enforces opaque errors
-- to callers (generic "Delete failed" message to users).
data DeleteResult = DeleteSucceeded | DeleteFailed
    deriving (Eq, Show)

-- | Generic transactional parent delete pattern.
--
-- The helper reports only commit success or mutation failure. Post-commit
-- effects such as audit logging, success messages, and redirects belong in the
-- controller branch that handles 'DeleteSucceeded'.
transactionalParentDelete
    :: IO ()        -- ^ Delete cleanup children.
    -> IO ()        -- ^ Delete parent.
    -> IO DeleteResult
transactionalParentDelete deleteChildren deleteParent = do
    result <- try transaction :: IO (Either SomeException ())
    case result of
        Right () -> pure DeleteSucceeded
        Left err -> do
            logError ("Transactional parent delete failed: " <> tshow err)
            pure DeleteFailed
  where
    transaction :: IO ()
    transaction = withTransaction $ do
        deleteChildren
        deleteParent

    tshow :: Show a => a -> Text
    tshow = Text.pack . show
Helper implementation examples
newtype ProjectId = ProjectId Int
    deriving (Eq, Show)

data ProjectAuditEntry = ProjectAuditEntry
    { auditProjectId :: ProjectId
    }

data ProjectCacheEntry = ProjectCacheEntry
    { cacheProjectId :: ProjectId
    }

-- | Check whether user-facing children still block deletion.
checkProjectDeletable :: ProjectId -> IO Bool
checkProjectDeletable _projectId = pure True

-- | Delete cleanup children in leaf-to-parent order.
deleteProjectCleanupChildren :: ProjectId -> IO ()
deleteProjectCleanupChildren projectId = do
    deleteProjectAuditEntries projectId
    deleteProjectCacheEntries projectId

-- Stub child cleanup actions for compilation.
deleteProjectAuditEntries :: ProjectId -> IO ()
deleteProjectAuditEntries _projectId = pure ()

deleteProjectCacheEntries :: ProjectId -> IO ()
deleteProjectCacheEntries _projectId = pure ()
Usage examples

Use this pattern when the parent action owns cleanup of dependent rows:

result <- transactionalParentDelete
    (deleteProjectCleanupChildren projectId)
    (deleteRecord project)
case result of
    DeleteSucceeded -> do
        Log.info $ "Deleted project " <> tshow projectId
        setSuccessMessage "Project deleted"
        redirectTo ProjectsAction
    DeleteFailed -> do
        setErrorMessage "Delete failed; no changes were made"
        redirectTo ShowProjectAction { projectId = projectId }

Do not use it when children have their own action lifecycle:

openTaskCount <- query @Task
    |> filterWhere (#projectId, projectId)
    |> fetchCount
when (openTaskCount > 0) do
    setErrorMessage "Cannot delete: tasks are still attached"
    redirectTo ShowProjectAction { projectId = projectId }
Standalone checks

GHC type-check (no runtime):

direnv exec . ghci -v0 -ignore-dot-ghci -fno-code \
  Patterns/Actions/TransactionalParentDelete.lhs

Expected: clean type-check, no warnings.

See also
  • ScopedRolePermissionRegistry — actor privilege for the parent delete
  • LastGuardianProtection — invariant checks before destructive actions
  • NarrowPublicFillBoundary — method allowlists for state-changing actions

Wreq JSON Provider Request

Raw source: Patterns/Actions/WreqJsonProviderRequest.lhs

Pattern intent and mechanism

Build external provider HTTP requests with wreq inside the provider module and return decoded provider wire values through IntegrationResultBoundary. The controller or component should not know about wreq options, headers, auth, query parameters, response bodies, or raw provider URLs.

The request shape is:

  1. construct a provider-specific request record,
  2. build wreq Options from that record,
  3. run getWith or postWith inside a redacted timeout wrapper,
  4. reject non-2xx statuses before decoding,
  5. apply asJSON,
  6. extract responseBody into a provider wire type,
  7. translate the wire value at the provider module boundary.

wreq raises exceptions for common HTTP, network, and JSON decode failures. Those exceptions belong inside the integration boundary. They must not become a successful empty result, and they must not leak as framework exceptions past the provider helper. Cancellation and shutdown still follow IntegrationResultBoundary and are re-thrown.

Use this pattern when:

  • an external provider exposes JSON over HTTP,
  • a provider call needs query parameters, bearer auth, or basic auth,
  • a token endpoint uses form POST and a search endpoint uses JSON GET,
  • provider HTTP mechanics would otherwise spread into controllers or views.

Search aliases: wreq, HTTP client, JSON request, API request, API client, external API, provider request, bearer token, basic auth, OAuth token endpoint.

Project-specific notes and rollout guidance

Keep these helpers private to the provider module unless a second real provider shows the same request shape. The public provider surface should expose application-facing values, not Response, Options, or auth tokens.

Before implementing a host integration, read the provider documentation and record the concrete auth flow, token URL, resource URLs, required JSON fields, IHP routes/actions, credential environment variables, and downstream flake packages. The pattern supplies the shape; those values remain provider- and project-specific.

Use Text for provider-facing values in host code. Convert only at the wreq boundary: URLs become String, bearer headers and basic-auth credentials become ByteString, query parameters remain Text through param.

Provider URLs that carry credentials, bearer auth, or credential-bearing form values must use HTTPS. Use mkProviderUrl for runtime values. The request runners also re-check the URL immediately before getWith or postWith, so a misconfigured http:// endpoint fails before secrets can be sent. Redirects are disabled by default; otherwise credentials can be forwarded to a redirected host or downgraded URL by the HTTP client.

Do not derive Show for bearer tokens, client secrets, auth form parameters, query parameters, or records that contain them. If logs need correlation, log the provider name and operation, not raw secrets, full URLs, parameters, or full Authorization headers.

Keep required provider fields required in the FromJSON instance. A decode failure is provider unavailability or schema drift; it is not a no-hit search.

This pattern does not add token caching, retry policy, rate limiting, or provider-specific status mapping. Add those only from production evidence. The baseline is one explicit request boundary with timeout and typed decoding.

Downstream projects that instantiate this pattern need the HTTP stack in their project dependency set. In an IHP flake, add these to the host project's ihp.haskellPackages list, not to ordng itself unless ordng starts using wreq at runtime:

aeson
lens
network-uri
wreq

API viability notes for the extracted shape:

  • getWith :: Options -> String -> IO (Response ByteString)
  • postWith :: Postable a => Options -> String -> a -> IO (Response ByteString)
  • asJSON :: FromJSON a => Response ByteString -> IO (Response a)
Supporting snippets

Token POST shape:

fetchProviderToken =
    case mkProviderUrl "https://accounts.provider.example/token" of
        Nothing -> pure (IntegrationUnavailable (IntegrationFailed providerName "provider URL must use HTTPS"))
        Just tokenUrl -> wreqJsonFormPost providerName WreqJsonFormPostRequest
            { wreqJsonFormPostUrl = tokenUrl
            , wreqJsonFormPostBasicAuth = Just providerBasicAuth
            , wreqJsonFormPostParams = [ProviderFormParam "grant_type" "client_credentials"]
            , wreqJsonFormPostTimeoutMicros = 5000000
            }

Search GET shape:

fetchProviderSearch token query =
    case mkProviderUrl "https://api.provider.example/search" of
        Nothing -> pure (IntegrationUnavailable (IntegrationFailed providerName "provider URL must use HTTPS"))
        Just searchUrl -> wreqJsonGet providerName WreqJsonGetRequest
            { wreqJsonGetUrl = searchUrl
            , wreqJsonGetBearerToken = Just token
            , wreqJsonGetQueryParams =
                [ ProviderQueryParam "q" query
                , ProviderQueryParam "type" "track"
                , ProviderQueryParam "limit" "10"
                ]
            , wreqJsonGetTimeoutMicros = 5000000
            }
Core reusable pattern infrastructure
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Patterns.Actions.WreqJsonProviderRequest
    ( ProviderUrl
    , mkProviderUrl
    , ProviderBearerToken(..)
    , ProviderBasicAuth(..)
    , ProviderQueryParam(..)
    , ProviderFormParam(..)
    , WreqJsonGetRequest(..)
    , WreqJsonFormPostRequest(..)
    , wreqJsonGet
    , wreqJsonFormPost
    ) where

import Prelude
import Control.Exception
    ( SomeAsyncException
    , SomeException
    , fromException
    , throwIO
    , try
    )
import Control.Lens ((&), (.~), (?~), (^.))
import Data.Aeson (FromJSON(..), withObject, (.:))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Network.URI (URI(..), parseURI)
import Network.Wreq
    ( FormParam((:=))
    , Options
    , Response
    , asJSON
    , auth
    , basicAuth
    , defaults
    , getWith
    , header
    , param
    , postWith
    , redirects
    , responseBody
    , responseStatus
    , statusCode
    )
import Patterns.Actions.IntegrationResultBoundary
import System.Timeout (timeout)

-- | Provider URL kept as Text until the wreq boundary.
-- URLs may embed provider-specific credentials; do not derive 'Show'.
newtype ProviderUrl = ProviderUrl Text
    deriving (Eq)

-- | Construct a provider URL only when it uses HTTPS.
mkProviderUrl :: Text -> Maybe ProviderUrl
mkProviderUrl url
    | providerUrlIsHttps url = Just (ProviderUrl url)
    | otherwise = Nothing

providerUrlText :: ProviderUrl -> Text
providerUrlText (ProviderUrl url) = url

providerUrlIsHttps :: Text -> Bool
providerUrlIsHttps url =
    case parseURI (T.unpack url) of
        Just URI { uriScheme = "https:", uriAuthority = Just _ } -> True
        _ -> False

-- | Bearer token for provider requests. Do not derive 'Show'.
newtype ProviderBearerToken = ProviderBearerToken Text
    deriving (Eq)

-- | Basic-auth credentials for token endpoints. Do not derive 'Show'.
data ProviderBasicAuth = ProviderBasicAuth
    { providerBasicAuthClientId :: Text
    , providerBasicAuthClientSecret :: Text
    }
    deriving (Eq)

-- | One query parameter for a provider GET request.
-- Parameter values may contain API keys or other secrets; do not derive 'Show'.
data ProviderQueryParam = ProviderQueryParam Text Text
    deriving (Eq)

-- | One form parameter for a provider POST request.
-- Parameter values may contain client secrets or refresh tokens; do not derive 'Show'.
data ProviderFormParam = ProviderFormParam Text Text
    deriving (Eq)

-- | JSON GET request against a provider endpoint.
data WreqJsonGetRequest = WreqJsonGetRequest
    { wreqJsonGetUrl :: ProviderUrl
    , wreqJsonGetBearerToken :: Maybe ProviderBearerToken
    , wreqJsonGetQueryParams :: [ProviderQueryParam]
    , wreqJsonGetTimeoutMicros :: Int
    }
    deriving (Eq)

-- | Form POST request that expects a JSON response.
data WreqJsonFormPostRequest = WreqJsonFormPostRequest
    { wreqJsonFormPostUrl :: ProviderUrl
    , wreqJsonFormPostBasicAuth :: Maybe ProviderBasicAuth
    , wreqJsonFormPostParams :: [ProviderFormParam]
    , wreqJsonFormPostTimeoutMicros :: Int
    }
    deriving (Eq)

The option builders keep header/auth/param construction local and explicit. They disable redirects before adding credentials or parameters.

wreqBaseOptions :: Options
wreqBaseOptions = defaults & redirects .~ 0

wreqGetOptions :: WreqJsonGetRequest -> Options
wreqGetOptions WreqJsonGetRequest
        { wreqJsonGetBearerToken = maybeBearerToken
        , wreqJsonGetQueryParams = queryParams
        } = addBearer maybeBearerToken (foldl addQueryParam wreqBaseOptions queryParams)
  where
    addQueryParam :: Options -> ProviderQueryParam -> Options
    addQueryParam options (ProviderQueryParam name value) =
        options & param name .~ [value]

    addBearer :: Maybe ProviderBearerToken -> Options -> Options
    addBearer Nothing options = options
    addBearer (Just (ProviderBearerToken token)) options =
        options & header "Authorization" .~ [encodeUtf8 ("Bearer " <> token)]

wreqFormPostOptions :: WreqJsonFormPostRequest -> Options
wreqFormPostOptions WreqJsonFormPostRequest
        { wreqJsonFormPostBasicAuth = maybeBasicAuth
        } = addBasicAuth maybeBasicAuth wreqBaseOptions
  where
    addBasicAuth :: Maybe ProviderBasicAuth -> Options -> Options
    addBasicAuth Nothing options = options
    addBasicAuth (Just ProviderBasicAuth
            { providerBasicAuthClientId = clientId
            , providerBasicAuthClientSecret = clientSecret
            }) options =
        options & auth ?~ basicAuth (encodeUtf8 clientId) (encodeUtf8 clientSecret)

The request runners wrap wreq exceptions and JSON decode failures at the provider boundary. They return decoded provider wire values, not raw responses. They check for a 2xx status before asJSON, because a provider error body may still match the expected JSON schema. Because wreq exception text can include URLs, parameters, or header context, the wrapper uses a redacted failure detail instead of displayException.

wreqJsonGet
    :: FromJSON response
    => Text
    -> WreqJsonGetRequest
    -> IO (IntegrationResult response)
wreqJsonGet provider request@WreqJsonGetRequest
        { wreqJsonGetUrl = providerUrl
        , wreqJsonGetTimeoutMicros = timeoutMicros
        } = case requireHttpsProviderUrl provider providerUrl of
    Left err -> pure (IntegrationUnavailable err)
    Right url -> wreqJsonTryWithTimeout provider "GET JSON request" timeoutMicros do
        response <- getWith (wreqGetOptions request) (T.unpack url)
        case requireSuccessfulResponse provider "GET JSON request" response of
            Left err -> pure (Left err)
            Right successfulResponse -> do
                decodedResponse <- asJSON successfulResponse
                pure (Right (decodedResponse ^. responseBody))

wreqJsonFormPost
    :: FromJSON response
    => Text
    -> WreqJsonFormPostRequest
    -> IO (IntegrationResult response)
wreqJsonFormPost provider request@WreqJsonFormPostRequest
        { wreqJsonFormPostUrl = providerUrl
        , wreqJsonFormPostParams = formParams
        , wreqJsonFormPostTimeoutMicros = timeoutMicros
        } = case requireHttpsProviderUrl provider providerUrl of
    Left err -> pure (IntegrationUnavailable err)
    Right url -> wreqJsonTryWithTimeout provider "form POST JSON request" timeoutMicros do
        response <- postWith (wreqFormPostOptions request) (T.unpack url) (toFormParams formParams)
        case requireSuccessfulResponse provider "form POST JSON request" response of
            Left err -> pure (Left err)
            Right successfulResponse -> do
                decodedResponse <- asJSON successfulResponse
                pure (Right (decodedResponse ^. responseBody))

requireHttpsProviderUrl :: Text -> ProviderUrl -> Either IntegrationError Text
requireHttpsProviderUrl provider providerUrl
    | providerUrlIsHttps url = Right url
    | otherwise = Left (IntegrationFailed provider "provider URL must use HTTPS")
  where
    url = providerUrlText providerUrl

requireSuccessfulResponse :: Text -> Text -> Response body -> Either IntegrationError (Response body)
requireSuccessfulResponse provider operation response
    | statusIsSuccessful status = Right response
    | otherwise = Left (IntegrationFailed provider ("redacted wreq " <> operation <> " returned non-2xx status"))
  where
    status = response ^. responseStatus . statusCode

statusIsSuccessful :: Int -> Bool
statusIsSuccessful status = status >= 200 && status < 300

wreqJsonTryWithTimeout :: Text -> Text -> Int -> IO (Either IntegrationError a) -> IO (IntegrationResult a)
wreqJsonTryWithTimeout provider operation timeoutMicros action = do
    result <- try (timeout timeoutMicros action)
    case result of
        Right (Just (Right value)) -> pure (IntegrationSucceeded value)
        Right (Just (Left err)) -> pure (IntegrationUnavailable err)
        Right Nothing -> pure (IntegrationUnavailable (IntegrationTimedOut provider timeoutMicros))
        Left (exception :: SomeException) -> wreqJsonException provider operation exception

wreqJsonException :: Text -> Text -> SomeException -> IO (IntegrationResult a)
wreqJsonException provider operation exception
    | Just (_ :: SomeAsyncException) <- fromException exception = throwIO exception
    | otherwise = pure (IntegrationUnavailable (IntegrationFailed provider redactedFailure))
  where
    redactedFailure = "redacted wreq " <> operation <> " failed"

toFormParams :: [ProviderFormParam] -> [FormParam]
toFormParams providerFormParams =
    map toFormParam providerFormParams
  where
    toFormParam :: ProviderFormParam -> FormParam
    toFormParam (ProviderFormParam name value) = encodeUtf8 name := value
Helper implementation examples

A token response wire type keeps the provider's JSON shape near its decoder.

newtype ProviderTokenResponse = ProviderTokenResponse
    { providerTokenAccessToken :: Text
    }
    deriving (Eq)

instance FromJSON ProviderTokenResponse where
    parseJSON = withObject "ProviderTokenResponse" $ \object ->
        ProviderTokenResponse <$> object .: "access_token"

A search response wire type models only fields needed by the first application surface. Required fields use required decoding.

newtype ProviderSearchResponse = ProviderSearchResponse
    { providerSearchItems :: [ProviderSearchItem]
    }
    deriving (Show, Eq)

instance FromJSON ProviderSearchResponse where
    parseJSON = withObject "ProviderSearchResponse" $ \object ->
        ProviderSearchResponse <$> object .: "items"

data ProviderSearchItem = ProviderSearchItem
    { providerSearchItemTitle :: Text
    , providerSearchItemUrl :: Text
    }
    deriving (Show, Eq)

instance FromJSON ProviderSearchItem where
    parseJSON = withObject "ProviderSearchItem" $ \object ->
        ProviderSearchItem
            <$> object .: "title"
            <*> object .: "url"

Provider-local constants and request builders are private implementation detail.

exampleProviderName :: Text
exampleProviderName = "ExampleProvider"

exampleTimeoutMicros :: Int
exampleTimeoutMicros = 5000000

exampleTokenRequest :: ProviderBasicAuth -> Maybe WreqJsonFormPostRequest
exampleTokenRequest credentials = do
    tokenUrl <- mkProviderUrl "https://accounts.provider.example/token"
    pure WreqJsonFormPostRequest
        { wreqJsonFormPostUrl = tokenUrl
        , wreqJsonFormPostBasicAuth = Just credentials
        , wreqJsonFormPostParams = [ProviderFormParam "grant_type" "client_credentials"]
        , wreqJsonFormPostTimeoutMicros = exampleTimeoutMicros
        }

exampleSearchRequest :: ProviderBearerToken -> Text -> Maybe WreqJsonGetRequest
exampleSearchRequest token query = do
    searchUrl <- mkProviderUrl "https://api.provider.example/search"
    pure WreqJsonGetRequest
        { wreqJsonGetUrl = searchUrl
        , wreqJsonGetBearerToken = Just token
        , wreqJsonGetQueryParams =
            [ ProviderQueryParam "q" query
            , ProviderQueryParam "type" "track"
            , ProviderQueryParam "limit" "10"
            ]
        , wreqJsonGetTimeoutMicros = exampleTimeoutMicros
        }

The provider helper composes token POST and search GET without exporting wreq mechanics.

searchProviderViaWreq
    :: ProviderBasicAuth
    -> Text
    -> IO (IntegrationResult [ProviderSearchItem])
searchProviderViaWreq credentials query =
    case exampleTokenRequest credentials of
        Nothing -> pure invalidProviderUrlResult
        Just tokenRequest -> do
            tokenResult <- wreqJsonFormPost exampleProviderName tokenRequest
            case tokenResult of
                IntegrationUnavailable err -> pure (IntegrationUnavailable err)
                IntegrationSucceeded ProviderTokenResponse { providerTokenAccessToken = accessToken } ->
                    case exampleSearchRequest (ProviderBearerToken accessToken) query of
                        Nothing -> pure invalidProviderUrlResult
                        Just searchRequest -> do
                            searchResult <- wreqJsonGet exampleProviderName searchRequest
                            case searchResult of
                                IntegrationUnavailable err -> pure (IntegrationUnavailable err)
                                IntegrationSucceeded ProviderSearchResponse { providerSearchItems = items } ->
                                    pure (IntegrationSucceeded items)

invalidProviderUrlResult :: IntegrationResult a
invalidProviderUrlResult =
    IntegrationUnavailable (IntegrationFailed exampleProviderName "provider URL must use HTTPS")
Usage examples

Callers see provider results through IntegrationResult; they do not see wreq responses.

renderProviderSearchItems :: IntegrationResult [ProviderSearchItem] -> Text
renderProviderSearchItems (IntegrationSucceeded items) =
    T.intercalate ", " (map providerSearchItemTitle items)
renderProviderSearchItems (IntegrationUnavailable (IntegrationDisabled _provider)) =
    ""
renderProviderSearchItems (IntegrationUnavailable (IntegrationTimedOut provider _timeoutMicros)) =
    "provider timed out: " <> provider
renderProviderSearchItems (IntegrationUnavailable (IntegrationFailed provider _details)) =
    "provider unavailable: " <> provider

Request construction is explicit and grepable at the provider boundary.

exampleCredentials :: ProviderBasicAuth
exampleCredentials = ProviderBasicAuth
    { providerBasicAuthClientId = "client-id"
    , providerBasicAuthClientSecret = "client-secret"
    }

exampleConfiguredTokenRequest :: Maybe WreqJsonFormPostRequest
exampleConfiguredTokenRequest = exampleTokenRequest exampleCredentials

exampleConfiguredSearchRequest :: Maybe WreqJsonGetRequest
exampleConfiguredSearchRequest = exampleSearchRequest (ProviderBearerToken "token") "search text"
Standalone checks

Not applicable in this pattern; sections 4, 5, and 6 compile standalone.

Bootstrap

Bootstrap Patterns

Raw source: Patterns/Bootstrap/README.md

Index and reference for project bootstrap patterns in this directory.

This topic covers the stable delta that brings a freshly generated IHP project onto the ordng baseline after ihp-new.

For step-by-step orchestration of the full bootstrap flow, see the Bootstrap recipe.

Candidate Pattern Set

Start from a fresh ihp-new result and keep only repeatable changes. Current extraction focus:

  • post-ihp-new flake.nix delta for the ordng baseline
  • deployment handoff to ihp-deployment-manager
  • agentic bootstrap handoff to pi
  • processing upstream IHP changes without losing the ordng canon

Scope

Use this topic only for changes that recur across projects and still make sense when compared against fresh generator output. Keep generator defaults unchanged unless there is a stable, reusable reason to change them.

This topic does not cover project-local pre-start hooks, unstable environment tweaks, or generator-provided CI defaults. Agent and review harness bootstrap is owned by pi; this topic may only add small handoff notes that point there.

This topic is mandatory only for the fully opinionated ordng baseline: follow the described bootstrap stack, including the companion repo ihp-deployment-manager, and the setup should match the intended ordng path. Projects that bootstrap or deploy their IHP application differently can still use the other ordng pattern topics without restriction.

Do not replicate external companion documentation here. ihp-deployment-manager remains the canonical place for its own deployment and operations guidance; ordng should point to it where relevant, not duplicate it. If a project follows this bootstrap path, its local AGENTS.md should point the agent to the canonical companion docs and record only project-local machine mapping, commands, and confirmation rules.

Map

Use map.md for the navigation entrypoint for this topic. The map diagram is a left-to-right inline D2 block in that Markdown file. The individual pattern files remain canonical for the meaning of each step.

For upstream-framework maintenance of the ordng baseline, see IhpUpstreamUpdates.md.

Pattern Shape

.lhs files in this topic follow the seven-section pattern skeleton from ../ARCHITECTURE.md.

Map

Raw source: Patterns/Bootstrap/map.md

This map is a visual navigation aid for the bootstrap topic. It shows which path to take depending on project state: new project, drifted existing project, or upstream IHP changes.

Maps navigate; they have branches, not numbered steps. For step-by-step orchestration, see the Bootstrap recipe.

Diagram
StartordngbaselinealignmentWho ismoving?NewprojectDrift /BrownfieldIHP upstreammovedInspectagainstordng baselineUpstreamchangesin drift?FollowIhpUpstreamUpdates.mdRoll outbootstrappatternsUpdatelocalrollout docsBaselinealigned new projectexisting projectyesnoupstream moved

Agentic Bootstrap Handoff

Raw source: Patterns/Bootstrap/AgenticHandoff.md

This note exists so bootstrap does not silently ignore the agentic part of the working setup.

Agent harness compatibility

ordng is harness agnostic: any agentic coding tooling can adopt ordng without special ceremony as long as the canonical project knowledge remains in normal repository docs rather than hidden prompt files.

Canonical agent guidance lives in AGENTS.md, CLAUDE.md is included as a thin compatibility shim, and for downstream projects adopting ordng patterns, see AGENTS_TEMPLATE.md.

Agentic baseline handoff

The canonical agentic bootstrap baseline does not live in ordng. It lives in the project's chosen agent harness.

Use this topic only to record that a fully opinionated ordng-style project usually also rolls out a small agentic baseline, for example:

  • AGENTS.md — agent runtime configuration
  • optional CLAUDE.md — compatibility shim
  • REVIEW_GUIDELINES.md — review conventions

Keep the ownership split explicit:

  • ordng owns reusable IHP and project-structure patterns.
  • The agent harness owns runtime, review integration, model/provider choices, and related bootstrap conventions.

For reference, a typical harness might provide:

  • runtime entrypoint, for example ~/.pi/agent/AGENTS.md
  • shared context, for example <harness-repo>/context/
  • architecture docs, for example <harness-repo>/docs/ARCHITECTURE.md
  • review templates, for example <harness-repo>/docs/REVIEW_GUIDELINES_TEMPLATE.md
  • task-specific guidance, for example <harness-repo>/skills/

A harness also provides the skills that make an agent capable of working with ordng in the first place: Haskell/IHP and HTMX proficiency. ordng does not provide these skills — they belong to the harness layer. An agent must already know Haskell, IHP, and HTMX before it can use ordng patterns effectively.

Quality management is the same ownership split. ordng owns the canonical quality lenses in docs/ordng/quality-management.md; the harness owns how those lenses enter review context.

For examples of such skills, see:

Do not duplicate those rules here. Use local project files only to instantiate the chosen agent baseline for the repo at hand.

Review quality-lens handoff

If the chosen harness has configurable review tooling, wire the canonical ordng quality-lens document into code review for ordng work and for host-project work that claims to apply ordng patterns.

The review should read docs/ordng/quality-management.md and apply only lenses that touch the changed pattern, feature, or host flow. The lenses are not a second generic checklist; they are a guardrail for the reviewer to ask whether the change is complete as a pattern or feature contract.

If the harness cannot preload review context, bring the QM document into the reviewing session manually before running review.

Recommended review behavior:

  • mention only applicable ordng quality lenses;
  • apply the simplicity/complexity-budget lens after correctness, safety, accessibility, and other concrete obligations are satisfied;
  • flag findings only when a lens exposes a concrete contract defect;
  • keep non-applicable lenses out of the review output;
  • route pattern-contract feedback back to ordng patterns or recipes;
  • route host-specific deviations to the host project;
  • do not duplicate the full QM document inside harness prompts.

This preserves the boundary: ordng owns the canonical quality semantics, the harness owns review execution.

Pattern conformance handoff

When an implementation claims to apply ordng patterns, the host project may run a separate pattern-conformance review after local work compiles and domain behavior is settled.

That review is a handoff back to ordng as pattern library context. Its scope is narrow:

  • verify that the named patterns were applied correctly,
  • flag missing edge cases or undocumented adaptations,
  • identify feedback that should refine an ordng pattern,
  • avoid re-reviewing unrelated domain logic or local compilation details.

This is not a separate ordng workflow engine. The agent harness decides how to run the review, which model or provider to use, and how findings are transferred back to the host project.

Typical review inputs:

  • code snapshot or Git diff,
  • list of ordng patterns used,
  • relevant domain constraints only where they affect pattern application,
  • known deliberate deviations.

Typical review output:

  • pattern reference, including file and section where possible,
  • problem description,
  • suggested fix or documentation note,
  • priority.

Findings should be applied deliberately by the host project. Do not auto-apply review suggestions merely because they look mechanical; even small edits can encode a wrong pattern interpretation.

Deployment Handoff

Raw source: Patterns/Bootstrap/DeploymentHandoff.lhs

Pattern intent and mechanism

Keep deployment workflow out of the application repo when the project follows the fully opinionated ordng deployment path.

The application repo should not duplicate the operational runbook. Instead, its local AGENTS.md should hand deployment work off to the companion repository ihp-deployment-manager.

This file records that handoff as an ordng bootstrap concern, but the canonical deployment workflow lives in ihp-deployment-manager, not here.

The local repo keeps only deployment facts that are genuinely local to the app, such as machine mapping, deploy entrypoints, or confirmation rules that differ locally.

Project-specific notes and rollout guidance

This pattern only applies when the project actually uses the companion deployment stack.

If the project deploys differently, omit this handoff and document the real local workflow instead.

When the handoff does apply, keep the asymmetry explicit:

  • the application repo owns local app facts,
  • ihp-deployment-manager owns the reusable deployment workflow,
  • pi similarly owns the agentic bootstrap baseline,
  • local docs should point to those canonical homes rather than restating them.
Supporting snippets

Typical local AGENTS.md deployment section:

  • name the section Deployment workflow,
  • point first to <paths.repos.ihp-deployment-manager>/AGENTS.md,
  • point second to <paths.repos.ihp-deployment-manager>/DEPLOYMENT_README.md,
  • note that Pi resolves <paths...> via ~/.pi/agent/paths.json,
  • keep only machine mapping, deploy entrypoints, and confirmation rules local.

Companion-side ownership stays explicit:

  • AGENTS.md in ihp-deployment-manager defines the assisted secure workflow,
  • DEPLOYMENT_README.md is for troubleshooting and exceptional cases.
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Bootstrap.DeploymentHandoff where

import Data.Text (Text)

newtype CompanionDocPath = CompanionDocPath FilePath
    deriving (Eq, Show)

data LocalDeploymentFact
    = MachineMapping
    | DeployEntrypoint
    | ConfirmationRule
    deriving (Eq, Show)

data DeploymentHandoff = DeploymentHandoff
    { workflowDoc :: CompanionDocPath
    , troubleshootingDoc :: CompanionDocPath
    , localFactsToKeep :: [LocalDeploymentFact]
    }
    deriving (Eq, Show)

ordngDeploymentHandoff :: DeploymentHandoff
ordngDeploymentHandoff =
    DeploymentHandoff
        { workflowDoc = CompanionDocPath "<paths.repos.ihp-deployment-manager>/AGENTS.md"
        , troubleshootingDoc = CompanionDocPath "<paths.repos.ihp-deployment-manager>/DEPLOYMENT_README.md"
        , localFactsToKeep =
            [ MachineMapping
            , DeployEntrypoint
            , ConfirmationRule
            ]
        }
Helper implementation examples

One small helper can render the fact categories that stay local.

localDeploymentFactLabel :: LocalDeploymentFact -> Text
localDeploymentFactLabel fact = case fact of
    MachineMapping -> "machine names and host mapping"
    DeployEntrypoint -> "project-specific deploy entrypoints or app names"
    ConfirmationRule -> "explicit confirmation rules if they differ locally"
Usage examples

A project can instantiate the handoff directly and derive the local checklist from it.

exampleDeploymentHandoff :: DeploymentHandoff
exampleDeploymentHandoff = ordngDeploymentHandoff

exampleLocalDeploymentChecklist :: [Text]
exampleLocalDeploymentChecklist =
    map localDeploymentFactLabel (localFactsToKeep exampleDeploymentHandoff)

Flake Delta

Raw source: Patterns/Bootstrap/FlakeDelta.lhs

Pattern intent and mechanism

Capture the stable flake.nix delta that ordng-style projects apply after ihp-new.

The point is not to rewrite the generated flake aggressively. The point is to keep the generator output as the baseline, then record only the changes that recur across projects.

Current stable delta:

  • set ihp.appName to the real project name,
  • keep cabal-install in ihp.haskellPackages,
  • remove the generator's local NixOS deployment scaffolding when deployment is handed off to the companion repo,
  • leave generated .envrc and start alone unless a separate reusable need appears.
Project-specific notes and rollout guidance

This pattern is based on the recurring delta seen in ordng-style projects when compared against a fresh ihp-new result.

The important boundary is negative as much as positive:

  • do not treat project-local pre-start hooks as bootstrap baseline,
  • do not treat unstable dotenv tweaks as bootstrap baseline,
  • do not treat generator-provided GitHub CI as ordng bootstrap baseline.

When rolling out this pattern in Pi, load the Nix skill first: <paths.repos.pi>/skills/nix/SKILL.md.

If a project follows the companion deployment path, the generated flake.nixosConfigurations block should not become the long-term deployment home for the app repo. Remove the corresponding generator-produced deployment files as well so the app repo does not present a second, stale deployment home. That concern belongs with the deployment handoff pattern.

Supporting snippets

Fresh ihp-new includes local deployment scaffolding in flake.nix:

inputs = {
    ihp.url = "github:digitallyinduced/ihp/v1.5";
    nixpkgs.follows = "ihp/nixpkgs";
    nixpkgs-nixos.follows = "ihp/nixpkgs-nixos";
    ...
};

outputs = inputs@{ self, nixpkgs, nixpkgs-nixos, ihp, flake-parts, systems, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
        ...

        flake.nixosConfigurations."production" = import ./Config/nix/hosts/production/host.nix { inherit inputs; };
    };

The recurring ordng-side delta is smaller:

inputs = {
    ihp.url = "github:digitallyinduced/ihp/v1.5";
    nixpkgs.follows = "ihp/nixpkgs";
    ...
};

outputs = inputs@{ self, nixpkgs, ihp, flake-parts, systems, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
        ...

        ihp = {
            appName = "my-project";
            ...
            haskellPackages = p: with p; [
                p.ihp
                cabal-install
                base
                wai
                text
            ];
        };
    };
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Bootstrap.FlakeDelta where

import Data.Text (Text)

data FlakeDeltaRule
    = SetRealAppName Text
    | AddCabalInstallToHaskellPackages
    | DropGeneratorNixpkgsNixosInput
    | DropGeneratorLocalNixosConfiguration
    | DropGeneratorLocalNixosFiles
    deriving (Eq, Show)

data BootstrapFlakeDelta = BootstrapFlakeDelta
    { preservedGeneratorFiles :: [FilePath]
    , flakeDeltaRules :: [FlakeDeltaRule]
    }
    deriving (Eq, Show)

ordngBootstrapFlakeDelta :: Text -> BootstrapFlakeDelta
ordngBootstrapFlakeDelta appName =
    BootstrapFlakeDelta
        { preservedGeneratorFiles = [".envrc", "start"]
        , flakeDeltaRules =
            [ SetRealAppName appName
            , AddCabalInstallToHaskellPackages
            , DropGeneratorNixpkgsNixosInput
            , DropGeneratorLocalNixosConfiguration
            , DropGeneratorLocalNixosFiles
            ]
        }
Helper implementation examples
flakeDeltaRuleSummary :: FlakeDeltaRule -> Text
flakeDeltaRuleSummary rule = case rule of
    SetRealAppName appName -> "set ihp.appName to " <> appName
    AddCabalInstallToHaskellPackages -> "keep cabal-install in ihp.haskellPackages"
    DropGeneratorNixpkgsNixosInput -> "drop nixpkgs-nixos from flake inputs"
    DropGeneratorLocalNixosConfiguration -> "drop local flake.nixosConfigurations deployment block"
    DropGeneratorLocalNixosFiles -> "drop generator-produced Config/nix deployment files"
Usage examples
exampleOrdngDelta :: BootstrapFlakeDelta
exampleOrdngDelta = ordngBootstrapFlakeDelta "ordng"

exampleDeltaSummary :: [Text]
exampleDeltaSummary = map flakeDeltaRuleSummary (flakeDeltaRules exampleOrdngDelta)

IHP Upstream Updates

Raw source: Patterns/Bootstrap/IhpUpstreamUpdates.md

How upstream IHP changes enter the ordng canon before they are rolled out to projects.

The order is strict: update the ordng Bootstrap patterns first, apply the result to the ordng IHP host application as the first implementation check, feed discoveries back into the canon, and only then update downstream projects.

Scope

Use this when:

  • the local ihp-upstream clone is updated
  • upstream CHANGELOG.md indicates IHP changes relevant to ordng
  • ordng bootstrap assumptions no longer match current generator output
  • a downstream project needs to move to a newer IHP baseline
  • upstream CLAUDE.md or other maintainer guidance changes

This pattern does not cover initial project bootstrap. For bringing a fresh ihp-new project onto the ordng baseline, see FlakeDelta.lhs and the main bootstrap workflow. A downstream project update should enter here only after the ordng canon has been checked and updated first.

Canonical Sources

Primary inspection surface:

  • <paths.repos.ihp-upstream> from ~/.pi/agent/paths.json

Read first:

Then follow the changelog's pointers, and dig deeper only when something breaks or behaves unexpectedly:

  • relevant framework source modules
  • local docs and guide pages
  • generated-project deltas where applicable
  • agent guidance files in the upstream repo (e.g. CLAUDE.md)
Classification

Before applying anything, classify each meaningful delta:

  • generator or bootstrap delta
  • framework API or convention change
  • documentation or maintainer-guidance change
  • upstream improvement that supersedes a local workaround
  • new reusable opportunity for ordng
  • purely local difference that should stay local

Do not flatten these into one mixed "update" bucket.

Decision

First assess scope:

  • Small — isolated change, no breaking surface, clear boundary. Implement directly.
  • Large — spans multiple areas, breaking changes, or needs intermediate verification. Write a plan and stage the work.

Then for each delta, decide explicitly:

  • adopt as-is
  • ignore
  • adapt locally
  • replace an existing ordng convention
  • extract a new ordng pattern
  • delete local code because upstream now has the better answer

When project rollout reveals reusable structure, update canonical ordng docs and pattern artifacts first, then continue the rollout. Do not silently patch a project and leave the canon behind.

Workflow
  1. Update or inspect <paths.repos.ihp-upstream>.
  2. Follow the changelog-first source order from Canonical sources.
  3. Update the ordng Bootstrap canon.
  4. Apply the updated canon to the ordng IHP host application.
  5. Feed discoveries from that implementation pass back into the canon.
  6. Only then update downstream projects.

For the ordng Bootstrap canon update:

  1. Compare upstream changes against current Bootstrap patterns.
  2. Compare against fresh ihp-new output when generator assumptions are affected; only the generated code is needed, no build.
  3. Classify each meaningful delta.
  4. Update Bootstrap docs and pattern material.
  5. Run the smallest authoritative checks for the touched pattern surface.
  6. Record what was learned, adopted, rejected, or promoted into canon.

For the ordng IHP host application pass:

  1. Roll out the updated Bootstrap canon to the ordng IHP host application.
  2. Run the smallest authoritative checks for the touched application surface.
  3. If the rollout exposes missing reusable structure, update Bootstrap docs and pattern material before continuing.
  4. Record host-application changes in ordng's CHANGELOG.md.

For a downstream project update:

  1. Confirm that the ordng canon has already been updated and checked against the ordng IHP host application.
  2. Assess project drift. If the project has drifted from the documented ordng canon through undocumented local changes, partial migrations, or stale workarounds, document that drift first.
  3. Compare the project against the updated ordng canon.
  4. Classify each meaningful local delta.
  5. Align the project.
  6. Run the smallest authoritative checks for the touched surface.
  7. Record what was updated in the project's own CHANGELOG.md.

Use normal CHANGELOG.md files for these records. No separate specialized changelog is needed.

Skills

When code changes are required, load the relevant language-specific skills before modifying pattern or application code. The host project's AGENTS.md should reference the correct skill names for its stack.

Verification

Stay close to the changed surface:

  • load affected modules in ghci
  • run explicit pattern checks for touched .lhs files
  • inspect generator output when bootstrap assumptions are involved
  • reload browser or dev server when rendered behavior is affected

Prefer small authoritative checks over broad ritual rebuilds.

What Becomes Pattern Material

Promote into ordng when the update reveals:

  • a stable post-ihp-new delta that recurs across projects
  • a reusable abstraction boundary above raw IHP
  • a better convention for applying or checking upstream changes
  • a repeated classification or migration decision that should stop living as oral tradition

Keep project-local machine details, one-off breakage notes, and temporary compatibility hacks out of the canon unless they have clearly stabilized.

Companion Repos

Do not duplicate companion-repo documentation here.

  • deployment workflow: canonical in ihp-deployment-manager
  • agent harness workflow: canonical in pi-ff
  • upstream framework guidance: canonical in ihp-upstream

ordng records only the reusable local delta: how an ordng-based project reads those sources, classifies their changes, and decides what enters the canon.

Composition

Composition Patterns

Raw source: Patterns/Composition/README.md

Index and reference for organizational patterns in this directory.

This topic covers reusable composition patterns for how application code is assembled across views, components, helpers, HTMX wiring, and adjacent web-layer modules.

Catalog

Pattern File Scope
ExternalProviderModuleBoundary ExternalProviderModuleBoundary.lhs Keep provider credentials, auth, HTTP, wire response types, and translation inside a provider module that exports app-facing values and functions

Candidate Pattern Set

This topic includes patterns such as:

  • local usage-documentation structure for ordng rollout
  • library-versus-product boundary and modularization
  • component-first architecture
  • keeping the semantic core small and pushing volatile complexity into outer layers
  • component action boundary
  • form component as source of truth
  • inline swapping versus full page
  • New/Edit as fallback to component-driven flows
  • page-versus-component boundary
  • redirect versus swap
  • MVP boundary in IHP applications
  • presentation-specific helper extraction
  • function placement decision tree
  • unique, descriptive function names for discovery in human and agent workflows (see ../ARCHITECTURE.md § "Pattern identity in application code" for the boundary between generic pattern names and domain-specific local names)
  • Web/ versus Application/Helper/* API boundary
  • keeping view rendering thin without hiding domain logic in the wrong layer

Scope

Patterns in this topic describe organizational seams: where logic lives, how concern-oriented patterns are composed, and how presentation-layer responsibilities are split across modules.

Concern-specific mechanics remain documented in the sibling topics and can be referenced from here where needed.

Pattern Shape

.lhs files in this topic follow the seven-section pattern skeleton from ../ARCHITECTURE.md.

External Provider Module Boundary

Raw source: Patterns/Composition/ExternalProviderModuleBoundary.lhs

Pattern intent and mechanism

Keep an external provider's wire protocol out of controllers, components, and domain-facing application code. A provider module owns credentials, auth, HTTP, JSON decoding, provider wire types, and translation into application-facing values. Callers receive typed application values plus explicit integration results; they do not receive raw provider response shapes.

The boundary has three layers:

  1. Provider implementation — credentials, auth, request construction, response decoding, and provider wire types.
  2. Translation seam — a small function that maps provider wire types into application-facing values.
  3. Public provider surface — exported search/fetch functions and exported application-facing result types only.

Use this pattern when:

  • a controller or component would otherwise know provider response fields,
  • provider credentials or auth are needed,
  • response schema drift must fail visibly instead of becoming empty defaults,
  • multiple callers should share one provider boundary.

Search aliases: API client, provider client, service adapter, SDK wrapper, DTO, JSON response, OAuth, API key, access token, credentials.

This is a composition pattern because it defines a code-placement seam across helper modules, controllers, and views. The request/result failure vocabulary it composes with is the Actions pattern IntegrationResultBoundary.

Project-specific notes and rollout guidance

Create one provider module per external service or protocol boundary. Name the module after the provider in host code, but keep the pattern vocabulary generic: provider, externalProvider, providerResponse, and appFacingResult.

Before implementing the host module, read the provider documentation and name the concrete credential source, environment variables, auth flow, provider URLs, JSON response shapes, IHP routes/actions, and required downstream packages. Those details are project-specific, but the provider module is where they become code.

Export only the application-facing result type and the functions callers need. Do not export credentials, tokens, auth helpers, request options, or provider wire types unless a second real call site proves the extra public surface is needed.

Keep provider wire types close to their FromJSON instances. Required provider fields should use required decoding. Optional provider fields should be marked optional explicitly. Do not fill required provider fields with empty strings just because the UI can render a blank; missing required data is schema drift or an operational failure.

Credentials should not derive Show. Equality is usually unnecessary too, but Eq is less dangerous than Show when tests need it. Never render or log raw secrets through the provider result surface.

Fully absent optional credentials should usually become IntegrationUnavailable (IntegrationDisabled provider). Partial or blank credential configuration is different: treat it as visible misconfiguration, not as a quietly disabled provider.

Supporting snippets

Provider module export shape:

module Application.Helper.ExampleProvider
    ( AppFacingResult(..)
    , searchExampleProvider
    ) where

Provider wire-type privacy shape:

-- Not exported
data ProviderSearchResponse = ProviderSearchResponse
    { providerItems :: [ProviderItem]
    }

instance FromJSON ProviderSearchResponse where
    parseJSON = withObject "ProviderSearchResponse" $ \object ->
        ProviderSearchResponse <$> object .: "items"

Boundary call shape:

searchExampleProvider query = integrationTryWithTimeout providerName timeoutMicros do
    response <- fetchProviderJson query
    pure (map toAppFacingResult (providerItems response))
Core reusable pattern infrastructure
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Composition.ExternalProviderModuleBoundary
    ( AppFacingResult(..)
    , searchExternalProvider
    ) where

import Prelude
import Data.Aeson (FromJSON(..), withObject, (.:), (.:?))
import Data.Text (Text)
import qualified Data.Text as T
import Patterns.Actions.IntegrationResultBoundary

providerName :: Text
providerName = "ExampleProvider"

-- | Credentials identify the application or integration account at the provider.
-- Do not derive 'Show' for records that contain secrets.
data ProviderCredentials = ProviderCredentials
    { providerClientId :: Text
    , providerClientSecret :: Text
    }
    deriving (Eq)

-- | Application-facing result exported by the provider module.
-- It contains only fields the application needs and uses names that make the
-- boundary visible without exposing the provider wire shape.
data AppFacingResult = AppFacingResult
    { appFacingTitle :: Text
    , appFacingAuthors :: [Text]
    , appFacingYear :: Maybe Text
    , appFacingExternalUrl :: Maybe Text
    }
    deriving (Show, Eq)

Provider response types are internal wire types. In a host project they are not exported from the provider module.

newtype ProviderSearchResponse = ProviderSearchResponse
    { providerResponseItems :: [ProviderItem]
    }
    deriving (Show, Eq)

instance FromJSON ProviderSearchResponse where
    parseJSON = withObject "ProviderSearchResponse" $ \object ->
        ProviderSearchResponse <$> object .: "items"

data ProviderItem = ProviderItem
    { providerItemTitle :: Text
    , providerItemAuthors :: [ProviderAuthor]
    , providerItemReleaseDate :: Maybe Text
    , providerItemUrls :: Maybe ProviderUrls
    }
    deriving (Show, Eq)

instance FromJSON ProviderItem where
    parseJSON = withObject "ProviderItem" $ \object ->
        ProviderItem
            <$> object .: "title"
            <*> object .: "authors"
            <*> object .:? "release_date"
            <*> object .:? "external_urls"

newtype ProviderAuthor = ProviderAuthor
    { providerAuthorName :: Text
    }
    deriving (Show, Eq)

instance FromJSON ProviderAuthor where
    parseJSON = withObject "ProviderAuthor" $ \object ->
        ProviderAuthor <$> object .: "name"

newtype ProviderUrls = ProviderUrls
    { providerCanonicalUrl :: Maybe Text
    }
    deriving (Show, Eq)

instance FromJSON ProviderUrls where
    parseJSON = withObject "ProviderUrls" $ \object ->
        ProviderUrls <$> object .:? "canonical"
Helper implementation examples

Credential loading returns a disabled integration only when optional credentials are fully absent. Partial or blank credential configuration is visible misconfiguration. The credential value itself stays private to the provider module.

loadProviderCredentials :: Maybe Text -> Maybe Text -> IntegrationResult ProviderCredentials
loadProviderCredentials Nothing Nothing =
    IntegrationUnavailable (IntegrationDisabled providerName)
loadProviderCredentials (Just clientId) (Just clientSecret)
    | T.null (T.strip clientId) = providerCredentialsMisconfigured
    | T.null (T.strip clientSecret) = providerCredentialsMisconfigured
    | otherwise = IntegrationSucceeded ProviderCredentials
        { providerClientId = clientId
        , providerClientSecret = clientSecret
        }
loadProviderCredentials _ _ =
    providerCredentialsMisconfigured

providerCredentialsMisconfigured :: IntegrationResult a
providerCredentialsMisconfigured =
    IntegrationUnavailable (IntegrationFailed providerName "partial or blank provider credentials")

The translation seam is explicit and typed. It is the only place where provider wire field names are mapped into application-facing names.

toAppFacingResult :: ProviderItem -> AppFacingResult
toAppFacingResult ProviderItem
        { providerItemTitle = title
        , providerItemAuthors = authors
        , providerItemReleaseDate = releaseDate
        , providerItemUrls = urls
        } = AppFacingResult
    { appFacingTitle = title
    , appFacingAuthors = map providerAuthorName authors
    , appFacingYear = T.take 4 <$> releaseDate
    , appFacingExternalUrl = urls >>= providerCanonicalUrl
    }

The public provider function exposes only app-facing values and the integration result boundary. The example avoids real HTTP so the pattern compiles standalone; a host project would put auth, HTTP, and JSON decoding in this function or in private helpers it calls.

searchExternalProvider :: Text -> IO (IntegrationResult [AppFacingResult])
searchExternalProvider query = integrationTry providerName do
    response <- fakeProviderSearch query
    pure (map toAppFacingResult (providerResponseItems response))

Private provider IO returns provider wire types, not application-facing values.

fakeProviderSearch :: Text -> IO ProviderSearchResponse
fakeProviderSearch query = pure ProviderSearchResponse
    { providerResponseItems =
        [ ProviderItem
            { providerItemTitle = query
            , providerItemAuthors = [ProviderAuthor "Example Author"]
            , providerItemReleaseDate = Just "2026-06-05"
            , providerItemUrls = Just (ProviderUrls (Just "https://provider.example/item"))
            }
        ]
    }
Usage examples

Controllers and components consume application-facing values only.

renderProviderResultTitles :: IntegrationResult [AppFacingResult] -> Text
renderProviderResultTitles (IntegrationSucceeded results) =
    T.intercalate ", " (map appFacingTitle results)
renderProviderResultTitles (IntegrationUnavailable (IntegrationDisabled _provider)) =
    ""
renderProviderResultTitles (IntegrationUnavailable (IntegrationTimedOut provider _timeoutMicros)) =
    "provider timed out: " <> provider
renderProviderResultTitles (IntegrationUnavailable (IntegrationFailed provider _details)) =
    "provider unavailable: " <> provider

A decode failure in a host provider module should become provider unavailability through IntegrationResultBoundary; it should not become an empty successful payload.

emptySuccessIsOnlyForRealNoHits :: IntegrationResult [AppFacingResult]
emptySuccessIsOnlyForRealNoHits = IntegrationSucceeded []
Standalone checks

Not applicable in this pattern; sections 4, 5, and 6 compile standalone.

ordng Usage-Documentation Structure

Raw source: Patterns/Composition/usage-documentation-structure.md

This document defines the canonical local documentation structure for ordng adoption in a concrete project. It is separate from the adoption workflow itself.

Purpose

Use this document to decide which local files belong under docs/ordng/ and what each file is for. The map for when to create and update them lives in ../map.md.

Local Document Set

The fully opinionated ordng path uses these local files under docs/ordng/:

  • README.md — current local ordng-usage state
  • log.md — important rollout decisions already made
  • plan.md — active local rollout work still to do
  • technical-debts.md — accepted current deviations from the intended path
Naming Rule

Use these names consistently. Do not rename them project by project without a strong local reason.

Role Boundaries
  • Do not use plan.md as a changelog.
  • Do not use technical-debts.md as a future rollout plan.
  • Do not turn README.md into a registry of every local application site of every pattern.
  • Keep log.md for decisions and notable rollout steps, not for routine work notes.

Domain

Domain Patterns

Raw source: Patterns/Domain/README.md

Index and reference for domain and data patterns in this directory.

Conceptual rationale lives in ../../Foundations/03-DomainAbstraction/README.md; this topic turns those ideas into rollout-oriented schema and usage patterns.

Topic Structure

  • 01-Schema/ contains D2-first schema patterns.
  • 02-Data/ contains .lhs patterns for query shapes, transactional writes, read projections, and other usage patterns over those schemas.

02-Data/, not Haskell/, matches the concern-oriented framing in ../ROADMAP.md (Domain & Data) better than a language label.

Schema Pattern Set

Each schema pattern is an <N-Name>.md file with an inline D2 diagram.

In each diagram, Traditional is the comparison schema and Atomic is the canonical pattern schema.

  • 01-Schema/01-TextBearingContentAsNode
  • 01-Schema/02-TypedNodeToNodeRelation
  • 01-Schema/03-InternalContentStructure
  • 01-Schema/04-TypedDomainEntityRelation
  • 01-Schema/05-MultipleParallelTaxonomies

The leading number is the recommended reading order used by the generated book.

Data Pattern Set

Placeholder.lhs reserves the future .lhs layer for additional patterns.

Pattern Shape in This Topic

The .lhs seven-section skeleton from ../ARCHITECTURE.md applies to files in 02-Data/.

Files in 01-Schema/ use this Markdown shape:

  1. what this pattern shows
  2. compared schemas
  3. how to read the diagram
  4. modeling decision
  5. tradeoffs
  6. related patterns

Schema diagrams stay abstract. Concrete examples belong in the prose, briefly.

Edge Colour Convention in Schema Diagrams

Schema diagrams use D2 with ELK layout. Edges are colour-coded by semantic role:

  • Red (#E85D4E) — foreign key to nodes or items
  • Green (#4CAF50) — foreign key to relation_types
  • Blue (#0D32B2) — foreign key to domain entities
  • Grey (#B0B4C2) — reference to enums or value sets (no arrowheads)

This convention applies across all schema patterns in 01-Schema/.

Scope Rules

  • Keep direct foreign keys for central operational structure.
  • Use typed relations when relation semantics should be named, reused, queried, or carry relation-specific fields.
  • Treat node-to-node relations as typed only.
  • Move text into nodes only when it should become reusable, addressable, or independently relatable.

Identity Convention

  • Domain entities get their own id.
  • Pure relation tables do not get their own id; use a compound primary key instead.
  • Exception: add an id when the relation itself becomes an addressable domain object.
  • Typical signals for that exception are independent referencing, versioning, commenting, authorization, or similar treatment as its own object.

Applying These Patterns to Existing "Traditional" Schemas

  • Start from the target system, not from the ordng pattern list.
  • Read the existing schema and the relevant usage paths first.
  • Choose one small domain slice.
  • Use the schema patterns as discussion prompts per entity and relation, not as a mechanical rollout order.
  • Write down the mapping from current structures to candidate pattern shapes before changing code.
  • Validate one coherent example end to end before broad rollout.
  • Feed back only generalizable insights into ordng.

Applying These Patterns when Creating Schemas from Scratch

  • Start from one small domain slice and its concrete usage paths.
  • Do not introduce nodes or typed relations by default; justify them per field and relation.
  • Use the schema patterns as design questions while shaping the first coherent model.
  • Prefer one end-to-end example over broad upfront generalization.
  • Validate that the resulting model stays usable in queries, writes, and projections before expanding it.

Performance Reminder

These models cost joins. Index relation tables by source, target, and type, avoid deep live traversals on routine request paths, and prefer dedicated read projections for frequently rendered views. If that is still not enough on hot paths, add explicit caching at the projection layer, not in the canonical model itself.

Schema

Text-Bearing Content as Node

Raw source: Patterns/Domain/01-Schema/01-TextBearingContentAsNode.md

This pattern shows when a text-bearing field should become a node instead of staying a local scalar field, for example a name or title. Model rationale: ../../../Foundations/03-DomainAbstraction/README.md.

Compared Schemas

The traditional approach keeps the text on domain_entity. The atomic approach keeps the entity explicit, but moves that text into nodes.

TraditionalAtomicdomain_entityiduuidPKlocal_text_contenttextnot_nulloperational_field_1textnot_nullnodesiduuidPKcontenttextnot_nullcontent_typenot_nullaliastextUNQdomain_entityiduuidPKnode_iduuidnot_null
How to Read the Diagram
  • the traditional domain_entity carries the text directly.
  • the atomic domain_entity keeps only identity plus node_id.
  • nodes.content stores the text itself.
  • nodes.content_type gives a small closed content distinction.
  • nodes.alias supports stable short references.
Modeling Decision

Use a node when the text should be reusable, addressable, or independently relatable. Keep a plain field when the text is purely local.

Tradeoffs

This gains reuse and later relationability, but adds joins.

02-TypedNodeToNodeRelation.md adds semantics between nodes. 03-InternalContentStructure.md extends one node into multi-part content.

Typed Node-to-Node Relation

Raw source: Patterns/Domain/01-Schema/02-TypedNodeToNodeRelation.md

This pattern requires every node-to-node link to carry a relation type; anonymous edges are not allowed, for example instead of bare links use types such as description or example. Model rationale: ../../../Foundations/03-DomainAbstraction/README.md.

Compared Schemas

The traditional approach uses plain node_links.

nodesiduuidPKcontenttextnot_nullnode_linkssource_node_iduuidnot_nulltarget_node_iduuidnot_nullpksource + targetPK

The atomic approach adds relation_types and requires each edge to name one.

ContentRelationsEnumsnodesiduuidPKcontenttextnot_nullcontent_typenot_nullaliastextUNQrelation_typesnametextPKinverse_nametextis_symmetricbooleannot_nullis_hierarchicalbooleannot_nullsource_mincardinality_boundnot_nullsource_maxcardinality_boundnot_nulltarget_mincardinality_boundnot_nulltarget_maxcardinality_boundnot_nullnode_relationssource_node_iduuidnot_nulltarget_node_iduuidnot_nullrelation_typetextnot_nullpksource + target + typePKcontent_typeordinary_languageenum_valuecodeenum_valuecardinality_boundzeroenum_valueoneenum_valuemanyenum_value
How to Read the Diagram
  • node_links says only that two nodes are connected.
  • relation_types makes semantics explicit.
  • inverse naming, symmetry, and hierarchy live on the type, but relation_types does not form its own hierarchy.
Modeling Decision

When nodes relate, use a typed relation. Never add an untyped node-edge layer.

Tradeoffs

The apparent alternative is an anonymous edge layer, but this pattern does not treat it as a valid option. The small savings in schema size are not worth the loss of explicit semantics, inspectability, and enforceability.

01-TextBearingContentAsNode.md supplies the nodes. 03-InternalContentStructure.md uses typed node relations inside one entity's content.

Internal Content Structure

Raw source: Patterns/Domain/01-Schema/03-InternalContentStructure.md

This pattern shows how several text-bearing contents of one entity can become one rooted node structure instead of several scalar fields or several direct node links, for example title, description, and definition of done. Model rationale: ../../../Foundations/03-DomainAbstraction/README.md.

Compared Schemas

The traditional approach stores several text fields directly on domain_entity.

domain_entityiduuidPKprimary_text_contenttextsecondary_text_contenttexttertiary_text_contenttextoperational_field_1textnot_null

The atomic approach gives the entity one content_root_node_id, then hangs further content nodes below that root through typed node relations.

Domain layerSemantic layerdomain_entityiduuidPKcontent_root_node_iduuidnot_nulloperational_field_1textnot_nullContentRelationsEnumsnodesiduuidPKcontenttextnot_nullcontent_typenot_nullaliastextUNQrelation_typesnametextPKinverse_nametextis_symmetricbooleannot_nullis_hierarchicalbooleannot_nullsource_mincardinality_boundnot_nullsource_maxcardinality_boundnot_nulltarget_mincardinality_boundnot_nulltarget_maxcardinality_boundnot_nullnode_relationssource_node_iduuidnot_nulltarget_node_iduuidnot_nullrelation_typetextnot_nullpksource + target + typePKcontent_typeordinary_languageenum_valuecodeenum_valuecardinality_boundzeroenum_valueoneenum_valuemanyenum_value
How to Read the Diagram
  • operational_field_1 stays local.
  • the root node carries the primary addressable content.
  • child nodes carry further content parts.
  • named relation_types, not only content_type or alias, express what each child is, for example description, definition_of_done, or footnote, and allow arbitrarily nested trees with both sibling and subordinate nodes.
Modeling Decision

Use this when the parts should be individually linkable, reusable, or extensible. Keep scalar columns when those parts are fixed and purely local.

Use child nodes only when the child content is semantically subordinate to its parent. If several parts belong to the same root but are not subordinate to one another, model them as siblings under that root. The schema allows both; the choice is made through the topology and the named relation_types.

If two contents are semantically distinct top-level parts rather than one rooted content structure, attach more than one node directly to the domain entity instead of forcing them under one root. That is a neighboring case, not the schema shown in this pattern.

Tradeoffs

This gives cleaner content composition, but adds joins and modeling discipline.

This builds on 01-TextBearingContentAsNode.md and 02-TypedNodeToNodeRelation.md.

Typed Domain-Entity Relation

Raw source: Patterns/Domain/01-Schema/04-TypedDomainEntityRelation.md

This pattern shows when explicit domain-entity relations should stay ordinary relational structure and when they should become typed relations, for example containment vs dependency. Model rationale: ../../../Foundations/03-DomainAbstraction/README.md.

Compared Schemas

Both schemas keep domain_entity_b -> domain_entity_a as direct operational structure. In addition, both schemas show a separate semantic relation family between domain_entity_a and domain_entity_b.

The traditional approach models that semantic relation family with one dedicated junction table.

domain_entity_aiduuidPKoperational_field_1textnot_nulldomain_entity_biduuidPKdomain_entity_a_iduuidnot_nulloperational_field_1textnot_nullsemantic_a_b_linkssource_entity_a_iduuidnot_nulltarget_entity_b_iduuidnot_nulldomain_relation_field_1textpksource + targetPK

The atomic approach models that same relation family through a typed relation table.

Domain layerSemantic layerdomain_entity_aiduuidPKoperational_field_1textnot_nulldomain_entity_biduuidPKdomain_entity_a_iduuidnot_nulloperational_field_1textnot_nullRelationsEnumsrelation_typesnametextPKinverse_nametextis_symmetricbooleannot_nullis_hierarchicalbooleannot_nullsource_mincardinality_boundnot_nullsource_maxcardinality_boundnot_nulltarget_mincardinality_boundnot_nulltarget_maxcardinality_boundnot_nulla_b_relationssource_entity_a_iduuidnot_nulltarget_entity_b_iduuidnot_nullrelation_typetextnot_nulldomain_relation_field_1textpksource + target + typePKcardinality_boundzeroenum_valueoneenum_valuemanyenum_value
How to Read the Diagram
  • domain_entity_b.domain_entity_a_id stays a direct foreign key.
  • the hierarchical reading is implicit in that direct structure, not encoded through an extra typed relation.
  • semantic_a_b_links and a_b_relations are a second, disjoint relation family between the same two entity types.
  • that second family moves from an ordinary junction table to a typed relation layer.
  • the same pattern also covers the special case A = B, then as a typed self-relation.
  • relation-specific payload stays on the relation row.
Modeling Decision

Keep direct foreign keys and ordinary junctions for central operational structure. Use typed domain relations when the relation semantics should be named, reused, queried, or carry relation-specific fields.

Tradeoffs

This avoids flattening the whole model into one abstraction, but introduces a second relation style to maintain.

03-InternalContentStructure.md is separate: it concerns node-internal content, not domain-entity structure.

Multiple Parallel Taxonomies

Raw source: Patterns/Domain/01-Schema/05-MultipleParallelTaxonomies.md

This pattern shows how the same item can belong to several taxonomic hierarchies at once, for example taxonomy A and taxonomy B. A taxonomy is a hierarchical ordering of one entity type — a tree over that entity. Model rationale: ../../../Foundations/03-DomainAbstraction/README.md.

Compared Schemas

The traditional approach uses one dedicated link table per taxonomy.

itemsiduuidPKtaxonomy_a_linkssource_item_iduuidnot_nulltarget_item_iduuidnot_nullpksource + targetPKtaxonomy_b_linkssource_item_iduuidnot_nulltarget_item_iduuidnot_nullpksource + targetPK

The atomic approach uses one typed item-relation layer for all taxonomies.

ContentRelationsEnumsitemsiduuidPKrelation_typesnametextPKinverse_nametextis_symmetricbooleannot_nullis_hierarchicalbooleannot_nullsource_mincardinality_boundnot_nullsource_maxcardinality_boundnot_nulltarget_mincardinality_boundnot_nulltarget_maxcardinality_boundnot_nullitem_relationssource_item_iduuidnot_nulltarget_item_iduuidnot_nullrelation_typetextnot_nullpksource + target + typePKcardinality_boundzeroenum_valueoneenum_valuemanyenum_value
How to Read the Diagram
  • the same item may appear in more than one taxonomy at the same time.
  • that item may be a node, but it may also be a domain entity that is classified in several ways.
  • the traditional approach encodes taxonomy purpose in separate tables such as taxonomy_a_links and taxonomy_b_links.
  • in trad, source_item_id and target_item_id are only link endpoints; hierarchy, inverse meaning, symmetry, cardinality, and other richer relation semantics must all be fixed by convention and application logic.
  • in atom, source_item_id and target_item_id are still only endpoints; the hierarchical reading becomes explicit through named relation_types such as taxonomy_a_parent and taxonomy_b_parent and their metadata.
  • the diagram shows only one item_relations table, but that is exactly the point: several parallel taxonomies are distinguished inside that one table by different hierarchical relation_types.
  • one shared relation layer can therefore host several parallel trees over the same item set.
Modeling Decision

Use this when the same item must be classified in more than one hierarchy for different purposes. The pattern is generic over what that item is: it may be a node, but it may also be a domain entity. Use distinct named hierarchical relation_types for those purposes.

Use distinct taxonomies only when they express genuinely different classification purposes, not merely alternate labels for the same tree.

Do not read the single item_relations table as a single taxonomy. In this pattern, one table is intentionally reused for several taxonomies, and the separation between those taxonomies is carried by the relation_types.

The same shared relation layer can also host non-taxonomic graph relations on the same item set; taxonomy remains only one distinguished use of that layer.

If there is only one fixed hierarchy and no need for shared relation infrastructure, a dedicated direct structure may still be enough.

Tradeoffs

This keeps the schema slimmer and makes traversal and application logic reusable across taxonomies, because the same abstraction only needs to be implemented once. The price is that taxonomy-specific semantics and constraints must be made explicit in relation_types and then enforced consistently in application logic.

This is analogous to 02-TypedNodeToNodeRelation.md: the same typed-relation idea is reused here for taxonomic classification.

Data

Domain Data Placeholder

Raw source: Patterns/Domain/02-Data/Placeholder.lhs

Pattern intent and mechanism

This placeholder reserves the future .lhs layer for Domain/Data usage patterns over the schema patterns in ../Schema/, especially query shapes, transactional writes, and read projections.

Project-specific notes and rollout guidance

Extract concrete patterns from host-project code, per ../../ROADMAP.md, before replacing this placeholder with real files.

Supporting snippets

None yet.

Core reusable pattern infrastructure

Placeholder module:

module Domain.Data.Placeholder where
Helper implementation examples

None yet.

Usage examples

None yet.

Standalone checks

No standalone check yet.

Single-Select Role Hierarchy

Raw source: Patterns/Domain/02-Data/SingleSelectRoleHierarchy.lhs

Pattern intent and mechanism

Store one role per membership — the highest the actor holds. All lower permissions are implied by that single value. The UI shows a single-select, the database stores one enum column, and the permission check derives the rest.

Use this pattern when:

  • roles form a linear chain (low to high),
  • higher roles subsume all lower ones,
  • the UI should not show multi-select checkboxes,
  • storage should not use arrays or join tables for role sets.

The stored role is the ceiling, not the set. If Alice is OrgLead, she implicitly has Participant rights. The permission check derives the rest. Never store [Audience, Participant, OrgLead] as a multi-value field.

Project-specific notes and rollout guidance

Define the hierarchy as an ordered enum. In Haskell, derive Enum and Bounded so the order is machine-readable. The lowest role is minBound, the highest is maxBound.

For permission checks, compare roles by their position in the hierarchy:

canActAs :: Role -> Role -> Bool
canActAs held required = fromEnum held >= fromEnum required

For write gating, check the held role against the target role:

canAssignRole :: Role -> Role -> Bool
canAssignRole held target = fromEnum held >= fromEnum target

Lateral roles (roles outside the linear chain) need special handling. If ExternalPartner is not in the chain but requires OrgLead rights, store OrgLead as the ceiling and handle ExternalPartner as a separate flag or relation. Do not add ExternalPartner to the linear enum unless it fits the implication chain cleanly.

In IHP (and other frameworks with database-generated enums), lateral roles are sometimes modeled as additional enum constructors rather than separate flags. This keeps the enum in one database column but breaks the linear fromEnum chain — the new constructor has no natural position in the hierarchy. If you choose this path, implement canActAs with an explicit mapping table instead of fromEnum comparison. The trade-off is:

  • Separate flag (isExternalPartner :: Bool) — requires a new database column, keeps the linear enum clean, fromEnum stays valid.
  • Additional enum constructor — no new column, but breaks the linear chain; requires explicit permission mapping.

Document the implication chain in the enum's module comment. This is the source of truth. When the chain changes, update the comment first, then the code.

Supporting snippets

Haskell enum with implication chain:

data Role = Audience | Participant | OrgTeam | Marketing | OrgLead
    deriving (Eq, Ord, Enum, Bounded, Show)

-- | Role implication chain (low to high):
-- | Audience ⊂ Participant ⊂ OrgTeam ⊂ Marketing ⊂ OrgLead
-- | ExternalPartner is lateral — requires OrgLead, stored separately.

Permission check by hierarchy position:

canActAs :: Role -> Role -> Bool
canActAs held required = fromEnum held >= fromEnum required
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Domain.Data.SingleSelectRoleHierarchy where

import Prelude

-- | A role in a linear hierarchy. Lower constructors imply fewer rights;
-- | higher constructors subsume all lower ones.
-- |
-- | The 'Enum' instance encodes the order. 'fromEnum' gives the position
-- | in the chain; higher numbers mean more rights.
data Role
    = Audience
    | Participant
    | OrgTeam
    | Marketing
    | OrgLead
    deriving (Eq, Ord, Enum, Bounded, Show)
Helper implementation examples
-- | Check if a held role satisfies a required role.
-- | Returns True when the held role is at or above the required role
-- | in the hierarchy.
canActAs :: Role -> Role -> Bool
canActAs held required = fromEnum held >= fromEnum required

-- | Check if a held role permits assigning a target role.
-- | The actor must hold a role at or above the target.
canAssignRole :: Role -> Role -> Bool
canAssignRole held target = fromEnum held >= fromEnum target

-- | All roles at or below the held role.
-- | Use this to populate a dropdown of assignable roles.
impliedRoles :: Role -> [Role]
impliedRoles held = filter (canActAs held) [minBound .. maxBound]
Usage examples

A project membership system with a role hierarchy.

-- | A membership record stores one role per user-project pair.
data Membership = Membership
    { membershipId :: Int
    , membershipProjectId :: Int
    , membershipUserId :: Int
    , membershipRole :: Role
    }
    deriving (Eq, Show)

-- | Alice is OrgLead on Project 1.
alice :: Membership
alice = Membership
    { membershipId = 1
    , membershipProjectId = 1
    , membershipUserId = 42
    , membershipRole = OrgLead
    }

-- | True: OrgLead can act as Participant.
aliceCanParticipate :: Bool
aliceCanParticipate = canActAs (membershipRole alice) Participant

-- | True: OrgLead can assign OrgTeam to someone else.
aliceCanAssignOrgTeam :: Bool
aliceCanAssignOrgTeam = canAssignRole (membershipRole alice) OrgTeam

-- | False: Participant cannot assign OrgLead.
participantCannotAssignOrgLead :: Bool
participantCannotAssignOrgLead =
    canAssignRole Participant OrgLead

A lateral role that is not part of the linear chain. ExternalPartner requires OrgLead rights but is conceptually separate.

-- | ExternalPartner is stored as a separate flag, not as a Role value.
-- | The check combines the role ceiling with the flag.
data ExtendedMembership = ExtendedMembership
    { extendedRole :: Role
    , isExternalPartner :: Bool
    }
    deriving (Eq, Show)

-- | An ExternalPartner must be OrgLead. The flag is checked separately.
canBeExternalPartner :: ExtendedMembership -> Bool
canBeExternalPartner em =
    canActAs (extendedRole em) OrgLead && isExternalPartner em

The dropdown for role assignment shows only roles the actor can assign.

-- | Roles Alice can assign to others on this project.
assignableRolesForAlice :: [Role]
assignableRolesForAlice = impliedRoles (membershipRole alice)
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Forms

Form Patterns

Raw source: Patterns/Forms/README.md

Index and reference for form patterns in this directory.

This topic covers the form lifecycle in HSX: field rendering, validation, dynamic fields, multi-model forms, file upload, and submission handling.

Patterns

Structure

  • FieldPrimitives/ — reusable field-level building blocks (planned; current accessibility field-structure pattern remains flat until the topic has enough field material to justify subdirectories).
  • Composed/ — multi-field and multi-step form patterns (planned).
  • Validation/ — reusable validation and error-shaping patterns (planned).
  • Submission/ — submission flow, file handling, and post-submit response patterns.

Pattern Shape

.lhs files in this topic follow the seven-section pattern skeleton from ../ARCHITECTURE.md.

Accessibility Field Structure

Raw source: Patterns/Forms/AccessibilityFieldStructure.lhs

Pattern intent and mechanism

Keep accessible field structure at the same helper boundary that renders the field. In IHP/HSX code, the first path should be the framework field generator: formFor, textField, selectField, and adjacent IHP helpers already encode much of the label/id/validation relationship. Extend or wrap that path before creating a second renderer.

This pattern is for the exception path: hand-rolled fields, raw HTMX fragments, and composite widgets that bypass the framework field generator. Those are where labels drift, aria-label overrides localized visible labels, composite button groups receive dangling <label for> attributes, and validation errors become visible text without a programmatic connection to the field.

This is not a generic forms accessibility guide. The ordng boundary is how IHP/HSX helpers decide whether they can rely on framework-generated structure or must explicitly preserve field identity, group labels, descriptions, and errors.

The recurring rules:

  1. Use the framework field generator first. Only hand-roll when the rendered shape cannot be expressed by the normal IHP helper.
  2. A <label for> is valid only for a single labelable control with a matching stable id.
  3. Never point for at a composite widget, button group, non-labelable element, absent element, or conditional hidden input. Use fieldset / legend, or role="group" with aria-labelledby, for grouped controls.
  4. For hand-rolled fields, keep id, name, label, help text, and error text in one helper/config boundary.
  5. Use aria-label only for genuine label-less exceptions. Do not add it to a control that already has a visible label; it overrides the label and can drift from localization.
  6. Connect help and error text with aria-describedby on the field.
  7. When a field has a validation error, render the error visibly and set aria-invalid="true" on the control.
  8. Mark required fields in the native control (required) and in visible copy when the local design needs it; do not rely on color alone.
  9. Preserve stable IDs across full render and HTMX replacement so labels, descriptions, and validation errors do not break after validation.
Project-specific notes and rollout guidance

Start by sorting fields by source. Do not replace framework-generated fields with a bespoke config system merely to satisfy this pattern; that would create the fragmentation the pattern is meant to prevent.

Rollout checks:

  1. Inventory field sources separately:
    • framework-generated fields (formFor, textField, selectField, etc.) that inherit IHP's label/id/validation structure;
    • hand-rolled or raw-HTMX inputs, selects, textareas, and composite widgets, which are the primary risk surface.
  2. For every hand-rolled field, identify its field identity (id + name) and visible label.
  3. For every composite widget, verify that no <label for> points at a button group, non-labelable element, absent element, or conditional hidden input.
  4. Remove redundant aria-label where <label for> already provides the name.
  5. Add aria-describedby IDs for help and error text owned by the field.
  6. Ensure invalid fields project both visible error text and aria-invalid.
  7. Check grouped controls for fieldset / legend or role="group" + aria-labelledby rather than a visual heading alone.
  8. For HTMX validation responses, verify that replacement fragments preserve the same field, help, group-label, and error IDs, and swap the ARIA attributes together with the visible error markup.

Host evidence from seshn:

  • Several <select> controls had localized visible labels but redundant English aria-label attributes. The repair was to remove the aria-label, not to translate it, because the label already supplied the accessible name.
  • Raw HTMX inputs and composite selection helpers were the main risk surface; framework-generated IHP fields already inherited much of the label/id structure.
  • renderFormSelectionField*-style button groups used <label for={field}> even though the target was a composite widget and the matching element was absent or only a conditional hidden input. That is a dangling association; grouped controls need fieldset / legend or role="group" + aria-labelledby instead.
  • Field dismiss and picker controls belong to AccessibleControlNames; the form field itself still needs the separate label/description/error structure captured here.
  • Disabled-until-valid submit affordance belongs to AccessibilitySemanticColor and SemanticValueAxes. This pattern covers the group-label and validation-feedback structure around those controls.
  • Custom HTMX validation fragments showed visible .is-invalid / .invalid-feedback markup without stable error IDs, aria-invalid, or aria-describedby. The fragment must swap the visual error and the programmatic association together.
Supporting snippets

A labeled select does not need aria-label:

<label class="form-label" for="musicalKey">Musical key</label>
<select class="form-select" id="musicalKey" name="musicalKey">
  ...
</select>

A composite button group must not use a dangling <label for>:

<div role="group" aria-labelledby="availability-group-label">
  <span id="availability-group-label" class="form-label">Availability</span>
  <button type="button" aria-pressed="true">Available</button>
  <button type="button" aria-pressed="false">Unavailable</button>
</div>

A native grouped radio set can use fieldset / legend:

<fieldset>
  <legend>Availability</legend>
  <label><input type="radio" name="availability" value="available"> Available</label>
  <label><input type="radio" name="availability" value="unavailable"> Unavailable</label>
</fieldset>

HTMX validation fragments swap the visible error and ARIA associations together:

<input id="artistName"
       name="artistName"
       class="form-control is-invalid"
       aria-invalid="true"
       aria-describedby="artistName-error">
<p id="artistName-error" class="invalid-feedback d-block">Artist name is required.</p>
Core reusable pattern infrastructure

The small core below is intentionally not a replacement for IHP's field generator. Use it at raw-HTMX or composite-widget boundaries where local code must carry the structure explicitly.

Only FieldId has a field-specific invariant: it must be one ID token, because spaces would split aria-describedby IDREFs. Labels, legends, help text, error text, and field names share the same non-empty text wrapper.

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Forms.AccessibilityFieldStructure
    ( NonEmptyText
    , nonEmptyTextValue
    , nonEmptyText
    , FieldId
    , fieldIdText
    , fieldIdFromText
    , RequiredState(..)
    , FieldIdentity(..)
    , FieldConfig(..)
    , fieldIdentityFromText
    , fieldConfigFromText
    , helpId
    , errorId
    , groupLabelId
    , describedByIds
    , describedByValue
    , isInvalidField
    , isRequiredField
    , renderFieldShell
    , renderFieldLabel
    , renderHelpText
    , renderErrorText
    , renderFieldset
    , renderAriaLabelledGroup
    ) where

import Data.Char (isSpace)
import qualified Data.Text as Text
import IHP.ViewPrelude

-- | Non-empty text for labels, legends, help text, error text, and field names.
-- The constructor is not exported; use 'nonEmptyText' at config boundaries.
newtype NonEmptyText = NonEmptyText
    { nonEmptyTextValue :: Text
    }
    deriving (Eq, Show)

nonEmptyText :: Text -> Maybe NonEmptyText
nonEmptyText raw =
    let stripped = Text.strip raw
    in if Text.null stripped
        then Nothing
        else Just (NonEmptyText stripped)

-- | Field id token used by label/@for@ and ARIA IDREFs.
-- The constructor is not exported; use 'fieldIdFromText'.
newtype FieldId = FieldId
    { fieldIdText :: Text
    }
    deriving (Eq, Show)

fieldIdFromText :: Text -> Maybe FieldId
fieldIdFromText rawId = do
    value <- nonEmptyText rawId
    let token = nonEmptyTextValue value
    if Text.any isSpace token
        then Nothing
        else Just (FieldId token)

data RequiredState
    = OptionalField
    | RequiredField
    deriving (Eq, Show, Enum, Bounded)

data FieldIdentity = FieldIdentity
    { fieldId :: FieldId
    , fieldInputName :: NonEmptyText
    }
    deriving (Eq, Show)

data FieldConfig = FieldConfig
    { fieldIdentity :: FieldIdentity
    , fieldCaption :: NonEmptyText
    , requiredState :: RequiredState
    , fieldHelp :: Maybe NonEmptyText
    , fieldError :: Maybe NonEmptyText
    }
    deriving (Eq, Show)

optionalNonEmptyText :: Maybe Text -> Maybe (Maybe NonEmptyText)
optionalNonEmptyText Nothing = Just Nothing
optionalNonEmptyText (Just raw) = Just <$> nonEmptyText raw

fieldIdentityFromText :: Text -> Text -> Maybe FieldIdentity
fieldIdentityFromText rawId rawName = do
    parsedId <- fieldIdFromText rawId
    parsedName <- nonEmptyText rawName
    pure FieldIdentity
        { fieldId = parsedId
        , fieldInputName = parsedName
        }

fieldConfigFromText :: Text -> Text -> Text -> RequiredState -> Maybe Text -> Maybe Text -> Maybe FieldConfig
fieldConfigFromText rawId rawName rawLabel required rawHelp rawError = do
    identity <- fieldIdentityFromText rawId rawName
    label <- nonEmptyText rawLabel
    help <- optionalNonEmptyText rawHelp
    err <- optionalNonEmptyText rawError
    pure FieldConfig
        { fieldIdentity = identity
        , fieldCaption = label
        , requiredState = required
        , fieldHelp = help
        , fieldError = err
        }

helpId :: FieldIdentity -> Text
helpId identity = fieldIdText (fieldId identity) <> "-help"

errorId :: FieldIdentity -> Text
errorId identity = fieldIdText (fieldId identity) <> "-error"

groupLabelId :: FieldId -> Text
groupLabelId groupId = fieldIdText groupId <> "-label"

describedByIds :: FieldConfig -> [Text]
describedByIds config =
    maybe [] (const [helpId identity]) (fieldHelp config)
        <> maybe [] (const [errorId identity]) (fieldError config)
  where
    identity = fieldIdentity config

describedByValue :: FieldConfig -> Maybe Text
describedByValue config =
    case describedByIds config of
        [] -> Nothing
        ids -> Just (Text.intercalate " " ids)

isInvalidField :: FieldConfig -> Bool
isInvalidField config = case fieldError config of
    Nothing -> False
    Just _ -> True

isRequiredField :: FieldConfig -> Bool
isRequiredField config = requiredState config == RequiredField
Helper implementation examples

Render the structural pieces around a project-specific raw input. Framework fields should usually keep using the framework renderer; this shell is for exceptions that must hand-roll markup.

renderFieldShell :: FieldConfig -> Html -> Html
renderFieldShell config inputHtml = [hsx|
    <div class="mb-3">
        {renderFieldLabel config}
        {inputHtml}
        {renderHelpText config}
        {renderErrorText config}
    </div>
|]

renderFieldLabel :: FieldConfig -> Html
renderFieldLabel config = [hsx|
    <label class="form-label" for={fieldIdText (fieldId identity)}>
        {nonEmptyTextValue (fieldCaption config)}
    </label>
|]
  where
    identity = fieldIdentity config

renderHelpText :: FieldConfig -> Html
renderHelpText config = case fieldHelp config of
    Nothing -> mempty
    Just help -> [hsx|
        <div id={helpId (fieldIdentity config)} class="form-text">
            {nonEmptyTextValue help}
        </div>
    |]

renderErrorText :: FieldConfig -> Html
renderErrorText config = case fieldError config of
    Nothing -> mempty
    Just err -> [hsx|
        <div id={errorId (fieldIdentity config)} class="invalid-feedback d-block">
            {nonEmptyTextValue err}
        </div>
    |]

renderFieldset :: NonEmptyText -> Html -> Html
renderFieldset legendText controls = [hsx|
    <fieldset>
        <legend>{nonEmptyTextValue legendText}</legend>
        {controls}
    </fieldset>
|]

renderAriaLabelledGroup :: FieldId -> NonEmptyText -> Html -> Html
renderAriaLabelledGroup groupId label controls = [hsx|
    <div role="group" aria-labelledby={groupLabelId groupId}>
        <span id={groupLabelId groupId} class="form-label">{nonEmptyTextValue label}</span>
        {controls}
    </div>
|]
Usage examples

A local raw-HTMX text-field helper builds the config once and projects all attributes from it. It does not emit an empty aria-describedby; it branches when no help/error text exists. It also derives invalid and required state from the same config instead of hardcoding them independently.

artistNameConfig :: Maybe FieldConfig
artistNameConfig = fieldConfigFromText
    "artistName"
    "artistName"
    "Artist name"
    RequiredField
    (Just "Use the public artist name.")
    (Just "Artist name is required.")

artistNameField :: Html
artistNameField = case artistNameConfig of
    Nothing -> [hsx|<div class="alert alert-danger">Invalid field configuration</div>|]
    Just config
        | Just describedBy <- describedByValue config
        , isInvalidField config
        , isRequiredField config ->
            let identity = fieldIdentity config
            in renderFieldShell config [hsx|
                <input class="form-control"
                       id={fieldIdText (fieldId identity)}
                       name={nonEmptyTextValue (fieldInputName identity)}
                       aria-describedby={describedBy}
                       aria-invalid="true"
                       required>
            |]
        | otherwise ->
            let identity = fieldIdentity config
            in renderFieldShell config [hsx|
                <input class="form-control"
                       id={fieldIdText (fieldId identity)}
                       name={nonEmptyTextValue (fieldInputName identity)}>
            |]

The snippet above shows the projection decisions. Every branch keeps the stable id and name; only optional aria-describedby, aria-invalid, and required vary. In real HSX helpers, cover the remaining combinations in the local style that produces valid input markup for the project.

A composite button group receives a group name without a dangling for:

availabilityGroup :: Html
availabilityGroup =
    case (fieldIdFromText "availability", nonEmptyText "Availability") of
        (Just groupId, Just label) -> renderAriaLabelledGroup groupId label [hsx|
            <button type="button" aria-pressed="true">Available</button>
            <button type="button" aria-pressed="false">Unavailable</button>
        |]
        _ -> [hsx|<div class="alert alert-danger">Invalid group configuration</div>|]

A native grouped radio set can use fieldset / legend:

availabilityRadioGroup :: Html
availabilityRadioGroup =
    case nonEmptyText "Availability" of
        Just legend -> renderFieldset legend [hsx|
            <label><input type="radio" name="availability" value="available"> Available</label>
            <label><input type="radio" name="availability" value="unavailable"> Unavailable</label>
        |]
        Nothing -> [hsx|<div class="alert alert-danger">Invalid group configuration</div>|]

A labeled select should not duplicate its name with aria-label:

musicalKeySelect :: Html
musicalKeySelect = [hsx|
    <label class="form-label" for="musicalKey">Musical key</label>
    <select class="form-select" name="musicalKey" id="musicalKey">
        <option value="c-major">C major</option>
    </select>
|]
Standalone checks
_checkDescribedBy :: Maybe (Maybe Text)
_checkDescribedBy = describedByValue <$> fieldConfigFromText
    "artistName"
    "artistName"
    "Artist name"
    RequiredField
    (Just "Use the public artist name.")
    (Just "Artist name is required.")

_checkBlankIdentityRejected :: Bool
_checkBlankIdentityRejected = fieldIdentityFromText "" "artistName" == Nothing

_checkWhitespaceIdRejected :: Bool
_checkWhitespaceIdRejected = fieldIdentityFromText "artist name" "artistName" == Nothing

_checkBlankLabelRejected :: Bool
_checkBlankLabelRejected = fieldConfigFromText "artistName" "artistName" "  " OptionalField Nothing Nothing == Nothing

_checkBlankLegendRejected :: Bool
_checkBlankLegendRejected = nonEmptyText "  " == Nothing

_checkInvalidProjection :: Bool
_checkInvalidProjection =
    case fieldConfigFromText "artistName" "artistName" "Artist name" RequiredField Nothing (Just "Artist name is required.") of
        Nothing -> False
        Just config -> isInvalidField config

Accessibility Validation Feedback

Raw source: Patterns/Forms/AccessibilityValidationFeedback.lhs

Pattern intent and mechanism

Render validation failures so users can find them and focus lands somewhere useful after the failed submission. In IHP/HSX code, field-level wiring belongs to AccessibilityFieldStructure; this pattern covers the form-level response shape around it:

  • a validation summary that lists the failing fields,
  • field-level errors already connected by stable IDs,
  • one explicit post-validation focus target,
  • HTMX fragments that swap visual error markup and focus metadata together.

This is not a generic validation guide. The ordng boundary is how an IHP/HSX form renderer turns application validation results into a reusable summary/focus contract, especially when the failed response is an HTMX fragment rather than a full page render.

The recurring rules:

  1. Field-level errors come first: every invalid field keeps stable id, error id, aria-describedby, and aria-invalid via AccessibilityFieldStructure.
  2. A form with multiple possible errors should render a summary before the fields or at the top of the replaced form fragment.
  3. Summary entries link to the stable field IDs, not to generated row positions or ephemeral component IDs.
  4. Choose one focus target after failed validation: usually the summary for multi-error forms, the first invalid field for short inline forms, or the current control when preserving focus is more orienting.
  5. HTMX validation responses use the shared HTMX focus marker from AccessibilityFocusAndLiveFeedback and include it in the same swapped fragment as the visible summary/error markup. Do not rely on a client script guessing from CSS classes such as .is-invalid.
  6. Focus targets that are not naturally focusable, such as a summary section, need tabindex="-1".
  7. A validation-summary label must not force a page heading level. Use a non-heading label by default, or choose a real heading level only at a page context that keeps the outline valid.
  8. Do not turn every validation failure into a page-wide live announcement. Keep feedback local to the form unless the whole page state changes.
Project-specific notes and rollout guidance

Start at failed-response renderers, not validation functions. Validation can be pure and correct while the rendered failure is inaccessible.

Rollout checks:

  1. Find form actions that return a failed full-page render, failed HTMX fragment, or modal/body replacement.
  2. Verify that field helpers already use AccessibilityFieldStructure for field-level aria-describedby and aria-invalid.
  3. For forms with more than one possible invalid field, render a summary with links to stable field IDs.
  4. Decide the focus strategy per form and encode it in the response. Do not let every caller invent its own focus convention.
  5. For HTMX failed responses, include the shared local HTMX focus marker in the swapped fragment and use the same small htmx:afterSwap hook as other HTMX focus-restoration flows.
  6. Verify with keyboard only: submit invalid data, observe where focus lands, tab through fields, and ensure error text is announced via field associations.

Host evidence from seshn:

  • Custom HTMX validation fragments rendered .is-invalid and .invalid-feedback visually but did not include stable error IDs, aria-invalid, or aria-describedby. That field-level wiring is now captured by AccessibilityFieldStructure; this pattern adds the response-level summary/focus contract.
  • Raw HTMX form fragments are the risk surface. Framework-generated IHP full forms already carry more structure, but their failed-response focus behavior still needs a concrete renderer decision.
  • seshn formFor New/Edit forms now have field-level aria-invalid, aria-describedby, and stable error IDs through their CSS framework path, but still lack response-level validation summaries and post-validation focus targets. This is the boundary this pattern owns: field-level wiring is done by AccessibilityFieldStructure; failed-response orientation is done here.
Supporting snippets

Validation summary before a form:

<section id="validation-summary"
         class="alert alert-danger"
         tabindex="-1"
         aria-labelledby="validation-summary-heading">
  <div id="validation-summary-heading" class="h5">Please fix these fields</div>
  <ul>
    <li><a href="#artistName">Artist name: Artist name is required.</a></li>
  </ul>
</section>

HTMX focus marker inside the swapped fragment:

<span hidden data-htmx-focus-target="validation-summary"></span>

Use the shared marker-driven hook from AccessibilityFocusAndLiveFeedback. Keep the hook narrow: it reads an explicit server-rendered marker. It does not scan the page for arbitrary invalid controls.

Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Forms.AccessibilityValidationFeedback
    ( FieldValidationError(..)
    , ValidationFeedback(..)
    , ValidationFocusStrategy(..)
    , fieldValidationErrorFromConfig
    , hasValidationErrors
    , validationSummaryHeadingId
    , validationFocusTargetId
    , validationFocusTargetDomId
    , renderValidationSummary
    , renderValidationFocusMarker
    , renderValidationFeedback
    ) where

import qualified Data.Text as Text
import IHP.ViewPrelude
import qualified Prelude as P
import Patterns.Htmx.Primitives.AccessibilityFocusAndLiveFeedback
    ( FocusAfterSwap(..)
    , DomId
    , domIdFromText
    , renderHtmxFocusMarker
    )
import Patterns.Forms.AccessibilityFieldStructure
    ( FieldConfig(..)
    , FieldId
    , NonEmptyText
    , fieldIdFromText
    , fieldCaption
    , fieldError
    , fieldId
    , fieldIdText
    , fieldIdentity
    , nonEmptyText
    , nonEmptyTextValue
    )

-- | One field error after validation has been shaped for rendering.
data FieldValidationError = FieldValidationError
    { validationFieldId :: FieldId
    , validationFieldLabel :: NonEmptyText
    , validationFieldMessage :: NonEmptyText
    }
    deriving (Eq, Show)

-- | Where focus should move after rendering failed validation.
data ValidationFocusStrategy
    = FocusValidationSummary
    | FocusFirstInvalidField
    | PreserveCurrentFocus
    deriving (Eq, Show, Enum, Bounded)

-- | Form-level validation feedback for a failed response.
data ValidationFeedback = ValidationFeedback
    { validationSummaryId :: FieldId
    , validationSummaryHeading :: NonEmptyText
    , validationFieldErrors :: [FieldValidationError]
    , validationFormErrors :: [NonEmptyText]
    , validationFocusStrategy :: ValidationFocusStrategy
    }
    deriving (Eq, Show)

-- | Convert a field config with an error into a summary entry.
fieldValidationErrorFromConfig :: FieldConfig -> Maybe FieldValidationError
fieldValidationErrorFromConfig config = do
    err <- fieldError config
    pure FieldValidationError
        { validationFieldId = fieldId (fieldIdentity config)
        , validationFieldLabel = fieldCaption config
        , validationFieldMessage = err
        }

hasValidationErrors :: ValidationFeedback -> Bool
hasValidationErrors feedback =
    not (P.null (validationFieldErrors feedback))
        || not (P.null (validationFormErrors feedback))

validationSummaryHeadingId :: ValidationFeedback -> Text
validationSummaryHeadingId feedback =
    fieldIdText (validationSummaryId feedback) <> "-heading"

validationFocusTargetId :: ValidationFeedback -> Maybe Text
validationFocusTargetId feedback
    | not (hasValidationErrors feedback) = Nothing
    | otherwise = case validationFocusStrategy feedback of
        FocusValidationSummary -> Just (fieldIdText (validationSummaryId feedback))
        FocusFirstInvalidField -> case validationFieldErrors feedback of
            [] -> Just (fieldIdText (validationSummaryId feedback))
            firstError : _ -> Just (fieldIdText (validationFieldId firstError))
        PreserveCurrentFocus -> Nothing

-- | Validation focus target expressed as the shared HTMX focus-marker id.
validationFocusTargetDomId :: ValidationFeedback -> Maybe DomId
validationFocusTargetDomId feedback = validationFocusTargetId feedback >>= domIdFromText
Helper implementation examples

Render a form-level validation summary. Field entries link to field IDs; form entries are listed as text because they do not have a single target field.

The default summary label is deliberately not a heading. Failed-response summaries can appear inside pages, modals, and fragments with different heading contexts; forcing <h2> here would couple this form pattern to the surrounding page outline. If a host wants the summary in heading navigation, render a local variant with the correct contextual heading level.

renderValidationSummary :: ValidationFeedback -> Html
renderValidationSummary feedback
    | not (hasValidationErrors feedback) = mempty
    | otherwise = [hsx|
        <section id={fieldIdText (validationSummaryId feedback)}
                 class="alert alert-danger"
                 tabindex="-1"
                 aria-labelledby={validationSummaryHeadingId feedback}>
            <div id={validationSummaryHeadingId feedback} class="h5">
                {nonEmptyTextValue (validationSummaryHeading feedback)}
            </div>
            <ul class="mb-0">
                {renderValidationSummaryItems feedback}
            </ul>
        </section>
    |]

renderValidationSummaryItems :: ValidationFeedback -> Html
renderValidationSummaryItems feedback =
    renderFieldSummaryItems (validationFieldErrors feedback)
        <> renderFormSummaryItems (validationFormErrors feedback)

renderFieldSummaryItems :: [FieldValidationError] -> Html
renderFieldSummaryItems [] = mempty
renderFieldSummaryItems (err : rest) = renderFieldSummaryItem err <> renderFieldSummaryItems rest

renderFormSummaryItems :: [NonEmptyText] -> Html
renderFormSummaryItems [] = mempty
renderFormSummaryItems (err : rest) = renderFormSummaryItem err <> renderFormSummaryItems rest

renderFieldSummaryItem :: FieldValidationError -> Html
renderFieldSummaryItem err = [hsx|
    <li>
        <a href={"#" <> fieldIdText (validationFieldId err)}>
            {nonEmptyTextValue (validationFieldLabel err)}: {nonEmptyTextValue (validationFieldMessage err)}
        </a>
    </li>
|]

renderFormSummaryItem :: NonEmptyText -> Html
renderFormSummaryItem err = [hsx|
    <li>{nonEmptyTextValue err}</li>
|]

renderValidationFocusMarker :: ValidationFeedback -> Html
renderValidationFocusMarker feedback = case validationFocusTargetDomId feedback of
    Nothing -> mempty
    Just targetId -> renderHtmxFocusMarker (FocusAfterSwapTarget targetId)

renderValidationFeedback :: ValidationFeedback -> Html
renderValidationFeedback feedback =
    renderValidationSummary feedback <> renderValidationFocusMarker feedback
Usage examples

A failed full-page render places the summary before the form and uses field configs from AccessibilityFieldStructure:

renderArtistFormWithErrors :: FieldConfig -> Html
renderArtistFormWithErrors artistNameConfig =
    let feedback = ValidationFeedback
            { validationSummaryId = summaryId
            , validationSummaryHeading = summaryHeading
            , validationFieldErrors = maybe [] pure (fieldValidationErrorFromConfig artistNameConfig)
            , validationFormErrors = []
            , validationFocusStrategy = FocusValidationSummary
            }
    in [hsx|
        {renderValidationFeedback feedback}
        <form method="POST" action="/artists">
            {renderArtistNameField artistNameConfig}
            <button type="submit">Save</button>
        </form>
    |]

For a very small inline HTMX form, focusing the first invalid field can be less jarring than focusing a summary:

inlineFeedback :: [FieldValidationError] -> ValidationFeedback
inlineFeedback errors = ValidationFeedback
    { validationSummaryId = summaryId
    , validationSummaryHeading = summaryHeading
    , validationFieldErrors = errors
    , validationFormErrors = []
    , validationFocusStrategy = FocusFirstInvalidField
    }

These examples assume trusted local constants for summaryId and summaryHeading, built through fieldIdFromText and nonEmptyText at the module/config boundary.

Standalone checks
-- The summary receives focus when the form has errors and chooses summary focus.
_checkSummaryFocus :: Maybe Text
_checkSummaryFocus = validationFocusTargetId sampleFeedback

-- First-invalid focus targets the first field error.
_checkFirstInvalidFocus :: Maybe Text
_checkFirstInvalidFocus = validationFocusTargetId sampleFeedback
    { validationFocusStrategy = FocusFirstInvalidField }

-- No errors means no focus marker.
_checkNoErrorFocus :: Maybe Text
_checkNoErrorFocus = validationFocusTargetId sampleFeedback
    { validationFieldErrors = []
    , validationFormErrors = []
    }

sampleFeedback :: ValidationFeedback
sampleFeedback = ValidationFeedback
    { validationSummaryId = sampleSummaryId
    , validationSummaryHeading = sampleSummaryHeading
    , validationFieldErrors = [sampleFieldError]
    , validationFormErrors = []
    , validationFocusStrategy = FocusValidationSummary
    }

sampleFieldError :: FieldValidationError
sampleFieldError = FieldValidationError
    { validationFieldId = sampleFieldId
    , validationFieldLabel = sampleFieldLabel
    , validationFieldMessage = sampleFieldMessage
    }

sampleSummaryId :: FieldId
sampleSummaryId = must "summary id" (fieldIdFromText "validation-summary")

sampleFieldId :: FieldId
sampleFieldId = must "field id" (fieldIdFromText "artistName")

sampleSummaryHeading :: NonEmptyText
sampleSummaryHeading = must "summary heading" (nonEmptyText "Please fix these fields")

sampleFieldLabel :: NonEmptyText
sampleFieldLabel = must "field label" (nonEmptyText "Artist name")

sampleFieldMessage :: NonEmptyText
sampleFieldMessage = must "field message" (nonEmptyText "Artist name is required")

must :: Text -> Maybe a -> a
must _ (Just value) = value
must label Nothing = P.error ("Invalid sample " <> Text.unpack label)

Submission

Submission

Raw source: Patterns/Forms/Submission/README.md

Patterns for the submission phase of form handling: taking a submitted payload, doing the work it implies, and returning the response.

Patterns
Image Upload

Raw source: Patterns/Forms/Submission/ImageUpload.lhs

Pattern intent and mechanism

Accept an image file from a user, validate it, derive a small set of size variants, and persist both the variants and a header record that the owning entity references via a nullable foreign key. The same shape serves avatars, logos, headers, and similar entity-owned artwork.

The pattern is organized in three layers, deliberately kept pluggable:

  • Client UX. A multipart form is submitted via HTMX. In-flight feedback composes with the generic indicator primitive (see cross-reference in Supporting snippets). Client-captured metadata (file_size, mime_type) is attached as hidden inputs so validation can start at the client and is re-checked server-side.
  • Action. The controller action receives the multipart payload, runs validation, calls a preprocessor to produce variants, writes each variant through a storage backend, then creates the persistence records and updates the owning entity's foreign key. Finally it returns an HTMX-aware response.
  • Persistence. A header record (one per upload) plus one record per variant; the owning entity holds a nullable UUID foreign key to the header record.

Three seams are intentionally held behind interfaces so a downstream project can substitute its own choices:

  • ImagePreprocessor — the image library (JuicyPixels/WebP, ImageMagick, native OS codecs, a remote service, etc.).
  • StorageBackend — the object store (local filesystem, S3, GCS, a signed-URL pipeline, etc.).
  • UploadValidation — the accept/reject policy (mime whitelist, size cap, minimum dimensions).

The persistence schema and the exact HTMX response convention are documented below but deliberately kept out of the reusable orchestration code; they vary per project and are appropriate to cover in the host application rather than forced by the pattern.

Project-specific notes and rollout guidance

A typical rollout configures the pattern along four axes.

Variant sizes. A common default set is four canonical sizes — thumbnail, profile, standard, and large — covering the range from inline listings to high-resolution detail views. Per-entity subsets are normal: avatars may need only thumbnail + profile; article hero images may need all four. Variants that collapse to identical dimensions (because the source image is smaller than the target) can be deduplicated to avoid storing identical bytes twice.

Preprocessor choice. A JuicyPixels + WebP binding is a common choice in IHP applications: bilinear downscale with no upscaling, per-variant quality settings, alpha preserved. Other libraries or output formats are swappable behind ImagePreprocessor without changing the rest of the pattern.

Storage choice. IHP's storeFileWithOptions with an S3-backed file storage is a common choice. A filesystem-backed alternative (shown in Helper implementation examples) is useful for local development, testing, and applications that prefer not to depend on an object store. Swapping backends is a one-function change.

Response strategy. When the action is invoked by HTMX (detected via the HX-Request header), successful uploads respond with HX-Redirect + HX-Refresh headers and an empty body, and failures set an X-Error header with a message body. For non-HTMX clients, both paths fall back to a flash message plus a full redirect. Controllers keep this response logic close to the record-creation code rather than inside the portable orchestration.

The persistence layer typically uses two tables. A media table holds a header record per upload (canonical URI, original filename, file size, mime type, optional caption, uploader id); an image variants table holds one row per produced variant (foreign key to the header, size label, per-variant URI, width, height, file size). The owning entity carries a nullable UUID column referencing the header row, with ON DELETE CASCADE so deleting the header clears the reference without a dangling foreign key. Full DDL is a Domain/Schema concern; the pattern here only documents the shape.

Supporting snippets

This pattern composes existing primitives rather than inlining their realization. Where an existing pattern already captures a piece, this section cross-references it rather than duplicating it.

In-flight feedback. Spinner and progress feedback wiring is captured in Patterns/Htmx/Primitives/Indicator.lhs. That file already notes two relevant points for image upload flows: (a) HTMX upload forms use hx-encoding="multipart/form-data", and (b) real progress composes with the htmx:xhr:progress event. A manually-controlled spinner lifecycle (disable-on-select → show-on-htmx:beforeRequest → restore-on-htmx:afterRequest) is a valid alternative composition when the simpler hx-indicator primitive is not expressive enough for a multi-state upload UI.

Primitives this pattern composes that are not yet in the library. Three small pieces appear in practical rollouts but do not yet have dedicated pattern files; they are candidates for later extraction so this pattern can cross-reference them instead of describing them:

  1. Client-side metadata capture into hidden inputs on file-select. Typical realization: on change of the <input type="file">, read file.size and file.type and inject them as hidden <input> elements named file_size and mime_type so the server receives an initial client view of both.
  2. hx-swap="none" combined with HX-Redirect / HX-Refresh as a response convention. Used when the response replaces no DOM content but navigates or refreshes the page.
  3. Submit-disabled-until-dirty on a single-file upload form. Start with disabled set on the submit control, enable on file select, disable again during the in-flight request.

Schema shape. Persistence uses two relational tables and an entity-side nullable foreign key; the exact DDL (column types, constraints, enum values for variant size) is a Domain/Schema concern and is appropriate for a separate pattern in that topic.

Core reusable pattern infrastructure

The orchestration layer is pure in the sense that it takes everything environment-specific through parameters: the preprocessor, the storage backend, and the validation policy are injected as records of functions, and the inputs (raw bytes, client-reported mime, client-reported size) are passed in by the caller. No controller context, no database, no specific imaging library.

{-# LANGUAGE OverloadedStrings #-}

module Patterns.Forms.Submission.ImageUpload where

import Prelude
import Data.Text (Text)
import qualified Data.Text as T
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as BL
import System.FilePath ((</>))
import qualified System.Directory as Dir

-- | Target output for one size variant.
data VariantSpec = VariantSpec
    { specName :: Text
    , specMaxDim :: Int
    , specQuality :: Int
    }
    deriving (Eq, Show)

-- | One processed output variant.
data ProcessedVariant = ProcessedVariant
    { pvSpec :: VariantSpec
    , pvBytes :: BL.ByteString
    , pvMime :: Text
    , pvWidth :: Int
    , pvHeight :: Int
    }

-- | Full preprocessing result.
newtype ProcessedImage = ProcessedImage { piVariants :: [ProcessedVariant] }

-- | Pluggable image preprocessing interface.
data ImagePreprocessor = ImagePreprocessor
    { preprocess :: ByteString -> [VariantSpec] -> IO (Maybe ProcessedImage)
    , imageDimensions :: ByteString -> IO (Maybe (Int, Int))
    }

-- | Handle for an object the storage backend has persisted.
data StoredObject = StoredObject
    { soUri :: Text
    , soKey :: Text
    }
    deriving (Eq, Show)

-- | Pluggable storage backend interface.
data StorageBackend = StorageBackend
    { storeBytes :: BL.ByteString -> Text -> Text -> Text -> IO StoredObject
    -- ^ bytes, directory, filename, content type
    }

-- | Validation policy applied before any heavy work.
data UploadValidation = UploadValidation
    { acceptedMimeTypes :: [Text]
    , maxSizeBytes :: Maybe Integer
    , minDimensions :: Maybe (Int, Int)
    }
    deriving (Show)

data UploadError
    = FileMissing
    | MimeRejected Text
    | TooLarge Integer
    | TooSmall (Int, Int)
    | PreprocessFailed
    | StorageFailed Text
    deriving (Show)

data UploadOutcome
    = UploadOk [(VariantSpec, StoredObject)]
    | UploadFailed UploadError

-- | Configuration bundle for 'runImageUpload'. Groups the six
-- environment-specific parameters so the orchestration entry point
-- stays readable at call sites.
data UploadConfig = UploadConfig
    { imagePreproc :: ImagePreprocessor
    , storageBackend :: StorageBackend
    , uploadValidation :: UploadValidation
    , variantSpecs :: [VariantSpec]
    , uploadDirectory :: Text
    , uploadBaseName :: Text
    }

-- | Validate → optionally check minimum dimensions → preprocess → store.
-- Returns an UploadOutcome describing either the stored variants or the
-- first error encountered.
runImageUpload
    :: UploadConfig
    -> ByteString
    -> Text
    -> Integer
    -> IO UploadOutcome
runImageUpload UploadConfig { imagePreproc = imagePreproc, storageBackend = storageBackend, uploadValidation = uploadValidation, variantSpecs = variantSpecs, uploadDirectory = uploadDirectory, uploadBaseName = uploadBaseName } raw clientMime clientSize =
    case validatePresubmit uploadValidation clientMime clientSize of
        Just err -> pure (UploadFailed err)
        Nothing -> do
            dimsErr <- checkMinDimensions (imageDimensions imagePreproc) (minDimensions uploadValidation) raw
            case dimsErr of
                Just err -> pure (UploadFailed err)
                Nothing -> do
                    maybeProcessed <- preprocess imagePreproc raw variantSpecs
                    case maybeProcessed of
                        Nothing -> pure (UploadFailed PreprocessFailed)
                        Just processed -> do
                            stored <- mapM (storeOne storageBackend uploadDirectory uploadBaseName) (piVariants processed)
                            pure (UploadOk stored)

validatePresubmit :: UploadValidation -> Text -> Integer -> Maybe UploadError
validatePresubmit v mime size
    | not (null (acceptedMimeTypes v)) && mime `notElem` acceptedMimeTypes v =
        Just (MimeRejected mime)
    | Just cap <- maxSizeBytes v, size > cap =
        Just (TooLarge size)
    | otherwise = Nothing

checkMinDimensions
    :: (ByteString -> IO (Maybe (Int, Int)))
    -> Maybe (Int, Int)
    -> ByteString
    -> IO (Maybe UploadError)
checkMinDimensions _ Nothing _ = pure Nothing
checkMinDimensions getDims (Just (minW, minH)) raw = do
    maybeDims <- getDims raw
    pure $ case maybeDims of
        Just (w, h) | w < minW || h < minH -> Just (TooSmall (w, h))
        _ -> Nothing

storeOne
    :: StorageBackend
    -> Text
    -> Text
    -> ProcessedVariant
    -> IO (VariantSpec, StoredObject)
storeOne backend directory baseName pv = do
    let spec = pvSpec pv
    let filename = baseName <> "_" <> specName spec
    obj <- storeBytes backend (pvBytes pv) directory filename (pvMime pv)
    pure (spec, obj)
Helper implementation examples

Three concrete pieces follow: a set of default configuration values, a stub preprocessor that passes bytes through unchanged, and a filesystem-backed storage backend. All three rely only on stdlib imports so the pattern file typechecks without a specific image library or cloud SDK in scope. Replacing the stubs with a real image library (JuicyPixels + WebP, for example) or a real object store (IHP's storeFileWithOptions against an S3 bucket, for example) is a one-record-at-a-time swap: no other code in the pattern needs to change.

Default configurations — a canonical four-variant size set and two example validation policies:

canonicalVariantSizes :: [VariantSpec]
canonicalVariantSizes =
    [ VariantSpec "thumbnail" 150 80
    , VariantSpec "profile" 512 90
    , VariantSpec "standard" 1024 90
    , VariantSpec "large" 1600 90
    ]

avatarValidation :: UploadValidation
avatarValidation = UploadValidation
    { acceptedMimeTypes = ["image/png", "image/jpeg"]
    , maxSizeBytes = Just (10 * 1024 * 1024)
    , minDimensions = Just (512, 512)
    }

logoValidation :: UploadValidation
logoValidation = avatarValidation { minDimensions = Just (256, 256) }

Identity preprocessor — useful as a stub during wiring and as a smoke test for the orchestration code. A production binding (for example against JuicyPixels' decodeImage + convertRGBA8 and Codec.Picture.WebP's encodeRgba8, with a bilinear no-upscale resize) drops into the same ImagePreprocessor shape:

identityPreprocessor :: ImagePreprocessor
identityPreprocessor = ImagePreprocessor
    { preprocess = \raw specs ->
        let oneVariant spec = ProcessedVariant
                { pvSpec = spec
                , pvBytes = BL.fromStrict raw
                , pvMime = "application/octet-stream"
                , pvWidth = 0
                , pvHeight = 0
                }
        in pure (Just (ProcessedImage (map oneVariant specs)))
    , imageDimensions = \_ -> pure Nothing
    }

Filesystem storage backend — writes each variant to a directory tree rooted at baseDir and returns a file:// URI. A cloud backend (S3, GCS, Cloudflare R2) wraps its SDK's put-object call in the same StorageBackend shape and returns the resulting public or signed URL:

filesystemStorageBackend :: FilePath -> StorageBackend
filesystemStorageBackend baseDir = StorageBackend
    { storeBytes = \bytes directory filename _contentType -> do
        let dirPath = baseDir </> T.unpack directory
        let absPath = dirPath </> T.unpack filename
        Dir.createDirectoryIfMissing True dirPath
        BL.writeFile absPath bytes
        pure StoredObject
            { soUri = "file://" <> T.pack absPath
            , soKey = T.pack (T.unpack directory </> T.unpack filename)
            }
    }
Usage examples

An action-layer caller typically composes the orchestration entry point with application-specific record creation and response handling. The following example shows the portable half — construction of the arguments and the call to runImageUpload — without assuming any particular ORM, controller framework, or HTMX response convention. A host application wraps this call in its own controller action, threads through the authenticated user, creates its Medium / ImageVariant records from the returned UploadOk payload, updates the owning entity's foreign key, and emits the desired HTMX-aware response:

uploadAvatarExample
    :: ByteString
    -> Text
    -> Integer
    -> IO UploadOutcome
uploadAvatarExample raw clientMime clientSize =
    runImageUpload uploadConfig raw clientMime clientSize
  where
    uploadConfig = UploadConfig
        { imagePreproc = identityPreprocessor
        , storageBackend = filesystemStorageBackend "/tmp/pattern-uploads"
        , uploadValidation = avatarValidation
        , variantSpecs = [thumbnailSpec, profileSpec]
        , uploadDirectory = "user_avatars"
        , uploadBaseName = "avatar-000001"
        }

    thumbnailSpec = VariantSpec "thumbnail" 150 80
    profileSpec = VariantSpec "profile" 512 90
Standalone checks

Not applicable in this pattern.

Haskell

Haskell

Raw source: Patterns/Haskell/README.md

Haskell language idioms and project conventions that recur across ordng and its reference hosts.

These patterns are not tied to any framework. They are structural habits that keep polymorphic code readable, maintainable, and agent-friendly.

Catalog

Pattern File Scope
ExplicitRecordBinding ExplicitRecordBinding.lhs Config-record deconstruction without extensions
PermissionGroupedConfigRecord PermissionGroupedConfigRecord.lhs Group permission booleans into semantic subrecords instead of adjacent positional Bools
ActorPredicateVocabulary ActorPredicateVocabulary.lhs Named predicates for presence (isLoggedIn) vs privilege (isAdmin/isMember) with HSX render gates

Actor Predicate Vocabulary

Raw source: Patterns/Haskell/ActorPredicateVocabulary.lhs

Pattern intent and mechanism

Define the bottom-most authentication layer as named predicates over Maybe User. This separates presence (authentication) from privilege (authorization) and provides render-gates for HSX.

The mechanism:

  1. Primitive predicates: isLoggedIn, isMember, isAdmin :: Maybe User -> Bool defined against their own primitive decision points. isLoggedIn is presence (isJust); privilege predicates use exhaustive enum classifiers, not each other.
  2. Exhaustive privilege classification: each privilege predicate classifies every current privilege constructor with a complete pattern match. A module-local -Werror=incomplete-patterns turns enum growth into a build failure at the classification site.
  3. Render gates: positive-only whenLoggedIn, whenAdmin for HSX with scope rule: whenAdmin is global admin chrome; per-resource gating stays on the permission/role axis.
  4. Axis separation: Presence/authentication ≠ privilege/authorization.
Project-specific notes and rollout guidance

Scope rule. whenAdmin controls global admin UI chrome (headers, navigation). Do not use it for per-resource authorization decisions; keep those on the ScopedRolePermissionRegistry axis.

Bit-rot safety. When extending the user role enum (e.g., adding Moderator), update each privilege predicate's exhaustive classifier. isLoggedIn stays isJust because presence is enum-independent. The compiler, not a runtime test, should fail if a privilege constructor has not been consciously classified.

HSX hygiene. Render gates return Html; their negative branch is mempty. If a view needs both branches, introduce an explicitly named local choice helper (ifLoggedIn, authChoice) rather than widening the when... gate.

Supporting snippets

Primitive presence predicate:

isLoggedIn :: Maybe User -> Bool
isLoggedIn = isJust

Exhaustive privilege classifiers:

{-# OPTIONS_GHC -Werror=incomplete-patterns #-}

isMember :: Maybe User -> Bool
isMember = maybe False (roleIsMember . userRole)
  where
    roleIsMember Member = True
    roleIsMember Admin  = False

isAdmin :: Maybe User -> Bool
isAdmin = maybe False (roleIsAdmin . userRole)
  where
    roleIsAdmin Admin  = True
    roleIsAdmin Member = False

Adding Moderator to Role now breaks the build until every classifier handles it explicitly.

Render gates for HSX:

whenLoggedIn :: Maybe User -> Html -> Html
whenLoggedIn mUser html =
    if isLoggedIn mUser then html else mempty

whenAdmin :: Maybe User -> Html -> Html
whenAdmin mUser html =
    if isAdmin mUser then html else mempty
Core reusable pattern infrastructure
{-# OPTIONS_GHC -Werror=incomplete-patterns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Patterns.Haskell.ActorPredicateVocabulary where

import Prelude
import Data.Text (Text)
import Data.Maybe (isJust)
import Data.String (IsString(..))

-- | Stub Html type for compilation (IHP provides the real Html type)
newtype Html = Html Text
    deriving (Eq, Show, Semigroup, Monoid)

instance IsString Html where
    fromString = Html . fromString

-- | Stub User type for compilation
data Role = Admin | Member
    deriving (Eq, Show)

data User = User
    { userRole :: Role
    }

-- | Presence: authentication axis
isLoggedIn :: Maybe User -> Bool
isLoggedIn = isJust

-- | Privilege: authorization axis - member
isMember :: Maybe User -> Bool
isMember = maybe False (roleIsMember . userRole)
  where
    roleIsMember Member = True
    roleIsMember Admin  = False

-- | Privilege: authorization axis - admin
isAdmin :: Maybe User -> Bool
isAdmin = maybe False (roleIsAdmin . userRole)
  where
    roleIsAdmin Admin  = True
    roleIsAdmin Member = False
-- | Render gate: presence
whenLoggedIn :: Maybe User -> Html -> Html
whenLoggedIn mUser html =
    if isLoggedIn mUser then html else mempty

-- | Render gate: admin privilege
whenAdmin :: Maybe User -> Html -> Html
whenAdmin mUser html =
    if isAdmin mUser then html else mempty
Helper implementation examples
-- Example: navigation chrome that only shows for admins
adminNavLink :: Maybe User -> Html
adminNavLink mUser = whenAdmin mUser "[Admin Panel]"

-- Example: account link that only shows for logged-in users
accountLink :: Maybe User -> Html
accountLink mUser = whenLoggedIn mUser "[Account]"
Usage examples

Controller providing currentUser to view:

action DashboardAction = do
    -- currentUserOrNothing :: Maybe User from IHP Auth
    render DashboardView { currentUser = currentUserOrNothing }

View using render gates:

render DashboardView { currentUser = currentUser } = [hsx|
    <nav>
        {whenAdmin currentUser [hsx|<a href="/admin">Admin</a>|]}
    </nav>
    {whenLoggedIn currentUser
        [hsx|<p>Your dashboard content</p>|]}
|]
Standalone checks

GHC type-check (no runtime):

direnv exec . ghci -v0 -ignore-dot-ghci -fno-code \
  Patterns/Haskell/ActorPredicateVocabulary.lhs

Expected: clean type-check, no warnings.

Exhaustiveness tripwire check:

-- Temporarily add a new Role constructor, e.g. Moderator, without updating
-- roleIsMember and roleIsAdmin. The GHC check above should fail with
-- -Wincomplete-patterns promoted to an error.

Explicit Record Binding

Raw source: Patterns/Haskell/ExplicitRecordBinding.lhs

Pattern intent and mechanism

Introduce a config record when a function accumulates too many positional arguments. Then deconstruct that record with an explicit record pattern in the argument list rather than using RecordWildCards or NamedFieldPuns.

The full refactor path is:

  1. Positional arguments — what you start with in host code.
  2. Config record — group related parameters into a single record type.
  3. Explicit record pattern — bind fields locally at the function clause.

Step 1 collapses arity. Step 2 keeps the binding site self-contained: every name introduced by the pattern is visible in the function signature and the clause head. No language extension is required, and there is no risk of name collisions with imported identifiers (for example IHP's SubmitButton, JobWorkerProcess, or other common record labels).

Best-practice threshold:

  • Three or fewer arguments — stay positional.
  • Four or more — consider a config record.

This threshold is a guideline, not a law. A function with four positional arguments is often still readable; with five it usually is not. When in doubt, introduce the record earlier rather than later.

Use this pattern when:

  • a function has four or more positional arguments,
  • several arguments travel together as a bundle across multiple call sites,
  • the record is polymorphic (carries type parameters),
  • the function body is large enough that record.field accessors would add noise,
  • the project avoids RecordWildCards to keep binding sites explicit.

The gain is largest for exported generic helpers that are called from many different modules. Local where-bound functions often stay positional because the surrounding context already names the concepts.

The rule of thumb is: if you would reach for RecordWildCards, use an explicit record pattern instead. It gives the same brevity without the extension and without the namespace pollution.

This pattern is not a smart constructor. The record constructor is exported and unconstrained. The goal is local binding convenience and Hoogle discoverability, not invariant enforcement at the construction site.

When multiple config records in the same module share field names, enable DuplicateRecordFields. The explicit record pattern still binds the names locally, so there is no ambiguity at the use site.

Project-specific notes and rollout guidance

Introduce the record pattern at the same time the config record is introduced. Refactor existing positional functions when the parameter count becomes painful (typically four or more).

When a config record has many fields, group related ones into nested records rather than flattening everything into one top-level record. The outer function then matches on the nested shape, and inner helpers match on the inner shape.

Keep record field names short but unambiguous inside the local scope. Prefixes like item (as in itemLabel, itemAction) are acceptable when the record is a config for a single component.

Finding candidates.

When scanning a module for functions ready for this refactor, look for definition lines with four or more positional arguments before the = sign.

Use two thresholds:

  • Five or more arguments — refactor now. These are almost always painful and the grep is unambiguous.
  • Four arguments — consider refactoring. Manual review is needed because the context may still be readable.

A practical grep for IHP projects (adjust paths to your source directories):

grep -rnE '^[[:space:]]*[a-z_][a-zA-Z0-9_]*([[:space:]]+[a-zA-Z0-9_]+){4,}[[:space:]]*=' Web/ Application/ \
  | grep -vE '\b(import|data|type|newtype|class|instance|where|deriving|in|let|if|then|else|case|of|do|return|pure)\b|--'

The grep -vE removes declarations (data, type), keywords (where, let), and comments. Even so, the raw output needs manual filtering:

  • where-bound local functions often stay positional because the surrounding context already names the concepts.
  • Functions that already use record patterns (Config { .. } =) are already refactored; skip them.
  • Type signatures (::) are not matched by the regex, which is correct.

A stronger signal than argument count alone is an argument bundle that travels together across call sites — repeated tuple parameters or long partial-application chains often mean a config record is already emerging implicitly.

Supporting snippets

Positional (before):

renderWithConfig label action rows value = ...

Record construction (intermediate):

renderWithConfig config =
    let Config { label = label, action = action, rows = rows, value = value } = config
    in ...

Explicit record pattern (canonical):

renderWithConfig Config { label = label, action = action, rows = rows, value = value } = ...

Nested record pattern:

renderWithConfig Config { label = label, action = action, inner = Inner { rows = rows, value = value } } = ...
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Haskell.ExplicitRecordBinding where

import Prelude
import Data.Text (Text, pack)

-- | A generic config record with two type parameters.
-- | The field names are prefixed to stay unique in local scope.
data Config a action = Config
    { itemLabel  :: a -> Text
    , itemAction :: Maybe Text -> action
    , itemRows   :: [Row a]
    , itemValue  :: Maybe a
    }

-- | A row of values with an associated tag.
newtype Row a = Row [(a, Tag)]
    deriving (Eq, Show)

-- | A tag for visual or semantic classification.
data Tag = Primary | Secondary | Neutral
    deriving (Eq, Show)
Helper implementation examples
-- | Render from a config using an explicit record pattern.
-- | All four fields are bound locally; no extension is needed.
renderFromConfig :: Config a action -> Text
renderFromConfig Config { itemLabel = itemLabel, itemAction = itemAction, itemRows = itemRows, itemValue = itemValue } =
    "Config with " <> pack (show (length itemRows)) <> " rows"

-- | A variant that ignores the action field.
-- | The explicit pattern omits unused fields; only bound names appear.
renderWithoutAction :: Config a action -> Text
renderWithoutAction Config { itemLabel = itemLabel, itemRows = itemRows, itemValue = itemValue } =
    "Config with " <> pack (show (length itemRows)) <> " rows and selected " <> maybe "none" itemLabel itemValue
Usage examples

A domain enum and its label function.

data Priority = Low | Medium | High
    deriving (Eq, Show)

priorityLabel :: Priority -> Text
priorityLabel Low    = "Low"
priorityLabel Medium = "Medium"
priorityLabel High   = "High"

An action type that receives the raw text value.

data UpdateAction = UpdateAction Int (Maybe Text)
    deriving (Eq, Show)

A concrete config with explicit construction.

priorityConfig :: Config Priority UpdateAction
priorityConfig = Config
    { itemLabel  = priorityLabel
    , itemAction = UpdateAction 1
    , itemRows   = [ Row [(Low, Neutral), (Medium, Primary), (High, Primary)] ]
    , itemValue  = Just Medium
    }

Calling the helper with the record pattern.

usageExample :: Text
usageExample = renderFromConfig priorityConfig
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Permission Grouped Config Record

Raw source: Patterns/Haskell/PermissionGroupedConfigRecord.lhs

Pattern intent and mechanism

When a view helper carries both data and several permission decisions, flattening the permissions into positional Bool arguments or into an unstructured top-level config record is fragile and type-poor. Adjacent Bools swap silently; a six-argument signature gives no semantic hint which boolean means what.

This pattern groups permission booleans into a dedicated semantic subrecord that travels inside the parent config. The subrecord name (SectionPermissions, ItemPermissions) makes the intent grepable; the field names inside make each decision self-documenting at the call site.

The refactor path is:

  1. Positional booleansrenderSection entity items today role canCreate canManage canWrite.
  2. Flat config recordSectionConfig { ..., canCreateItem, canManageItem, canWriteStatus }.
  3. Grouped permission subrecordSectionConfig { ..., permissions = SectionPermissions { canCreateItem, canManageItem, canWriteStatus } }.

Step 3 keeps the permission bundle visible as a unit. When a controller computes decisions and passes them to a view, the subrecord constructor line is a single grepable boundary.

Use this pattern when:

  • a view or helper function takes three or more permission booleans,
  • the same permission bundle is passed to multiple helpers (section, row, inline control),
  • permission decisions are computed by the controller and consumed by the view; they must not be re-derived inside the helper.

This is a Haskell pattern because the mechanism is record structure. It complements ExplicitRecordBinding, which covers the generic arity-collapse rule; this pattern adds the permission-specific grouping rule.

Project-specific notes and rollout guidance

Keep the permission subrecord pure data. Do not put derived presentation state (isEngaged, shouldHighlight) into the permission record; that belongs in the view or in a separate presentation helper. The permission record answers "what may this actor do?" not "how should this look?"

Name the subrecord after the section or component it serves: ProjectSectionPermissions, TaskItemPermissions, EventsSectionPermissions. The examples below use generic names (SectionPermissions, ItemPermissions) as compile-only scaffolding; in production code, use domain-specific names that make the context grepable. The parent config names both data and permission dependencies explicitly:

data SectionConfig = SectionConfig
    { parentEntity :: EntityWithTitle
    , childItems   :: [ItemWithTitle]
    , today        :: Day
    , permissions  :: SectionPermissions
    }

A single controller action may build the permission subrecord once and pass it to every helper that needs it:

let permissions = SectionPermissions
        { canCreateItem = canInScope ctx CreateItem
        , canManageItem = canInScope ctx ManageItem
        , canWriteStatus = canInScope ctx WriteStatus
        }
render SectionConfig { parentEntity = parentEntity, childItems = childItems, today = today, permissions = permissions }

When a helper needs only a subset of the permissions, deconstruct the subrecord explicitly rather than passing the whole bundle and ignoring fields. This keeps the helper's contract honest.

Do not use RecordWildCards on the permission subrecord at the call site. Explicit field names prevent silent swaps when the subrecord gains a new field.

Supporting snippets

Before: positional booleans adjacent to data arguments.

-- Fragile: a swap between canManageItem and canWriteStatus
-- compiles silently and changes security behavior.
itemsSection
    :: EntityWithTitle -> [ItemWithTitle] -> Day
    -> Bool -> Bool -> Bool -> Html
itemsSection entity items today canCreateItem canManageItem canWriteItemStatus =
    [hsx| ... |]

After: named permission subrecord inside the config.

itemsSection :: SectionConfig -> Html
itemsSection SectionConfig { parentEntity = parentEntity, childItems = childItems, today = today, permissions = permissions } =
    let SectionPermissions { canCreateItem = canCreateItem, canManageItem = canManageItem, canWriteStatus = canWriteItemStatus } = permissions
    in [hsx| ... |]

Row helper with its own permission subset:

data ItemRowConfig = ItemRowConfig
    { itemData    :: ItemWithTitle
    , today       :: Day
    , permissions :: ItemRowPermissions
    }

data ItemRowPermissions = ItemRowPermissions
    { canEditItem   :: Bool
    , canDeleteItem :: Bool
    }
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Haskell.PermissionGroupedConfigRecord where

import Prelude
import Data.Text (Text, pack)

-- | Generic permission subrecord for a section that creates, manages,
-- and writes status on items.
data SectionPermissions = SectionPermissions
    { canCreateItem :: Bool
    , canManageItem :: Bool
    , canWriteStatus :: Bool
    }
    deriving (Eq, Show)

-- | Generic section config that carries both data and permission state.
data SectionConfig item = SectionConfig
    { sectionTitle :: Text
    , sectionItems :: [item]
    , sectionPermissions :: SectionPermissions
    }
    deriving (Eq, Show)

-- | Generic item config with a narrower permission subset.
data ItemConfig item = ItemConfig
    { itemData :: item
    , itemPermissions :: ItemPermissions
    }
    deriving (Eq, Show)

-- | Narrower permission bundle for a single row or inline control.
data ItemPermissions = ItemPermissions
    { canEditItem :: Bool
    , canDeleteItem :: Bool
    }
    deriving (Eq, Show)
Helper implementation examples

Section renderer consuming a grouped permission subrecord.

-- | Render a section. Permission decisions are deconstructed from the
-- nested subrecord; the function signature names only the config.
renderSection :: SectionConfig Text -> Text
renderSection SectionConfig { sectionTitle = sectionTitle, sectionItems = sectionItems, sectionPermissions = sectionPermissions } =
    let SectionPermissions { canCreateItem = canCreateItem, canManageItem = canManageItem, canWriteStatus = canWriteStatus } = sectionPermissions
        actionCount = length (filter id [canCreateItem, canManageItem, canWriteStatus])
    in sectionTitle <> " (" <> pack (show actionCount) <> " actions permitted)"

Row renderer with a narrower permission subset.

-- | Render a single row. The helper only receives the permissions it
-- needs, not the full section bundle.
renderRow :: ItemConfig Text -> Text
renderRow ItemConfig { itemData = itemData, itemPermissions = itemPermissions } =
    let ItemPermissions { canEditItem = canEditItem, canDeleteItem = canDeleteItem } = itemPermissions
        suffix = if canEditItem && canDeleteItem
                 then " [full control]"
                 else if canEditItem
                      then " [edit only]"
                      else " [read only]"
    in itemData <> suffix
Usage examples

Concrete data types for demonstration.

-- | A domain item with a title.
data Item = Item { itemTitle :: Text }
    deriving (Eq, Show)

Controller computes permissions once, passes them to the section helper.

-- | Simulated controller boundary: compute permissions, then build config.
exampleSectionConfig :: SectionConfig Text
exampleSectionConfig = SectionConfig
    { sectionTitle = "Upcoming Items"
    , sectionItems = ["Jazz Night", "Open Mic"]
    , sectionPermissions = SectionPermissions
        { canCreateItem = True
        , canManageItem = True
        , canWriteStatus = False
        }
    }

-- | The rendered section. Permission count is 2 (create + manage).
sectionOutput :: Text
sectionOutput = renderSection exampleSectionConfig

Row config with a narrower permission subset.

-- | A row config with only edit and delete permissions.
exampleRowConfig :: ItemConfig Text
exampleRowConfig = ItemConfig
    { itemData = "Jazz Night"
    , itemPermissions = ItemPermissions
        { canEditItem = True
        , canDeleteItem = False
        }
    }

-- | The rendered row shows "[edit only]".
rowOutput :: Text
rowOutput = renderRow exampleRowConfig

Grepable permission flow: the subrecord constructor line is the boundary.

-- | True: the section output contains the permission count.
sectionHasTwoActions :: Bool
sectionHasTwoActions = sectionOutput == "Upcoming Items (2 actions permitted)"

-- | True: the row output reflects the narrower permission subset.
rowIsEditOnly :: Bool
rowIsEditOnly = rowOutput == "Jazz Night [edit only]"
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. The SectionPermissions and ItemPermissions subrecords are generic stand-ins for domain-specific permission bundles such as SectionPermissions or ItemPermissions.

HTMX

HTMX Patterns

Raw source: Patterns/Htmx/README.md

Index and reference for the HTMX pattern catalogue in this directory.

This README uses interaction-first grouping, true to HTMX behavior, and adds Atomic Design vocabulary as a secondary lens for communication.

Glossary (canonical terms used in this topic):

  • target container = the DOM node intended to be replaced by an HTMX response.
  • self-swap = the replaced node targets itself (typically hx-target="this", hx-swap="outerHTML").
  • modal mount = stable layout-level modal target (e.g. #modal-container).
  • OOB (Out-of-Band) = update of DOM nodes outside the primary response target (hx-swap-oob).
  • HSX = IHP HTML/HSX templating syntax (quasiquoted HTML).

Table of Contents

Plan and Rollout Context

Topic-completion notes and current host-project feedback are documented in:

Naming

Prefer names at the level of the reusable interaction pattern, not at the level of one concrete host-project use case.

Current naming still spans HTMX mechanisms, interaction patterns, and migration/reference labels. The aim is to stabilize it around the most reusable pattern boundary in each case.

Within this topic, the most stable naming categories are:

Category What the name captures Typical files
HTMX mechanism / primitive target, swap, indicator, OOB update Primitives/Indicator.lhs, Primitives/OobUpdates.lhs
Interaction pattern a reusable UX flow built from HTMX primitives Composed/Typeahead.lhs, Composed/AssociationTable.lhs
Migration note source material and migration context Migration/MigrateReactPatterns.lhs, Migration/MigrateReactD3StackCharts.lhs

For reusable patterns, prefer the first two categories. Avoid names that mix a specific use case with a low-level mechanism unless that use case is the actual pattern boundary.

Pattern Structure

This diagram shows the current topic structure: shared infrastructure at the bottom, foundation primitives above it, composed interactions above them, and migration notes kept separate.

Solid edges mean "builds on". Dashed edges mean "optional / commonly composed with", not a hard dependency.

Within the general seven-part pattern skeleton defined in Patterns/ARCHITECTURE.md, small HTMX primitives often place typed selectors, canonical constants, and other portable support code in section 4, while the visible interaction shape (hx-target, hx-swap, trigger wiring) appears most directly in section 6. This is intentional: section 4 captures the reusable abstraction layer, section 6 shows the HTMX pattern at the call site.

Shared infrastructureFoundation primitivesComposed interactionsMigration referencesTypesIndicatorFilterTargetNestedControlClickIsolationAccessibilityFocusAndLiveFeedbackOobUpdatesModalEnumFromParamInlineDropdownInlineButtonGroupConditionalHtmxComponentPermissionAwareEnumControlTypeaheadTargetedPartialSectionFilterSpeculativeExternalSearchFeedAssociationTableMigrateReactPatternsMigrateReactD3StackCharts

Pattern Catalog

The catalog is organized from foundation primitives through composed interactions to migration notes. A typical dependency chain is Indicator / FilterTarget / OOB / Modal -> Typeahead -> AssociationTable; InlineDropdown builds on the self-swap side of FilterTarget and commonly composes with NestedControlClickIsolation; the clearable specialization adds the Maybe a option lift.

Accessibility-relevant HTMX entry points:

For full cross-topic order, use ../../Recipes/AccessibilityHardening.md.

A) Foundation Primitives (Interaction Atoms)
  • Shared/Types.lhs
    • Interaction purpose: shared typed selector vocabulary for HTMX attributes such as hx-target and hx-indicator
    • Atomic/Storybook analogy: low-level interaction utility / typed selector glossary
    • Typical scenario: reuse HxSelector across multiple HTMX patterns instead of repeating raw selector strings
  • Primitives/Indicator.lhs
    • Interaction purpose: request-in-flight feedback
    • Atomic/Storybook analogy: UI atom + loading state story
    • Typical scenario: show spinner or pending affordance during hx-* request
  • Primitives/FilterTarget.lhs
    • Interaction purpose: isolate updates to a dedicated fragment target
    • Atomic/Storybook analogy: container atom + filtered-results story
    • Typical scenario: submit filters, replace only results table/list
  • Primitives/NestedControlClickIsolation.lhs
    • Interaction purpose: keep nested controls usable inside clickable HTMX containers
    • Atomic/Storybook analogy: event-boundary atom + clickable-row/card story
    • Typical scenario: edit/delete buttons, checkboxes, or dropdowns inside clickable rows, cards, tiles, or list items
  • Primitives/AccessibilityFocusAndLiveFeedback.lhs
    • Interaction purpose: keep users oriented after HTMX swaps through explicit focus targets and local live feedback
    • Atomic/Storybook analogy: response-contract atom + async feedback story
    • Typical scenario: self-swapping fragments, result-list updates, and OOB updates that need focus continuity or a local status announcement
  • Primitives/OobUpdates.lhs
    • Interaction purpose: update additional DOM nodes from one response using OOB (hx-swap-oob) swaps
    • Atomic/Storybook analogy: cross-component sync atom + derived-values story
    • Typical scenario: update totals/derived cells without replacing input region
  • Primitives/Modal.lhs
    • Interaction purpose: stable modal mount point (#modal-container)
    • Atomic/Storybook analogy: layout atom + modal-open/modal-close stories
    • Typical scenario: load/replace modal body via HTMX from different screens
  • Primitives/EnumFromParam.lhs
    • Interaction purpose: parse posted text into typed enum values at the action boundary
    • Atomic/Storybook analogy: input-validation atom + type-safe boundary story
    • Typical scenario: translate Maybe Text from an HTMX dropdown post into Maybe Enum
  • Primitives/SemanticValueAxes.lhs
    • Interaction purpose: separate the meaning of a discrete value from its visual intensity and interactivity
    • Atomic/Storybook analogy: design-token atom (domain colour × salience × actionability)
    • Typical scenario: render any categorical value (enum, badge, status) with consistent semantic colour independent of whether it is clickable, read-only, or disabled
B) Composed Interactions (Molecules/Organisms)
  • Composed/InlineDropdown.lhs
    • Interaction purpose: inline enum-like selection with self-swapping replacement boundary; base pattern for non-clearable fields, with a clearable specialization for nullable values
    • Atomic/Storybook analogy: molecule (dropdown trigger + option list + optional clear action)
    • Builds on: target/swap pattern; commonly nested control click isolation
    • Typical scenario: edit a value in place from a row, card, tile, or compact detail fragment; clearable variant for nullable fields; policy-aware variant with disabled-but-visible options
  • Composed/InlineButtonGroup.lhs
    • Interaction purpose: inline enum-like selection via exposed buttons with self-swapping replacement boundary
    • Atomic/Storybook analogy: molecule (button row + selection action)
    • Builds on: target/swap pattern; commonly nested control click isolation
    • Typical scenario: edit a small enum in place where immediate visibility of all options matters; variant with hidden input for traditional form integration in New or Edit views; policy-aware variant with disabled-but-visible options
  • Composed/Typeahead.lhs
    • Interaction purpose: search-as-you-type and select from server-rendered matches
    • Atomic/Storybook analogy: molecule (input + result list + selection action)
    • Builds on: target/swap pattern; commonly indicator
    • Typical scenario: entity lookup/attach flow in forms
  • Composed/ConditionalHtmxComponent.lhs
    • Interaction purpose: swap between empty (CTA) and present (edit/view) states via a stable HTMX replacement boundary
    • Atomic/Storybook analogy: molecule (container + conditional content)
    • Builds on: target/swap pattern; optionally nested control click isolation
    • Typical scenario: subscribe/unsubscribe toggle, create/delete presence indicator, or any two-state empty/present flow
    • Contains: any other HTMX pattern as swapEmpty or swapPresent (e.g. InlineDropdown for inline editing, InlineButtonGroup for status selection)
  • Composed/PermissionAwareEnumControl.lhs
    • Interaction purpose: apply one permission predicate and one semantic enum configuration across dropdown, button-group, and static read-only shapes
    • Atomic/Storybook analogy: organism wrapper (policy context + enum control shape)
    • Builds on: InlineDropdown, InlineButtonGroup, action policy gates; optionally nested control click isolation
    • Typical scenario: enum field rendered in table rows, show pages, forms, and HTMX update responses without permission drift
    • Critical rule: initial render and HTMX response must both return the permission-aware wrapper, never a raw interactive control
  • Composed/TargetedPartialSectionFilter.lhs
    • Interaction purpose: filter a page section in place via HTMX, returning only that section when the request targets it, and the full page otherwise
    • Atomic/Storybook analogy: organism (filter bar + stable section wrapper + conditional controller response)
    • Builds on: FilterTarget; optionally SemanticValueAxes for filter-button presentation
    • Typical scenario: time filters, status filters, or any tab-like section switch that should update the URL and replace only the relevant section
    • Critical rule: the HTMX response must include the section wrapper with its stable id, not just inner content
  • Composed/SpeculativeExternalSearchFeed.lhs
    • Interaction purpose: fire an external provider search in parallel with local search while keeping local and provider result targets separate
    • Atomic/Storybook analogy: organism adjunct (local search plus external fallback feed)
    • Builds on: Typeahead and FilterTarget; optionally Indicator; server side composes with external provider endpoint/result patterns
    • Typical scenario: enrich local entity search with optional provider results without letting the provider replace local results
  • Composed/AssociationTable.lhs
    • Interaction purpose: manage many-to-many associations end-to-end
    • Atomic/Storybook analogy: organism (table + search + selection + delete/confirm)
    • Builds on: typeahead, modal, optional OOB updates
    • Typical scenario: attach/detach related records in admin UIs
C) Migration References (Story/Playground References)

Source and Migration Context

Primary source material was migrated from:

  • docs/Fundamental_HTMX_Patterns/*

The .lhs files in this directory keep narrative and companion code in one place.

Lightweight Check (When Needed)

Run parse/typecheck only from the project dev environment:

direnv exec . ghci -v0 -ignore-dot-ghci -fno-code Patterns/Htmx/Shared/*.lhs Patterns/Htmx/Primitives/*.lhs Patterns/Htmx/Composed/*.lhs Patterns/Htmx/Migration/*.lhs

Companion-Code Notes

Some .lhs companions intentionally include placeholder types/helpers or small compile-safety adjustments so they remain standalone-checkable and HLS/GHCi-friendly.

Document file-specific notes close to the relevant snippet inside the same .lhs file.

Composed

Association Table (Typeahead + OOB Delete)

Raw source: Patterns/Htmx/Composed/AssociationTable.lhs

Pattern intent and mechanism

Manage many-to-many associations with one composed HTMX flow: load current associations, search candidates, accumulate selections, and remove existing rows with OOB updates.

Typical use: admin-to-entity association managers.

Project-specific notes and rollout guidance

Practical rollout guidance:

  • normalize naming for search target containers and hidden-input chain nodes
  • pick one delete behavior per flow (modal + OOB, or simple self-swap)
  • keep modal targeting aligned with the canonical modal mount pattern

Performance and UX notes:

  • exclude already-associated + pending ids from typeahead queries
  • support batch creation with accumulated hidden inputs (paramList on submit)
  • reuse one modal clear action when possible
Supporting snippets

Interaction topology:

Page View  -->  hx-trigger="load"  -->  AssociationsAction (renders all rows)
                                              |
                                    forEach admins renderAdmin
                                              |
                              +-----------+---+---+-----------+
                              |           |       |           |
                         Badge Buttons   Typeahead Input    Submit
                              |           |                   |
                    hx-get Modal    hx-post Search    POST Form
                              |           |                   |
                    Delete OOB    Select (hidden input)   Bulk Create

Key wiring snippets:

  1. Entry component (hx-trigger="load")
adminScorecardComponent = [hsx|
    <div id="admin-scorecard-component">
        <div id="admin-scorecard-assocs">
            <div hx-get={AssociationsAction} hx-trigger="load"></div>
        </div>
    </div>
|]
  1. Typeahead search (hx-post, debounced)
<input hx-post={TypeAheadAction}
       hx-trigger="keyup changed delay:300ms"
       hx-target="next .options-component"
       hx-swap="innerHTML"
       name="_query" />
  1. Delete via OOB fragment
action DeleteAction { assocId = assocId } = do
    fetch assocId >>= deleteRecord
    respondHtml [hsx|<div id={"assoc-" <> show assocId} hx-swap-oob="true"></div>|]
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Composed.AssociationTable where

import Prelude
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude hiding (show, fetch, deleteRecord)
import Patterns.Htmx.Shared.Types

-- Minimal action placeholders so HSX route expressions are well-formed.
data AssociationsAction = AssociationsAction

data TypeAheadAction = TypeAheadAction

data ModalAction = ModalAction Int

data DeleteAction = DeleteAction { assocId :: Int }

instance HasPath AssociationsAction where
    pathTo AssociationsAction = "/associations"

instance HasPath TypeAheadAction where
    pathTo TypeAheadAction = "/typeahead"

instance HasPath ModalAction where
    pathTo (ModalAction assocId) = "/modal/" <> tshow assocId

instance HasPath DeleteAction where
    pathTo (DeleteAction assocId) = "/delete/" <> tshow assocId

hxAssociationOptionsSelector :: HxSelector
hxAssociationOptionsSelector = HxNext ".options-component"

hxAssociationModalSelector :: HxSelector
hxAssociationModalSelector = hxIdSelector "modal-container"
Helper implementation examples

Not applicable in this pattern.

Usage examples

Association shell:

adminScorecardComponent :: Html
adminScorecardComponent = [hsx|
    <div id="admin-scorecard-component">
        <div id="admin-scorecard-assocs">
            <div hx-get={AssociationsAction} hx-trigger="load"></div>
        </div>
    </div>
|]

Typeahead input:

typeAheadSearchInput :: Html
typeAheadSearchInput = [hsx|
    <input hx-post={TypeAheadAction}
           hx-trigger="keyup changed delay:300ms"
           hx-target={renderHxSelector hxAssociationOptionsSelector}
           hx-swap="innerHTML"
           name="_query" />
|]

Delete action with OOB row removal:

action :: DeleteAction -> IO ()
action DeleteAction { assocId = assocId } = do
    fetch assocId >>= deleteRecord
    respondHtml [hsx|<div id={"assoc-" <> show assocId} hx-swap-oob="true"></div>|]

Modal open/delete triggers:

badgeButton :: Int -> Html
badgeButton assocId = [hsx|
    <button hx-get={ModalAction assocId}
            hx-target={renderHxSelector hxAssociationModalSelector}
            hx-swap="innerHTML"></button>
|]

modalDeleteButton :: Int -> Html
modalDeleteButton assocId = [hsx|
    <button hx-delete={DeleteAction assocId}
            hx-confirm="Remove?"
            hx-swap="outerHTML swap:1s"></button>
|]

Boilerplate stubs:

data Assoc = Assoc

fetch :: Int -> IO Assoc
fetch _ = undefined

deleteRecord :: Assoc -> IO ()
deleteRecord _ = undefined

respondHtml :: Html -> IO ()
respondHtml _ = undefined
Standalone checks

Not applicable in this pattern.

Conditional HTMX Component

Raw source: Patterns/Htmx/Composed/ConditionalHtmxComponent.lhs

Pattern intent and mechanism

Render a container that swaps between two mutually exclusive states based on a Maybe entity value: an empty state (call-to-action) when the value is Nothing, and a present state (view, edit, or detail) when the value is Just.

The container carries a stable id. Inner actions render replacements that target this same id with hx-swap="outerHTML", so the container itself is replaced in place when the state changes.

Use this pattern when:

  • a page section has two stable shapes (empty vs present) that never coexist,
  • the transition between shapes is triggered by user interaction (create, delete, subscribe, unsubscribe),
  • the empty state invites action (a CTA button or link) and the present state shows the result or an inline edit control.

This pattern is fundamentally a replacement boundary, not a content toggle. The server decides which shape to render on each request; there is no client-side state management beyond HTMX's normal swap.

When the inner component lives inside a clickable row, card, or tile, compose it with NestedControlClickIsolation.

Project-specific notes and rollout guidance

Keep the container id stable across both states. The empty-state action and the present-state action must both target the same id and use hx-swap="outerHTML".

Keep swapEmpty and swapPresent as caller-supplied renderers. The generic helper knows nothing about the domain shape of either state.

When the empty state is a CTA button that triggers an HTMX POST, the response must render the present state inside a container with the same id. When the present state contains a delete/unsubscribe action, the response must render the empty state inside a container with the same id.

This pattern is a container, not a leaf. swapEmpty and swapPresent can be any other HTMX pattern — for example InlineDropdown for inline editing of a subscription role, or InlineButtonGroup for status selection. The ConditionalHtmxComponent provides the stable replacement boundary; the inner patterns provide the interaction.

When the inner pattern lives inside a clickable row, card, or tile, compose it with NestedControlClickIsolation. The isolation wraps the inner pattern, and ConditionalHtmxComponent wraps the isolation.

Supporting snippets

Self-swap boundary:

<div id={swapId}>
    {content}
</div>

Empty state with HTMX action:

[hsx|
    <button type="button"
            hx-post={pathTo CreateAction}
            hx-target="#container-id"
            hx-swap="outerHTML">
        Subscribe
    </button>
|]

Present state with HTMX delete:

[hsx|
    <button type="button"
            hx-post={pathTo DeleteAction}
            hx-target="#container-id"
            hx-swap="outerHTML">
        Unsubscribe
    </button>
|]
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Composed.ConditionalHtmxComponent where

import Prelude
import Data.Text (Text)
import IHP.ViewPrelude
import IHP.RouterSupport (HasPath (..))

-- | Configuration for a conditional HTMX component.
-- | The caller supplies the container id and both state renderers.
-- | The helper selects which renderer to use based on @Maybe entity@.
data ConditionalHtmxConfig entity = ConditionalHtmxConfig
    { swapId :: Text
    , swapEntity :: Maybe entity
    , swapEmpty :: Html
    , swapPresent :: entity -> Html
    }
Helper implementation examples
-- | Render the conditional component.
-- | Uses an explicit record pattern per the ExplicitRecordBinding convention.
conditionalHtmxComponent :: ConditionalHtmxConfig entity -> Html
conditionalHtmxComponent ConditionalHtmxConfig { swapId, swapEntity, swapEmpty, swapPresent } =
    let content = case swapEntity of
            Nothing -> swapEmpty
            Just entity -> swapPresent entity
    in [hsx|
        <div id={swapId}>
            {content}
        </div>
    |]
Usage examples

A domain entity and its action types.

data Subscription = Subscription
    { subscriptionId :: Int
    , subscriptionRole :: Text
    }
    deriving (Eq, Show)

data CreateSubscriptionAction = CreateSubscriptionAction Int

instance HasPath CreateSubscriptionAction where
    pathTo (CreateSubscriptionAction entityId) =
        "/entities/" <> tshow entityId <> "/subscribe"

data DeleteSubscriptionAction = DeleteSubscriptionAction Int

instance HasPath DeleteSubscriptionAction where
    pathTo (DeleteSubscriptionAction entityId) =
        "/entities/" <> tshow entityId <> "/unsubscribe"

The empty state renders a CTA that creates the entity.

renderEmptyState :: Int -> Html
renderEmptyState entityId = [hsx|
    <button type="button"
            class="btn btn-primary"
            hx-post={pathTo (CreateSubscriptionAction entityId)}
            hx-target="#subscription-component"
            hx-swap="outerHTML">
        Subscribe
    </button>
|]

The present state renders the entity with a delete action. The delete targets the subscription's own id, not the parent entity.

renderPresentState :: Subscription -> Html
renderPresentState subscription = [hsx|
    <div class="d-flex align-items-center gap-2">
        <span class="badge bg-secondary">{subscriptionRole subscription}</span>
        <button type="button"
                class="btn btn-sm btn-outline-danger"
                hx-post={pathTo (DeleteSubscriptionAction (subscriptionId subscription))}
                hx-target="#subscription-component"
                hx-swap="outerHTML">
            Unsubscribe
        </button>
    </div>
|]

A local helper ties the domain to the generic shape.

subscriptionComponent :: Int -> Maybe Subscription -> Html
subscriptionComponent entityId maybeSubscription =
    conditionalHtmxComponent config
  where
    config :: ConditionalHtmxConfig Subscription
    config = ConditionalHtmxConfig
        { swapId = "subscription-component"
        , swapEntity = maybeSubscription
        , swapEmpty = renderEmptyState entityId
        , swapPresent = renderPresentState
        }
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Inline Button Group

Raw source: Patterns/Htmx/Composed/InlineButtonGroup.lhs

Pattern intent and mechanism

Render a compact inline button group for an enum-like value. The options are always visible as individual buttons, not hidden inside a dropdown menu. The selected value is highlighted; clicking any button updates the value in place via HTMX self-swap.

Use this pattern when:

  • the option count is small (typically 2–6 values),
  • immediate visibility of all options matters more than compactness,
  • the selection should feel like a toggle rather than a menu choice.

This pattern works on detail, show, or edit pages where the surrounding layout provides enough space for exposed buttons. Good grouping and visual hierarchy can scale to many options (for example a circle-of-fifths key selector with a dozen values). The limiting factor is whether the layout remains immediately scannable, not a fixed count.

In table cells or compact list items, always use InlineDropdown, never a button group. A dropdown keeps the cell compact and the row navigable; exposed buttons would break both.

When the button group lives inside a clickable container, compose it with NestedControlClickIsolation. For any layout where the options would no longer be scannable at a glance, prefer InlineDropdown.

Project-specific notes and rollout guidance

Keep domain labels and action names outside the generic helper. The caller decides what each option means in the domain.

Keep visual semantics in the option configuration (ButtonRow), not in local overrides. The selected button's appearance should derive from its configured semantic.

A hidden input can be included alongside the buttons when the component lives inside a traditional form that submits via POST rather than HTMX self-swap. This is common in New or Edit views where the button group is one field among many and the page has a submit button.

When a view uses HTMX self-swap, the button group updates in place and no hidden input is needed. When a view uses a traditional form, the hidden input carries the selected value to the server on submit.

Policy note: for existing records, this pattern prefers inline HTMX editing where possible. Edit views with hidden inputs are supported but should be treated as a bridge toward full inline editing. New views always need a form with hidden input because no record exists yet to update in place.

When a button group is wrapped in a permission-aware enum control, update both the initial page renderer and every HTMX response renderer. A common bug is to render the permission-aware wrapper on first page load, then return the raw interactive button group after an update. That can re-enable controls in the DOM for actors who should only receive the static fallback. See PermissionAwareEnumControl for the cross-shape wrapper pattern.

Supporting snippets

Self-swap boundary:

<div hx-target="this" hx-swap="outerHTML">
    ...
</div>

Inside a clickable parent, compose with NestedControlClickIsolation:

isolatedGroup = isolateNestedControlClick buttonGroup

With hidden input for form integration:

[hsx|
    <div hx-target="this" hx-swap="outerHTML">
        {buttons}
        {when isSelected hiddenInput}
    </div>
|]
Core reusable pattern infrastructure
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Composed.InlineButtonGroup where

import Prelude
import Data.Text (Text)
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude
import Patterns.Htmx.Primitives.NestedControlClickIsolation

-- | Visual intent for option buttons.
data ButtonSemantic
    = Typical
    | Variant
    | Affirmative
    | Negative
    | Neutral
    deriving (Eq, Show)

-- | A row of options with their visual semantics.
newtype ButtonRow a = ButtonRow [(a, ButtonSemantic)]
    deriving (Eq, Show)

-- | Configuration for an inline button group.
data InlineButtonGroupConfig a action = InlineButtonGroupConfig
    { label :: a -> Text
    , action :: Maybe Text -> action
    , rows :: [ButtonRow a]
    , value :: Maybe a
    , isDisabled :: a -> Bool
    , targetSwap :: Maybe (Text, Text)
    -- ^ Optional (targetSelector, swapMode) to override the default
    --   self-swap boundary. The selector is passed directly to HTMX's
    --   hx-target attribute (e.g. "#modal-body", "closest tr").
    --   Use this when the button group lives inside a container that
    --   should not be replaced (e.g. a modal or card).
    }
Helper implementation examples

This renderer is intentionally small. Real applications may add their own design system classes, grouping labels, or responsive layouts.

renderInlineButtonGroup :: forall a action. (Eq a, InputValue a, HasPath action) => InlineButtonGroupConfig a action -> Html
renderInlineButtonGroup InlineButtonGroupConfig { label, action, rows, value, isDisabled, targetSwap } =
    case targetSwap of
        Nothing -> [hsx|
            <div hx-target="this" hx-swap="outerHTML">
                {innerContent}
            </div>
        |]
        Just (targetSel, swapMode) -> [hsx|
            <div hx-target={targetSel} hx-swap={swapMode}>
                {innerContent}
            </div>
        |]
  where
    innerContent :: Html
    innerContent = [hsx|
        <div class="d-flex flex-column gap-2">
            {forEach rows renderRow}
        </div>
    |]

    renderRow (ButtonRow pairs) = [hsx|
        <div class="d-flex flex-lg-row flex-column gap-2">
            {forEach pairs renderButton}
        </div>
    |]

    renderButton (option, semantic)
      | isDisabled option = [hsx|
        <button type="button"
                class={buttonClasses semantic isSelected}
                disabled
                aria-disabled="true">
            {label option}
        </button>
    |]
      | otherwise = [hsx|
        <button type="button"
                class={buttonClasses semantic isSelected}
                hx-post={pathTo (action (Just (inputValue option)))}>
            {label option}
        </button>
    |]
      where
        isSelected :: Bool
        isSelected = value == Just option

buttonClasses :: ButtonSemantic -> Bool -> Text
buttonClasses semantic isSelected =
    let color = semanticColor semantic
        btnCl = if isSelected then "btn-" <> color else "btn-outline-" <> color
    in "btn btn-select " <> btnCl

semanticColor :: ButtonSemantic -> Text
semanticColor semantic = case semantic of
    Typical -> "primary"
    Variant -> "info"
    Affirmative -> "success"
    Negative -> "danger"
    Neutral -> "light"
renderInlineButtonGroupWithHiddenInput :: forall a action. (Eq a, InputValue a, HasPath action) => InlineButtonGroupConfig a action -> Text -> Html
renderInlineButtonGroupWithHiddenInput InlineButtonGroupConfig { label, action, rows, value, isDisabled, targetSwap } fieldName =
    case targetSwap of
        Nothing -> [hsx|
            <div hx-target="this" hx-swap="outerHTML">
                {innerContent}
            </div>
        |]
        Just (targetSel, swapMode) -> [hsx|
            <div hx-target={targetSel} hx-swap={swapMode}>
                {innerContent}
            </div>
        |]
  where
    innerContent :: Html
    innerContent = [hsx|
        <div class="form-group mb-3">
            <div class="d-flex flex-column gap-2">
                {forEach rows renderRow}
            </div>
            {hiddenInput}
        </div>
    |]

    renderRow (ButtonRow pairs) = [hsx|
        <div class="d-flex flex-lg-row flex-column gap-2">
            {forEach pairs renderButton}
        </div>
    |]

    renderButton (option, semantic)
      | isDisabled option = [hsx|
        <button type="button"
                class={buttonClasses semantic isSelected}
                disabled
                aria-disabled="true">
            {label option}
        </button>
    |]
      | otherwise = [hsx|
        <button type="button"
                class={buttonClasses semantic isSelected}
                hx-post={pathTo (action (Just (inputValue option)))}>
            {label option}
        </button>
    |]
      where
        isSelected :: Bool
        isSelected = value == Just option

    hiddenInput :: Html
    hiddenInput = case value of
        Nothing -> mempty
        Just val -> [hsx|<input type="hidden" name={fieldName} value={inputValue val} />|]

A static variant renders all options as non-interactive spans. The selected value is filled; all others are outlined. Use this when the actor lacks edit rights — for example on Show pages where the full state spectrum should remain visible but the user is not authorized to change it:

renderStaticInlineButtonGroup :: forall a action. Eq a => InlineButtonGroupConfig a action -> Html
renderStaticInlineButtonGroup InlineButtonGroupConfig { label, rows, value } =
    [hsx|
        <div class="d-flex flex-column gap-2" role="group">
            {forEach rows renderRow}
        </div>
    |]
  where
    renderRow (ButtonRow pairs) = [hsx|
        <div class="d-flex flex-lg-row flex-column gap-2">
            {forEach pairs renderOption}
        </div>
    |]

    renderOption (option, semantic) =
        let isSelected = value == Just option
            classes = "btn btn-select " <> buttonToneClass isSelected semantic
                      <> " pe-none user-select-none"
        in [hsx|<span class={classes}>{label option}</span>|]

    buttonToneClass :: Bool -> ButtonSemantic -> Text
    buttonToneClass isSelected semantic =
        let color = semanticColor semantic
        in if isSelected then "btn-" <> color else "btn-outline-" <> color

Permission-aware composition: at the call site, check whether the actor has write rights. If yes, render the interactive group; if no, render the static variant. The permission check belongs in the controller or view, not inside the generic renderer.

permissionAwareButtonGroup :: forall a action. (Eq a, InputValue a, HasPath action) => Bool -> InlineButtonGroupConfig a action -> Html
permissionAwareButtonGroup canEdit config =
    if canEdit
        then renderInlineButtonGroup config
        else renderStaticInlineButtonGroup config

A pure action button without a discrete value does not use DomainSemantic. When the action is denied, render it as an outline button that is disabled and isolated. The outline signals "this slot exists but is inactive" without implying a semantic value:

newEntityButton :: Bool -> Text -> Html
newEntityButton canCreate newPath =
    if canCreate
        then isolateNestedControlClick [hsx|
            <a href={newPath} class="btn btn-primary">
                New
            </a>
        |]
        else isolateNestedControlClick [hsx|
            <button type="button"
                    class="btn btn-outline-primary"
                    disabled
                    aria-disabled="true">
                New
            </button>
        |]
Usage examples

A small enum-like value supplies labels and input values.

data Priority
    = Low
    | Medium
    | High
    deriving (Eq, Show)

instance InputValue Priority where
    inputValue priority = case priority of
        Low -> "low"
        Medium -> "medium"
        High -> "high"

priorityLabel :: Priority -> Text
priorityLabel priority = case priority of
    Low -> "Low"
    Medium -> "Medium"
    High -> "High"

priorityRows :: [ButtonRow Priority]
priorityRows =
    [ ButtonRow
        [ (Low, Neutral)
        , (Medium, Typical)
        , (High, Negative)
        ]
    ]

The action receives the posted text value.

data UpdatePriorityAction = UpdatePriorityAction Int (Maybe Text)

instance HasPath UpdatePriorityAction where
    pathTo (UpdatePriorityAction entityId maybePriority) = case maybePriority of
        Nothing -> "/entities/" <> tshow entityId <> "/priority"
        Just priority -> "/entities/" <> tshow entityId <> "/priority?priority=" <> priority

A local helper keeps the domain name while making the button group shape explicit.

priorityInlineButtonGroup :: Int -> Maybe Priority -> Html
priorityInlineButtonGroup entityId selectedPriority = buttonGroup
  where
    buttonGroup :: Html
    buttonGroup = renderInlineButtonGroup config

    config :: InlineButtonGroupConfig Priority UpdatePriorityAction
    config = InlineButtonGroupConfig
        { label = priorityLabel
        , action = UpdatePriorityAction entityId
        , rows = priorityRows
        , value = selectedPriority
        , isDisabled = const False
        , targetSwap = Nothing
        }

For a New or Edit view that submits via a traditional form, use the variant with hidden input:

priorityEditButtonGroup :: Int -> Maybe Priority -> Html
priorityEditButtonGroup entityId selectedPriority =
    renderInlineButtonGroupWithHiddenInput config "priority"
  where
    config :: InlineButtonGroupConfig Priority UpdatePriorityAction
    config = InlineButtonGroupConfig
        { label = priorityLabel
        , action = UpdatePriorityAction entityId
        , rows = priorityRows
        , value = selectedPriority
        , isDisabled = const False
        , targetSwap = Nothing
        }
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Inline Dropdown

Raw source: Patterns/Htmx/Composed/InlineDropdown.lhs

Pattern intent and mechanism

Render a compact inline dropdown for an enum-like value. The UI edits the value in place, returns a small HTML fragment, and keeps the dropdown as the replacement boundary.

The base pattern uses a as the option type and Maybe a as the selected value. When the value is nullable, a clearable specialization lifts the option type to Maybe a and the selected value to Maybe (Maybe a), where Just Nothing means the clear option is explicitly selected.

Use the base pattern when every option maps to a valid enum value and there is no "none" state. Use the clearable specialization when the field is nullable and the UI should allow explicit clearing.

When the dropdown lives inside a clickable row, card, tile, or list item, compose it with NestedControlClickIsolation.

When the option count is small (2–6 values) and immediate visibility matters more than compactness, consider InlineButtonGroup instead. The dropdown keeps options collapsed; the button group exposes them as individual buttons.

Project-specific notes and rollout guidance

Keep domain labels and action names outside the generic helper. The caller decides what each option means in the domain.

Keep visual semantics in the option configuration (ButtonRow), not in local overrides. The trigger appearance should derive from the selected option's configured semantic. If the trigger needs to diverge from the option semantics, that mismatch belongs in the reusable config or the pattern itself, not at the call site.

Trigger semantic override is needed only when the selected value is not represented in the configured option rows. For a non-clearable dropdown, the selected value always exists in buttonRows, so the generic renderer derives the trigger semantic automatically. For a clearable dropdown with no current value (Nothing), the clear option is not part of the base rows, so the trigger needs an explicit semantic override or a domain-specific default.

One ButtonRow configuration can be reused across multiple fields or actions. The config describes the enum values and their semantics; it is decoupled from the specific database column or action constructor.

Compose with isolateNestedControlClick when the dropdown lives inside a clickable container. Defensive use in non-clickable contexts is acceptable — the wrapper is harmless and protects against future layout changes.

Real applications usually already have generic dropdown infrastructure — for example a DropdownConfig with mkDropdownConfig and renderDropdown that handles self-swap, trigger rendering, and option layout for any single-select value. This pattern does not replace that infrastructure. It describes the shape (option rows, selected value, self-swap boundary) that you layer on top of an existing generic dropdown helper.

Enum parsing from a posted text value is a runtime concern. Prefer a fail-fast parser (Either or validation error) when the input may come from an uncontrolled source. A silent default is acceptable only when the source is a controlled dropdown and the fallback is documented near the parser.

Accessibility baseline:

  • Use the platform dropdown behavior, or a design-system dropdown that preserves keyboard operation.
  • Mark the trigger with aria-haspopup="true" and reflect the expanded state via aria-expanded.
  • Mark the selected option semantically, for example with aria-pressed, and do not make selection visible by color alone.
  • Treat focus restoration after hx-swap="outerHTML" as a separate validation point. Add a JavaScript hook only after checking the concrete host flow.

When every option is disabled (isDisabled = const True), the trigger renders as a plain <span> with the semantic colour preserved. The user sees the value's meaning (for example "confirmed" in green) but gets no dropdown arrow, hover feedback, or click affordance. The generic renderer does this automatically so the call site only needs to set isDisabled.

When only some options are disabled, the trigger stays clickable and disabled options keep their semantic colour. This is a policy signal, not a visual style: the value is visible but not selectable.

When a dropdown is wrapped in a permission-aware enum control, update both the initial page renderer and every HTMX response renderer. A common bug is to render the permission-aware wrapper on first page load, then return the raw interactive dropdown after an update. That can re-enable controls in the DOM for actors who should only receive the static fallback. See PermissionAwareEnumControl for the cross-shape wrapper pattern.

See SemanticValueAxes for the full treatment of domain colour, salience, and actionability as separate axes.

Supporting snippets

Base case (non-clearable, no Maybe lift):

baseRows :: [ButtonRow Coverage]
config = mkDropdownConfig labelFn actionConstructor baseRows (Just currentValue)
dropdown = renderDropdown config

Clearable option lifting:

baseRows :: [ButtonRow Role]
clearableRows :: [ButtonRow (Maybe Role)]
clearableRows = appendClearOption clearOption currentRole baseRows

Selected value shapes:

-- Base case
selectedValue :: Maybe Coverage
selectedValue = Just currentCoverage

-- Clearable case
selectedValue :: Maybe (Maybe Role)
selectedValue = selectedClearableValue currentRole

Self-swap boundary:

<div class="dropdown" hx-target="this" hx-swap="outerHTML">
    ...
</div>

Inside a clickable parent, compose with NestedControlClickIsolation:

isolatedDropdown = isolateNestedControlClick dropdown
Core reusable pattern infrastructure
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

-- DuplicateRecordFields: two config records share field names in this module.

module Patterns.Htmx.Composed.InlineDropdown where

import Prelude
import Data.Text (Text)
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude
import Patterns.Htmx.Primitives.NestedControlClickIsolation

-- | Visual intent for option buttons.
-- Applications usually map this to their design system classes.
data ButtonSemantic
    = Typical
    | Variant
    | Affirmative
    | Negative
    | Neutral
    deriving (Eq, Show)

-- | A row of options with their visual semantics.
newtype ButtonRow a = ButtonRow [(a, ButtonSemantic)]
    deriving (Eq, Show)

-- | Configuration for a non-clearable inline dropdown.
data InlineDropdownConfig a action = InlineDropdownConfig
    { label :: a -> Text
    , action :: Maybe Text -> action
    , rows :: [ButtonRow a]
    , value :: Maybe a
    , trigger :: Maybe a -> Html
    , isDisabled :: a -> Bool
    }

-- | Configuration for a self-swapping clearable inline dropdown.
data ClearableInlineDropdownConfig a action = ClearableInlineDropdownConfig
    { label :: a -> Text
    , action :: Maybe Text -> action
    , rows :: [ButtonRow a]
    , value :: Maybe a
    , clear :: ClearOption a
    , trigger :: Maybe a -> Html
    , isDisabled :: a -> Bool
    , isClearDisabled :: Bool
    }

-- | Clear-option presentation can depend on the current value.
data ClearOption a = ClearOption
    { clearLabel :: Maybe a -> Text
    , clearSemantic :: Maybe a -> ButtonSemantic
    }

-- | Generic selected-value shape for clearable dropdowns.
--
-- `Just Nothing` means the clear option is explicitly selected.
selectedClearableValue :: Maybe a -> Maybe (Maybe a)
selectedClearableValue = Just

-- | Lift a plain button row into the clearable shape.
liftButtonRow :: ButtonRow a -> ButtonRow (Maybe a)
liftButtonRow (ButtonRow pairs) = ButtonRow [ (Just value, semantic) | (value, semantic) <- pairs ]

-- | Lift ordinary option rows into `Maybe a` rows and append the clear option.
appendClearOption :: ClearOption a -> Maybe a -> [ButtonRow a] -> [ButtonRow (Maybe a)]
appendClearOption clearOption currentValue rows =
    let clearPair = (Nothing, clearSemantic clearOption currentValue)
    in case reverse (map liftButtonRow rows) of
        [] -> [ButtonRow [clearPair]]
        (ButtonRow pairs : rest) ->
            reverse (ButtonRow (pairs <> [clearPair]) : rest)

-- | Check whether a clearable option is the currently selected one.
--
-- `selectedValue` is `Maybe (Maybe a)`; `option` is `Maybe a`.
-- `Just Nothing` means the user explicitly selected the clear option.
isClearableOptionSelected :: Eq a => Maybe (Maybe a) -> Maybe a -> Bool
isClearableOptionSelected selectedValue option = selectedValue == Just option

-- | Configuration for clearable option label rendering.
-- | Extracted to demonstrate explicit record binding.
-- | See Patterns.Haskell.ExplicitRecordBinding for the convention.
data OptionLabelConfig a = OptionLabelConfig
    { clear :: ClearOption a
    , label :: a -> Text
    , value :: Maybe a
    }

-- | Label a lifted option.
clearableOptionLabel :: OptionLabelConfig a -> Maybe a -> Text
clearableOptionLabel OptionLabelConfig { clear, label, value } option = case option of
    Nothing -> clearLabel clear value
    Just val -> label val

-- | Value posted for a lifted option.
--
-- The clear option posts the empty string. In IHP, boundary code can parse that
-- empty input as `Nothing` via normal `Maybe Text` parameter handling.
clearableActionValue :: InputValue a => Maybe a -> Maybe Text
clearableActionValue option = case option of
    Nothing -> Just ""
    Just value -> Just (inputValue value)
Helper implementation examples

This renderer is intentionally small. Real applications may add their own design system classes, trigger content, title text, or menu grouping, while keeping the same option-row shape.

Base-case renderer for non-clearable dropdowns:

renderInlineDropdown :: forall a action. (Eq a, InputValue a, HasPath action) => InlineDropdownConfig a action -> Html
renderInlineDropdown InlineDropdownConfig { label, action, rows, value, trigger, isDisabled } = [hsx|
    <div class="dropdown" hx-target="this" hx-swap="outerHTML">
        {triggerButton}
        <div class="dropdown-menu">
            {forEach rows renderRow}
        </div>
    </div>
|]
  where
    selectedSemantic :: ButtonSemantic
    selectedSemantic = selectedSemantics rows value

    allDisabled :: Bool
    allDisabled = all (\(val, _) -> isDisabled val) (concatMap (\(ButtonRow ps) -> ps) rows)

    triggerButton :: Html
    triggerButton
      | allDisabled = [hsx|
        <span class={"btn " <> semanticButtonClass selectedSemantic}>
            {trigger value}
        </span>
    |]
      | otherwise = [hsx|
        <button type="button"
                class={"btn " <> semanticButtonClass selectedSemantic <> " dropdown-toggle"}
                data-bs-toggle="dropdown"
                aria-expanded="false"
                aria-haspopup="true">
            {trigger value}
        </button>
    |]

    renderRow (ButtonRow pairs) = [hsx|
        <div class="d-flex flex-column gap-1">
            {forEach pairs renderOption}
        </div>
    |]

    renderOption (option, semantic)
      | isDisabled option = [hsx|
        <button type="button"
                class={optionClasses semantic isSelected}
                aria-pressed={isSelected}
                disabled
                aria-disabled="true">
            {label option}
        </button>
    |]
      | otherwise = [hsx|
        <button type="button"
                class={optionClasses semantic isSelected}
                aria-pressed={isSelected}
                hx-post={pathTo (action (Just (inputValue option)))}>
            {label option}
        </button>
    |]
      where
        isSelected :: Bool
        isSelected = value == Just option

Clearable renderer with the Maybe a option lift:

renderClearableInlineDropdown :: forall a action. (Eq a, InputValue a, HasPath action) => ClearableInlineDropdownConfig a action -> Html
renderClearableInlineDropdown ClearableInlineDropdownConfig { label, action, rows, value, clear, trigger, isDisabled, isClearDisabled } = [hsx|
    <div class="dropdown" hx-target="this" hx-swap="outerHTML">
        {triggerButton}
        <div class="dropdown-menu">
            {forEach clearableRows renderRow}
        </div>
    </div>
|]
  where
    clearableRows :: [ButtonRow (Maybe a)]
    clearableRows = appendClearOption clear value rows

    selectedValue :: Maybe (Maybe a)
    selectedValue = selectedClearableValue value

    selectedSemantic :: ButtonSemantic
    selectedSemantic = selectedSemantics clearableRows selectedValue

    allDisabled :: Bool
    allDisabled = all optionIsDisabled (concatMap (\(ButtonRow ps) -> ps) clearableRows)
      where
        optionIsDisabled (Just val, _) = isDisabled val
        optionIsDisabled (Nothing, _) = isClearDisabled

    triggerButton :: Html
    triggerButton
      | allDisabled = [hsx|
        <span class={"btn " <> semanticButtonClass selectedSemantic}>
            {trigger value}
        </span>
    |]
      | otherwise = [hsx|
        <button type="button"
                class={"btn " <> semanticButtonClass selectedSemantic <> " dropdown-toggle"}
                data-bs-toggle="dropdown"
                aria-expanded="false"
                aria-haspopup="true">
            {trigger value}
        </button>
    |]

    labelConfig :: OptionLabelConfig a
    labelConfig = OptionLabelConfig
        { clear = clear
        , label = label
        , value = value
        }

    renderRow (ButtonRow pairs) = [hsx|
        <div class="d-flex flex-column gap-1">
            {forEach pairs renderOption}
        </div>
    |]

    renderOption (option, semantic)
      | isOptionDisabled option = [hsx|
        <button type="button"
                class={optionClasses semantic isSelected}
                aria-pressed={isSelected}
                disabled
                aria-disabled="true">
            {clearableOptionLabel labelConfig option}
        </button>
    |]
      | otherwise = [hsx|
        <button type="button"
                class={optionClasses semantic isSelected}
                aria-pressed={isSelected}
                hx-post={pathTo (action (clearableActionValue option))}>
            {clearableOptionLabel labelConfig option}
        </button>
    |]
      where
        isSelected :: Bool
        isSelected = isClearableOptionSelected selectedValue option

        isOptionDisabled :: Maybe a -> Bool
        isOptionDisabled (Just val) = isDisabled val
        isOptionDisabled Nothing = isClearDisabled

A small helper can compute the trigger semantic from the selected option. If no configured option matches, fall back to Typical instead of inventing a silent domain default.

selectedSemantics :: Eq a => [ButtonRow a] -> Maybe a -> ButtonSemantic
selectedSemantics rows selectedValue = case matches of
    semantic : _ -> semantic
    [] -> Typical
  where
    matches :: [ButtonSemantic]
    matches = [ semantic | ButtonRow pairs <- rows, (value, semantic) <- pairs, Just value == selectedValue ]

optionClasses :: ButtonSemantic -> Bool -> Text
optionClasses semantic isSelected =
    let color = semanticButtonColor semantic
        btnCl = if isSelected then "btn-" <> color else "btn-outline-" <> color
        activeCl = if isSelected then " active" else ""
    in "dropdown-item " <> btnCl <> activeCl

semanticButtonColor :: ButtonSemantic -> Text
semanticButtonColor semantic = case semantic of
    Typical -> "primary"
    Variant -> "info"
    Affirmative -> "success"
    Negative -> "danger"
    Neutral -> "light"

semanticButtonClass :: ButtonSemantic -> Text
semanticButtonClass semantic = "btn-" <> semanticButtonColor semantic
Usage examples

A small enum-like value supplies labels and input values.

data Role
    = Reader
    | Editor
    | Owner
    deriving (Eq, Show)

instance InputValue Role where
    inputValue role = case role of
        Reader -> "reader"
        Editor -> "editor"
        Owner -> "owner"

roleLabel :: Role -> Text
roleLabel role = case role of
    Reader -> "Reader"
    Editor -> "Editor"
    Owner -> "Owner"

roleRows :: [ButtonRow Role]
roleRows =
    [ ButtonRow
        [ (Reader, Typical)
        , (Editor, Variant)
        , (Owner, Affirmative)
        ]
    ]

Base case: non-clearable inline dropdown for a required enum field.

data UpdateRoleAction = UpdateRoleAction Int (Maybe Text)

instance HasPath UpdateRoleAction where
    pathTo (UpdateRoleAction entityId maybeRole) = case maybeRole of
        Nothing -> "/entities/" <> tshow entityId <> "/role"
        Just role -> "/entities/" <> tshow entityId <> "/role?role=" <> role

roleInlineDropdown :: Int -> Role -> Html
roleInlineDropdown entityId selectedRole = isolateNestedControlClick dropdown
  where
    dropdown = renderInlineDropdown config

    config :: InlineDropdownConfig Role UpdateRoleAction
    config = InlineDropdownConfig
        { label = roleLabel
        , action = UpdateRoleAction entityId
        , rows = roleRows
        , value = Just selectedRole
        , trigger = trigger
        , isDisabled = const False
        }

    trigger :: Maybe Role -> Html
    trigger role = case role of
        Nothing -> [hsx|<span class="text-muted">–</span>|]
        Just value -> toHtml (roleLabel value)

Clearable case: a nullable field with an explicit clear option.

roleClearOption :: ClearOption Role
roleClearOption = ClearOption
    { clearLabel = \case
        Nothing -> "–"
        Just _ -> "Clear"
    , clearSemantic = \case
        Nothing -> Neutral
        Just _ -> Negative
    }

roleClearableInlineDropdown :: Int -> Maybe Role -> Html
roleClearableInlineDropdown entityId selectedRole = isolateNestedControlClick dropdown
  where
    dropdown = renderClearableInlineDropdown config

    config :: ClearableInlineDropdownConfig Role UpdateRoleAction
    config = ClearableInlineDropdownConfig
        { label = roleLabel
        , action = UpdateRoleAction entityId
        , rows = roleRows
        , value = selectedRole
        , clear = roleClearOption
        , trigger = trigger
        , isDisabled = const False
        , isClearDisabled = False
        }

    trigger :: Maybe Role -> Html
    trigger role = case role of
        Nothing -> [hsx|<span class="text-muted">–</span>|]
        Just value -> toHtml (roleLabel value)
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Permission-Aware Enum Control

Raw source: Patterns/Htmx/Composed/PermissionAwareEnumControl.lhs

Pattern intent and mechanism

Render one enum-like domain field through multiple visual shapes while applying one central permission predicate and one central semantic configuration.

The same field may appear as:

  • a compact dropdown in table or list rows,
  • an expanded button group on show pages,
  • a static selected indicator for read-only compact views,
  • a static full button group when the full state spectrum should remain visible.

The permission decision belongs to the domain or policy layer. The UI renderers consume that decision; they do not invent their own rules. This keeps the initial page render, HTMX update response, and read-only fallback aligned.

When the permission decision is role-based and shared across multiple actions in the same scope, ScopedRolePermissionRegistry produces the boolean that this pattern consumes.

Use this pattern when:

  • one enum-like field appears in several view shapes,
  • the same actor permission controls all shapes,
  • denied actors should see the current value without receiving an interactive control,
  • HTMX updates replace the control in place.

The core mechanism is a wrapper around existing controls: it receives the permission context, the shared semantic option rows, the selected value, and the interactive renderer. If permission is granted, it returns the interactive control. If permission is denied, it returns a static semantic rendering.

Project-specific notes and rollout guidance

Compute permission from the same actor context that the controller gate uses. Do not derive view permission from a looser context than the controller uses, and do not derive controller permission from the target resource. For the actor versus target rule, see Patterns.Actions.ActorTargetAuthGate.

Initial page rendering is not enough. Every HTMX response that replaces the control must call the same permission-aware wrapper. Returning a raw dropdown or button group after an update can re-enable controls in the DOM even when the initial page render was read-only.

Keep semantic rows shared. If the dropdown and the expanded button group use different ButtonRow definitions for the same field, color and ordering drift will make permission states harder to audit.

The EnumControlShape type is optional. It is only useful when a project really uses both compact and expanded shapes for the same field. If only one shape is needed, skip the type and export an alias instead:

renderCoverageEnumField = renderPermissionAwareCompact

The alias keeps the pattern identity grepable while avoiding unnecessary indirection. Add the shape type later when a second shape is actually needed.

When call sites become long, prefer direct record construction anyway. A positional builder undermines the benefit of ExplicitRecordBinding — the point of the config record is to make dependencies explicit, not to hide them behind a positional wrapper. See Patterns.Haskell.ExplicitRecordBinding for the rationale.

This pattern covers enum controls only. It does not cover submit buttons, edit/delete row actions, row navigation, uniqueness checks, or last-owner data invariants.

Supporting snippets

Initial render:

renderPermissionAwareCompact config

HTMX response after update:

respondHtml (renderPermissionAwareCompact config { selectedValue = Just newValue })

Do not return the raw interactive control:

-- Wrong: bypasses the permission-aware fallback after the first update.
respondHtml (renderInlineDropdown rawDropdownConfig)

Static full-state rendering for read-only show pages:

renderPermissionAwareExpanded config
Core reusable pattern infrastructure
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Composed.PermissionAwareEnumControl where

import Prelude
import Data.Maybe (listToMaybe)
import Data.Text (Text)
import IHP.ViewPrelude
import Patterns.Htmx.Primitives.SemanticValueAxes (DomainSemantic (..), semanticColor)

-- | A row of enum options with their semantic styling.
newtype ButtonRow a = ButtonRow [(a, DomainSemantic)]
    deriving (Eq, Show)

-- | Permission decision for rendering an enum control.
data PermissionDecision
    = Permitted
    | Denied Text
    deriving (Eq, Show)

-- | Visual shape requested by a call site.
data EnumControlShape
    = CompactControl
    | ExpandedControl
    deriving (Eq, Show)

-- | Configuration for a permission-aware enum control.
--
-- The interactive renderers are supplied by sibling patterns such as
-- InlineDropdown and InlineButtonGroup. This pattern decides whether to use
-- them or fall back to static semantic rendering.
data PermissionAwareEnumControlConfig context a = PermissionAwareEnumControlConfig
    { permissionContext :: context
    , permissionDecision :: context -> PermissionDecision
    , label :: a -> Text
    , rows :: [ButtonRow a]
    , selectedValue :: Maybe a
    , renderCompactInteractive :: Html
    , renderExpandedInteractive :: Html
    }
Helper implementation examples

The main renderer chooses a visual shape and applies the permission decision.

renderPermissionAwareEnumControl
    :: Eq a
    => EnumControlShape -> PermissionAwareEnumControlConfig context a -> Html
renderPermissionAwareEnumControl shape config@PermissionAwareEnumControlConfig { permissionContext, permissionDecision } =
    case permissionDecision permissionContext of
        Permitted -> renderInteractive shape config
        Denied _ -> renderStatic shape config

renderPermissionAwareCompact :: Eq a => PermissionAwareEnumControlConfig context a -> Html
renderPermissionAwareCompact = renderPermissionAwareEnumControl CompactControl

renderPermissionAwareExpanded :: Eq a => PermissionAwareEnumControlConfig context a -> Html
renderPermissionAwareExpanded = renderPermissionAwareEnumControl ExpandedControl

Interactive shapes are supplied by existing controls.

renderInteractive :: EnumControlShape -> PermissionAwareEnumControlConfig context a -> Html
renderInteractive shape PermissionAwareEnumControlConfig { renderCompactInteractive, renderExpandedInteractive } =
    case shape of
        CompactControl -> renderCompactInteractive
        ExpandedControl -> renderExpandedInteractive

Static fallback keeps semantic colour and selected state without actionability.

renderStatic :: Eq a => EnumControlShape -> PermissionAwareEnumControlConfig context a -> Html
renderStatic shape PermissionAwareEnumControlConfig { label, rows, selectedValue } =
    case shape of
        CompactControl -> renderStaticSelectedSemanticButton label rows selectedValue
        ExpandedControl -> renderStaticSemanticButtonGroup label rows selectedValue

renderStaticSelectedSemanticButton :: Eq a => (a -> Text) -> [ButtonRow a] -> Maybe a -> Html
renderStaticSelectedSemanticButton label rows selectedValue =
    case selectedValue of
        Nothing -> [hsx|<span class="badge bg-light text-dark">–</span>|]
        Just value -> [hsx|
            <span class={"btn btn-select " <> buttonToneClass True (selectedSemantic rows selectedValue) <> " pe-none user-select-none"}>
                {label value}
            </span>
        |]

renderStaticSemanticButtonGroup :: Eq a => (a -> Text) -> [ButtonRow a] -> Maybe a -> Html
renderStaticSemanticButtonGroup label rows selectedValue = [hsx|
    <div class="d-flex flex-column gap-2" role="group">
        {forEach rows renderRow}
    </div>
|]
  where
    renderRow (ButtonRow pairs) = [hsx|
        <div class="d-flex flex-lg-row flex-column gap-2">
            {forEach pairs renderOption}
        </div>
    |]

    renderOption (option, semantic) = [hsx|
        <span class={"btn btn-select " <> buttonToneClass (selectedValue == Just option) semantic <> " pe-none user-select-none"}>
            {label option}
        </span>
    |]

selectedSemantic :: Eq a => [ButtonRow a] -> Maybe a -> DomainSemantic
selectedSemantic rows selectedValue =
    case listToMaybe [ semantic | ButtonRow pairs <- rows, (value, semantic) <- pairs, Just value == selectedValue ] of
        Just semantic -> semantic
        Nothing -> Neutral

buttonToneClass :: Bool -> DomainSemantic -> Text
buttonToneClass isSelected semantic =
    let color = semanticColor semantic
    in if isSelected then "btn-" <> color else "btn-outline-" <> color
Usage examples

A small domain model supplies a permission context and enum-like value.

data User = User
    { userId :: Int
    , userIsAdmin :: Bool
    }
    deriving (Eq, Show)

data Role = Member | Manager
    deriving (Eq, Show)

data Coverage = OneSong | CoupleSongs | AllSongs
    deriving (Eq, Show)

data ItemPermissionContext = ItemPermissionContext
    { actor :: Maybe User
    , actorRole :: Maybe Role
    , targetUserId :: Int
    }
    deriving (Eq, Show)

The permission predicate is domain-level and shared by every renderer.

canEditItem :: ItemPermissionContext -> PermissionDecision
canEditItem ItemPermissionContext { actor = Just user, actorRole, targetUserId }
    | userIsAdmin user = Permitted
    | userId user == targetUserId = Permitted
    | actorRole == Just Manager = Permitted
    | otherwise = Denied "item is read-only for this actor"
canEditItem ItemPermissionContext { actor = Nothing } =
    Denied "anonymous users cannot edit item"

One semantic configuration is shared by compact and expanded shapes.

coverageRows :: [ButtonRow Coverage]
coverageRows =
    [ ButtonRow
        [ (OneSong, Variant)
        , (CoupleSongs, Variant)
        , (AllSongs, Affirmative)
        ]
    ]

coverageLabel :: Coverage -> Text
coverageLabel = \case
    OneSong -> "One song"
    CoupleSongs -> "Couple songs"
    AllSongs -> "All songs"

Interactive renderers usually come from InlineDropdown and InlineButtonGroup. The example keeps them small placeholders so the pattern remains standalone.

interactiveCoverageDropdown :: Coverage -> Html
interactiveCoverageDropdown selected = [hsx|
    <button type="button" class="btn btn-primary dropdown-toggle" hx-post="/items/1/coverage">
        {coverageLabel selected}
    </button>
|]

interactiveCoverageButtons :: Coverage -> Html
interactiveCoverageButtons selected = [hsx|
    <div hx-target="this" hx-swap="outerHTML">
        <button type="button" class="btn btn-primary" hx-post="/items/1/coverage">
            {coverageLabel selected}
        </button>
    </div>
|]

A local helper ties the permission context to both visual shapes.

coverageControlConfig :: ItemPermissionContext -> Coverage -> PermissionAwareEnumControlConfig ItemPermissionContext Coverage
coverageControlConfig context selected = PermissionAwareEnumControlConfig
    { permissionContext = context
    , permissionDecision = canEditItem
    , label = coverageLabel
    , rows = coverageRows
    , selectedValue = Just selected
    , renderCompactInteractive = interactiveCoverageDropdown selected
    , renderExpandedInteractive = interactiveCoverageButtons selected
    }

coverageCompactControl :: ItemPermissionContext -> Coverage -> Html
coverageCompactControl context selected =
    renderPermissionAwareCompact (coverageControlConfig context selected)

coverageExpandedControl :: ItemPermissionContext -> Coverage -> Html
coverageExpandedControl context selected =
    renderPermissionAwareExpanded (coverageControlConfig context selected)

HTMX responses must return the same permission-aware wrapper with the updated value, not the raw interactive control.

coverageHtmxResponse :: ItemPermissionContext -> Coverage -> Html
coverageHtmxResponse context newCoverage =
    renderPermissionAwareCompact (coverageControlConfig context newCoverage)
Standalone checks

The infrastructure and examples above compile as one module under the normal ordng pattern check. No extra scaffolding is required.

Speculative External Search Feed

Raw source: Patterns/Htmx/Composed/SpeculativeExternalSearchFeed.lhs

Pattern intent and mechanism

Fire an external provider search in parallel with a local typeahead or search so provider latency is hidden and fallback results can appear without replacing the local result set. The external provider feed is not the local search. It has its own trigger element, endpoint, and target container.

The interaction shape is:

  1. a visible input drives the local search,
  2. a hidden HTMX listener observes that input via from:#input,
  3. the hidden listener includes the visible input value with hx-include,
  4. the external provider response swaps into a dedicated target,
  5. local results and external provider results remain separate fragments.

This is not a selection typeahead unless result rows add their own select actions. The reusable interaction is a parallel external result feed.

Use this pattern when:

  • local search should remain primary,
  • external provider latency should not block local feedback,
  • provider results are optional enrichment or fallback material,
  • provider failures should not look like local no-result success.

Search aliases: fallback search, parallel search, speculative search, autocomplete, typeahead, hx-include, hx-trigger, from:#input, stale response.

Project-specific notes and rollout guidance

Build this on the existing Typeahead and FilterTarget vocabulary. The visible input may use the normal local typeahead pattern; the external provider feed uses a second, hidden HTMX trigger bound to the same input.

Do not add changed to the hidden listener. HTMX evaluates changed against the listener element itself; a hidden div has no changing value, so the modifier can suppress every request.

Do not add a load trigger by default. It sends an empty provider search before the user has typed anything and can spend auth, token, or search latency for no useful result.

Target a dedicated external result container. The provider response should be an inner fragment for that container and must not replace the local search result container.

Gate the external provider endpoint when it can spend provider quota, cost, or request time. Compose with ExternalProviderEndpointGate on the server side and with ExternalSearchQueryPolicy inside the provider boundary.

Fast typing can let an older provider response arrive after a newer local or provider response. If semantic freshness matters for the feature, add an echoed query token, request id, or cancellation strategy. That is a separate follow-up pattern, not part of the baseline feed.

Supporting snippets

Minimal HTMX shape:

<input
  id="entity-input"
  name="query"
  hx-post="/local/search"
  hx-target="#local-feedback"
  hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
  hx-swap="innerHTML" />

<div
  id="external-search-listener"
  style="display:none"
  hx-post="/external/search"
  hx-include="#entity-input"
  hx-target="#external-feedback"
  hx-trigger="input delay:300ms from:#entity-input, keyup[key=='Enter'] from:#entity-input"
  hx-swap="innerHTML"></div>

<div id="local-feedback"></div>
<div id="external-feedback"></div>

Server-side response mapping:

searchExternalProviderComponent query = do
    result <- liftIO (searchExternalProvider query)
    respondHtml (renderExternalProviderResult result)
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Composed.SpeculativeExternalSearchFeed where

import Prelude
import Data.Text (Text)
import qualified Data.Text as T
import IHP.ViewPrelude
import Patterns.Htmx.Shared.Types

-- | Configuration for one visible local search plus one hidden external feed.
data SpeculativeExternalSearchFeed = SpeculativeExternalSearchFeed
    { speculativeInputId :: Text
    , speculativeInputName :: Text
    , speculativeLocalSearchPath :: Text
    , speculativeLocalTargetId :: Text
    , speculativeExternalListenerId :: Text
    , speculativeExternalSearchPath :: Text
    , speculativeExternalTargetId :: Text
    , speculativeDelayMs :: Int
    }
    deriving (Show, Eq)

Selectors are derived from target ids in the config so attributes and rendered containers stay aligned.

speculativeInputSelector :: SpeculativeExternalSearchFeed -> HxSelector
speculativeInputSelector SpeculativeExternalSearchFeed { speculativeInputId = inputId } =
    HxId inputId

speculativeInputSelectorText :: SpeculativeExternalSearchFeed -> Text
speculativeInputSelectorText config = renderHxSelector (speculativeInputSelector config)

speculativeLocalTargetSelector :: SpeculativeExternalSearchFeed -> HxSelector
speculativeLocalTargetSelector SpeculativeExternalSearchFeed { speculativeLocalTargetId = localTargetId } =
    HxId localTargetId

speculativeExternalTargetSelector :: SpeculativeExternalSearchFeed -> HxSelector
speculativeExternalTargetSelector SpeculativeExternalSearchFeed { speculativeExternalTargetId = externalTargetId } =
    HxId externalTargetId

speculativeExternalTrigger :: SpeculativeExternalSearchFeed -> Text
speculativeExternalTrigger config@SpeculativeExternalSearchFeed { speculativeDelayMs = delayMs } =
    "input delay:" <> delayText <> "ms from:" <> inputSelector
        <> ", keyup[key=='Enter'] from:" <> inputSelector
  where
    delayText = tshow delayMs
    inputSelector = speculativeInputSelectorText config
Helper implementation examples

A typical configuration has one local target and one external provider target.

exampleSpeculativeExternalSearchFeed :: SpeculativeExternalSearchFeed
exampleSpeculativeExternalSearchFeed = SpeculativeExternalSearchFeed
    { speculativeInputId = "entity-input"
    , speculativeInputName = "query"
    , speculativeLocalSearchPath = "/local/search"
    , speculativeLocalTargetId = "local-feedback"
    , speculativeExternalListenerId = "external-search-listener"
    , speculativeExternalSearchPath = "/external/search"
    , speculativeExternalTargetId = "external-feedback"
    , speculativeDelayMs = 300
    }

The hidden listener is generated from the same config as the visible input.

externalSearchListener :: SpeculativeExternalSearchFeed -> Html
externalSearchListener config@SpeculativeExternalSearchFeed
        { speculativeExternalListenerId = externalListenerId
        , speculativeExternalSearchPath = externalSearchPath
        } = [hsx|
    <div
        id={externalListenerId}
        style="display:none"
        hx-post={externalSearchPath}
        hx-include={speculativeInputSelectorText config}
        hx-target={renderHxSelector (speculativeExternalTargetSelector config)}
        hx-trigger={speculativeExternalTrigger config}
        hx-swap="innerHTML">
    </div>
|]
Usage examples

A visible local input and hidden external provider listener can be rendered as a single interaction unit.

speculativeExternalSearchComponent :: SpeculativeExternalSearchFeed -> Html
speculativeExternalSearchComponent config@SpeculativeExternalSearchFeed
        { speculativeInputId = inputId
        , speculativeInputName = inputName
        , speculativeLocalSearchPath = localSearchPath
        , speculativeLocalTargetId = localTargetId
        , speculativeExternalTargetId = externalTargetId
        } = [hsx|
    <div class="speculative-external-search-feed">
        <input
            id={inputId}
            name={inputName}
            type="search"
            autocomplete="off"
            hx-post={localSearchPath}
            hx-target={renderHxSelector (speculativeLocalTargetSelector config)}
            hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
            hx-swap="innerHTML" />

        {externalSearchListener config}

        <div id={localTargetId}></div>
        <div id={externalTargetId}></div>
    </div>
|]

The external trigger text deliberately omits changed and load.

exampleExternalTrigger :: Text
exampleExternalTrigger = speculativeExternalTrigger exampleSpeculativeExternalSearchFeed

externalTriggerMentionsChanged :: Bool
externalTriggerMentionsChanged = "changed" `T.isInfixOf` exampleExternalTrigger

externalTriggerMentionsLoad :: Bool
externalTriggerMentionsLoad = "load" `T.isInfixOf` exampleExternalTrigger
Standalone checks

Not applicable in this pattern; sections 4, 5, and 6 compile standalone.

Targeted Partial Section Filter

Raw source: Patterns/Htmx/Composed/TargetedPartialSectionFilter.lhs

Pattern intent and mechanism

Render a page section that can be filtered in place via HTMX, returning only that section when the request carries a matching HX-Target header, and the full page otherwise.

The pattern has three coordinated parts:

  1. Controller conditional: inspect HX-Request and HX-Target; when both are present and the target matches the section id, respond with the partial section only.
  2. Stable section wrapper: the section is wrapped in a node with a stable id so HTMX can swap it reliably on every update.
  3. Filter trigger: links or buttons that issue hx-get against the same action, targeting the stable section id, with hx-swap="outerHTML" and hx-push-url="true" so the URL reflects the filter state.

The href attribute remains on the trigger as a non-HTMX fallback.

This pattern composes with FilterTarget (the primitive target/swap mechanism) and SemanticValueAxes (visual state of the active filter button). When a filter update must also refresh other page regions, compose with OobUpdates instead of expanding the section boundary.

Project-specific notes and rollout guidance

Normalize HX-Target values. The header may arrive with or without a leading #. Check both shapes ("section-id" and "#section-id") in the controller guard.

Freeze the controller context before rendering a partial. IHP's implicit context (?context) may carry mutable state; freeze it so the partial render is self-contained.

Return the wrapper, not the inner fragment. The HTMX response must include <div id="section-id">...</div> so the swap replaces the old wrapper atomically. Returning only inner content breaks the next swap because the target id disappears from the DOM.

CSS guardrail for nested padding. When the section wrapper sits between .container-fluid and .content-padded, the DOM shape is .container-fluid > #section-id > .content-padded. A partial swap can duplicate padding because the replacement node is inserted inside the padded container. Apply a CSS rule scoped to the section-wrapper class that suppresses .content-padded padding when it is a descendant of a targeted partial section:

.targeted-partial-section > .content-padded {
    padding-left: 0;
    padding-right: 0;
}

Keep filter state in query parameters. The href and hx-get should point to the same URL so non-HTMX and HTMX requests converge on the same action logic. Read parameters with paramOrNothing and supply defaults.

Pattern identity in host code. Name the partial section renderer after the domain section plus the pattern reference: itemsSection, itemsIndexSection, tasksSection. Name the filter button renderer after the filter axis plus FilterButtons: timeFilterButtons, statusFilterButtons.

Supporting snippets

HTMX attributes used by the filter trigger:

CSS guardrail for padding suppression:

.targeted-partial-section > .content-padded {
    padding-left: 0;
    padding-right: 0;
}
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Composed.TargetedPartialSectionFilter where

import Prelude
import Data.Text (Text)
import IHP.ViewPrelude
import IHP.RouterSupport (HasPath (..))

import Patterns.Htmx.Shared.Types

-- | Configuration for a section filter link.
-- | The caller supplies the target section id, the action URL, the current
-- | filter value, and the label to display.
data SectionFilterLinkConfig = SectionFilterLinkConfig
    { sectionId :: Text
    , filterUrl :: Text
    , isSelected :: Bool
    , linkLabel :: Text
    }

-- | Configuration for a stable section wrapper.
-- | The caller supplies the section id and the inner content.
data SectionWrapperConfig = SectionWrapperConfig
    { wrapperId :: Text
    , wrapperContent :: Html
    }
Helper implementation examples
-- | Render a filter link that works both with and without HTMX.
-- | When selected, render a static span (no interaction).
-- | When not selected, render an anchor with HTMX attributes.
renderSectionFilterLink :: SectionFilterLinkConfig -> Html
renderSectionFilterLink SectionFilterLinkConfig { sectionId = sectionId, filterUrl = filterUrl, isSelected = isSelected, linkLabel = linkLabel } =
    if isSelected
        then [hsx|
            <span class={classes [("btn", True), ("btn-select", True), ("pe-none", True), ("user-select-none", True)]}>
                {linkLabel}
            </span>
        |]
        else [hsx|
            <a href={filterUrl}
               class={classes [("btn", True), ("btn-select", True)]}
               hx-get={filterUrl}
               hx-target={renderHxSelector (hxIdSelector sectionId)}
               hx-swap="outerHTML"
               hx-push-url="true"
               data-turbolinks="false">
                {linkLabel}
            </a>
        |]

-- | Render a stable section wrapper.
-- | The wrapper node carries the id that HTMX targets during swaps.
renderSectionWrapper :: SectionWrapperConfig -> Html
renderSectionWrapper SectionWrapperConfig { wrapperId = wrapperId, wrapperContent = wrapperContent } = [hsx|
    <div id={wrapperId} class="targeted-partial-section">
        {wrapperContent}
    </div>
|]
Usage examples

Domain types and actions for the example.

data Item = Item
    { itemId :: Int
    , itemName :: Text
    }
    deriving (Eq, Show)

data ListAction = ListAction
    deriving (Eq, Show)

instance HasPath ListAction where
    pathTo ListAction = "/items"

data ItemFilter = Upcoming | Past | All
    deriving (Eq, Show)

filterValueText :: ItemFilter -> Text
filterValueText Upcoming = "upcoming"
filterValueText Past     = "past"
filterValueText All      = "all"

filterLabelText :: ItemFilter -> Text
filterLabelText Upcoming = "Upcoming"
filterLabelText Past     = "Past"
filterLabelText All      = "All"

The view defines a partial section renderer and a filter button bar.

itemsSectionId :: Text
itemsSectionId = "items-section"

itemsSection :: [Item] -> ItemFilter -> Html
itemsSection items currentFilter =
    renderSectionWrapper SectionWrapperConfig
        { wrapperId = itemsSectionId
        , wrapperContent = sectionContent
        }
  where
    sectionContent = [hsx|
        <div>
            {filterButtons}
            {itemList}
        </div>
    |]
    filterButtons = [hsx|
        <div class="d-flex gap-2 mb-3">
            {renderFilterButton Upcoming}
            {renderFilterButton Past}
            {renderFilterButton All}
        </div>
    |]
    renderFilterButton filterValue =
        let url = pathTo ListAction <> "?filter=" <> filterValueText filterValue
        in renderSectionFilterLink SectionFilterLinkConfig
            { sectionId = itemsSectionId
            , filterUrl = url
            , isSelected = currentFilter == filterValue
            , linkLabel = filterLabelText filterValue
            }
    itemList = [hsx|
        <ul>
            {forEach items renderItem}
        </ul>
    |]
    renderItem Item { itemName = itemName } = [hsx|<li>{itemName}</li>|]

The controller decides between partial and full-page render. In prose form (the exact IHP wiring depends on the implicit parameter context):

let hxTarget = lookup "HX-Target" (requestHeaders request)
let isSectionFilter = lookup "HX-Request" (requestHeaders request) == Just "true"
                   && (hxTarget == Just "#items-section" || hxTarget == Just "items-section")
if isSectionFilter
    then do
        frozenContext <- freeze ?context
        let ?context = frozenContext
        respondHtml (itemsSection items currentFilter)
    else render ListView { items = items, currentFilter = currentFilter }
Standalone checks
-- | Minimal check data.
exampleItems :: [Item]
exampleItems =
    [ Item 1 "First item"
    , Item 2 "Second item"
    ]

-- | Render the section for visual inspection.
exampleSection :: Html
exampleSection = itemsSection exampleItems Upcoming
Typeahead

Raw source: Patterns/Htmx/Composed/Typeahead.lhs

Pattern intent and mechanism

Use a text input to query matching records and swap a selectable result list into a dedicated target via HTMX.

This is useful for selection workflows where a free-text query drives server-rendered options and user choice feeds subsequent UI steps.

Project-specific notes and rollout guidance

Use consistent names for the result container and the "next selected" target chain across entities.

When rolling out, align debouncing and empty-query behavior first, then align option rendering/selection behavior.

Supporting snippets

Server-side shape:

searchComponent selectAction existingFibreIds targetFibreName =
  if targetFibreName == ""
    then respondHtml mempty
    else do
       fibre <- query
                |> filterWhereNotIn (#id, existingFibreIds)
                |> filterWhereILike (#wardName, "%" <> targetFibreName <> "%")
                |> orderBy #wardName
                |> fetch
       if null fibre
         then respondHtml mempty
         else respondHtml ((typeAheadComponent selectAction) fibre)

View-side shape:

typeAheadComponent selectAction fibres = [hsx|
  <output>
    <div class="typeahead-dropdown-component dropdown show">
      { forEach fibres (renderTypeAheadOption selectAction) }
    </div>
  </output>
|]

Option item shape:

renderTypeAheadOption selectAction fibre = [hsx|
  <a hx-get={selectAction fibre.id}
     hx-target="next .next-selected-fibre-id"
     hx-swap="outerHTML">
      { fibre.name }
  </a>
|]
Core reusable pattern infrastructure
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Composed.Typeahead where

import Prelude
import Data.Proxy (Proxy)
import GHC.TypeLits (Symbol)
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude hiding (show, null, query, fetch, (|>))
import Patterns.Htmx.Shared.Types

data Ward = Ward
    { id :: Int
    , wardName :: Text
    , name :: Text
    }

data SelectAction = SelectAction Int

instance HasPath SelectAction where
    pathTo (SelectAction wardId) = "/typeahead/select/" <> tshow wardId

hxSelectedFibreTargetSelector :: HxSelector
hxSelectedFibreTargetSelector = HxNext ".next-selected-fibre-id"
Helper implementation examples

Not applicable in this pattern.

Usage examples

Typeahead search action:

searchComponent :: (Int -> SelectAction) -> [Int] -> Text -> IO ()
searchComponent selectAction existingFibreIds targetFibreName  =
  if (targetFibreName == (""::Text) )
    then respondHtml mempty
    else do
       fibre <- query
                |> filterWhereNotIn(#id, existingFibreIds)
                |> filterWhereILike(#wardName, "%" <> targetFibreName <> "%")
                |> orderBy #wardName
                |> fetch
       if null fibre
         then respondHtml mempty
         else respondHtml ((typeAheadComponent selectAction) fibre)

Dropdown shell and option renderer:

typeAheadComponent :: (Int -> SelectAction) -> [Ward] -> Html
typeAheadComponent _ _ = [hsx|
                               <output>
                                 <div class="typeahead-dropdown-component dropdown show">
                                   <a href="#" class="dropdown-item" role="option">Example option</a>
                                 </div>
                               </output>
                               |]

renderTypeAheadOption :: (Int -> SelectAction) -> Ward -> Html
renderTypeAheadOption selectAction fibre = [hsx| <a href="#" class="dropdown-item" role="option"
                                                   hx-get={selectAction fibre.id}
                                                   hx-target={renderHxSelector hxSelectedFibreTargetSelector}
                                                   hx-swap="outerHTML"
                                                   data-script="on click DO HYPERSCRIPT IF NECESSARY"
                                                   aria-label={fibre.name}>
                                      { fibre.name }
                                   </a>
                                 |]

Boilerplate stubs:

newtype QueryBuilder a = QueryBuilder ()

query :: QueryBuilder Ward
query = QueryBuilder ()

(|>) :: a -> (a -> b) -> b
(|>) x f = f x

filterWhereNotIn :: (Proxy (field :: Symbol), [Int]) -> QueryBuilder Ward -> QueryBuilder Ward
filterWhereNotIn _ qb = qb

filterWhereILike :: (Proxy (field :: Symbol), Text) -> QueryBuilder Ward -> QueryBuilder Ward
filterWhereILike _ qb = qb

orderBy :: Proxy (field :: Symbol) -> QueryBuilder Ward -> QueryBuilder Ward
orderBy _ qb = qb

fetch :: QueryBuilder Ward -> IO [Ward]
fetch _ = pure []

respondHtml :: Html -> IO ()
respondHtml _ = pure ()
Standalone checks

Not applicable in this pattern.

Migration

React/D3 Stack Charts

Raw source: Patterns/Htmx/Migration/MigrateReactD3StackCharts.lhs

Pattern intent and mechanism

Reference material for translating a React/D3 stacked-series chart into a Haskell/HTMX-friendly architecture and data model.

Project-specific notes and rollout guidance

Target direction: define a generic stacked-series option for time-series data (for example shift categories like VMS, EMAIL, CONSULTANT) and compose adjacent booking overlays where needed.

Supporting snippets

Legacy React/D3 snippet (cluster breakdown stacked by day):

import { UtilityStackComponent } from './UtilityStack';

Here is the scheme for the Data

CREATE TABLE utility_capacities (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    utility_id UUID NOT NULL,
    supply REAL NOT NULL,
    unit TEXT NOT NULL,
    utility_name TEXT NOT NULL,
    rfsu_date DATE NOT NULL
);
    const utilityCapacities = isSteamUtilityPackage ? useQuery(query('utility_capacities')
        .filterWhere('utilityName', 'Steam')
        .orderBy('rfsuDate')) : useQuery(query('utility_capacities')
            .whereIn('utilityId', selectedUtilityIds)
            .orderBy('rfsuDate'));

    // Compute the width of a day.         
    const start = new Date(2015, 02, 01);
    const end = new Date(2015, 02, 02);
    const dayWidth = (timeScale(end) - timeScale(start));
    const legendColour = "#e0e9f2";
    const labelFontSize = 14;    

    const capacityRange = (utilityCapacities) ? [0, d3.max(scanUtilityCapacities)] : [0, 100]

    // Time Scales these mapped to our haskell Scale Class
    const timeScale = useMemo(() => d3.scaleTime()
        .domain(dateRange)
        .range([margin.left, chartWidth - margin.right])
        , [chartWidth]);


    const yScale = (!isCapacityScale) ? d3.scaleLinear()
        .domain(quantityRange)
        .range([chartHeight - margin.bottom, margin.top])
        : d3.scaleLinear()
            .domain(capacityRange)
            .range([chartHeight - margin.bottom, margin.top]);


    // Here We make our Series Component see below for js definition.
    // Remap dataKeys in the order specified in dataLabels
    const orderedDataKeys = dataLabels.map(label => {
        // Reverse map from label back to the original key in dataKeys
        return dataKeys.find(key => idMap[key.toUpperCase()] ? (idMap[key.toUpperCase()] === label) : key === label);
    });

    let newDataset = assignDefaultValues(dataLabels, flattenedData);


    // cf.d3/docs/d3-shape/stack.md

    const series = d3.stack()
        .keys(orderedDataKeys)
        .value(([, group], key) => (group.get(key) || { quantity: 0 }).quantity)
        (d3.index(newDataset, d => d.date, d => d.key));

    // yAxis marks
    // ...
            {
                orderedDataKeys.map((clusterId, index) => {
                    let lab = idMap[clusterId] ?? idMap[clusterId.toUpperCase()]
                    return <>
                        <rect key={clusterId} x={108}
                            y={14 * index + 50}
                            width={12}
                            height={14}
                            fill={clusterColorScale((utilityGroupingLabel === 'GroupByAction') ? lab.toUpperCase(): clusterId  )} ></rect>
                    </>
                })
            }

            <g ref={svgRef}></g>

            {yTickValues.map((yTick, index) => <line key={index} x1={margin.left - 12} y1={yScale(yTick)} x2={margin.left} y2={yScale(yTick)} strokeWidth="0.75" stroke="#004A96" />)}
            {yTickValues.map((yTick, index) => <line key={index} x1={margin.left} y1={yScale(yTick)} x2={chartWidth - margin.right} y2={yScale(yTick)} strokeWidth="0.25" stroke="#004A96" strokeDasharray={"1 2"} />)}
            {yTickValues.slice(0, -2).map((yTick, index) => <text fill={basfBlue} key={index} x={margin.left} dx={-52} y={yScale(yTick) + 6}> {yTick} </text>)}

            {series.map(clusterData => clusterData.map(d =>
                <g>
                    <rect className="utility-rect" fill={clusterColorScale(clusterData.key)} x={timeScale(d.data[0])} y={yScale(d[1])}
                        height={yScale(d[0]) - yScale(d[1])} width={dayWidth - dayWidth / 10}
                        onMouseEnter={() => {
                            let dA = utilityRundownData.find(ud => (new Date(ud.day)).getTime() === d.data[0].getTime());
                            setDaysActivities({ 'day': dA?.day, 'daysActivities': dA?.daysActivities });
                        }}
                        onMouseLeave={() => setDaysActivities(null)}></rect>
                    <title>
                        {isGroupByCluster ?
                            <text fontSize={14}> {clusters.find(cluster => cluster.id === clusterData.key)?.prefix ?? clusterData.key} : {(d[1] - d[0])?.toFixed(1)} {unit}</text> :
                            <text fontSize={14}> {utilities.find(utility => utility.id === clusterData.key)?.utilityKey} : {(d[1] - d[0])?.toFixed(1)} {unit}</text>
                        }
                    </title>
                </g>
            )
            )
            }
import array from "./array.js";
import constant from "./constant.js";
import offsetNone from "./offset/none.js";
import orderNone from "./order/none.js";

function stackValue(d, key) {
  return d[key];
}

function stackSeries(key) {
  const series = [];
  series.key = key;
  return series;
}

export default function() {
  var keys = constant([]),
      order = orderNone,
      offset = offsetNone,
      value = stackValue;

  function stack(data) {
    var sz = Array.from(keys.apply(this, arguments), stackSeries),
        i, n = sz.length, j = -1,
        oz;

    for (const d of data) {
      for (i = 0, ++j; i < n; ++i) {
        (sz[i][j] = [0, +value(d, sz[i].key, j, data)]).data = d;
      }
    }

    for (i = 0, oz = array(order(sz)); i < n; ++i) {
      sz[oz[i]].index = i;
    }

    offset(sz, oz);
    return sz;
  }

  stack.keys = function(_) {
    return arguments.length ? (keys = typeof _ === "function" ? _ : constant(Array.from(_)), stack) : keys;
  };

  stack.value = function(_) {
    return arguments.length ? (value = typeof _ === "function" ? _ : constant(+_), stack) : value;
  };

  stack.order = function(_) {
    return arguments.length ? (order = _ == null ? orderNone : typeof _ === "function" ? _ : constant(Array.from(_)), stack) : order;
  };

  stack.offset = function(_) {
    return arguments.length ? (offset = _ == null ? offsetNone : _, stack) : offset;
  };

  return stack;
}
Core reusable pattern infrastructure

Not applicable in this pattern.

Helper implementation examples

Not applicable in this pattern.

Usage examples
module Patterns.Htmx.Migration.MigrateReactD3StackCharts where

{-
import { UtilityStackComponent } from './UtilityStack';
-}

{-
CREATE TABLE utility_capacities (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    utility_id UUID NOT NULL,
    supply REAL NOT NULL,
    unit TEXT NOT NULL,
    utility_name TEXT NOT NULL,
    rfsu_date DATE NOT NULL
);

    const utilityCapacities = isSteamUtilityPackage ? useQuery(query('utility_capacities')
        .filterWhere('utilityName', 'Steam')
        .orderBy('rfsuDate')) : useQuery(query('utility_capacities')
            .whereIn('utilityId', selectedUtilityIds)
            .orderBy('rfsuDate'));

    // Compute the width of a day.
    const start = new Date(2015, 02, 01);
    const end = new Date(2015, 02, 02);
    const dayWidth = (timeScale(end) - timeScale(start));
    const legendColour = "#e0e9f2";
    const labelFontSize = 14;

    const capacityRange = (utilityCapacities) ? [0, d3.max(scanUtilityCapacities)] : [0, 100]

    // Time Scales these mapped to our haskell Scale Class
    const timeScale = useMemo(() => d3.scaleTime()
        .domain(dateRange)
        .range([margin.left, chartWidth - margin.right])
        , [chartWidth]);


    const yScale = (!isCapacityScale) ? d3.scaleLinear()
        .domain(quantityRange)
        .range([chartHeight - margin.bottom, margin.top])
        : d3.scaleLinear()
            .domain(capacityRange)
            .range([chartHeight - margin.bottom, margin.top]);


    // Here We make our Series Component see below for js definition.
    // Remap dataKeys in the order specified in dataLabels
    const orderedDataKeys = dataLabels.map(label => {
        // Reverse map from label back to the original key in dataKeys
        return dataKeys.find(key => idMap[key.toUpperCase()] ? (idMap[key.toUpperCase()] === label) : key === label);
    });

    let newDataset = assignDefaultValues(dataLabels, flattenedData);


    // cf.d3/docs/d3-shape/stack.md

    const series = d3.stack()
        .keys(orderedDataKeys)
        .value(([, group], key) => (group.get(key) || { quantity: 0 }).quantity)
        (d3.index(newDataset, d => d.date, d => d.key));

    // yAxis marks
    // ...
            {
                orderedDataKeys.map((clusterId, index) => {
                    let lab = idMap[clusterId] ?? idMap[clusterId.toUpperCase()]
                    return <>
                        <rect key={clusterId} x={108}
                            y={14 * index + 50}
                            width={12}
                            height={14}
                            fill={clusterColorScale((utilityGroupingLabel === 'GroupByAction') ? lab.toUpperCase(): clusterId  )} ></rect>
                    </>
                })
            }

            <g ref={svgRef}></g>

            {yTickValues.map((yTick, index) => <line key={index} x1={margin.left - 12} y1={yScale(yTick)} x2={margin.left} y2={yScale(yTick)} strokeWidth="0.75" stroke="#004A96" />)}
            {yTickValues.map((yTick, index) => <line key={index} x1={margin.left} y1={yScale(yTick)} x2={chartWidth - margin.right} y2={yScale(yTick)} strokeWidth="0.25" stroke="#004A96" strokeDasharray={"1 2"} />)}
            {yTickValues.slice(0, -2).map((yTick, index) => <text fill={basfBlue} key={index} x={margin.left} dx={(-52)} y={yScale(yTick) + 6}> {yTick} </text>)}

            {series.map(clusterData => clusterData.map(d =>
                <g>
                    <rect className="utility-rect" fill={clusterColorScale(clusterData.key)} x={timeScale(d.data[0])} y={yScale(d[1])}
                        height={yScale(d[0]) - yScale(d[1])} width={dayWidth - dayWidth / 10}
                        onMouseEnter={() => {
                            let dA = utilityRundownData.find(ud => (new Date(ud.day)).getTime() === d.data[0].getTime());
                            setDaysActivities({ 'day': dA?.day, 'daysActivities': dA?.daysActivities });
                        }}
                        onMouseLeave={() => setDaysActivities(null)}></rect>
                    <title>
                        {isGroupByCluster ?
                            <text fontSize={14}> {clusters.find(cluster => cluster.id === clusterData.key)?.prefix ?? clusterData.key} : {(d[1] - d[0])?.toFixed(1)} {unit}</text> :
                            <text fontSize={14}> {utilities.find(utility => utility.id === clusterData.key)?.utilityKey} : {(d[1] - d[0])?.toFixed(1)} {unit}</text>
                        }
                    </title>
                </g>
            )
            )
            }
-}

{-
stack.js
import array from "./array.js";
import constant from "./constant.js";
import offsetNone from "./offset/none.js";
import orderNone from "./order/none.js";

function stackValue(d, key) {
  return d[key];
}

function stackSeries(key) {
  const series = [];
  series.key = key;
  return series;
}

export default function() {
  var keys = constant([]),
      order = orderNone,
      offset = offsetNone,
      value = stackValue;

  function stack(data) {
    var sz = Array.from(keys.apply(this, arguments), stackSeries),
        i, n = sz.length, j = -1,
        oz;

    for (const d of data) {
      for (i = 0, ++j; i < n; ++i) {
        (sz[i][j] = [0, +value(d, sz[i].key, j, data)]).data = d;
      }
    }

    for (i = 0, oz = array(order(sz)); i < n; ++i) {
      sz[oz[i]].index = i;
    }

    offset(sz, oz);
    return sz;
  }

  stack.keys = function(_) {
    return arguments.length ? (keys = typeof _ === "function" ? _ : constant(Array.from(_)), stack) : keys;
  };

  stack.value = function(_) {
    return arguments.length ? (value = typeof _ === "function" ? _ : constant(+_), stack) : value;
  };

  stack.order = function(_) {
    return arguments.length ? (order = _ == null ? orderNone : typeof _ === "function" ? _ : constant(Array.from(_)), stack) : order;
  };

  stack.offset = function(_) {
    return arguments.length ? (offset = _ == null ? offsetNone : _, stack) : offset;
  };

  return stack;
}
-}
Standalone checks

Not applicable in this pattern.

React Patterns

Raw source: Patterns/Htmx/Migration/MigrateReactPatterns.lhs

Pattern intent and mechanism

Move React data+view patterns to server-rendered HTMX + HSX by extracting the required query shape first, then re-implementing UI behavior incrementally.

Project-specific notes and rollout guidance

For migration rollout, handle interaction refinements (for example alarm/status cues) via HTMX action responses and OOB updates once query shape and base rendering are stable.

Supporting snippets

Start from a complex component (for example, Scorecard) and isolate the data section first:

    // Fetch Data
    const wardBalancedScorecards: Array<WardBalancedScorecard> = useQuery(query('ward_balanced_scorecards').filterWhere('wardId', user?.wardId));
    const scorecards: Array<BalancedScorecard> = useQuery(query('balanced_scorecards').whereIn('id', wardBalancedScorecards?.map(wardBsc => wardBsc.balancedScorecardId) || [null]));
    const bscsPerspectives: Array<BalancedScorecardPerspective> = useQuery(query('balanced_scorecard_perspectives').whereIn('balancedScorecardId', scorecards?.map(bsc => bsc.id) || [null]));
    const perspectives: Array<Perspective> = useQuery(query('perspectives').whereIn('id', bscsPerspectives?.map(bscsPerspective => bscsPerspective.perspectiveId) || [null]));

    // These can be fetched from js:
    const ward = useQuerySingleResult(query('wards').filterWhere('id', user?.wardId));
    const metrics: Array<Metric> = useQuery(query('metrics').whereIn('perspectiveId', perspectives?.map(perspective => perspective.id) || [null]));

    // This is the only dynamic data at ward level:
    const allMetricDates: Array<MetricDate> = useQuery(query('metric_dates').filterWhere('wardId', user?.wardId).whereIn('metricId', metrics?.map(metric => metric.id) || [null]).orderByAsc('reportDueAt'));
    const qips: Array<QIP> = useQuery(query('q_i_ps').whereIn('metricDateId', allMetricDates?.map(metricDate => metricDate.id) || [null]).orderByDesc('updatedAt'));
    const finishedPerspectives: Array<FinishedPerspective> = useQuery(query('finished_perspectives').filterWhere('wardId', user?.wardId)
        .whereIn('perspectiveId', perspectives?.map(perspective => perspective.id) || [null])
        .filterWhere('reportDueAt', reportDueAt));

    // Get previous month's QIPs
    const previousMonthMetricDates = allMetricDates?.filter(md => md.reportDueAt === lastMonthReportDueAt) || [];
    const previousMonthQips = qips?.filter(qip =>
        previousMonthMetricDates.some(md => md.id === qip.metricDateId)
    ).slice(0, 5) || []; // Limit to 5 most recent

The data required in the view can be translated into a query based on what is actually required in the Component down stream like:

e.g. From a different table but can be specialized to the views requirements.

data ClientPoolEntry = ClientPoolEntry
    { cpProviderName  :: Text
    , cpProviderId    :: Int32
    , cpClientName    :: Text
    , cpClientId      :: Int32
    , cpUserCount     :: Int64
    , cpCreatedAt     :: Day
    } deriving stock (Show, Generic)
      deriving anyclass (DecodeRow)

clientPool :: Session.Session (Vector ClientPoolEntry)
clientPool = Session.statement () $ interp True [sql|
    SELECT
        sa."accountName"::text              AS provider_name,
        sa.id::int4                         AS provider_id,
        ta."accountName"::text              AS client_name,
        ta.id::int4                         AS client_id,
        COUNT(DISTINCT u.id)::int8          AS user_count,
        ta."createdAt"::date                AS created_at
    FROM connections c
    JOIN accounts sa ON sa.id = c."sourceAccountId"
    JOIN accounts ta ON ta.id = c."targetAccountId"
    LEFT JOIN users u ON u."accountId" = ta.id
    WHERE c."archivedAt" IS NULL
      AND ta."isTest" = false
      AND sa."isTest" = false
    GROUP BY sa.id, sa."accountName", ta.id, ta."accountName", ta."createdAt"
    ORDER BY sa."accountName", ta."accountName"
|]

Then we can migrate the visual aspects of the component to hsx: We can easily map over e.g. the list of QIPS (fetched via a query) with hsx blocks, and each metric input can become an hsx block.

        <div className="scorecard-layout">
            <aside className={`scorecard-sidebar ${key === 'dashboard' ? 'd-none' : ''}`}>
                <div>
                    <div className="sidebar-header">Scorecards</div>
                    <ul className="scorecard-list">
                        {scorecards?.map(scorecard => (
                            <li key={scorecard.id} className="scorecard-item active">
                                {scorecard.name}
                            </li>
                        ))}
                    </ul>
                </div>

                <div className="sidebar-qips">
                    <div className="sidebar-qips-header">Previous Month QIPs</div>
                    {previousMonthQips.length > 0 ? (
                        previousMonthQips.map(qip => {
                            const metricDate = allMetricDates?.find(md => md.id === qip.metricDateId);
                            const metric = metrics?.find(m => m.id === metricDate?.metricId);
                            const qipText = [
                                metric?.name,
                                qip.comment,
                                qip.actionsRequired,
                                qip.location,
                                qip.responsiblePerson,
                                qip.targetTimeframe
                            ].filter(Boolean).join(' · ');
                            return (
                                <div key={qip.id} className="sidebar-qip-item">
                                    <div className="sidebar-qip-text">{qipText}</div>
                                    {qip.status === 'qip_open' && (
                                        <button
                                            className="sidebar-qip-close"
                                            onClick={() => {
                                                if (confirm('Close this QIP? It will remain in the database but will not be active.')) {
                                                    updateRecord('q_i_ps', qip.id, { status: 'qip_closed' });
                                                }
                                            }}
                                            title="Mark as resolved"
                                        >

                                        </button>
                                    )}
                                </div>
                            );
                        })
                    ) : (
                        <div className="sidebar-qip-empty">No QIPs last month</div>
                    )}
                </div>
            </aside>
            <div className="scorecard-main">
                <Tabs
                    id="controlled-tab-example"
                    activeKey={key}
                    onSelect={(k) => setKey(k)}
                    className="mb-2">
                    <Tab eventKey="scorecard" title="Scorecard">
                        {perspectives && metrics && finishedPerspectives && allMetricDates && qips
                            && <MetricInputs user={user}
                                wardId={user.wardId}
                                isDirector={false}
                                perspectives={perspectives}
                                finishedPerspectives={finishedPerspectives}
                                lastMonthReportDueAt={lastMonthReportDueAt}
                                reportDueAt={reportDueAt}
                                month={month}
                                year={metricYear}
                                setMetricYear={setMetricYear}
                                metrics={metrics}
                                allMetricDates={allMetricDates}
                                qips={qips} />
                        }
                    </Tab>
                    <Tab eventKey="dashboard" title="Dashboard">
                        {(key === 'dashboard' && ward) ? <Dashboard perspectives={perspectives} metricYear={metricYear} setMetricYear={setMetricYear} ward={ward} metrics={metrics} qips={qips} allMetricDates={allMetricDates} /> : <></>}
                    </Tab>
                    <Tab eventKey="instructions" title="Instructions">
                        <Instructions />
                    </Tab>
                </Tabs>
            </div>
        </div>
    );
Core reusable pattern infrastructure

Not applicable in this pattern.

Helper implementation examples

Not applicable in this pattern.

Usage examples
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}

module Patterns.Htmx.Migration.MigrateReactPatterns where

import Prelude
import Data.Int (Int32, Int64)
import Data.Text (Text)
import Data.Time.Calendar (Day)
import Data.Vector (Vector)
import qualified Data.Vector as Vector
import GHC.Generics (Generic)

{-
    // Fetch Data
    const wardBalancedScorecards: Array<WardBalancedScorecard> = useQuery(query('ward_balanced_scorecards').filterWhere('wardId', user?.wardId));
    const scorecards: Array<BalancedScorecard> = useQuery(query('balanced_scorecards').whereIn('id', wardBalancedScorecards?.map(wardBsc => wardBsc.balancedScorecardId) || [null]));
    const bscsPerspectives: Array<BalancedScorecardPerspective> = useQuery(query('balanced_scorecard_perspectives').whereIn('balancedScorecardId', scorecards?.map(bsc => bsc.id) || [null]));
    const perspectives: Array<Perspective> = useQuery(query('perspectives').whereIn('id', bscsPerspectives?.map(bscsPerspective => bscsPerspective.perspectiveId) || [null]));

    // These can be fetched from js:
    const ward = useQuerySingleResult(query('wards').filterWhere('id', user?.wardId));
    const metrics: Array<Metric> = useQuery(query('metrics').whereIn('perspectiveId', perspectives?.map(perspective => perspective.id) || [null]));

    // This is the only dynamic data at ward level:
    const allMetricDates: Array<MetricDate> = useQuery(query('metric_dates').filterWhere('wardId', user?.wardId).whereIn('metricId', metrics?.map(metric => metric.id) || [null]).orderByAsc('reportDueAt'));
    const qips: Array<QIP> = useQuery(query('q_i_ps').whereIn('metricDateId', allMetricDates?.map(metricDate => metricDate.id) || [null]).orderByDesc('updatedAt'));
    const finishedPerspectives: Array<FinishedPerspective> = useQuery(query('finished_perspectives').filterWhere('wardId', user?.wardId)
        .whereIn('perspectiveId', perspectives?.map(perspective => perspective.id) || [null])
        .filterWhere('reportDueAt', reportDueAt));

    // Get previous month's QIPs
    const previousMonthMetricDates = allMetricDates?.filter(md => md.reportDueAt === lastMonthReportDueAt) || [];
    const previousMonthQips = qips?.filter(qip =>
        previousMonthMetricDates.some(md => md.id === qip.metricDateId)
    ).slice(0, 5) || []; // Limit to 5 most recent
-}

{-
data ClientPoolEntry = ClientPoolEntry
    { cpProviderName  :: Text
    , cpProviderId    :: Int32
    , cpClientName    :: Text
    , cpClientId      :: Int32
    , cpUserCount     :: Int64
    , cpCreatedAt     :: Day
    } deriving stock (Show, Generic)
      deriving anyclass (DecodeRow)

clientPool :: Session.Session (Vector ClientPoolEntry)
clientPool = Session.statement () $ interp True [sql|
    SELECT
        sa."accountName"::text              AS provider_name,
        sa.id::int4                         AS provider_id,
        ta."accountName"::text              AS client_name,
        ta.id::int4                         AS client_id,
        COUNT(DISTINCT u.id)::int8          AS user_count,
        ta."createdAt"::date                AS created_at
    FROM connections c
    JOIN accounts sa ON sa.id = c."sourceAccountId"
    JOIN accounts ta ON ta.id = c."targetAccountId"
    LEFT JOIN users u ON u."accountId" = ta.id
    WHERE c."archivedAt" IS NULL
      AND ta."isTest" = false
      AND sa."isTest" = false
    GROUP BY sa.id, sa."accountName", ta.id, ta."accountName", ta."createdAt"
    ORDER BY sa."accountName", ta."accountName"
|]
-}

-- Compile-friendly placeholder around the same row shape.
data ClientPoolEntry = ClientPoolEntry
    { cpProviderName  :: Text
    , cpProviderId    :: Int32
    , cpClientName    :: Text
    , cpClientId      :: Int32
    , cpUserCount     :: Int64
    , cpCreatedAt     :: Day
    } deriving stock (Show, Generic)

clientPool :: IO (Vector ClientPoolEntry)
clientPool = pure Vector.empty

{-
        <div className="scorecard-layout">
            <aside className={`scorecard-sidebar ${key === 'dashboard' ? 'd-none' : ''}`}>
                <div>
                    <div className="sidebar-header">Scorecards</div>
                    <ul className="scorecard-list">
                        {scorecards?.map(scorecard => (
                            <li key={scorecard.id} className="scorecard-item active">
                                {scorecard.name}
                            </li>
                        ))}
                    </ul>
                </div>

                <div className="sidebar-qips">
                    <div className="sidebar-qips-header">Previous Month QIPs</div>
                    {previousMonthQips.length > 0 ? (
                        previousMonthQips.map(qip => {
                            const metricDate = allMetricDates?.find(md => md.id === qip.metricDateId);
                            const metric = metrics?.find(m => m.id === metricDate?.metricId);
                            const qipText = [
                                metric?.name,
                                qip.comment,
                                qip.actionsRequired,
                                qip.location,
                                qip.responsiblePerson,
                                qip.targetTimeframe
                            ].filter(Boolean).join(' · ');
                            return (
                                <div key={qip.id} className="sidebar-qip-item">
                                    <div className="sidebar-qip-text">{qipText}</div>
                                    {qip.status === 'qip_open' && (
                                        <button
                                            className="sidebar-qip-close"
                                            onClick={() => {
                                                if (confirm('Close this QIP? It will remain in the database but will not be active.')) {
                                                    updateRecord('q_i_ps', qip.id, { status: 'qip_closed' });
                                                }
                                            }}
                                            title="Mark as resolved"
                                        >

                                        </button>
                                    )}
                                </div>
                            );
                        })
                    ) : (
                        <div className="sidebar-qip-empty">No QIPs last month</div>
                    )}
                </div>
            </aside>
            <div className="scorecard-main">
                <Tabs
                    id="controlled-tab-example"
                    activeKey={key}
                    onSelect={(k) => setKey(k)}
                    className="mb-2">
                    <Tab eventKey="scorecard" title="Scorecard">
                        {perspectives && metrics && finishedPerspectives && allMetricDates && qips
                            && <MetricInputs user={user}
                                wardId={user.wardId}
                                isDirector={false}
                                perspectives={perspectives}
                                finishedPerspectives={finishedPerspectives}
                                lastMonthReportDueAt={lastMonthReportDueAt}
                                reportDueAt={reportDueAt}
                                month={month}
                                year={metricYear}
                                setMetricYear={setMetricYear}
                                metrics={metrics}
                                allMetricDates={allMetricDates}
                                qips={qips} />
                        }
                    </Tab>
                    <Tab eventKey="dashboard" title="Dashboard">
                        {(key === 'dashboard' && ward) ? <Dashboard perspectives={perspectives} metricYear={metricYear} setMetricYear={setMetricYear} ward={ward} metrics={metrics} qips={qips} allMetricDates={allMetricDates} /> : <></>}
                    </Tab>
                    <Tab eventKey="instructions" title="Instructions">
                        <Instructions />
                    </Tab>
                </Tabs>
            </div>
        </div>
    );
-}
Standalone checks

Not applicable in this pattern.

Primitives

Accessibility Focus and Live Feedback

Raw source: Patterns/Htmx/Primitives/AccessibilityFocusAndLiveFeedback.lhs

Pattern intent and mechanism

Keep users oriented when HTMX replaces part of the page. The pattern boundary is not generic ARIA guidance; it is the IHP/HSX response contract for HTMX swaps: the server renders the replacement fragment, so the same fragment must carry any focus target or live-status region needed after the swap.

Use this pattern when an HTMX interaction:

  • self-swaps with hx-swap="outerHTML", especially when the triggering control is replaced,
  • updates a nearby result/list/detail region while focus remains elsewhere,
  • completes an asynchronous operation whose result is not obvious from the focused control,
  • sends out-of-band updates that affect user orientation.

The recurring rules:

  1. Prefer native focus preservation when it works. Do not add a global focus hook merely because HTMX is present.
  2. When the swap destroys the focused element and orientation is lost, the server response names one focus target inside the swapped fragment.
  3. Focus targets that are not naturally focusable need tabindex="-1".
  4. Live feedback stays local to the changed region or triggering component.
  5. A live-region node should already exist before its text changes. For HTMX swaps, keep a stable local status node outside the replaced fragment and update its contents with an OOB or dedicated inner swap.
  6. Blank or cleared states must update the live region too. If a typeahead clears the visual results for an empty query, the response also clears the status text so stale announcements do not remain.
  7. Result-count messages count semantic matches, not necessarily every rendered row. If selected or pinned items stay visible beside matches, announce the match count or change the wording.
  8. Do not scan the DOM for .is-invalid, .alert, or other CSS-state classes. A client hook may act only on explicit server-rendered markers.
  9. Validation-specific summary/focus behavior belongs to Patterns/Forms/AccessibilityValidationFeedback.lhs; this pattern covers non-form HTMX swaps and the shared focus/live-region mechanism.
Project-specific notes and rollout guidance

Start from concrete browser flows. Many HTMX swaps preserve enough orientation without custom JavaScript; adding focus jumps too early can be worse than doing nothing.

Rollout checks:

  1. Inventory hx-swap="outerHTML", self-swaps, OOB updates, and modal/list/detail replacements.
  2. Test with keyboard only: trigger the interaction, observe whether focus is still visible and whether the next Tab position makes sense.
  3. If focus is lost or the next position is disorienting, render one explicit focus marker in the response fragment.
  4. If the interaction changes content away from focus, mount a stable local live region near the changed content or triggering control, then update its text from the HTMX response.
  5. Keep messages specific and short: "3 matching members" is useful; "Updated" is usually too vague. Count the semantic result set that changed, not every visible helper row.
  6. For blank queries or clear actions, explicitly clear the live-region text in the HTMX response.
  7. For form validation failures, use AccessibilityValidationFeedback instead of a one-off HTMX marker convention.

Host evidence:

  • seshn custom HTMX validation fragments showed why the server-rendered fragment must carry focus/error metadata instead of leaving the browser hook to infer state from CSS classes.
  • seshn search/typeahead result swaps for Song, Artist, and User currently have no live-region result-count feedback; the application JavaScript only toggles a visual .searching class. This is the canonical live-status gap this pattern addresses.
  • seshn matrix focus handling currently focuses input[name="effortDays"] globally after every htmx:afterSettle. That concrete selector-based focus repair is the anti-pattern the marker-driven response contract replaces.
  • seshn dropdown and permission-aware enum controls showed the narrower case: validate focus after self-swap first; add restoration only when the concrete widget loses orientation.
  • The local ordng host has no HTMX surface yet, so this pattern remains backed by HTMX-heavy host evidence rather than local app backfill.
Supporting snippets

Self-swapping fragment with an explicit focus target:

<div id="member-filter-panel">
  <span hidden data-htmx-focus-target="member-filter-heading"></span>
  <h2 id="member-filter-heading" tabindex="-1">Members</h2>
  <!-- updated controls/results -->
</div>

Stable local live region mounted before HTMX updates occur:

<div id="member-results-status"
     role="status"
     aria-live="polite"
     aria-atomic="true"></div>

HTMX response updating that existing region out of band:

<div id="member-results-status" hx-swap-oob="innerHTML">
  3 matching members
</div>

HTMX response clearing that region when a blank query clears the visual target:

<div id="member-results-status" hx-swap-oob="innerHTML"></div>

Do not rely on a newly inserted, already populated polite live region being announced. The status node should survive before its text changes, and clear branches should clear the status node as deliberately as they clear visible results.

Narrow client hook:

document.body.addEventListener("htmx:afterSwap", (event) => {
  const marker = event.detail.elt?.querySelector?.("[data-htmx-focus-target]");
  const targetId = marker?.getAttribute("data-htmx-focus-target");
  if (!targetId) return;

  const target = document.getElementById(targetId);
  if (target) target.focus();
});

The hook reads a server-rendered marker inside the swapped fragment. It does not choose targets by CSS class, role, or global page state.

Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Primitives.AccessibilityFocusAndLiveFeedback
    ( DomId
    , domIdFromText
    , domIdText
    , FocusAfterSwap(..)
    , LiveRegionPoliteness(..)
    , LiveRegionAtomicity(..)
    , LiveFeedback(..)
    , HtmxAccessibilityFeedback(..)
    , htmxFocusTargetAttribute
    , htmxLiveRegionSelectorAttribute
    , renderHtmxFocusMarker
    , renderLiveFeedbackRegion
    , renderVisuallyHiddenLiveFeedbackRegion
    , renderLiveFeedbackOobUpdate
    , renderHtmxAccessibilityFeedback
    , htmxAccessibilityAfterSwapHook
    , memberFilterPanelId
    , memberFilterHeadingId
    , memberResultsStatusId
    , memberResultsTargetSelector
    , memberSearchPage
    , memberSearchPanel
    , renderMemberSearchResultsFragment
    , renderClearedMemberSearchResultsFragment
    , incrementalMemberSearchPage
    , incrementalMemberResults
    ) where

import Data.Char (isSpace)
import Data.Text (Text)
import qualified Data.Text as Text
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude
import qualified Prelude as P
import Patterns.Htmx.Shared.Types

-- | DOM id used by HTMX accessibility markers.
--
-- The smart constructor rejects blank and whitespace-containing ids because
-- these ids are used as `document.getElementById` targets and fragment links.
newtype DomId = DomId Text
    deriving (Eq, Show)

domIdFromText :: Text -> Maybe DomId
domIdFromText raw
    | Text.null trimmed = Nothing
    | Text.any isSpace trimmed = Nothing
    | otherwise = Just (DomId trimmed)
  where
    trimmed = Text.strip raw

domIdText :: DomId -> Text
domIdText (DomId value) = value

-- | Focus behavior requested by the server-rendered HTMX response.
data FocusAfterSwap
    = PreserveBrowserFocus
    | FocusAfterSwapTarget DomId
    deriving (Eq, Show)

-- | Live-region politeness for asynchronous HTMX feedback.
data LiveRegionPoliteness
    = Polite
    | Assertive
    deriving (Eq, Show, Enum, Bounded)

-- | Whether assistive technology should announce the whole live-region content.
data LiveRegionAtomicity
    = Atomic
    | NotAtomic
    deriving (Eq, Show, Enum, Bounded)

-- | Local live feedback rendered with or near the changed HTMX region.
data LiveFeedback = LiveFeedback
    { liveFeedbackId :: DomId
    , liveFeedbackPoliteness :: LiveRegionPoliteness
    , liveFeedbackAtomicity :: LiveRegionAtomicity
    , liveFeedbackMessage :: Maybe Text
    }
    deriving (Eq, Show)

-- | Accessibility metadata carried by an HTMX replacement fragment.
data HtmxAccessibilityFeedback = HtmxAccessibilityFeedback
    { htmxFocusAfterSwap :: FocusAfterSwap
    , htmxLiveFeedback :: Maybe LiveFeedback
    }
    deriving (Eq, Show)

htmxFocusTargetAttribute :: Text
htmxFocusTargetAttribute = "data-htmx-focus-target"

htmxLiveRegionSelectorAttribute :: Text
htmxLiveRegionSelectorAttribute = "data-htmx-live-region"

renderPoliteness :: LiveRegionPoliteness -> Text
renderPoliteness Polite = "polite"
renderPoliteness Assertive = "assertive"

renderAtomicity :: LiveRegionAtomicity -> Text
renderAtomicity Atomic = "true"
renderAtomicity NotAtomic = "false"

renderLiveRegionRole :: LiveRegionPoliteness -> Text
renderLiveRegionRole Polite = "status"
renderLiveRegionRole Assertive = "alert"

-- | Module-local helper for literal example ids.
--
-- Application code should use `domIdFromText` at the boundary where ids enter
-- the system. This helper exists only so the checked examples do not bypass the
-- smart constructor with raw `DomId` constructors.
exampleDomId :: Text -> DomId
exampleDomId value =
    case domIdFromText value of
        Just domId -> domId
        Nothing -> P.error ("invalid example DOM id: " <> Text.unpack value)
Helper implementation examples

Render the focus marker only when the response asks for an explicit target. PreserveBrowserFocus means the host flow has been checked and does not need a focus jump. For outerHTML swaps, place the marker inside the swapped root; a sibling marker can be invisible to an htmx:afterSwap hook that starts from event.detail.elt.

renderHtmxFocusMarker :: FocusAfterSwap -> Html
renderHtmxFocusMarker PreserveBrowserFocus = mempty
renderHtmxFocusMarker (FocusAfterSwapTarget targetId) = [hsx|
    <span hidden data-htmx-focus-target={domIdText targetId}></span>
|]

Render a stable local live region before HTMX updates occur. It may be empty; that is the preferred shape for polite announcements because assistive technology can observe a later text change.

The renderer intentionally emits both the landmark role and the explicit aria-live value. role="status" implies polite announcements and role="alert" implies assertive announcements, but keeping the explicit value makes the contract visible and avoids relying on every assistive-technology stack applying implicit role properties consistently.

renderLiveFeedbackRegion :: LiveFeedback -> Html
renderLiveFeedbackRegion = renderLiveFeedbackRegionWithClass ""

-- | Variant for status text that is useful to screen-reader users but would be
-- redundant visual UI. `visually-hidden` is Bootstrap's screen-reader-only
-- utility class; replace it with the host's equivalent when not using Bootstrap.
renderVisuallyHiddenLiveFeedbackRegion :: LiveFeedback -> Html
renderVisuallyHiddenLiveFeedbackRegion = renderLiveFeedbackRegionWithClass "visually-hidden"

renderLiveFeedbackRegionWithClass :: Text -> LiveFeedback -> Html
renderLiveFeedbackRegionWithClass className feedback = [hsx|
    <div id={domIdText (liveFeedbackId feedback)}
         class={className}
         data-htmx-live-region="true"
         role={renderLiveRegionRole (liveFeedbackPoliteness feedback)}
         aria-live={renderPoliteness (liveFeedbackPoliteness feedback)}
         aria-atomic={renderAtomicity (liveFeedbackAtomicity feedback)}></div>
|]

Render an HTMX out-of-band update for that existing region. The response node is not the live region itself; HTMX uses it to change the already-mounted node's contents.

renderLiveFeedbackOobUpdate :: LiveFeedback -> Html
renderLiveFeedbackOobUpdate feedback = [hsx|
    <div id={domIdText (liveFeedbackId feedback)} hx-swap-oob="innerHTML">
        {renderLiveFeedbackMessage (liveFeedbackMessage feedback)}
    </div>
|]

renderLiveFeedbackMessage :: Maybe Text -> Html
renderLiveFeedbackMessage Nothing = mempty
renderLiveFeedbackMessage (Just message) = [hsx|{message}|]

renderHtmxAccessibilityFeedback :: HtmxAccessibilityFeedback -> Html
renderHtmxAccessibilityFeedback feedback =
    renderHtmxFocusMarker (htmxFocusAfterSwap feedback)
        <> renderMaybeLiveFeedbackOobUpdate (htmxLiveFeedback feedback)

renderMaybeLiveFeedbackOobUpdate :: Maybe LiveFeedback -> Html
renderMaybeLiveFeedbackOobUpdate Nothing = mempty
renderMaybeLiveFeedbackOobUpdate (Just feedback) = renderLiveFeedbackOobUpdate feedback

The JavaScript hook is deliberately tiny and marker-driven. Install it once in application JavaScript when a host flow starts rendering data-htmx-focus-target.

htmxAccessibilityAfterSwapHook :: Text
htmxAccessibilityAfterSwapHook = Text.unlines
    [ "document.body.addEventListener(\"htmx:afterSwap\", (event) => {"
    , "  const marker = event.detail.elt?.querySelector?.(\"[data-htmx-focus-target]\");"
    , "  const targetId = marker?.getAttribute(\"data-htmx-focus-target\");"
    , "  if (!targetId) return;"
    , ""
    , "  const target = document.getElementById(targetId);"
    , "  if (target) target.focus();"
    , "});"
    ]
Usage examples

A result panel can self-swap while keeping focus on a stable heading inside the replacement. The focus marker is inside the swapped root. The live region is mounted outside the self-swapping panel and is updated out of band, so the changed result count is announced without moving focus to every new result.

memberFilterPanelId :: DomId
memberFilterPanelId = exampleDomId "member-filter-panel"

memberFilterHeadingId :: DomId
memberFilterHeadingId = exampleDomId "member-filter-heading"

memberResultsStatusId :: DomId
memberResultsStatusId = exampleDomId "member-results-status"

memberResultsInlineStatusId :: DomId
memberResultsInlineStatusId = exampleDomId "member-results-inline-status"

memberResultsTargetSelector :: HxSelector
memberResultsTargetSelector = HxId (domIdText memberFilterPanelId)

memberResultsLiveFeedback :: Maybe Text -> LiveFeedback
memberResultsLiveFeedback message = LiveFeedback
    { liveFeedbackId = memberResultsStatusId
    , liveFeedbackPoliteness = Polite
    , liveFeedbackAtomicity = Atomic
    , liveFeedbackMessage = message
    }

memberSearchPage :: Text -> [MemberSearchResult] -> Html
memberSearchPage query results = [hsx|
    <div>
        {renderVisuallyHiddenLiveFeedbackRegion (memberResultsLiveFeedback Nothing)}
        {memberSearchPanel query results}
    </div>
|]

memberSearchPanel :: Text -> [MemberSearchResult] -> Html
memberSearchPanel = memberSearchPanelWithFocus PreserveBrowserFocus

memberSearchPanelWithFocus :: FocusAfterSwap -> Text -> [MemberSearchResult] -> Html
memberSearchPanelWithFocus focusAfterSwap query results = [hsx|
    <section id={domIdText memberFilterPanelId}
             aria-labelledby={domIdText memberFilterHeadingId}>
        {renderHtmxFocusMarker focusAfterSwap}
        <h2 id={domIdText memberFilterHeadingId} tabindex="-1">Members</h2>
        <form hx-get={MemberSearchAction}
              hx-target={renderHxSelector memberResultsTargetSelector}
              hx-swap="outerHTML">
            <label for="member-query">Search members</label>
            <input id="member-query" name="query" type="search" value={query} />
            <button type="submit">Search</button>
        </form>
        {renderMemberResults results}
    </section>
|]

renderMemberSearchResultsFragment :: Text -> [MemberSearchResult] -> Html
renderMemberSearchResultsFragment query semanticMatches =
    memberSearchPanelWithFocus (FocusAfterSwapTarget memberFilterHeadingId) query semanticMatches
        <> renderLiveFeedbackOobUpdate (memberResultsLiveFeedback (Just (memberSearchMatchCountMessage semanticMatches)))

-- | Blank-query response: clear visible results and clear the existing live
-- status node. Leaving the old status text mounted can prevent a later identical
-- count from being announced.
renderClearedMemberSearchResultsFragment :: Text -> Html
renderClearedMemberSearchResultsFragment query =
    memberSearchPanelWithFocus PreserveBrowserFocus query []
        <> renderLiveFeedbackOobUpdate (memberResultsLiveFeedback Nothing)

For incremental search where the input keeps focus and only a result list is updated, preserve browser focus and render only local status feedback. The initial page mounts the stable live-region node and the initial results without an OOB node; the HTMX response fragment later updates both the results and the already-mounted status node.

incrementalMemberResultsLiveFeedback :: Maybe Text -> LiveFeedback
incrementalMemberResultsLiveFeedback message = LiveFeedback
    { liveFeedbackId = memberResultsInlineStatusId
    , liveFeedbackPoliteness = Polite
    , liveFeedbackAtomicity = Atomic
    , liveFeedbackMessage = message
    }

incrementalMemberSearchPage :: [MemberSearchResult] -> Html
incrementalMemberSearchPage results = [hsx|
    <div>
        {renderVisuallyHiddenLiveFeedbackRegion (incrementalMemberResultsLiveFeedback Nothing)}
        {incrementalMemberResultsContent results}
    </div>
|]

incrementalMemberResultsContent :: [MemberSearchResult] -> Html
incrementalMemberResultsContent results = [hsx|
    <div id="member-results">
        {renderMemberResults results}
    </div>
|]

incrementalMemberResults :: [MemberSearchResult] -> Html
incrementalMemberResults semanticMatches =
    incrementalMemberResultsContent semanticMatches <> renderHtmxAccessibilityFeedback feedback
  where
    feedback :: HtmxAccessibilityFeedback
    feedback = HtmxAccessibilityFeedback
        { htmxFocusAfterSwap = PreserveBrowserFocus
        , htmxLiveFeedback = Just (incrementalMemberResultsLiveFeedback (Just (memberSearchMatchCountMessage semanticMatches)))
        }
Standalone checks
-- | Placeholder action for demonstration purposes only.
data MemberSearchAction = MemberSearchAction

instance HasPath MemberSearchAction where
    pathTo MemberSearchAction = "/members/search"

newtype MemberSearchResult = MemberSearchResult Text

renderMemberResults :: [MemberSearchResult] -> Html
renderMemberResults results = [hsx|
    <ul>
        {renderMemberResultItems results}
    </ul>
|]

renderMemberResultItems :: [MemberSearchResult] -> Html
renderMemberResultItems [] = mempty
renderMemberResultItems (result : rest) = renderMemberResult result <> renderMemberResultItems rest

renderMemberResult :: MemberSearchResult -> Html
renderMemberResult (MemberSearchResult name) = [hsx|
    <li>{name}</li>
|]

-- | Count semantic search matches, not necessarily every visible row. If a host
-- keeps selected or pinned items visible beside matches, pass only the match set
-- here or change the message wording.
memberSearchMatchCountMessage :: [MemberSearchResult] -> Text
memberSearchMatchCountMessage semanticMatches =
    case P.length semanticMatches of
        1 -> "1 matching member"
        count -> tshow count <> " matching members"
Enum from Request Parameter

Raw source: Patterns/Htmx/Primitives/EnumFromParam.lhs

Pattern intent and mechanism

Parse a posted enum value from a Maybe Text request parameter in an IHP action handler. The empty string represents the clear/none option and maps to Nothing. Any other non-empty value is matched against the enum's inputValue representations.

Three shapes are common:

  1. Fail-fast, clearableparseEnum :: Maybe Text -> Either Text (Maybe a). Use for nullable enum fields with an explicit clear option. The empty string maps to Nothing; unknown values return Left.

  2. Fail-fast, requiredparseRequiredEnum :: Maybe Text -> Either Text a. Use for non-nullable enum fields without a clear option. Missing or empty input returns Left.

  3. Silent defaultparseEnumOrDefault :: a -> Maybe Text -> a. Return a value directly with a documented fallback. Use this only rarely, when the source is a strictly controlled dropdown and swallowing unknown input as the default is explicitly acceptable. In most cases, prefer fail-fast.

This pattern sits at the boundary between an HTMX view that posts text values and the action that translates them into typed domain values.

It is specific to HTMX inline-editing flows. Traditional form submissions should use IHP's param validation instead; they do not need a manual Text-to-enum parser because the framework validates and parses the value directly from the form field.

Project-specific notes and rollout guidance

Keep the parser generic over the enum type. In IHP, allEnumValues and inputValue provide the necessary runtime information.

When the view uses InlineDropdown with a clear option, the action receives Just "" for the clear selection. The parser should treat this as Nothing. When the view uses the non-clearable variant, the parameter should never be Nothing or empty in normal flow.

Document the chosen strategy (fail-fast or silent-default) at every call site. Do not mix strategies for the same parameter without explicit justification.

IHP generates Haskell enum types from the PostgreSQL schema, so exhaustive pattern matching on the enum type itself is checked at compile time. That compile-time safety does not extend to the parser, because the parser receives untyped Text from the HTTP request. The parser is a runtime boundary. Keep the compile-time exhaustiveness in views and controllers that consume the parsed enum value, and treat the parser as the guarded entry point.

Supporting snippets

Fail-fast shape:

parseEnum :: (Enum a, InputValue a) => Maybe Text -> Either Text (Maybe a)
parseEnum Nothing = Right Nothing
parseEnum (Just "") = Right Nothing
parseEnum (Just txt) =
    case find (\e -> inputValue e == txt) (allEnumValues @a) of
        Nothing -> Left ("Unknown value: " <> txt)
        Just value -> Right (Just value)

Required fail-fast shape:

parseRequiredEnum :: (Enum a, InputValue a) => Maybe Text -> Either Text a
parseRequiredEnum Nothing = Left "Missing value"
parseRequiredEnum (Just "") = Left "Missing value"
parseRequiredEnum (Just txt) =
    case find (\e -> inputValue e == txt) (allEnumValues @a) of
        Nothing -> Left ("Unknown value: " <> txt)
        Just value -> Right value

Silent-default shape (rare, use with explicit justification):

parseEnumOrDefault :: (Enum a, InputValue a) => a -> Maybe Text -> a
parseEnumOrDefault defaultValue Nothing = defaultValue
parseEnumOrDefault defaultValue (Just "") = defaultValue
parseEnumOrDefault defaultValue (Just txt) =
    fromMaybe defaultValue $ find (\e -> inputValue e == txt) (allEnumValues @a)
Core reusable pattern infrastructure
{-# LANGUAGE ImplicitParams #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Patterns.Htmx.Primitives.EnumFromParam where

import Prelude
import Data.Maybe (fromMaybe, listToMaybe)
import Data.Text (Text)
import IHP.ViewPrelude

-- | Fail-fast parser for nullable enum fields.
parseEnum :: forall a. (Enum a, InputValue a) => Maybe Text -> Either Text (Maybe a)
parseEnum Nothing = Right Nothing
parseEnum (Just txt) | txt == "" = Right Nothing
parseEnum (Just txt) =
    case find (\e -> inputValue e == txt) (allEnumValues @a) of
        Nothing -> Left ("Unknown value: " <> txt)
        Just value -> Right (Just value)

-- | Fail-fast parser for required enum fields (no clear option).
parseRequiredEnum :: forall a. (Enum a, InputValue a) => Maybe Text -> Either Text a
parseRequiredEnum Nothing = Left "Missing value"
parseRequiredEnum (Just txt) | txt == "" = Left "Missing value"
parseRequiredEnum (Just txt) =
    case find (\e -> inputValue e == txt) (allEnumValues @a) of
        Nothing -> Left ("Unknown value: " <> txt)
        Just value -> Right value

-- | Silent-default parser: unknown or missing values fall back to a default.
--
-- This parser is total: every input maps to a valid value. Unknown values
-- receive the same fallback as missing or empty values. Use this only rarely,
-- when the source is a strictly controlled dropdown and swallowing unknown
-- input as the default is explicitly acceptable. In most cases, prefer
-- fail-fast.
parseEnumOrDefault :: forall a. (Enum a, InputValue a) => a -> Maybe Text -> a
parseEnumOrDefault defaultValue Nothing = defaultValue
parseEnumOrDefault defaultValue (Just txt) | txt == "" = defaultValue
parseEnumOrDefault defaultValue (Just txt) =
    fromMaybe defaultValue $ find (\e -> inputValue e == txt) (allEnumValues @a)
Usage examples

A clearable enum field uses the fail-fast parser. The action boundary must handle the Left case explicitly — do not collapse it into Nothing with either (const Nothing) id, because a parse error would then be indistinguishable from an explicit clear.

updateRole :: Maybe Text -> Either Text (Maybe Role)
updateRole mTxt = parseEnum @Role mTxt

In the action, translate Left into an HTTP 400 with text/plain so the client receives a readable error without replacing the surrounding page:

respondEnumParseError :: Text -> IO ()
respondEnumParseError err =
    respondAndExit (responseLBS status400 [("Content-Type", "text/plain")] (cs err))

A non-clearable enum field uses the required fail-fast parser.

updateCoverage :: Maybe Text -> Either Text Coverage
updateCoverage mTxt = parseRequiredEnum @Coverage mTxt

When the current form value and a newly selected value both arrive as parameters — for example currentCoverage and selectedCoverage — the fromMaybe currentCoverage selectedCoverage pattern is a separate concern. It is not part of EnumFromParam; it belongs to a companion pattern around component rendering with parameter overrides.

Do not mix parsing strategies for the same parameter. If the view uses parseEnum (fail-fast), the action must handle Left. If the view uses parseEnumOrDefault (silent-default), the action receives a plain value and can proceed directly. Switching strategies mid-flow creates invisible failures.

Standalone checks

These placeholders let the usage examples compile as part of the module check.

data Role = Reader | Editor | Owner deriving (Eq, Show, Enum)
data Coverage = AllSongs | MyCovers | Requests deriving (Eq, Show, Enum)

instance InputValue Role where
    inputValue Reader = "reader"
    inputValue Editor = "editor"
    inputValue Owner = "owner"

instance InputValue Coverage where
    inputValue AllSongs = "allsongs"
    inputValue MyCovers = "mycovers"
    inputValue Requests = "requests"
data Entity = Entity { role :: Maybe Role, coverage :: Coverage }
    deriving (Eq, Show)

data UpdateRoleAction = UpdateRoleAction { entityId :: Int, selectedRole :: Maybe Text }
data UpdateCoverageAction = UpdateCoverageAction { itemId :: Int, selectedCoverage :: Maybe Text }

roleInlineDropdown :: Int -> Maybe Role -> Html
roleInlineDropdown _ _ = mempty

coverageInlineDropdown :: Int -> Coverage -> Html
coverageInlineDropdown _ _ = mempty
Filter Target

Raw source: Patterns/Htmx/Primitives/FilterTarget.lhs

Pattern intent and mechanism

Use an explicit HTMX target so a filter interaction replaces only the fragment that represents filtered results.

The core idea is not "forms" or "filters" by themselves, but targeted fragment replacement: one trigger issues a request, and the response is shaped for one specific DOM replacement boundary.

This pattern has two equally canonical variants of the same mechanism:

  • dedicated result container: the trigger updates a separate stable results node
  • self-targeting replacement: the trigger container targets itself and is replaced as a unit
Project-specific notes and rollout guidance

Present the dedicated result-container variant first. It is usually the more common and easier-to-read form because the trigger and replacement boundary are visibly separate.

Present the self-targeting variant as the second, equal variant of the same pattern. Use it when the filter controls and filtered output are one component boundary and should be rerendered together.

In host applications, normalize naming first: one action name, one target id, and one response shape per filter flow. If one interaction also needs to update other parts of the screen, compose this pattern with OobUpdates instead of stretching one target beyond its boundary.

When a self-swapping control lives inside a clickable parent container, compose this pattern with NestedControlClickIsolation so the child update does not also trigger parent navigation.

Typeahead builds on the same targeting idea, but adds incremental search and selection behavior. FilterTarget stays the smaller primitive.

cf. Targeted Partial Section Filter

Supporting snippets

This pattern combines HTMX's canonical target/swap mechanism with the common "Active Search" example shape from the official site:

Typical dedicated-container wiring:

<form hx-get={FilterAction}
      hx-target={renderHxSelector hxFilterResultsSelector}
      hx-swap="innerHTML">
    <input type="search" name="query" placeholder="Filter results" />
</form>

<div id={hxFilterResultsId}>
    {renderFilteredResults initialResults}
</div>

Typical self-targeting wiring:

<form hx-get={FilterAction}
      hx-target={renderHxSelector hxFilterSelfTargetSelector}
      hx-swap="outerHTML">
    <input type="search" name="query" placeholder="Filter results" />
    {renderFilteredResults initialResults}
</form>

Server-side response rule:

  • for innerHTML into a dedicated container, return that container's inner fragment only
  • for self-targeting outerHTML, return the whole replacement node
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Primitives.FilterTarget where

import Prelude
import Data.Text (Text)
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude

-- HLS may report this pattern-module import as not loaded because `Patterns/**`
-- is outside the active cradle; rely on the explicit GHC check for authority.
import Patterns.Htmx.Shared.Types

-- | Canonical id for a dedicated filtered-results container.
--
-- Applications should usually define a domain-specific id per filter flow.
-- This example constant documents the naming shape.
hxFilterResultsId :: Text
hxFilterResultsId = "filter-results"

-- | Canonical selector for a dedicated filtered-results container.
hxFilterResultsSelector :: HxSelector
hxFilterResultsSelector = hxIdSelector hxFilterResultsId

-- | Canonical selector for self-targeting replacement.
--
-- Use this together with `hx-swap="outerHTML"` when one component boundary
-- should replace itself.
hxFilterSelfTargetSelector :: HxSelector
hxFilterSelfTargetSelector = HxThis
Helper implementation examples

Not applicable in this pattern.

Usage examples

Dedicated result container:

-- | Placeholder action for demonstration purposes only.
data FilterAction = FilterAction

instance HasPath FilterAction where
    pathTo FilterAction = "/filter"

-- | Placeholder result type for the examples below.
data SearchResult = SearchResult Text

-- | Dedicated result-container variant:
-- the form triggers the request, but a separate results node is replaced.
filterControlsWithDedicatedTarget :: [SearchResult] -> Html
filterControlsWithDedicatedTarget initialResults = [hsx|
    <div>
        <form hx-get={FilterAction}
              hx-target={renderHxSelector hxFilterResultsSelector}
              hx-swap="innerHTML">
            <input type="search" name="query" placeholder="Filter results" />
            <button type="submit">Apply</button>
        </form>

        <div id={hxFilterResultsId}>
            {renderFilteredResults initialResults}
        </div>
    </div>
|]

Self-targeting replacement:

-- | Self-targeting variant:
-- the form is both trigger and replacement boundary.
filterPanelWithSelfTarget :: [SearchResult] -> Html
filterPanelWithSelfTarget initialResults = [hsx|
    <form hx-get={FilterAction}
          hx-target={renderHxSelector hxFilterSelfTargetSelector}
          hx-swap="outerHTML">
        <input type="search" name="query" placeholder="Filter results" />
        <button type="submit">Apply</button>
        {renderFilteredResults initialResults}
    </form>
|]

Server response shapes:

-- | Dedicated result-container response:
-- return only the fragment intended for the target container's inner HTML.
renderDedicatedFilterResultsFragment :: [SearchResult] -> Html
renderDedicatedFilterResultsFragment = renderFilteredResults

-- | Self-targeting response:
-- return the whole replacement node.
renderSelfTargetingFilterPanel :: [SearchResult] -> Html
renderSelfTargetingFilterPanel results = [hsx|
    <form hx-get={FilterAction}
          hx-target={renderHxSelector hxFilterSelfTargetSelector}
          hx-swap="outerHTML">
        <input type="search" name="query" placeholder="Filter results" />
        <button type="submit">Apply</button>
        {renderFilteredResults results}
    </form>
|]
Standalone checks
renderFilteredResults :: [SearchResult] -> Html
renderFilteredResults results = [hsx|
    <ul>
        {forEach results renderResult}
    </ul>
|]

renderResult (SearchResult label) = [hsx|<li>{label}</li>|]
Indicator

Raw source: Patterns/Htmx/Primitives/Indicator.lhs

Pattern intent and mechanism

Show loading feedback while an HTMX request is in flight.

HTMX adds htmx-request to the triggering element (or indicator target context), and indicator CSS reveals feedback elements during that request lifecycle.

Indicators are visual-agnostic: animated spinner image, progress bar, branded icon, or just text.

Project-specific notes and rollout guidance

Not applicable in this pattern.

Supporting snippets

CSS (required once in app styles):

.htmx-indicator {
    opacity: 0;
    visibility: hidden;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
    opacity: 1;
    visibility: visible;
    transition: opacity 200ms ease-in;
}

/* Disable submit buttons while request is in flight */
.htmx-request button[type="submit"] {
    pointer-events: none;
    opacity: 0.65;
    cursor: not-allowed;
}

Typical wiring:

  • request trigger: hx-post / hx-get
  • indicator selector: hx-indicator
  • response target: hx-target

For file uploads, use hx-encoding="multipart/form-data". If upload progress matters, listen to htmx:xhr:progress and drive a progress bar; hx-indicator can still be used for in-flight feedback around the submit control.

Implementation modes: - Indeterminate indicator (spinner/text): hx-indicator + .htmx-indicator CSS. - Determinate progress bar: upload form (hx-encoding="multipart/form-data") + htmx:xhr:progress event wiring + progress UI updates. - In server-heavy upload flows, progress often needs a second phase: upload progress, then indeterminate "processing..." while server-side work continues.

Open visual-decision note: projects may still need to choose between a library-provided indicator family (generic spinner/progress bar assets) and project-specific indicator visuals such as a branded SVG or company-logo spinner. This pattern stays visual-agnostic on purpose; that decision is still WIP and does not block use of the primitive HTMX mechanism documented here.

Generic guidance: this file defines the primitive indicator behavior. HTMX upload flows are first-class (hx-encoding="multipart/form-data"); when real progress is available, compose this primitive with htmx:xhr:progress. Multi-step submit flows (loading + success + redirect) belong to composed patterns.

Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Primitives.Indicator where

import Prelude
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude
import Patterns.Htmx.Shared.Types

-- | CSS class name used by indicator elements.
-- HTMX default indicator CSS keys off this class.
hxIndicatorCssClass :: Text
hxIndicatorCssClass = "htmx-indicator"

-- | Default indicator asset used by this example.
-- Applications should replace with their own canonical asset path.
hxIndicatorAssetPath :: Text
hxIndicatorAssetPath = "/bars.svg"

-- | Default accessibility label for indicator imagery.
hxIndicatorAltText :: Text
hxIndicatorAltText = "Loading"

-- | Default inline text for text-based indicator examples.
hxIndicatorText :: Text
hxIndicatorText = "Loading..."

-- | Wrapper class for search inputs that host a local indicator.
hxSearchInputWithIndicatorClass :: Text
hxSearchInputWithIndicatorClass = "search-input-with-indicator"

-- | CSS id for the indicator element in the id-based example.
hxIndicatorSpinnerId :: Text
hxIndicatorSpinnerId = "my-spinner"

-- | Typed selector for the indicator element.
hxIndicatorSpinnerSelector :: HxSelector
hxIndicatorSpinnerSelector = hxIdSelector hxIndicatorSpinnerId

-- | Typed selector for the results target element.
hxIndicatorResultsSelector :: HxSelector
hxIndicatorResultsSelector = hxIdSelector "results"

-- | Canonical local selector for component-scoped indicator targeting.
-- Renders to `closest .search-input-with-indicator`.
hxSearchInputWithIndicatorSelector :: HxSelector
hxSearchInputWithIndicatorSelector = HxClosest ("." <> hxSearchInputWithIndicatorClass)
Helper implementation examples

Not applicable in this pattern.

Usage examples

Explicit id-based indicator selector (using centralized example constants for class/text/asset):

indicatorForm :: Html
indicatorForm = [hsx|
<form hx-post={SomeAction}
      hx-indicator={renderHxSelector hxIndicatorSpinnerSelector}
      hx-target={renderHxSelector hxIndicatorResultsSelector}
      class="my-form">

    <button type="submit" class="btn btn-primary">
        Submit
        <img id={hxIndicatorSpinnerId}
             class={hxIndicatorCssClass}
             src={hxIndicatorAssetPath}
             alt={hxIndicatorAltText}/>
    </button>
</form>
|]

Local component-scoped selector via closest …:

closestIndicatorForm :: Html
closestIndicatorForm = [hsx|
<div class={hxSearchInputWithIndicatorClass}>
    <input type="search"
           hx-post={SomeAction}
           hx-target={renderHxSelector hxIndicatorResultsSelector}
           hx-indicator={renderHxSelector hxSearchInputWithIndicatorSelector} />
    <span class={hxIndicatorCssClass}>{hxIndicatorText}</span>
</div>
|]
Standalone checks
-- | Placeholder action for demonstration purposes only.
--
-- In real IHP applications, use your auto-routed controller actions instead.
data SomeAction = SomeAction

instance HasPath SomeAction where
    pathTo SomeAction = "/some-action"
Modal Mount

Raw source: Patterns/Htmx/Primitives/Modal.lhs

Pattern intent and mechanism

Use one stable HTMX modal mount point in the shared layout (#modal-container) so any screen can load modal UI into a predictable place.

This pattern treats two modal-targeting variants as canonical:

  • whole-mount swap: load the full modal shell into the global mount
  • inner-content swap: keep a stable modal shell and replace only .modal-content
Project-specific notes and rollout guidance

The inner-content variant is used for feature notes and detail panels.

Use the whole-mount variant when you want one simple global target and stateless modal rendering. Use the inner-content variant when a stable Bootstrap modal instance should stay alive across content updates.

Supporting snippets

If your application already defines HTMX selector helpers, prefer reusing those app-level helpers.

import Application.Helper.HTMX

hxModalTargetSelector :: HxSelector
hxModalTargetSelector = hxIdSelector "modal-container"
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Primitives.Modal where

import Prelude
import Data.Text (Text)
import IHP.ViewPrelude

-- HLS may report this pattern-module import as not loaded because `Patterns/**`
-- is outside the active cradle; rely on the explicit GHC check for authority.
import Patterns.Htmx.Shared.Types

-- | CSS id for the canonical HTMX modal mount point.
hxModalMountId :: Text
hxModalMountId = "modal-container"

-- | Canonical selector for targeting the whole shared mount.
--
-- Use this when the response returns the whole modal shell.
hxModalTargetSelector :: HxSelector
hxModalTargetSelector = hxIdSelector hxModalMountId

-- | Target the `.modal-content` node inside an already-mounted Bootstrap modal.
--
-- Use this when the modal shell stays stable and HTMX only replaces inner
-- content.
hxModalContentSelector :: Text -> HxSelector
hxModalContentSelector modalId = HxRaw ("#" <> modalId <> " .modal-content")
Helper implementation examples

Not applicable in this pattern.

Usage examples

Layout with one stable modal mount point:

-- | Layout shape with a stable modal mount point.
layoutSnippet :: Html -> Html -> Html -> Html
layoutSnippet navbar renderFlashMessages inner = [hsx|
    <body>
        {navbar}
        <div class="container-fluid mt-4">
            {renderFlashMessages}
            {inner}
        </div>
        <div id={hxModalMountId}></div>
    </body>
|]

Whole-mount variant:

-- | Trigger that loads a full modal shell into the shared mount.
openModalButton :: Text -> Text -> Html
openModalButton url label = [hsx|
    <button class="btn btn-outline-primary"
            hx-get={url}
            hx-target={renderHxSelector hxModalTargetSelector}
            hx-swap="innerHTML">
        {label}
    </button>
|]

-- | Empty mount response for explicit modal reset/close flows.
clearModalMount :: Html
clearModalMount = [hsx|<div id={hxModalMountId}></div>|]

Inner-content variant:

hxNotesModalId :: Text
hxNotesModalId = "notesModal"

hxNotesModalSelector :: HxSelector
hxNotesModalSelector = hxIdSelector hxNotesModalId

hxNotesModalContentSelector :: HxSelector
hxNotesModalContentSelector = hxModalContentSelector hxNotesModalId

notesModalShell :: Html
notesModalShell = [hsx|
    <div id={hxNotesModalId}
         class="modal fade"
         tabindex="-1"
         aria-hidden="true"
         data-bs-backdrop="static"
         data-bs-keyboard="false"
         hx-on="notesSaved: (() => { const el = document.getElementById('notesModal'); const inst = bootstrap.Modal.getInstance(el); if (inst) { inst.hide(); } })()">
        <div class="modal-dialog modal-dialog-centered">
            <div class="modal-content"></div>
        </div>
    </div>
|]

openNotesButton :: Text -> Html
openNotesButton url = [hsx|
    <button type="button"
            hx-get={url}
            hx-target={renderHxSelector hxNotesModalContentSelector}
            hx-swap="outerHTML"
            data-bs-toggle="modal"
            data-bs-target={renderHxSelector hxNotesModalSelector}>
        Edit notes
    </button>
|]
Standalone checks

Not applicable in this pattern.

Nested Control Click Isolation

Raw source: Patterns/Htmx/Primitives/NestedControlClickIsolation.lhs

Pattern intent and mechanism

Use a named wrapper around interactive child controls when they live inside a clickable HTMX parent container. The wrapper stops the child click event from bubbling into the parent, so the child action runs without also triggering the parent navigation.

Typical parent containers are table rows or cards with hx-get navigation, but the pattern applies to any click-reactive container, such as list items, tiles, panels, or custom component shells. Typical nested controls are edit/delete buttons, checkboxes, dropdown triggers, dropdown menu items, or links whose action is not the parent container action.

Use onclick="event.stopPropagation()" as the canonical mixed-control solution. It works for native controls, Bootstrap controls, and HTMX-triggering controls. Use hx-trigger="click consume" only as an HTMX-only variant when the child interaction itself is an HTMX click trigger and no non-HTMX click handling needs to be protected.

This pattern commonly composes with FilterTarget: a child control may isolate its click from the parent and still update itself or a nearby fragment through a normal HTMX target/swap boundary.

Project-specific notes and rollout guidance

Keep domain-facing helper names stable when the domain meaning is clearer than the event-isolation mechanism. A named isolation helper is enough to make the ordng pattern grepable in application code.

When a shared renderer has several clickable layouts, check every layout it emits. Desktop table rows and mobile cards are common examples, but the pattern is not limited to them; it protects child controls from any clickable parent container.

Isolation follows the interaction contract of the rendered occurrence, not the CSS class. See SemanticValueAxes for the three actionability levels (Actionable, Static, Disabled):

Occurrence Actionability Isolation
Active edit/delete button, dropdown trigger Actionable isolateNestedControlClick
Disabled action button (e.g. edit/delete in action column) Disabled isolateNestedControlClick ✓ — "nothing happens" is correct
Dropdown with disabled options Control is Actionable, options are partly Disabled isolateNestedControlClick ✓ (dropdown opens, options refuse action)
Read-only status badge, semantic label Static No isolation — click passes through to row
Permission-denied enum fallback (static <span>) Static No isolation — click passes through to row

Only wrap with isolation when the rendered occurrence is a nested control or action affordance, including disabled controls whose invocation is intentionally refused. A static semantic indicator is part of the row information, not a control; it stays transparent to parent navigation.

Permission-gated controls should stay visible as disabled or static controls when their existence provides useful orientation. A disabled action button still counts as a control and keeps nested-click isolation; a passive indicator does not. Row and card navigation remains available outside the control area, never through it.

Shrink-to-control isolation:

A block-level wrapper (<div>) inside a table cell or card body can create a dead click zone: clicks on empty space inside the cell but outside the child control do not bubble to the parent row/card, so nothing happens. The canonical helper therefore shrinks to the control size (d-inline-block). Only the control itself stops propagation; empty cell space continues to trigger parent navigation.

If you intentionally want the entire cell to be non-clickable (for example to reserve space for future controls), use a deliberate full-cell wrapper and document that choice.

HSX placement rule:

When the child control is itself built from HSX, bind the child or the isolated wrapper in a where binding. Do not nest a helper call with an HSX argument directly inside another [hsx|...|] block; that shape is fragile with HSX quasiquotation.

Supporting snippets

Failure shape without isolation:

<tr hx-get={pathTo (ShowEntityAction entity.id)} hx-target="body">
    <td>{entity.name}</td>
    <td>
        <button hx-post={pathTo (UpdateEntityAction entity.id)}>
            Update
        </button>
    </td>
</tr>

Clicking the button can also bubble to the row and trigger the parent hx-get. The fix is to wrap the child control, not to weaken the parent navigation.

Canonical shape:

<td>
    {isolateNestedControlClick updateButton}
</td>

HTMX-only variant:

<button hx-post={pathTo (UpdateEntityAction entity.id)}
        hx-trigger="click consume">
    Update
</button>

Use the HTMX-only variant only when the child interaction is itself fully represented by that HTMX trigger. It does not cover Bootstrap dropdown behavior or other non-HTMX click handlers.

Core reusable pattern infrastructure

The core helper is intentionally small. Its value is mostly naming: application code can preserve the pattern identity instead of scattering an anonymous event handler string.

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Primitives.NestedControlClickIsolation where

import Prelude
import Data.Text (Text)
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude

-- | Isolate a nested control so its clicks do not bubble to a parent
-- clickable row, card, or similar HTMX container.
--
-- Uses `d-inline-block` so the wrapper shrinks to the control size.
-- Empty space in the surrounding table cell or card area continues to
-- trigger the parent click handler.
isolateNestedControlClick :: Html -> Html
isolateNestedControlClick inner = [hsx|
    <div class="d-inline-block" onclick="event.stopPropagation()">
        {inner}
    </div>
|]

Optional hardening: event.stopImmediatePropagation() also prevents any remaining listeners on the same element from firing. Use it only when you have a reproducible browser test showing the issue; stopPropagation() is the safer default.

Helper implementation examples

Action-button wrappers can keep their local name and call the pattern helper only in the branch that needs click isolation.

isolatedActionButtons :: Html -> Html -> Html
isolatedActionButtons editButton deleteButton = isolateNestedControlClick wrapped
  where
    wrapped :: Html
    wrapped = [hsx|
        <div class="d-flex gap-2">
            {editButton}
            {deleteButton}
        </div>
    |]

A conditional wrapper applies isolation only when the control is interactive. Read-only indicators remain unwrapped so the parent row click still navigates:

wrapIfInteractive :: Bool -> Html -> Html
wrapIfInteractive isInteractive html =
    if isInteractive then isolateNestedControlClick html else html

Inline controls can likewise keep their domain name. The component is still an inline checkbox; click isolation is one environmental requirement.

isolatedInlineCheckbox :: EntityRow -> Html
isolatedInlineCheckbox entity = isolateNestedControlClick checkbox
  where
    checkbox :: Html
    checkbox = [hsx|
        <div class="form-check form-check-inline">
            <input type="checkbox"
                   class="form-check-input"
                   checked={entity.entitySelected}
                   hx-post={pathTo (ToggleEntitySelectionAction entity.entityId)}
                   hx-trigger="change"
                   hx-include="unset" />
        </div>
    |]

A dropdown or any other HSX-built child should be bound first, then isolated. This keeps nested HSX quasiquotation out of the call site.

isolatedRoleDropdown :: EntityRow -> Html
isolatedRoleDropdown entity = isolateNestedControlClick dropdown
  where
    dropdown :: Html
    dropdown = [hsx|
        <div class="dropdown" hx-target="this" hx-swap="outerHTML">
            <button type="button"
                    class="btn btn-sm btn-primary dropdown-toggle"
                    data-bs-toggle="dropdown">
                Role
            </button>
            <div class="dropdown-menu">
                <button type="button"
                        class="dropdown-item"
                        hx-post={pathTo (UpdateEntityRoleAction entity.entityId "member")}>
                    Member
                </button>
            </div>
        </div>
    |]
Usage examples

A clickable table row can contain isolated child controls. Clicking normal row cells still navigates to the parent entity; clicking the child controls does not.

clickableEntityRow :: EntityRow -> Html
clickableEntityRow entity = [hsx|
    <tr data-entity-id={tshow entity.entityId}
        hx-get={pathTo (ShowEntityAction entity.entityId)}
        hx-target="body"
        class="row-link">
        <td>{entity.entityLabel}</td>
        <td>{isolatedInlineCheckbox entity}</td>
        <td class="table-actions">{isolatedActionButtons editButton deleteButton}</td>
    </tr>
|]
  where
    editButton :: Html
    editButton = [hsx|
        <a href={pathTo (ShowEntityAction entity.entityId)}
           class="btn btn-sm btn-secondary">
            Edit
        </a>
    |]

    deleteButton :: Html
    deleteButton = [hsx|
        <button type="button"
                class="btn btn-sm btn-secondary"
                hx-post={pathTo (ToggleEntitySelectionAction entity.entityId)}
                hx-trigger="click consume">
            Toggle
        </button>
    |]

The same child helper works in a clickable card.

clickableEntityCard :: EntityRow -> Html
clickableEntityCard entity = [hsx|
    <div class="card entity-card"
         data-entity-id={tshow entity.entityId}
         hx-get={pathTo (ShowEntityAction entity.entityId)}
         hx-target="body"
         style="cursor: pointer;">
        <div class="card-body">
            <h3>{entity.entityLabel}</h3>
            {isolatedRoleDropdown entity}
        </div>
    </div>
|]
Standalone checks

The infrastructure and examples above compile with the following local placeholders.

-- | Placeholder row model for standalone examples.
data EntityRow = EntityRow
    { entityId :: Int
    , entityLabel :: Text
    , entitySelected :: Bool
    }

-- | Placeholder parent navigation action.
data ShowEntityAction = ShowEntityAction Int

instance HasPath ShowEntityAction where
    pathTo (ShowEntityAction entityId) = "/entities/" <> tshow entityId

-- | Placeholder inline update action.
data ToggleEntitySelectionAction = ToggleEntitySelectionAction Int

instance HasPath ToggleEntitySelectionAction where
    pathTo (ToggleEntitySelectionAction entityId) = "/entities/" <> tshow entityId <> "/toggle-selection"

-- | Placeholder dropdown update action.
data UpdateEntityRoleAction = UpdateEntityRoleAction Int Text

instance HasPath UpdateEntityRoleAction where
    pathTo (UpdateEntityRoleAction entityId role) = "/entities/" <> tshow entityId <> "/role/" <> role
OOB Updates

Raw source: Patterns/Htmx/Primitives/OobUpdates.lhs

Pattern intent and mechanism

When an HTMX response needs to update data outside the primary request target, use hx-swap-oob for those secondary regions.

Example use case: two inputs (a, b) recalculate separate derived cells (e.g. k*a, a-b). Another common use is a stable local status or live-region node whose text is updated by an otherwise unrelated fragment swap.

Project-specific notes and rollout guidance

Why this works:

  1. The target region already exists in the current DOM with a stable id.
  2. The HTMX response includes response-only elements marked with hx-swap-oob.
  3. HTMX matches those response elements by id and updates the existing DOM nodes during the same round-trip.

Use OOB when updates naturally span multiple regions; prefer simpler self-swaps when updates remain local.

Important boundary: OOB nodes belong in HTMX responses, not in the normal initial page render. Rendering both the stable target and the OOB response node in the initial DOM creates duplicate IDs and an inert hx-swap-oob attribute.

Two swap modes recur:

  • hx-swap-oob="true" replaces the matched element itself.
  • hx-swap-oob="innerHTML" preserves the matched element and replaces only its contents. Prefer this for stable live regions and other nodes whose element identity, role, or ARIA attributes must survive.

The primary trigger does not have to use hx-swap="none". That is the right shape when only OOB regions change, but a response can also perform a normal primary swap and include extra OOB updates.

Supporting snippets

View snippet (inputs use hx-swap="none", derived cells have stable ids):

renderRow :: Row -> Html
renderRow row = [hsx|
    <tr>
        <td>
            <input hx-post={UpdateRowAction row.rowId}
                   hx-trigger="keyup changed delay:300ms"
                   hx-swap="none"
                   name="a" type="text" value={show row.a}>
        </td>
        <td>
            <input hx-post={UpdateRowAction row.rowId}
                   hx-trigger="keyup changed delay:300ms"
                   hx-swap="none"
                   name="b" type="text" value={show row.b}>
        </td>
        <td id={"scaled_" <> inputValue row.rowId}>{show (k * row.a)}</td>
        <td id={"diff_"   <> inputValue row.rowId}>{show (row.a - row.b)}</td>
    </tr>
|]

Controller snippet (respond with OOB fragments only):

action UpdateRowAction { targetRowId = targetRowId } = do
    let a = paramOrDefault 0 "a"
        b = paramOrDefault 0 "b"
    row <- fetch targetRowId
           >>= pure . set #a a . set #b b
           >>= updateRecord

    let scaled = k * a
        diff   = a - b

    respondHtml [hsx|
        <td id={"scaled_" <> inputValue row.rowId} hx-swap-oob="true">{show scaled}</td>
        <td id={"diff_"   <> inputValue row.rowId} hx-swap-oob="true">{show diff}</td>
    |]

Stable status target plus response-only inner update:

-- Initial/full-page render: stable target only.
<div id="member-results-status"
     role="status"
     aria-live="polite"
     aria-atomic="true"></div>

-- HTMX response only: update the existing target's contents.
<div id="member-results-status" hx-swap-oob="innerHTML">
    3 matching members
</div>
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Primitives.OobUpdates where

import Prelude
import Data.Text (Text)
import IHP.RouterSupport (HasPath (..))
import IHP.ViewPrelude hiding (show, fetch, updateRecord)

-- | Common OOB swap modes.
data OobSwapMode
    = OobReplaceElement
    | OobInnerHtml
    deriving (Eq, Show)

renderOobSwapMode :: OobSwapMode -> Text
renderOobSwapMode OobReplaceElement = "true"
renderOobSwapMode OobInnerHtml = "innerHTML"

-- Placeholder row/action model to keep the snippet close to the markdown.
data Row = Row
    { rowId :: Int
    , a :: Int
    , b :: Int
    }

data UpdateRowAction = UpdateRowAction { targetRowId :: Int }

instance HasPath UpdateRowAction where
    pathTo UpdateRowAction { targetRowId = targetRowId } = "/update-row/" <> tshow targetRowId

k :: Int
k = 2
Helper implementation examples

Not applicable in this pattern.

Usage examples

View-side wiring (stable ids + hx-swap="none"):

renderRow :: Row -> Html
renderRow row = [hsx|
    <tr>
        <td>
            <input hx-post={UpdateRowAction row.rowId}
                   hx-trigger="keyup changed delay:300ms"
                   hx-swap="none"
                   name="a" type="text" value={show row.a}>
        </td>
        <td>
            <input hx-post={UpdateRowAction row.rowId}
                   hx-trigger="keyup changed delay:300ms"
                   hx-swap="none"
                   name="b" type="text" value={show row.b}>
        </td>
        <td id={"scaled_" <> inputValue row.rowId}>{show (k * row.a)}</td>
        <td id={"diff_"   <> inputValue row.rowId}>{show (row.a - row.b)}</td>
    </tr>
|]

Controller-side OOB response fragments:

action :: UpdateRowAction -> IO ()
action UpdateRowAction { targetRowId = targetRowId } = do
    let a = paramOrDefault 0 "a"
        b = paramOrDefault 0 "b"
    row <- fetch targetRowId
           >>= (\r -> pure (r { a = a, b = b }))
           >>= updateRecord

    let scaled = k * a
        diff   = a - b

    respondHtml [hsx|
        <td id={"scaled_" <> inputValue row.rowId} hx-swap-oob={renderOobSwapMode OobReplaceElement}>{show scaled}</td>
        <td id={"diff_"   <> inputValue row.rowId} hx-swap-oob={renderOobSwapMode OobReplaceElement}>{show diff}</td>
    |]

Inner-content OOB update for a stable status node:

renderStatusOobUpdate :: Text -> Html
renderStatusOobUpdate message = [hsx|
    <div id="member-results-status" hx-swap-oob={renderOobSwapMode OobInnerHtml}>
        {message}
    </div>
|]

Boilerplate stubs:

paramOrDefault :: a -> Text -> a
paramOrDefault defaultValue _ = defaultValue

fetch :: Int -> IO Row
fetch _ = pure (Row { rowId = 0, a = 0, b = 0 })

updateRecord :: Row -> IO Row
updateRecord r = pure r

respondHtml :: Html -> IO ()
respondHtml _ = pure ()
Standalone checks

Not applicable in this pattern.

Semantic Value Axes

Raw source: Patterns/Htmx/Primitives/SemanticValueAxes.lhs

Pattern intent and mechanism

Separate the domain meaning of a discrete value from the contextual salience and interaction contract of the parameter or control that renders it. Domain, salience, and actionability are three independent axes of any categorical value displayed in the UI.

This pattern applies wherever a discrete value (database enum, calculated category, or domain sum type) is rendered as a visual element: buttons, badges, status indicators, table cells, or read-only labels. It is not specific to dropdowns or button groups.

Project-specific notes and rollout guidance

Three independent axes:

Axis Scope Decision question Type Example values Typical visual projection
Domain individual enum value semantics What does this value mean in the domain? DomainSemantic Affirmative, Negative, Neutral, … colour family (success, danger, light, …)
Salience parameter/control occurrence in this view How salient is this parameter in this context? Salience Primary, Secondary visual tone (Filled vs. Outlined)
Actionability rendered control/option occurrence Is mutation offered here? Actionability Actionable, Static, Disabled, StaticUnavailablePlaceholder element affordance: hover/focus behaviour, dropdown arrow, disabled attribute, unavailable dimming, assistive-technology exposure

These axes are orthogonal. Affirmative + Primary + Static is a valid combination: an important read-only status that may render as a green filled badge. Negative + Primary + Disabled is also valid: an important option inside an otherwise interactive control that refuses this specific operation.

The last column is deliberately a projection, not a fourth axis. The three semantic axes drive three different visual facets: domain controls colour, salience controls filled versus outlined treatment, and actionability controls affordance such as hover/focus behaviour, dropdown arrows, disabled attributes, visible unavailable dimming, assistive-technology exposure, and whether the element is a control at all.

Domain — value semantics, not UI state:

DomainSemantic describes the ontological meaning of the value:

Value Default colour Meaning
Typical primary (blue) The common, default, usual case
Variant info (cyan) A valid alternative, equally legitimate
Exceptional dark (near-black) Rare but valid, edge case
Affirmative success (green) Strong positive / yes / approval / success
Negative danger (red) Strong negative / no / rejection / problem
Clarify warning (yellow/orange) Needs more context / attention
Neutral light (grey/background) Neither / n/a / abstain / not applicable

Guardrails:

  • Never mix Disabled, Selected, or ReadOnly into DomainSemantic. These are actionability or renderer states, not domain meanings.
  • Never derive the semantic from the user's role or permissions. The domain meaning of "confirmed" is Affirmative regardless of who is looking at it.
  • Name the type DomainSemantic, not ButtonSemantic. The semantics apply to badges, table cells, and labels too.
  • When Typical must use a semantically loaded colour (especially red or green), use a distinct tone so users can still map it intuitively. Example: if your brand colour is strawberry red, choose a different red shade for Negative so the "danger" semantic remains readable.
  • Do not apply DomainSemantic to buttons that are pure actions without a discrete value. A disabled "New" button in an index header carries no enum state, no selection, and no domain meaning. Use an outlined visual tone (btn-outline-primary.disabled) instead of forcing an arbitrary DomainSemantic. Salience does not apply here either: for enum buttons, Filled means selected, but a "New" button has no selected state — the dimension simply does not exist.

Salience — contextual importance of the parameter:

Salience describes how prominent this parameter or control occurrence is in the current view. It is not a property of the individual enum value. If an item's participation status is primary on a show page, the parameter remains primary whether its current value is "yes", "maybe", or "no".

Primary    — This parameter is focal or decision-relevant in this context.
Secondary  — This parameter is background, auxiliary, or shown mainly for transparency.

Old trap: a Bool parameter named selected that secretly meant "filled vs. outlined". Selection state and visual tone are renderer concerns. Salience answers the semantic question: how salient is this parameter in this context?

Guardrails:

  • Do not name visual tone Salience. Filled and Outlined are render tones, not salience values.
  • Do not let isDisabled bleed into salience decisions. A disabled option in a primary control is still part of a primary parameter.
  • Do not choose salience from the current enum value. Choose it from the view's purpose and the parameter's role in that view.

Actionability — interaction contract:

Actionability is not part of DomainSemantic or Salience. It is determined at the call site by the renderer, not by the value's semantic type.

Four actionability levels:

  • Actionable — a real control, usually a <button> with hx-post or dropdown behaviour. The user can invoke a change from this rendered element.
  • Static — a display element, usually a <span> with button or badge styling plus pe-none and user-select-none. No operation is offered by this element. Use this for read-only current-state display and for fully non-interactive fallbacks.
  • Disabled — a disabled control inside an otherwise interactive control set. The operation shape exists, but this specific option is currently unavailable. Use this to keep unavailable options visible for transparency.
  • Static unavailable placeholder — a non-control placeholder, usually a dimmed <span aria-hidden="true">, used only to preserve row, column, or action-cell alignment when the viewer cannot invoke the operation at all. It is visible for layout/transparency but hidden from assistive technology.

The difference between static, disabled, and static-unavailable-placeholder is structural: static means "this is display, not a control"; disabled means "this is a control option, but invocation is currently refused"; static unavailable placeholder means "this visual slot is unavailable to this viewer and should not be exposed as a control or as meaningful content". If every option in a control would be disabled, render the whole thing as static instead of showing a dead dropdown or button group.

Implementation rules:

  1. Choose DomainSemantic from the domain value only. Never derive it from user role, selection state, permissions, or view placement.
  2. Analyse the presentation context for this parameter occurrence:
    • What is the user's current task on this page?
    • Does this parameter affect what the user should decide or do?
    • Is it central status/action information, or supporting metadata?
    • Is mutation offered here, and is the actor allowed to perform it?
  3. Choose Salience from the parameter's role in that context:
    • focal, status-bearing, or decision-relevant parameter → Primary;
    • background, auxiliary, repeated-row, or transparency-only parameter → Secondary.
  4. Choose Actionability from the interaction contract:
    • mutation is available from this element → Actionable;
    • no mutation should be offered here → Static;
    • the surrounding control is interactive but this option is blocked → Disabled;
    • the visual action slot should remain only as an unavailable layout filler and must not be exposed to assistive technology → StaticUnavailablePlaceholder;
    • all options would be disabled → collapse to Static.
  5. Derive visual tone after the three semantic axes are chosen. A common Bootstrap mapping is: Primary + current value → Filled, unselected alternatives → Outlined, and SecondaryOutlined.
  6. Disabled options keep the same domain colour and derived visual tone they would otherwise have. Preserve hue, but emit a visible disabled affordance such as opacity. Do not desaturate or grey them into another semantic, and do not rely on native disabled styling when the design system overrides control colours.
  7. Unavailable-state dimming depends on visual tone, text-label presence, and actionability. Icon-only disabled controls and icon-only static unavailable placeholders can fade deepest because only a glyph must remain recognizable. Labeled filled controls have a floor because uniform opacity can erode the internal text-on-fill contrast pair. Labeled outlined controls have a similar floor because their foreground label becomes too faint if they inherit icon-only dimming.

The following matrix is only about the two context-sensitive axes: Salience and Actionability. DomainSemantic does not appear here because it is assigned before context analysis, from the enum value's domain meaning alone.

Context-to-axis decision table:

Context analysis result Salience Actionability Typical element Typical visual tone
Parameter is central to the user's current task and mutation is offered Primary Actionable <button> / dropdown trigger current Filled, alternatives Outlined
Parameter is central status/decision information, but mutation is not offered here Primary Static <span> styled as button/badge current Filled
Parameter is supporting metadata, repeated-row information, or transparency-only Secondary Static <span> styled as subdued button/badge current Outlined
Action slot exists for table/card alignment, but this viewer cannot invoke it at all n/a or inherited visual tone StaticUnavailablePlaceholder dimmed <span aria-hidden="true"> same hue/tone as the action slot, plus dimming
Parameter is editable, but one option is blocked by policy or state inherited from parameter Disabled for that option disabled <button> / option same hue/tone as if enabled, plus tone-dependent dimming

The call site decides domain semantics, salience, and actionability. The generic renderer only translates the chosen axes into visual tone and HTML.

Supporting snippets

Bootstrap colour mapping: semanticColor maps each DomainSemantic to a Bootstrap colour name ("primary", "success", "danger", etc.).

Semantic presentation mapping: SemanticPresentation joins the three axes plus renderer selection state. semanticButtonClass derives the Bootstrap colour/tone class ("btn-success" or "btn-outline-danger"), while semanticElementAffordance derives the element-level interaction hints, visible unavailable dimming, and assistive-technology exposure.

Selection state is a renderer input, not a salience value. Use selectionStateFromBool True = Selected and selectionStateFromBool False = Unselected when adapting an existing Bool.

Pure action buttons such as edit/delete do not carry DomainSemantic, but they still carry Actionability and label presence. Route their disabled and static-unavailable-placeholder branches through the same affordance emitter (actionAffordanceForTone) instead of hardcoding a disabled span or keying dimming to a color class such as .btn-secondary.disabled.

Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Htmx.Primitives.SemanticValueAxes where

import Data.Maybe (fromMaybe, listToMaybe)
import Data.Text (Text, pack)
import Prelude

-- | Ontological meaning of a discrete value. Describes what the value
-- means in the domain, independent of UI state or user role.
data DomainSemantic
    = Typical       -- ^ The common, default, usual case
    | Variant       -- ^ A valid alternative, equally legitimate
    | Exceptional   -- ^ Rare but valid, edge case
    | Affirmative   -- ^ Strong positive / yes / approval / success
    | Negative      -- ^ Strong negative / no / rejection / problem
    | Clarify       -- ^ Needs more context / attention
    | Neutral       -- ^ Neither / n/a / abstain / not applicable
    deriving (Eq, Show, Enum, Bounded)

-- | Contextual importance of this parameter or control occurrence.
-- Orthogonal to 'DomainSemantic' and 'Actionability'.
data Salience
    = Primary    -- ^ Focal or decision-relevant in this view
    | Secondary  -- ^ Background, auxiliary, or transparency-only in this view
    deriving (Eq, Show, Enum, Bounded)

-- | Interaction contract of this rendered occurrence.
data Actionability
    = Actionable  -- ^ Mutation is offered from this element
    | Static      -- ^ Display only; no operation is offered here
    | Disabled    -- ^ Option is visible inside an interactive set but blocked
    | StaticUnavailablePlaceholder
      -- ^ Dimmed non-control filler hidden from assistive technology
    deriving (Eq, Show, Enum, Bounded)

-- | Renderer-level selection state.
data SelectionState
    = Selected
    | Unselected
    deriving (Eq, Show, Enum, Bounded)

-- | All semantic axes for one rendered value occurrence, plus renderer
-- selection state. Call sites should normally construct this and let renderer
-- helpers derive CSS tone and affordance from it.
data SemanticPresentation = SemanticPresentation
    { domainSemantic :: DomainSemantic
    , salience :: Salience
    , actionability :: Actionability
    , selectionState :: SelectionState
    }
    deriving (Eq, Show)

-- | Whether the rendered element has a text label whose contrast must stay readable.
data LabelPresence
    = IconOnlyControl
    | LabeledControl
    deriving (Eq, Show, Enum, Bounded)

-- | Visual dimming to apply for unavailable affordance while preserving hue.
data DimLevel
    = NoDim
    | LabeledFilledDisabledDim
    | LabeledOutlinedDisabledDim
    | IconOnlyDisabledDim
    deriving (Eq, Show, Enum, Bounded)

-- | Element-level affordance derived from actionability, visual tone, and label presence.
data ElementAffordance = ElementAffordance
    { rendersAsControl :: Bool
    , isDisabledControl :: Bool
    , hasInteractiveAffordance :: Bool
    , dimLevel :: DimLevel
    , isHiddenFromAssistiveTech :: Bool
    }
    deriving (Eq, Show)

-- | Derived renderer-level visual tone. This is not a semantic axis.
-- Do not persist this or choose it at call sites; derive it from
-- 'Salience' and 'SelectionState'.
data VisualTone
    = Filled
    | Outlined
    deriving (Eq, Show, Enum, Bounded)

-- | Result of looking up the semantic meaning for the selected value.
data SemanticLookupResult
    = NoSelectedValue
    | SelectedSemantic DomainSemantic
    | MissingSelectedSemantic
    deriving (Eq, Show)

-- | Map a semantic meaning to a Bootstrap colour name.
semanticColor :: DomainSemantic -> Text
semanticColor Typical      = "primary"
semanticColor Variant      = "info"
semanticColor Exceptional  = "dark"
semanticColor Affirmative  = "success"
semanticColor Negative     = "danger"
semanticColor Clarify      = "warning"
semanticColor Neutral      = "light"

-- | Derive a renderer tone from semantic salience and selection state.
salienceTone :: Salience -> SelectionState -> VisualTone
salienceTone Primary Selected     = Filled
salienceTone Primary Unselected   = Outlined
salienceTone Secondary Selected   = Outlined
salienceTone Secondary Unselected = Outlined

-- | Common Bootstrap mapping from semantic presentation to a colour/tone
-- class, e.g. "btn-success" or "btn-outline-danger". Actionability is part of
-- the presentation record, but does not change the colour/tone class; use
-- 'semanticElementAffordance' for element-level behaviour.
semanticButtonClass :: SemanticPresentation -> Text
semanticButtonClass SemanticPresentation { domainSemantic = semantic, salience = sal, selectionState = selection } =
    let color = semanticColor semantic
    in case salienceTone sal selection of
        Filled   -> "btn-"         <> color
        Outlined -> "btn-outline-" <> color

-- | Derive element-level affordance from a visual tone, label presence, and actionability.
--
-- This helper is useful for pure action buttons that do not carry a
-- 'DomainSemantic' but still need the same unavailable-affordance path.
actionAffordanceForTone :: VisualTone -> LabelPresence -> Actionability -> ElementAffordance
actionAffordanceForTone tone labelPresence action =
    case action of
        Actionable -> ElementAffordance
            { rendersAsControl = True
            , isDisabledControl = False
            , hasInteractiveAffordance = True
            , dimLevel = NoDim
            , isHiddenFromAssistiveTech = False
            }
        Static -> ElementAffordance
            { rendersAsControl = False
            , isDisabledControl = False
            , hasInteractiveAffordance = False
            , dimLevel = NoDim
            , isHiddenFromAssistiveTech = False
            }
        Disabled -> ElementAffordance
            { rendersAsControl = True
            , isDisabledControl = True
            , hasInteractiveAffordance = False
            , dimLevel = disabledDimLevel tone labelPresence
            , isHiddenFromAssistiveTech = False
            }
        StaticUnavailablePlaceholder -> ElementAffordance
            { rendersAsControl = False
            , isDisabledControl = False
            , hasInteractiveAffordance = False
            , dimLevel = disabledDimLevel tone labelPresence
            , isHiddenFromAssistiveTech = True
            }

-- | Unavailable-state dimming depends on visual tone and text-label presence.
disabledDimLevel :: VisualTone -> LabelPresence -> DimLevel
disabledDimLevel _ IconOnlyControl = IconOnlyDisabledDim
disabledDimLevel Filled LabeledControl = LabeledFilledDisabledDim
disabledDimLevel Outlined LabeledControl = LabeledOutlinedDisabledDim

-- | Optional CSS hook for unavailable-state dimming.
dimLevelClass :: DimLevel -> Text
dimLevelClass NoDim = ""
dimLevelClass LabeledFilledDisabledDim = "is-disabled-filled-labeled"
dimLevelClass LabeledOutlinedDisabledDim = "is-disabled-outlined-labeled"
dimLevelClass IconOnlyDisabledDim = "is-disabled-icon-only"

-- | Derive element-level affordance from the presentation's visual tone,
-- label presence, and actionability.
semanticElementAffordance :: LabelPresence -> SemanticPresentation -> ElementAffordance
semanticElementAffordance labelPresence SemanticPresentation { salience = sal, selectionState = selection, actionability = action } =
    actionAffordanceForTone (salienceTone sal selection) labelPresence action

-- | If every option in an interactive set would be disabled, collapse the set
-- to static display. A fully disabled control is dead UI; a static fallback is
-- clearer.
collapseAllDisabledToStatic :: [SemanticPresentation] -> [SemanticPresentation]
collapseAllDisabledToStatic presentations
    | not (null presentations) && all ((== Disabled) . actionability) presentations =
        [ presentation { actionability = Static } | presentation <- presentations ]
    | otherwise = presentations

-- | Preserve the distinction between no selected value and a selected value
-- missing from the semantic configuration.
selectedSemanticFor :: Eq a => [(a, DomainSemantic)] -> Maybe a -> SemanticLookupResult
selectedSemanticFor _ Nothing = NoSelectedValue
selectedSemanticFor options (Just selected) =
    case listToMaybe [ semantic | (value, semantic) <- options, value == selected ] of
        Just semantic -> SelectedSemantic semantic
        Nothing -> MissingSelectedSemantic

-- | Conservative rendering fallback for selected-value lookup.
-- No value is neutral; missing semantics are suspicious and render as Clarify.
semanticForLookupResult :: SemanticLookupResult -> DomainSemantic
semanticForLookupResult NoSelectedValue = Neutral
semanticForLookupResult (SelectedSemantic semantic) = semantic
semanticForLookupResult MissingSelectedSemantic = Clarify

-- | Convenience adapter for existing selected flags.
selectionStateFromBool :: Bool -> SelectionState
selectionStateFromBool True  = Selected
selectionStateFromBool False = Unselected
Helper implementation examples

A generic CSS-class builder for any framework (not Bootstrap-specific) takes the whole presentation and derives visual tone internally:

-- | Build a CSS class from a prefix and semantic presentation.
-- Example: semanticPrefixedClass "badge" presentation = "badge badge-filled-success"
semanticPrefixedClass :: Text -> SemanticPresentation -> Text
semanticPrefixedClass prefix SemanticPresentation { domainSemantic = semantic, salience = sal, selectionState = selection } =
    let intensity = case salienceTone sal selection of
            Filled   -> "filled"
            Outlined -> "outlined"
        color = semanticColor semantic
    in prefix <> " " <> prefix <> "-" <> intensity <> "-" <> color

A read-only semantic indicator that preserves colour and salience but removes all interaction:

renderStaticSemantic :: Eq a
                     => Salience            -- ^ parameter salience in this view
                     -> [(a, DomainSemantic)]  -- ^ all options
                     -> Maybe a             -- ^ selected value
                     -> Text                -- ^ CSS classes
renderStaticSemantic salience options value =
    let selectedSemantic = semanticForLookupResult (selectedSemanticFor options value)
        presentation = SemanticPresentation
            { domainSemantic = selectedSemantic
            , salience = salience
            , actionability = Static
            , selectionState = Selected
            }
    in "btn " <> semanticButtonClass presentation <> " pe-none user-select-none"
Usage examples

Item status in a list (read-only, primary parameter):

An anonymous viewer sees the item status but cannot change it. The status parameter is primary in this view, so its current value renders as a static, filled, affirmative indicator:

itemStatusIndicator status =
    let semantic = case status of
            Open      -> Affirmative
            Closed    -> Typical
            Later     -> Neutral
        presentation = SemanticPresentation
            { domainSemantic = semantic
            , salience = Primary
            , actionability = Static
            , selectionState = Selected
            }
        classes = "btn " <> semanticButtonClass presentation
                  <> " pe-none user-select-none"
    in [hsx|
        <span class={classes}>
            {showStatus status}
        </span>
    |]

Subscription role in a table (secondary parameter):

A logged-out user sees the subscription column but has no role here. The parameter is visible for transparency but not central to the view, so it is rendered with secondary salience:

subscriptionRoleUnavailable role =
    let semantic = fromMaybe Neutral $ fmap roleSemantic role
        presentation = SemanticPresentation
            { domainSemantic = semantic
            , salience = Secondary
            , actionability = Static
            , selectionState = Selected
            }
        classes = "btn " <> semanticButtonClass presentation
                  <> " pe-none user-select-none"
    in [hsx|
        <span class={classes}>
            {maybe "–" show role}
        </span>
    |]

Disabled option inside a dropdown:

A manager sees all role options but cannot demote the last owner. The "Owner" option is disabled but keeps its semantic colour and derived visual tone so the manager understands what is unavailable:

renderOption (role, semantic)
    | isDisabled role =
        let presentation = SemanticPresentation
                { domainSemantic = semantic
                , salience = Primary
                , actionability = Disabled
                , selectionState = selectionStateFromBool isSelected
                }
            affordance = semanticElementAffordance LabeledControl presentation
        in [hsx|
            <button type="button"
                    class={"btn btn-sm " <> semanticButtonClass presentation <> " " <> dimLevelClass (dimLevel affordance)}
                    disabled
                    aria-disabled="true">
                {label role}
            </button>
        |]
    | otherwise = -- actionable button

Pure action button unavailable affordance:

A pure edit/delete action has no enum value and therefore no DomainSemantic. It still routes disabled behavior and dimming through actionAffordanceForTone instead of a color-class-specific CSS branch. Choose label presence from the rendered control, not from the action type:

disabledIconOnlyEditButton =
    let affordance = actionAffordanceForTone Outlined IconOnlyControl Disabled
    in [hsx|
        <button type="button"
                class={"btn btn-outline-secondary " <> dimLevelClass (dimLevel affordance)}
                disabled
                aria-disabled="true"
                aria-label="Edit">
            <span aria-hidden="true">✎</span>
        </button>
    |]

disabledLabeledEditButton =
    let affordance = actionAffordanceForTone Outlined LabeledControl Disabled
    in [hsx|
        <button type="button"
                class={"btn btn-outline-secondary " <> dimLevelClass (dimLevel affordance)}
                disabled
                aria-disabled="true">
            Edit
        </button>
    |]

Static unavailable placeholder affordance:

A fully unauthorized edit/delete cell is not a disabled control: the viewer has no operation to invoke. Render a dimmed non-control placeholder for visual alignment and hide it from assistive technology. A blocked action next to a live sibling, such as delete blocked by dependents, stays a real disabled button.

unauthorizedIconOnlyEditPlaceholder =
    let affordance = actionAffordanceForTone Outlined IconOnlyControl StaticUnavailablePlaceholder
    in [hsx|
        <span class={"btn btn-outline-secondary pe-none user-select-none " <> dimLevelClass (dimLevel affordance)}
              aria-hidden="true">

        </span>
    |]
Standalone checks
-- Verify that semantic colour/tone derivation is defined for all combinations.
_checkAllCombinations :: [(Text, Text)]
_checkAllCombinations =
    [ ( tshow sem <> " / " <> tshow sal <> " / " <> tshow action <> " / " <> tshow selection
      , semanticButtonClass SemanticPresentation
            { domainSemantic = sem
            , salience = sal
            , actionability = action
            , selectionState = selection
            }
      )
    | sem <- [minBound .. maxBound]
    , sal <- [minBound .. maxBound]
    , action <- [minBound .. maxBound]
    , selection <- [minBound .. maxBound]
    ]
  where
    tshow :: Show a => a -> Text
    tshow = Data.Text.pack . show

-- Verify that unavailable dimming is tone- and label-presence-dependent.
_checkUnavailableAffordances :: [((Actionability, VisualTone, LabelPresence), (Bool, Bool, DimLevel))]
_checkUnavailableAffordances =
    [ ( (action, tone, labelPresence)
      , ( rendersAsControl affordance
        , isHiddenFromAssistiveTech affordance
        , dimLevel affordance
        )
      )
    | action <- [Disabled, StaticUnavailablePlaceholder]
    , tone <- [minBound .. maxBound]
    , labelPresence <- [minBound .. maxBound]
    , let affordance = actionAffordanceForTone tone labelPresence action
    ]

Shared

Types

Raw source: Patterns/Htmx/Shared/Types.lhs

Pattern intent and mechanism

Define a shared selector type for HTMX attributes so patterns can reference targets consistently without scattering raw selector strings.

Current scope is intentionally narrow:

  • hx-target
  • hx-indicator
  • similar HTMX attributes that accept selector-like values

We do not model all HTMX attributes here.

Project-specific notes and rollout guidance

If your application already defines HTMX selector types (for example in Application.Helper.HTMX), either:

  1. reuse application types and adapt pattern snippets, or
  2. adopt this pattern module for cross-pattern consistency.

Keep one selector vocabulary per app to avoid drift.

Supporting snippets

Selector shapes already used across this topic include:

  • modal mount by id: #modal-container
  • indicator/result nodes by id/class
  • relational selectors such as next .options-component
  • the special HTMX selector this
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Htmx.Shared.Types where

import Prelude
import Data.Text (Text)
import IHP.ViewPrelude

-- | Typed selectors for HTMX attributes such as `hx-target` and `hx-indicator`.
data HxSelector
    = HxId Text
    | HxClass Text
    | HxThis
    | HxClosest Text
    | HxFind Text
    | HxNext Text
    | HxPrevious Text
    | HxRaw Text
    deriving (Show, Eq)

-- | Render an `HxSelector` for use in HTMX attributes.
renderHxSelector :: HxSelector -> Text
renderHxSelector (HxId elementId) = "#" <> elementId
renderHxSelector (HxClass className) = "." <> className
renderHxSelector HxThis = "this"
renderHxSelector (HxClosest selector) = "closest " <> selector
renderHxSelector (HxFind selector) = "find " <> selector
renderHxSelector (HxNext selector) = "next " <> selector
renderHxSelector (HxPrevious selector) = "previous " <> selector
renderHxSelector (HxRaw selector) = selector

-- | Smart constructor for the most common case: targeting an element by id.
hxIdSelector :: Text -> HxSelector
hxIdSelector = HxId
Helper implementation examples

Not applicable in this pattern.

Usage examples

Current pattern fit:

  • Primitives/Modal.lhsHxId "modal-container"
  • Primitives/Indicator.lhsHxId "my-spinner", HxId "results"
  • Composed/Typeahead.lhsHxNext ".next-selected-fibre-id"
  • Composed/AssociationTable.lhsHxNext ".options-component", modal mount id

Escape hatch:

Use HxRaw when a selector shape is not yet modeled. Add a dedicated constructor only after the same shape recurs across multiple patterns.

Standalone checks

Not applicable in this pattern.

Views

View Patterns

Raw source: Patterns/Views/README.md

Index and reference for view composition patterns in this directory.

This topic covers templates, partials, reusable visual building blocks, and data projection patterns, usually expressed in HSX.

Patterns

  • AccessibleControlNames.lhs — accessible names for icon-only controls, native action-vs-navigation elements, and hidden static placeholders.
  • AccessibilityHeadingOutline.lhs — gap-free heading outline, de-heading non-section prompts, and normalizing embedded document fragments.
  • AccessibilityLandmarks.lhs — one primary <main> landmark, labeled navigation landmarks, and separation of landmark semantics from layout mechanics.
  • KeyboardOperability.lhs — keyboard walkthroughs, native activation semantics, focus targets, tabindex guardrails, and disclosure choices.
  • AccessibilitySemanticColor.lhs — semantic color tokens, stable control foregrounds, icon inheritance, and unavailable-state color affordance.
  • SectionedContainer.lhs — alternating content-padded and full-bleed bands for page sections; explicit BodyRole instead of monolithic containers.

Accessibility notes

Start with AccessibilityLandmarks.lhs when a layout shell, scroll container, sticky footer, modal mount, or repeated navigation region creates unclear page landmarks. Use AccessibilityHeadingOutline.lhs when section headings, CTA prompts, or embedded rendered fragments disturb the page outline. Use AccessibleControlNames.lhs when icon-only controls, HTMX action elements, picker buttons, repeated close buttons, or redundant aria-label attributes make control names drift from the real operation. Use KeyboardOperability.lhs when tab order, native Enter/Space activation, focus visibility, tabindex, disclosure behavior, or custom-widget keyboard behavior needs review. Use AccessibilitySemanticColor.lhs when palette tokens, hover/focus states, icon inheritance, or disabled controls drift away from accessible semantic color.

Planned Structure

  • Primitives/ for small reusable view building blocks.
  • Composed/ for multi-part views that coordinate several building blocks.
  • Templates/ for page or section skeletons that arrange subordinate view pieces.

Pattern Shape

.lhs files in this topic follow the seven-section pattern skeleton from ../ARCHITECTURE.md.

Accessibility Heading Outline

Raw source: Patterns/Views/AccessibilityHeadingOutline.lhs

Pattern intent and mechanism

Keep page headings structural. A heading exists because it names a section of content, not because its default font size looks right. The rendered page should have one page-level <h1> and then descend without skipped levels.

This pattern has two recurring parts:

  • De-heading: render non-section prompts, labels, and call-to-action copy as non-heading elements, then apply visual size through CSS.
  • Embedded-document heading normalization: when a Markdown or literate Haskell fragment is embedded inside a larger page outline, shift the fragment's headings so they sit under the surrounding section instead of injecting a second page-level <h1>.

The failure modes are easy to miss because they often look visually correct:

<h2>Dashboard</h2>
<h4>Want to add an item?</h4>

The <h4> is not a subsection of "Dashboard"; it is prompt text styled like a heading. The accessible shape is:

<h2>Dashboard</h2>
<p class="cta-question">Want to add an item?</p>

The class can reuse the same size token as a heading, but the element stays semantic prose.

For embedded documentation, the local page owns the outer outline:

<h1>Product</h1>
<h2>Patterns</h2>
<h3>Views</h3>
<article>
  <h4>Accessibility Heading Outline</h4>
  <h5>Pattern intent and mechanism</h5>
</article>

The source file may still have an H1 and H2s. The embedding layer normalizes the rendered fragment so the page outline remains gap-free.

Project-specific notes and rollout guidance

Start by deciding which layer owns the page H1. In a normal IHP view, the page view usually owns it. In a generated book or documentation shell, the outer page may own the H1 while embedded fragments provide lower-level headings.

Rollout checks:

  1. Identify the one page-level H1.
  2. For every other heading, ask whether it names a real section.
  3. Replace non-section headings with semantic prose or component labels.
  4. Preserve visual treatment with a class when the typography is intentional.
  5. For generated fragments, normalize headings at the embedding boundary rather than editing every source file to match one host page.
  6. Cap deeply embedded headings at H6 rather than producing invalid levels.

Host evidence from seshn:

A call-to-action prompt used an <h4> because it visually matched nearby UI. That created a heading skip and made the prompt appear in screen-reader heading navigation as if it were a section. The reusable pattern is de-heading: render the prompt as a <p> and apply the visual size through a role-specific class.

The same rollout confirmed several adjacent cases: card titles, form-group labels, and live-search/provider result labels were visually heading-like but not section headings. They kept their typography via component-role classes such as .entity-card-title, .form-group-heading, and .search-result-heading, not via generic fake-heading classes. One label that did name a real section was promoted from <h3> to <h2> instead of being de-headed.

Host evidence from the ordng IHP host:

The book page renders one visible hero H1, then generated part/chapter/group sections. Rendered Markdown and .lhs fragments arrive with their own H1/H2 structure. The host therefore needs embedding-boundary heading normalization: the fragment's H1 becomes the article heading at the correct depth, and its subheadings are shifted under it.

Supporting snippets

De-heading a call-to-action prompt:

<p class="cta-question">Want to add an item?</p>
<a class="btn btn-primary" href="/items/new">Add item</a>
.cta-question {
  font-size: var(--heading-sm-size);
  font-weight: var(--heading-weight);
  line-height: var(--heading-line-height);
  margin: 0;
}

The class name should describe the component role, not pretend that the element is a heading. Use a real heading only when the text begins a section.

Other de-headed component labels:

<p class="entity-card-title">Example item</p>
<p class="form-group-heading">Provider details</p>
<p class="search-result-heading">Provider result</p>

These names preserve local component meaning. Avoid generic classes such as fake-heading, because they describe the visual hack rather than the rendered role.

Embedded fragment normalization:

<section>
  <h3>Views</h3>
  <article>
    <!-- source fragment h1 normalized to h4 -->
    <h4>Sectioned Containers</h4>
    <!-- source fragment h2 normalized to h5 -->
    <h5>Pattern intent and mechanism</h5>
  </article>
</section>
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Views.AccessibilityHeadingOutline where

import qualified Data.Text as Text
import IHP.ViewPrelude

-- | Heading level constrained to HTML's h1..h6 range.
newtype HeadingLevel = HeadingLevel { unHeadingLevel :: Int }
    deriving (Eq, Show)

-- | Smart constructor for heading levels. Values outside h1..h6 are clamped.
headingLevel :: Int -> HeadingLevel
headingLevel n = HeadingLevel (clampHeadingLevel n)

-- | Clamp a numeric heading level to HTML's h1..h6 range.
clampHeadingLevel :: Int -> Int
clampHeadingLevel n
    | n < 1 = 1
    | n > 6 = 6
    | otherwise = n

-- | The next structural heading level below the given level.
childHeadingLevel :: HeadingLevel -> HeadingLevel
childHeadingLevel (HeadingLevel n) = headingLevel (n + 1)

-- | Render a heading at the requested structural level.
renderHeading :: HeadingLevel -> Text -> Html
renderHeading (HeadingLevel level) label = case level of
    1 -> [hsx|<h1>{label}</h1>|]
    2 -> [hsx|<h2>{label}</h2>|]
    3 -> [hsx|<h3>{label}</h3>|]
    4 -> [hsx|<h4>{label}</h4>|]
    5 -> [hsx|<h5>{label}</h5>|]
    _ -> [hsx|<h6>{label}</h6>|]

-- | Render text that looks like a heading but is not a section heading.
renderDeHeadedPrompt :: Text -> Text -> Html
renderDeHeadedPrompt classes label = [hsx|<p class={classes}>{label}</p>|]

-- | Shift Pandoc-generated heading tags so a fragment can be embedded under a
-- surrounding page outline.
--
-- If @topLevel@ is h4, source h1 becomes h4, h2 becomes h5, and h3+ become h6.
-- This is intentionally text-level: it is for trusted generated fragments at a
-- rendering boundary, not for sanitizing arbitrary user HTML.
shiftHtmlHeadings :: HeadingLevel -> Text -> Text
shiftHtmlHeadings (HeadingLevel topLevel) html =
    foldl replaceLevel html [6,5..1]
  where
    replaceLevel :: Text -> Int -> Text
    replaceLevel acc sourceLevel =
        let targetLevel = clampHeadingLevel (topLevel + sourceLevel - 1)
        in replaceHeadingTag sourceLevel targetLevel acc

replaceHeadingTag :: Int -> Int -> Text -> Text
replaceHeadingTag sourceLevel targetLevel text =
    Text.replace ("</h" <> levelText sourceLevel <> ">") ("</h" <> levelText targetLevel <> ">")
        (Text.replace ("<h" <> levelText sourceLevel) ("<h" <> levelText targetLevel) text)

levelText :: Int -> Text
levelText 1 = "1"
levelText 2 = "2"
levelText 3 = "3"
levelText 4 = "4"
levelText 5 = "5"
levelText _ = "6"
Helper implementation examples

A host view can de-head a prompt while preserving the old visual scale:

exampleInlineCta :: Html
exampleInlineCta = [hsx|
    <div class="d-flex align-items-center gap-2">
        {renderDeHeadedPrompt "cta-question" "Want to add an item?"}
        <a class="btn btn-primary" href="/items/new">Add item</a>
    </div>
|]

Role-specific component labels follow the same rule:

exampleProviderResultLabel :: Text -> Html
exampleProviderResultLabel resultName = [hsx|
    <p class="search-result-heading">{resultName}</p>
|]

A generated book page can shift a rendered document fragment under its local article depth:

exampleEmbeddedFragment :: Text -> Html
exampleEmbeddedFragment renderedHtml = [hsx|
    <article class="pattern-doc">
        {preEscapedToHtml (shiftHtmlHeadings (headingLevel 4) renderedHtml)}
    </article>
|]
Usage examples

Use the explicit heading renderer when a helper receives the surrounding level from its caller:

exampleSectionWithConfigurableHeading :: HeadingLevel -> Text -> Html -> Html
exampleSectionWithConfigurableHeading level title body = [hsx|
    <section>
        {renderHeading level title}
        {body}
    </section>
|]

Use childHeadingLevel when a component introduces a real subsection:

exampleNestedSection :: HeadingLevel -> Html
exampleNestedSection parentLevel = [hsx|
    <section>
        {renderHeading parentLevel "Items"}
        <section>
            {renderHeading (childHeadingLevel parentLevel) "Archived items"}
            <p>No archived items.</p>
        </section>
    </section>
|]
Standalone checks

This module compiles standalone with IHP's view prelude. In a project that uses the pattern, verify:

  • The rendered page has one page-level H1.
  • Visible headings descend without skipped levels.
  • Non-section prompts, card titles, form-group labels, and result labels do not appear in the heading list.
  • Embedded generated fragments are normalized at the embedding boundary.
  • Deeply nested generated headings cap at H6 rather than producing invalid heading tags.

Accessibility Landmarks

Raw source: Patterns/Views/AccessibilityLandmarks.lhs

Pattern intent and mechanism

Expose the page's semantic regions through stable HTML landmarks without tying those landmarks to layout mechanics. A page should have one primary <main> landmark, repeated chrome should stay outside it, and repeated navigation regions should carry explicit labels.

The recurring failure is to put a landmark on whichever element already happens to own scrolling, flex layout, sticky footer behavior, or page padding. That couples two independent responsibilities:

  • Landmark semantics: what region is this for assistive technology?
  • Layout mechanics: which element owns padding, scrolling, flex, or sticky footer behavior?

Those responsibilities may live on the same element when that is natural, but this should be an explicit choice. If the scroll or sticky-footer container also contains the footer, modal mounts, or global navigation, it is not the page's primary content landmark and should not be <main>.

The baseline shape:

<body>
  <a class="skip-link" href="#main-content">Skip to main content</a>
  <header>...</header>
  <div class="page-scroll-shell">
    <main id="main-content" tabindex="-1">...</main>
  </div>
  <footer>...</footer>
  <div id="modal-container"></div>
</body>

The scroll shell is a plain <div> because its job is mechanical. The <main> wraps only the page's primary content. The footer remains exposed as contentinfo; the modal mount remains outside the main content flow.

Project-specific notes and rollout guidance

Start with the application layout, not individual pages. Most landmark problems are introduced once in the shell and then repeated everywhere.

Rollout checks:

  1. Find the outer layout wrapper used by normal pages.
  2. Identify global chrome: header, navbar, footer, modal mounts, toast mounts, live-region mounts, and other cross-page infrastructure.
  3. Put exactly the primary page content in one <main id="main-content" tabindex="-1">.
  4. Keep layout-only wrappers as <div> unless they also have a semantic region role.
  5. Add a skip link when keyboard users must traverse repeated chrome before reaching page content. The target must be programmatically focusable so activation moves keyboard focus, not only scroll position.
  6. Label repeated navigation landmarks with aria-label, especially desktop and mobile variants of the same table of contents.

Host evidence from seshn:

The concrete host problem was a sticky-footer / scroll-container shell that was tempting to mark as <main>, even though it also scoped footer and modal infrastructure. The reusable lesson is not the specific markup but the separation: sticky-footer mechanics stay on a plain container; <main> moves to the inner primary-content element.

Host evidence from the ordng IHP host:

The local layout originally used a plain .container-fluid around all rendered content. The book page also had several nested <nav> elements for the table of contents. The appropriate local shape is one explicit <main> in the layout and only the actual desktop/mobile table-of-contents regions as named navigation landmarks; nested navigation lists can remain ordinary .nav containers.

Supporting snippets

Skip-link CSS:

.skip-link {
  position: absolute;
  left: 0.75rem;
  top: 0.75rem;
  z-index: 1100;
  transform: translateY(-150%);
  transition: transform 0.15s ease;
}

.skip-link:focus {
  transform: translateY(0);
}

The exact visual treatment is project-specific. The invariant is that the link is first in the body, points at the main landmark, becomes visible on focus, and lands on a focusable target such as <main tabindex="-1">.

Repeated navigation landmarks:

<nav aria-label="Primary navigation">...</nav>
<nav aria-label="Pattern table of contents">...</nav>

Do not turn every nested .nav class into a landmark. Bootstrap's .nav class is a styling hook; the semantic <nav> element should be reserved for actual navigation regions that are useful in the landmark list.

Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Views.AccessibilityLandmarks where

import IHP.ViewPrelude

-- | Configuration for the page's primary content landmark.
--
-- Keep this small: the pattern is about the semantic seam, not a full layout
-- DSL. Header, footer, and modal mounts are passed separately by the local
-- layout renderer.
data PageLandmarkConfig = PageLandmarkConfig
    { mainId :: Text
    , mainClasses :: Text
    , skipLinkLabel :: Maybe Text
    }

-- | Conventional baseline used by the examples.
defaultPageLandmarkConfig :: PageLandmarkConfig
defaultPageLandmarkConfig = PageLandmarkConfig
    { mainId = "main-content"
    , mainClasses = "container-fluid mt-4"
    , skipLinkLabel = Just "Skip to main content"
    }

-- | Render a complete landmark shell around already-rendered local regions.
--
-- Header, footer, and modal mounts stay outside the primary content landmark.
renderPageLandmarks :: PageLandmarkConfig -> Html -> Html -> Html -> Html -> Html
renderPageLandmarks config headerHtml mainHtml footerHtml modalMountHtml =
    renderSkipLink (mainId config) (skipLinkLabel config)
    <> headerHtml
    <> renderMainLandmark config mainHtml
    <> footerHtml
    <> modalMountHtml

-- | Render only the primary content landmark.
renderMainLandmark :: PageLandmarkConfig -> Html -> Html
renderMainLandmark config inner = [hsx|
    <main id={mainId config} class={mainClasses config} tabindex="-1">
        {inner}
    </main>
|]

-- | Render a focus-visible skip link for keyboard users.
renderSkipLink :: Text -> Maybe Text -> Html
renderSkipLink _ Nothing = mempty
renderSkipLink targetId (Just label) = [hsx|
    <a class="skip-link" href={"#" <> targetId}>{label}</a>
|]

-- | Render a named navigation landmark.
renderNamedNavigation :: Text -> Html -> Html
renderNamedNavigation label inner = [hsx|
    <nav aria-label={label}>{inner}</nav>
|]

-- | Render a named navigation landmark while preserving local styling classes.
renderNamedNavigationWithClasses :: Text -> Text -> Html -> Html
renderNamedNavigationWithClasses label classes inner = [hsx|
    <nav class={classes} aria-label={label}>{inner}</nav>
|]
Helper implementation examples

A local IHP layout can keep its existing container class while making the primary landmark explicit:

exampleDefaultLayoutBody :: Html -> Html
exampleDefaultLayoutBody inner = [hsx|
    <body>
        {renderPageLandmarks defaultPageLandmarkConfig mempty inner mempty mempty}
    </body>
|]

A layout with chrome keeps that chrome outside <main>:

exampleShellWithChrome :: Html -> Html -> Html -> Html
exampleShellWithChrome headerHtml pageHtml footerHtml = [hsx|
    <body>
        {renderPageLandmarks defaultPageLandmarkConfig headerHtml pageHtml footerHtml modalMount}
    </body>
|]
  where
    modalMount :: Html
    modalMount = [hsx|<div id="modal-container"></div>|]

A table-of-contents renderer should expose the outer region as a landmark but keep nested list structure as styled containers:

exampleTableOfContents :: Html -> Html
exampleTableOfContents tocItems =
    renderNamedNavigationWithClasses
        "Pattern table of contents"
        "pattern-nav-sidebar"
        [hsx|
            <div class="nav nav-pills flex-column">
                {tocItems}
            </div>
        |]
Usage examples

Use a custom main class when the host project separates the scroll shell from content padding:

exampleSeparatedScrollShell :: Html -> Html
exampleSeparatedScrollShell pageContent = [hsx|
    <div class="page-scroll-shell">
        {renderMainLandmark config pageContent}
    </div>
|]
  where
    config :: PageLandmarkConfig
    config = defaultPageLandmarkConfig { mainClasses = "content-padded" }

Use named navigation for repeated desktop and mobile table-of-contents regions:

exampleRepeatedTocLandmarks :: Html -> Html -> Html
exampleRepeatedTocLandmarks desktopToc mobileToc = [hsx|
    {renderNamedNavigationWithClasses "Pattern table of contents" "pattern-nav-sidebar d-none d-lg-block" desktopToc}
    {renderNamedNavigationWithClasses "Pattern table of contents" "pattern-nav-sidebar d-lg-none" mobileToc}
|]
Standalone checks

This module compiles standalone with IHP's view prelude. In a project that uses the pattern, verify:

  • The rendered page has exactly one <main> landmark.
  • Header, footer, modal mounts, toast mounts, and global live-region mounts are outside <main> unless they are genuinely page-primary content.
  • Repeated navigation landmarks have useful labels.
  • Keyboard focus can reach the skip link and activation moves focus to the skip link target, not merely scroll position.
  • Changing scroll or sticky-footer wrappers does not change landmark semantics.

Accessibility Semantic Color

Raw source: Patterns/Views/AccessibilitySemanticColor.lhs

Pattern intent and mechanism

Keep color decisions semantic and accessible at the same time. A color token should say what it means, a control should have one text-color source, and every interactive state should remain legible.

This is not a generic CSS accessibility guide. WCAG and browser tooling own the mechanics of contrast measurement. The ordng pattern boundary is narrower: how IHP/HSX components and their CSS tokens preserve semantic meaning while meeting contrast requirements.

Ten rules recur:

  • Keep defended brand colors fixed. If a brand or identity color is the deliberate anchor, fix contrast by changing the paired foreground, icon, or adjacent surface, not the brand token.
  • Preserve muted meaning. When a neutral color means "de-emphasized" or "not-yet-relevant", choose the lightest passing token rather than darkening it until the meaning disappears.
  • Check translucent hover fills after compositing. A 20% accent fill on a pale page is a new mixed color. Check text contrast against that composited hover surface, not against the page background or the raw accent token.
  • Treat -strong tokens as dual-duty when they are used that way. A token that is both outline text on a pale background and loud hover fill must satisfy both jobs; it may need to be darker than a naive hover shade.
  • Keep fill and foreground tokens separate. A color that works as a tinted cell background with dark text does not automatically work as colored text.
  • Use one text-color source per semantic. A control's text and icons inherit one semantic foreground across normal, hover, focus, active, and disabled states.
  • Let icons follow currentColor. Icons inside controls inherit the control's color; global link or icon rules must not capture them.
  • Fade unavailable actions by tone and label presence; do not desaturate them into another semantic. A disabled control or static unavailable placeholder should read as the same future action with lower affordance, not as an unrelated brown or grey element. A single opacity is rarely right: icon-only controls/placeholders can fade deepest; labeled filled controls have a floor because uniform opacity can erode their internal text-on-fill contrast; labeled outlined controls also have a floor because their foreground label can become too faint.
  • Document deliberate below-normal-text-AA signal policy. Use it only when the signal text still meets the WCAG large-text threshold, is actually rendered large or bold enough to qualify, has a second non-color channel, and explains the exception at the token.
  • Reassert signal foregrounds over semantic descendants. A loud or inverted signal background that sets a paired foreground must override every semantically colored descendant text. A child foreground that is legible on its normal surface can fail inside the signal context.

These rules complement Patterns/Htmx/Primitives/SemanticValueAxes.lhs: that pattern separates domain meaning, salience, and actionability. This pattern covers the CSS-token and cascade side once those semantic axes have been chosen. For accessible names and native action-vs-navigation element choice, use AccessibleControlNames.

Project-specific notes and rollout guidance

Start from the rendered component, not from the palette file. A palette can be internally tidy while a concrete hover state or icon still fails contrast.

Rollout checks:

  1. Identify the semantic role of the color: brand, neutral-muted, warning, danger, success, selected, disabled, or supporting chrome.
  2. Decide whether the foreground or background side of the pair is fixed. Brand anchors usually are; generic neutrals often are not.
  3. Pick the minimum token change that restores contrast without changing the semantic role.
  4. Put foreground color in one place for the component semantic.
  5. Make icons inherit with currentColor / color: inherit.
  6. Check normal, hover, focus, active, selected, disabled, and static unavailable placeholder states.
  7. For translucent hover fills, check the foreground against the composited color that users actually see.
  8. If a token is used both as outline text and hover fill, verify both uses.
  9. When repairing one composited-hover-fill or dual-duty -strong token, sweep the whole sibling family. Other outline-* variants share the mechanism and can hide latent failures.
  10. If a signal pair falls below normal-text AA, allow it only when it still meets the WCAG large-text threshold, the rendered text qualifies as large or bold, a second channel exists, and the token comment documents the policy.
  11. In every loud or inverted signal context, reassert the signal foreground over all semantically colored descendant text.
  12. Record audit context when a Lighthouse or axe number is cited.

Host evidence from seshn:

  • A defended chamois / sheet-music-paper identity color stayed fixed; contrast was restored by adjusting the paired foreground rather than mutating the brand color.
  • Muted warm greys were tuned to just clear AA so they still read as muted.
  • .btn-light moved to one text-color source; icons and states inherit it.
  • A table edit-button icon became invisible when cascade leakage made the icon strawberry on a strawberry hover background. The fix excluded .btn from table-link color inheritance and reasserted icon inheritance inside buttons.
  • Disabled upload/provider controls and static unavailable edit/delete placeholders kept their hue and used opacity rather than grayscale/desaturation, so unavailable-to-enabled still read as the same action gaining affordance. Icon-only edit/delete controls/placeholders could fade deeply because no text label had to remain readable. Filled labeled buttons and labeled outlined actions both needed a moderate floor because uniform opacity weakens either the internal text-on-fill pair or the foreground label itself.
  • A translucent outline-button hover fill had to be checked after compositing over the page background: an orange token passed on the pale background but failed against its 20% amber hover surface.
  • -strong tokens did double duty as readable outline text and loud hover fill, which forced deeper values than a naive hover shade.
  • A deliberate red/yellow signal pair stayed below normal-text AA but still met the large-text threshold; it was documented as a large/bold emergency-style signal with a second non-color channel.
  • A matrix color that worked as a tinted cell fill with dark text did not work as colored text; fill suitability was not treated as foreground suitability.
  • Dedicated blue/brown foreground tokens were legible on chamois but leaked into signal-red blocked-total cells at roughly 2:1 until the signal context reasserted its paired yellow foreground over every semantic-colored child.

Host evidence from the ordng IHP host:

The local ordng host has project-owned palette tokens in static/colors.css and component rules in static/app.css. This slice records the reusable principles but does not claim a local Lighthouse/contrast pass yet; measured contrast findings should be added only after an audit with build/profile/auth context.

Supporting snippets

Token names should describe semantic use, not only visual appearance:

:root {
  --control-neutral-muted-bg: var(--warm-grey-light);
  --control-neutral-muted-text: var(--brown-full);
  --control-neutral-muted-hover-bg: var(--warm-grey-lightest);
}

Dual-duty -strong tokens should document both jobs:

:root {
  /* Used as outline text on pale surfaces and as loud hover fill.
     Must pass both uses; darker than a naive hover shade on purpose. */
  --status-warning-strong: #8a4f00;
}

Translucent hover fills must be checked after compositing:

.btn-outline-warning {
  color: var(--status-warning-strong);
  background: transparent;
}

.btn-outline-warning:hover,
.btn-outline-warning:focus {
  color: var(--status-warning-strong);
  /* Check contrast against this 20% fill composited over the page background. */
  background: color-mix(in srgb, var(--status-warning-strong) 20%, transparent);
}

A fill token is not automatically a foreground token:

.matrix-cell--available {
  background: var(--status-available-fill);
  color: var(--matrix-cell-text);
}

.matrix-label--available {
  /* Separate token: the fill token may fail when used as text. */
  color: var(--status-available-text);
}

A deliberate below-normal-text-AA signal policy must say so at the token:

:root {
  /* Emergency signal pair: below normal-text AA but above the WCAG
     large-text threshold. Use only for text that actually qualifies as
     large/bold, and never as the only cue. */
  --signal-emergency-bg: var(--signal-red);
  --signal-emergency-text: var(--signal-yellow);
}

Signal contexts must reassert their paired foreground over semantic descendants:

.blocked-total-signal {
  color: var(--signal-emergency-text);
  background: var(--signal-emergency-bg);
}

.blocked-total-signal :is(.semantic-text, .matrix-label, [data-semantic-color]) {
  color: inherit;
}

.blocked-total-signal .bi,
.blocked-total-signal svg {
  color: currentColor;
  fill: currentColor;
}

Do not enumerate only the child classes that failed today. Any descendant text whose color is chosen by semantic token logic must be pulled back to the signal foreground inside the signal context.

One foreground source per semantic control:

.btn-neutral-muted {
  color: var(--control-neutral-muted-text);
  background: var(--control-neutral-muted-bg);
  border-color: var(--control-neutral-muted-bg);
}

.btn-neutral-muted:hover,
.btn-neutral-muted:focus,
.btn-neutral-muted:active {
  color: var(--control-neutral-muted-text);
  background: var(--control-neutral-muted-hover-bg);
  border-color: var(--control-neutral-muted-hover-bg);
}

.btn-neutral-muted .bi,
.btn-neutral-muted svg {
  color: currentColor;
  fill: currentColor;
}

Cascade isolation for links around controls:

.table a:not(.btn):not(:hover):not(:focus) {
  color: inherit;
}

.btn .bi,
.btn svg {
  color: inherit;
  fill: currentColor;
}

Unavailable actions keep semantic hue and lose affordance. Dimming depth is part of the affordance projection, not a single global constant:

.btn:disabled,
.btn.disabled {
  filter: none;
}

.is-disabled-filled-labeled,
.is-disabled-outlined-labeled {
  opacity: 0.5; /* keep text labels readable */
}

.is-disabled-icon-only {
  opacity: 0.3; /* no text-label contrast floor */
}

Do not use grayscale filters for unavailable branded controls unless grayscale is itself the intended semantic. Do not key dimming to a color class such as .btn-secondary.disabled; key it to visual tone, label presence, and actionability.

Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Views.AccessibilitySemanticColor where

import Data.Text (Text)
import Prelude

-- | Which side of a color pair is semantically fixed.
--
-- Brand anchors are side-specific: a brand background and a brand foreground
-- require opposite repair moves when contrast fails.
data ContrastAnchor
    = BrandBackgroundFixed
    | BrandForegroundFixed
    | BackgroundFixed
    | ForegroundFixed
    | NoFixedAnchor
    deriving (Eq, Show)

-- | Which side should be adjusted first when contrast fails.
data ContrastAdjustment
    = AdjustForeground
    | AdjustBackground
    | AdjustEitherSide
    deriving (Eq, Show)

-- | Decide the first adjustment target from the semantic anchor.
--
-- This does not calculate contrast. It records the design decision before a
-- concrete WCAG check chooses the exact token.
chooseContrastAdjustment :: ContrastAnchor -> ContrastAdjustment
chooseContrastAdjustment BrandBackgroundFixed = AdjustForeground
chooseContrastAdjustment BrandForegroundFixed = AdjustBackground
chooseContrastAdjustment BackgroundFixed = AdjustForeground
chooseContrastAdjustment ForegroundFixed = AdjustBackground
chooseContrastAdjustment NoFixedAnchor = AdjustEitherSide

-- | Whether one token is used for one visual job or deliberately does two jobs.
data TokenDuty
    = SingleDutyToken
    | DualDutyStrongToken
    deriving (Eq, Show)

-- | Whether the surrounding surface is ordinary or a loud/inverted signal
-- context. This is independent of the parent pair's contrast threshold.
data SignalContext
    = NormalColorContext
    | LoudSignalContext
    | InvertedSignalContext
    deriving (Eq, Show)

-- | Whether a signal context should let descendants keep their normal semantic
-- foregrounds or reassert the signal foreground.
data SignalForegroundPolicy
    = KeepDescendantSemanticForegrounds
    | ReassertSignalForeground
    deriving (Eq, Show)

-- | Loud or inverted signal contexts should reassert their foreground over
-- semantic descendants. Normal contexts can leave descendants alone.
requiresSignalForegroundReassertion :: SignalContext -> SignalForegroundPolicy
requiresSignalForegroundReassertion NormalColorContext = KeepDescendantSemanticForegrounds
requiresSignalForegroundReassertion LoudSignalContext = ReassertSignalForeground
requiresSignalForegroundReassertion InvertedSignalContext = ReassertSignalForeground

-- | Which text contrast threshold applies to a deliberate signal pair.
--
-- The second constructor is not a waiver. It still requires the WCAG large-text
-- threshold, a rendered large/bold text treatment, and a second non-color cue.
data ContrastPolicy
    = MustMeetNormalTextAA
    | MustMeetLargeTextAAWithSecondCue
    deriving (Eq, Show)

-- | Semantic role of a rendered color token.
data ColorSemanticRole
    = BrandIdentity
    | NeutralMuted
    | NeutralDefault
    | PositiveSignal
    | NegativeSignal
    | WarningSignal
    | DisabledAffordance
    deriving (Eq, Show, Enum, Bounded)

-- | CSS class stems used by local renderers.
semanticColorClassStem :: ColorSemanticRole -> Text
semanticColorClassStem BrandIdentity = "brand"
semanticColorClassStem NeutralMuted = "neutral-muted"
semanticColorClassStem NeutralDefault = "neutral"
semanticColorClassStem PositiveSignal = "positive"
semanticColorClassStem NegativeSignal = "negative"
semanticColorClassStem WarningSignal = "warning"
semanticColorClassStem DisabledAffordance = "disabled-affordance"

-- | A semantic foreground/background pair for a control state.
data SemanticColorPair = SemanticColorPair
    { foregroundToken :: Text
    , backgroundToken :: Text
    }
    deriving (Eq, Show)

-- | A complete state set for one semantic control.
--
-- Keeping the foreground token explicit in every state makes drift visible in
-- reviews. In CSS the same token should normally be assigned once and inherited.
data SemanticControlPalette = SemanticControlPalette
    { normalPair :: SemanticColorPair
    , hoverPair :: SemanticColorPair
    , focusPair :: SemanticColorPair
    , activePair :: SemanticColorPair
    , disabledPair :: SemanticColorPair
    }
    deriving (Eq, Show)

-- | True when every state uses the same foreground token.
usesSingleForegroundToken :: SemanticControlPalette -> Bool
usesSingleForegroundToken palette =
    foregroundToken (normalPair palette) == foregroundToken (hoverPair palette)
        && foregroundToken (normalPair palette) == foregroundToken (focusPair palette)
        && foregroundToken (normalPair palette) == foregroundToken (activePair palette)
        && foregroundToken (normalPair palette) == foregroundToken (disabledPair palette)
Helper implementation examples

A local renderer can map semantic roles to CSS class names while leaving exact color values in CSS tokens:

semanticButtonClass :: ColorSemanticRole -> Text
semanticButtonClass role = "btn-" <> semanticColorClassStem role

A palette check can flag foreground drift before a browser audit checks actual contrast ratios:

neutralMutedPalette :: SemanticControlPalette
neutralMutedPalette = SemanticControlPalette
    { normalPair = SemanticColorPair "--control-neutral-muted-text" "--control-neutral-muted-bg"
    , hoverPair = SemanticColorPair "--control-neutral-muted-text" "--control-neutral-muted-hover-bg"
    , focusPair = SemanticColorPair "--control-neutral-muted-text" "--control-neutral-muted-hover-bg"
    , activePair = SemanticColorPair "--control-neutral-muted-text" "--control-neutral-muted-bg"
    , disabledPair = SemanticColorPair "--control-neutral-muted-text" "--control-neutral-muted-bg"
    }

neutralMutedHasStableForeground :: Bool
neutralMutedHasStableForeground = usesSingleForegroundToken neutralMutedPalette
Usage examples

Use chooseContrastAdjustment before editing palette tokens:

brandBackgroundContrastAdjustment :: ContrastAdjustment
brandBackgroundContrastAdjustment = chooseContrastAdjustment BrandBackgroundFixed

brandForegroundContrastAdjustment :: ContrastAdjustment
brandForegroundContrastAdjustment = chooseContrastAdjustment BrandForegroundFixed

Use semantic class stems to keep HSX helpers grepable without baking color values into Haskell:

exampleStatusClass :: Text
exampleStatusClass = semanticButtonClass NeutralMuted
Standalone checks

This module compiles standalone. In a project that uses the pattern, verify:

  • Every semantic control has one foreground source across normal, hover, focus, active, disabled, and static unavailable placeholder states.
  • Icons inside controls use currentColor / inherited color.
  • Global link or icon rules do not override icons inside buttons.
  • Muted tokens still read as muted after meeting contrast.
  • Translucent hover fills are checked against their composited color.
  • Dual-duty -strong tokens pass as both outline text and hover/fill accents.
  • Fill tokens are not reused as foreground tokens unless that foreground use is separately checked.
  • Repairing one composited-hover-fill or dual-duty -strong token triggers a sweep of the sibling family.
  • Any signal pair below normal-text AA still meets the WCAG large-text threshold, is limited to rendered text that qualifies as large/bold, is documented at the token, and has a second non-color cue.
  • Every signal or inverted background overrides all semantically colored descendant text, not just the listed child classes that failed first.
  • Disabled controls and static unavailable placeholders keep their semantic hue and lose affordance through tone- and label-presence-dependent opacity or equivalent state treatment, not grayscale/desaturation. Labeled controls keep enough opacity for text contrast; icon-only controls/placeholders may fade deeper.
  • Any Lighthouse/axe contrast number records build, browser profile, auth state, viewport, and failing selectors.

Accessible Control Names

Raw source: Patterns/Views/AccessibleControlNames.lhs

Pattern intent and mechanism

Give every interactive control the accessible name that matches the operation it actually performs. In IHP/HSX code, icon-only controls and shared action helpers are the usual failure points: the visual icon is present, but the rendered <button> or <a> has no programmatic name, has a generic name such as "Close", or has a stale name copied from another entity.

The pattern boundary is deliberately narrow. This is not a generic ARIA naming tutorial. ordng captures how IHP/HSX render helpers and a local i18n vocabulary should produce accessible control names, choose native elements, and avoid duplicate names.

The recurring rules:

  1. Every icon-only control needs an accessible name. Use aria-label or visually hidden text on the control itself.
  2. Name the operation, not the visual treatment. Reopening a picker is "Choose another member", not "Edit user".
  3. Include target context when several same-shaped controls coexist. Use "Close artist input" rather than a generic "Close" for a repeated field dismiss button.
  4. Generic verbs are acceptable only when the surrounding component makes the target unambiguous, for example the single close button in a modal.
  5. Use localized control vocabulary in button/control form. Do not hardcode English labels in helpers that otherwise render localized UI. Reject blank localized labels before rendering; aria-label="" is still unnamed.
  6. Decorative icons inside a named control are hidden with aria-hidden="true". Their color follows the control through currentColor; see AccessibilitySemanticColor.
  7. Mutating actions are <button> elements or form submits, including HTMX actions. Navigation is an <a href> with a real target; reject empty, placeholder, and JavaScript pseudo-targets in shared helpers.
  8. Do not double-name controls. If a control already has visible text or a <label for> relationship, do not add aria-label; it overrides the visible or labeled name and creates drift.
  9. Centralize names in shared render helpers. Table actions, header actions, close buttons, media buttons, and picker controls should not each hand-roll naming.
  10. Follow actionability. A real disabled action remains a named disabled <button>. A pure alignment placeholder is a dimmed non-control hidden from assistive technology and receives no name; see SemanticValueAxes.
Project-specific notes and rollout guidance

Start from rendered controls, then trace them back to helpers. Most missing names are not isolated call-site bugs; they come from one icon-button helper that is reused in every table row.

Rollout checks:

  1. Find icon-only <button> and <a> elements in tables, cards, headers, media controls, dropdown toggles, and picker widgets.
  2. For each control, ask what operation it actually performs and whether the current name says that operation.
  3. Replace vague or stale names with localized operation labels that include the target context when needed.
  4. Remove redundant aria-label from controls that already have visible text or a proper <label for> relationship.
  5. Mark inner decorative icons as aria-hidden="true".
  6. Move naming into the shared helper that creates the repeated control.
  7. Check permission-gated controls against SemanticValueAxes: disabled exposed controls stay named; static unavailable placeholders are hidden from assistive technology.

Host evidence from seshn:

  • Live and disabled edit/delete/remove icon controls had no names. They were fixed centrally in table/header action helpers with localized CRUD labels; inner icons became decorative.
  • A picker reopen button was named "Edit User" although it reopened selection and the app-facing entity was "Member". The name became "Choose another Member" / "anderes Mitglied wählen".
  • A repeated field dismiss control regressed from "Close Artist Input Field" to generic "Close". The contextual localized name was restored.
  • Three <select> controls had redundant English aria-label attributes that overrode existing localized <label for> names. The labels were removed, not translated.
  • The user-menu avatar toggle had alt="" and no control name. It was named with the member name because the avatar was the only visible content.
  • New localized control verbs such as close/cancel were defined in button form and followed the existing CRUD verb casing convention.
Supporting snippets

Icon-only control with a localized accessible name and decorative icon:

<button type="button" aria-label="Edit membership">
  <span class="bi bi-pencil" aria-hidden="true"></span>
</button>

Visible text button. Do not add a duplicate aria-label:

<button type="submit">Save</button>

Labeled field. The <label for> already supplies the control name:

<label for="member-role">Role</label>
<select id="member-role" name="role">...</select>

Action versus navigation:

<button type="button" hx-delete="/sessions/current">Log out</button>
<a href="/members/1">Open member profile</a>

Static unavailable placeholder. It is visible for alignment only and hidden from assistive technology:

<span class="btn btn-outline-secondary is-disabled-icon-only"
      aria-hidden="true">
  <span class="bi bi-pencil" aria-hidden="true"></span>
</span>

Localized control vocabulary should store button/control labels directly. For German standalone verbs, prefer the button form used by neighboring CRUD labels, for example bearbeiten, löschen, schließen, abbrechen. Do not derive those labels by lowercasing sentence labels at the call site. Route helper input through a non-empty label constructor before rendering.

Navigation helpers should receive a route-typed target where the framework makes that practical. When a helper boundary only has Text, wrap it in a smart constructor that rejects fake targets such as "", "#", and JavaScript pseudo-links.

Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Views.AccessibleControlNames
    ( AccessibleName(..)
    , NameSpecificity(..)
    , ControlElement(..)
    , ControlOperation(..)
    , LocalizedControlLabel
    , localizedControlLabel
    , requiredLocalizedControlLabel
    , controlLabelText
    , NavigationTarget
    , navigationTarget
    , navigationTargetText
    , elementForOperation
    , ariaLabelText
    , isDecorativeName
    , isSpecificEnough
    , nameForAffordance
    , nameForActionability
    , renderIconOnlyButton
    , renderVisibleTextButton
    , renderNavigationLink
    , renderUnavailableIconPlaceholder
    ) where

import qualified Data.Text as Text
import IHP.ViewPrelude
import qualified Prelude as P
import Patterns.Htmx.Primitives.SemanticValueAxes
    ( Actionability(..)
    , ElementAffordance(..)
    , LabelPresence(..)
    , VisualTone(..)
    , actionAffordanceForTone
    , dimLevel
    , dimLevelClass
    )

-- | Where a rendered control's accessible name comes from.
data AccessibleName
    = FromVisibleLabel
      -- ^ Visible text or a <label for> relationship already names it.
    | FromAriaLabel LocalizedControlLabel
      -- ^ Icon-only or ambiguous control; supply a localized non-empty name.
    | Decorative
      -- ^ Not exposed to assistive technology.
    deriving (Eq, Show)

-- | Whether a generic verb is safe at this call site.
data NameSpecificity
    = GenericName
      -- ^ Allowed only when the component has one obvious target.
    | ContextualName
      -- ^ Carries the target, entity, or field context.
    deriving (Eq, Show, Enum, Bounded)

-- | Native element family selected from the rendered operation.
data ControlElement
    = NativeButton
    | NativeLink
    | NonControlPlaceholder
    deriving (Eq, Show, Enum, Bounded)

-- | Real navigation target. The constructor is intentionally not exported:
-- use 'navigationTarget' so helper code cannot render "", "#", or JavaScript
-- pseudo-targets as navigation.
newtype NavigationTarget = NavigationTarget
    { navigationTargetText :: Text
    }
    deriving (Eq, Show)

-- | Operation shape before rendering.
data ControlOperation
    = MutatingAction
    | NavigationAction NavigationTarget
    | AlignmentOnlyPlaceholder
    deriving (Eq, Show)

-- | Localized control label in the form used directly on a button/control.
--
-- The constructor is intentionally not exported: use 'localizedControlLabel'
-- so icon-only controls cannot accidentally render aria-label="".
newtype LocalizedControlLabel = LocalizedControlLabel
    { controlLabelText :: Text
    }
    deriving (Eq, Show)

-- | Build a non-empty localized control label after trimming whitespace.
localizedControlLabel :: Text -> Maybe LocalizedControlLabel
localizedControlLabel rawLabel =
    let stripped = Text.strip rawLabel
    in if Text.null stripped
        then Nothing
        else Just (LocalizedControlLabel stripped)

-- | Fail at vocabulary/config construction time when a required control label is
-- blank. Use this at trusted localization boundaries, not for user input.
requiredLocalizedControlLabel :: Text -> LocalizedControlLabel
requiredLocalizedControlLabel rawLabel =
    case localizedControlLabel rawLabel of
        Just label -> label
        Nothing -> P.error "Accessible control label must not be blank"

-- | Build a real navigation target after trimming whitespace.
--
-- Reject common fake targets. Route-typed URLs are preferable in application
-- code; this newtype is the minimal reusable guard for plain Text snippets.
navigationTarget :: Text -> Maybe NavigationTarget
navigationTarget rawTarget =
    let stripped = Text.strip rawTarget
        lowered = Text.toLower stripped
    in if Text.null stripped
        || stripped == "#"
        || "javascript:" `Text.isPrefixOf` lowered
        then Nothing
        else Just (NavigationTarget stripped)

-- | Choose the native element from the operation, not from visual styling.
elementForOperation :: ControlOperation -> ControlElement
elementForOperation MutatingAction = NativeButton
elementForOperation (NavigationAction _) = NativeLink
elementForOperation AlignmentOnlyPlaceholder = NonControlPlaceholder

-- | Controls with visible text or label relationships should not receive a
-- duplicate aria-label.
ariaLabelText :: AccessibleName -> Maybe Text
ariaLabelText FromVisibleLabel = Nothing
ariaLabelText (FromAriaLabel label) = Just (controlLabelText label)
ariaLabelText Decorative = Nothing

-- | Decorative content is hidden from assistive technology.
isDecorativeName :: AccessibleName -> Bool
isDecorativeName Decorative = True
isDecorativeName _ = False

-- | Generic names are acceptable only when the component context is
-- unambiguous. Repeated row/header/action controls should be contextual.
isSpecificEnough :: NameSpecificity -> Bool -> Bool
isSpecificEnough GenericName isSingleObviousTarget = isSingleObviousTarget
isSpecificEnough ContextualName _ = True

-- | Project an accessible-name decision through an element affordance.
--
-- Static unavailable placeholders are hidden from assistive technology and
-- therefore receive no accessible name, even if the live sibling would be named.
nameForAffordance :: ElementAffordance -> AccessibleName -> AccessibleName
nameForAffordance affordance proposedName
    | isHiddenFromAssistiveTech affordance = Decorative
    | otherwise = proposedName

-- | Actionability-level helper for pure actions that use SemanticValueAxes but
-- do not carry DomainSemantic.
nameForActionability :: VisualTone -> LabelPresence -> Actionability -> AccessibleName -> AccessibleName
nameForActionability tone labelPresence action proposedName =
    nameForAffordance (actionAffordanceForTone tone labelPresence action) proposedName
Helper implementation examples

Render an icon-only mutation button. The name comes from localized vocabulary; the icon is decorative:

renderIconOnlyButton :: LocalizedControlLabel -> Text -> Html
renderIconOnlyButton label iconClass = [hsx|
    <button type="button" aria-label={controlLabelText label}>
        <span class={iconClass} aria-hidden="true"></span>
    </button>
|]

Render a visible-text button. It already has a name, so no aria-label is emitted:

renderVisibleTextButton :: LocalizedControlLabel -> Html
renderVisibleTextButton label = [hsx|
    <button type="button">{controlLabelText label}</button>
|]

Render a navigation link only when there is a real target:

renderNavigationLink :: NavigationTarget -> LocalizedControlLabel -> Html
renderNavigationLink target label = [hsx|
    <a href={navigationTargetText target}>{controlLabelText label}</a>
|]

Render a static unavailable icon placeholder. The slot keeps visual alignment, but is not a control and has no accessible name:

renderUnavailableIconPlaceholder :: Text -> Html
renderUnavailableIconPlaceholder iconClass =
    let affordance = actionAffordanceForTone Outlined IconOnlyControl StaticUnavailablePlaceholder
    in [hsx|
        <span class={"btn btn-outline-secondary pe-none user-select-none " <> dimLevelClass (dimLevel affordance)}
              aria-hidden="true">
            <span class={iconClass} aria-hidden="true"></span>
        </span>
    |]
Usage examples

Centralized table action helpers validate required labels at the vocabulary or configuration boundary. A blank control name is a bug; do not erase the control with mempty because that hides the failure and changes the UI.

editActionButton :: Text -> Html
editActionButton memberName =
    renderIconOnlyButton
        (requiredLocalizedControlLabel ("Edit " <> memberName))
        "bi bi-pencil"

deleteActionButton :: Text -> Html
deleteActionButton memberName =
    renderIconOnlyButton
        (requiredLocalizedControlLabel ("Delete " <> memberName))
        "bi bi-trash"

Picker reopen button named for the operation:

chooseAnotherMemberButton :: Html
chooseAnotherMemberButton =
    renderIconOnlyButton
        (requiredLocalizedControlLabel "Choose another member")
        "bi bi-person-plus"

Repeated dismiss button with target context:

closeArtistInputButton :: Html
closeArtistInputButton =
    renderIconOnlyButton
        (requiredLocalizedControlLabel "Close artist input")
        "bi bi-x-lg"

Do not add aria-label to a labeled select:

memberRoleField :: Html
memberRoleField = [hsx|
    <label for="member-role">Role</label>
    <select id="member-role" name="role">
        <option>Owner</option>
        <option>Member</option>
    </select>
|]

Disabled exposed action versus static placeholder:

disabledDeleteButton :: Html
disabledDeleteButton = [hsx|
    <button type="button" disabled aria-disabled="true" aria-label="Delete member">
        <span class="bi bi-trash" aria-hidden="true"></span>
    </button>
|]

unauthorizedDeletePlaceholder :: Html
unauthorizedDeletePlaceholder =
    renderUnavailableIconPlaceholder "bi bi-trash"
Standalone checks
-- Visible labels and label relationships do not get duplicate aria-labels.
_checkAriaLabelProjection :: [(AccessibleName, Maybe Text)]
_checkAriaLabelProjection =
    [ (FromVisibleLabel, ariaLabelText FromVisibleLabel)
    , (FromAriaLabel _checkedLabel, ariaLabelText (FromAriaLabel _checkedLabel))
    , (Decorative, ariaLabelText Decorative)
    ]

-- Blank localized labels are rejected before rendering.
_checkBlankLabelRejected :: Bool
_checkBlankLabelRejected = localizedControlLabel "  " == Nothing

-- Static unavailable placeholders are hidden and named as Decorative.
_checkActionabilityNameProjection :: [(Actionability, AccessibleName)]
_checkActionabilityNameProjection =
    [ (action, nameForActionability Outlined IconOnlyControl action (FromAriaLabel _checkedLabel))
    | action <- [Actionable, Disabled, StaticUnavailablePlaceholder]
    ]

-- Native element selection follows operation shape.
_checkNativeElementProjection :: [(ControlOperation, ControlElement)]
_checkNativeElementProjection =
    [ (MutatingAction, elementForOperation MutatingAction)
    , (NavigationAction _checkedTarget, elementForOperation (NavigationAction _checkedTarget))
    , (AlignmentOnlyPlaceholder, elementForOperation AlignmentOnlyPlaceholder)
    ]

-- Fake navigation targets are rejected before rendering.
_checkFakeTargetsRejected :: [Bool]
_checkFakeTargetsRejected =
    [ navigationTarget "" == Nothing
    , navigationTarget "#" == Nothing
    , navigationTarget "javascript:void(0)" == Nothing
    , navigationTarget "javascript: void(0)" == Nothing
    , navigationTarget "javascript:alert(1)" == Nothing
    ]

_checkedLabel :: LocalizedControlLabel
_checkedLabel = requiredLocalizedControlLabel "edit"

_checkedTarget :: NavigationTarget
_checkedTarget =
    case navigationTarget "/members/1" of
        Just target -> target
        Nothing -> P.error "Expected valid navigation target"

Keyboard Operability

Raw source: Patterns/Views/KeyboardOperability.lhs

Pattern intent and mechanism

Make rendered IHP/HSX screens operable by keyboard before adding custom widget logic. The pattern boundary is the view/helper contract that decides whether an operation is rendered as a native keyboard-operable element, a programmatic focus target, or an explicit custom-widget exception.

Keyboard access starts with browser defaults:

  • Tab moves to the next sequential focus target.
  • Shift+Tab moves backward.
  • Enter activates links and buttons.
  • Space activates buttons, checkboxes, and similar native controls.
  • Arrow keys belong to specific native controls and composite widgets, not to arbitrary clickable containers.

On macOS/Safari, users may need Safari's setting for tabbing to all controls (or Option+Tab in older configurations). That is not an application feature; the application responsibility is to expose the right focusable elements and not break native focus behavior.

The recurring rules:

  1. Prefer native keyboard-operable elements: <button>, <a href>, form fields, <details>/<summary>, and framework-backed modal/dialog primitives.
  2. Do not use clickable <div>/<span> elements for operations that can be a button or link. Plain-text inline actions should still be real buttons, styled visually neutral while preserving focus indication.
  3. Whole-row or whole-card click behavior must also expose a real keyboard target. Prefer an explicit link slot over wrapping arbitrary field HTML in an <a>, because field values may already contain links.
  4. Link names should usually be the entity title, not a redundant verb such as "Open". If a card has no title, provide an explicit fallback label.
  5. Keep DOM order aligned with visual reading and interaction order.
  6. Keep focus visible. Do not remove outlines unless the replacement is at least as visible and works in every state.
  7. Use tabindex="-1" on the meaningful element that receives programmatic focus: the real <main>, the labeled validation summary section, a heading, or a non-semantic fragment container only when no better semantic element exists.
  8. Avoid tabindex="0" except for genuine custom-widget focus roots; document their keyboard behavior when used.
  9. Never use positive tabindex values. They create a parallel tab order that diverges from the DOM.
  10. tabindex="-1" does not make a region inert and does not remove focusable descendants from the tab order. Hide the region, use inert with an appropriate fallback, or disable/remove descendant controls explicitly.
  11. Dynamic HTMX swaps should preserve focus, use autofocus on the swapped input when the focus target is obvious, or use the marker-driven focus contract in AccessibilityFocusAndLiveFeedback. Avoid global afterSettle focus selectors.
Project-specific notes and rollout guidance

Run the keyboard pass after semantic structure and native control naming. If a control is the wrong element or unnamed, the tab walkthrough will produce noisy symptoms instead of a clear fix.

Rollout checks:

  1. Start at page load. Press Tab from the top of the page and verify the first useful target is the skip link when repeated chrome exists.
  2. Follow the whole page with Tab and Shift+Tab. The order should match the visible order and should not enter inert placeholders.
  3. Activate every focused control with keyboard only. Buttons need Enter and Space; links need Enter and real href navigation.
  4. Check dropdowns, typeaheads, modals, and inline HTMX controls in their open, closed, loading, and swapped states.
  5. Search rendered markup and CSS for keyboard hazards: onclick on non-native elements, role="button" on links, href="#", positive tabindex, hidden focus outlines, and visual reordering that changes perceived order.
  6. For clickable rows or cards, verify there is a real link in the tab order; do not rely on row click handlers as the only navigation path.
  7. For inline edit controls, replace clickable text spans with neutral-styled buttons and preserve the focus ring.
  8. For custom composite widgets, write down the expected keyboard model before implementing it. If the behavior is a normal disclosure, prefer <details>/<summary> or a button with aria-expanded over inventing a new widget.

Host evidence:

  • seshn accessible-name and action-vs-link fixes showed why native operation choice is a keyboard rule as well as a naming rule: a state-changing HTMX action rendered as a fake link does not carry native button activation semantics.
  • seshn dropdown and permission-aware enum controls showed the need to verify self-swapping widgets with keyboard before adding focus restoration.
  • seshn clickable row/card review confirmed that keyboard users need a real link target even when pointer users can click the whole container; wrapping arbitrary row/card contents in an anchor risks nested anchors when field values already contain links.
  • seshn inline edit review confirmed that clickable text must become a neutral styled <button type="button">, not a clickable span.
  • seshn matrix focus handling showed the HTMX anti-pattern: global afterSettle focus selectors should be replaced by autofocus on the swapped input or the marker-driven focus contract.
  • NestedControlClickIsolation remains relevant for keyboard walkthroughs: parent row/card navigation must not make nested controls unusable or depend on click-only behavior.
  • The local ordng host now has a skip link and a focusable <main> target; this is the baseline page-load keyboard entry path.
Supporting snippets

Native operation choice:

<button type="button" hx-delete="/sessions/current">Log out</button>
<a href="/members/1">Anna Example</a>

Clickable card with an explicit keyboard link slot. The link name is the entity title; the card body is not wrapped in an anchor, so nested links remain valid:

<article class="member-card" data-clickable-card="true">
  <a class="visually-hidden-focusable" href="/members/1">Anna Example</a>
  <h2>Anna Example</h2>
  <p><a href="mailto:anna@example.test">anna@example.test</a></p>
</article>

Plain text inline action as a real button:

<button type="button" class="plain-text-button">Edit</button>

The class may remove button chrome, but it must not hide the focus indicator.

Programmatic focus target, not a normal tab stop:

<main id="main-content" tabindex="-1">...</main>

Disclosure before custom accordion JavaScript:

<details>
  <summary>Advanced filters</summary>
  <!-- filter fields -->
</details>

Custom disclosure trigger only when the native element is not enough:

<button type="button" aria-expanded="false" aria-controls="advanced-filters">
  Advanced filters
</button>
<div id="advanced-filters" hidden>...</div>

Anti-patterns:

<div onclick="save()" tabindex="0">Save</div>
<a href="#" onclick="deleteRow()">Delete</a>
<div tabindex="3">First?</div>
Core reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Patterns.Views.KeyboardOperability
    ( FocusableTargetId
    , focusableTargetIdFromText
    , focusableTargetIdText
    , KeyboardControlLabel
    , keyboardControlLabel
    , keyboardControlLabelText
    , InteractionIntent(..)
    , KeyboardContract(..)
    , FocusPlacement(..)
    , RegionOperability(..)
    , FocusVisibility(..)
    , keyboardContractForIntent
    , isNativeKeyboardContract
    , focusPlacementTabIndex
    , regionNeedsDescendantFocusHandling
    , renderNonSemanticProgrammaticFocusTarget
    , renderNativeDisclosure
    , renderCustomDisclosureButton
    , renderFocusableContainerLinkSlot
    , renderPlainTextButton
    , renderKeyboardChecklistWarning
    ) where

import Data.Char (isSpace)
import Data.Text (Text)
import qualified Data.Text as Text
import IHP.ViewPrelude
import Patterns.Views.AccessibleControlNames
    ( NavigationTarget
    , navigationTargetText
    )

-- | Stable DOM id used for programmatic focus targets.
--
-- The constructor is intentionally not exported. Programmatic focus ids are used
-- by skip links, validation summaries, and HTMX focus markers, so reject blank
-- and whitespace-containing ids at the boundary.
newtype FocusableTargetId = FocusableTargetId
    { focusableTargetIdText :: Text
    }
    deriving (Eq, Show)

focusableTargetIdFromText :: Text -> Maybe FocusableTargetId
focusableTargetIdFromText raw =
    let trimmed = Text.strip raw
    in if Text.null trimmed || Text.any isSpace trimmed
        then Nothing
        else Just (FocusableTargetId trimmed)

-- | Non-empty visible label for interactive keyboard controls.
--
-- The constructor is intentionally not exported so disclosure helpers cannot
-- render an empty <summary> or nameless <button>.
newtype KeyboardControlLabel = KeyboardControlLabel
    { keyboardControlLabelText :: Text
    }
    deriving (Eq, Show)

keyboardControlLabel :: Text -> Maybe KeyboardControlLabel
keyboardControlLabel raw =
    let trimmed = Text.strip raw
    in if Text.null trimmed
        then Nothing
        else Just (KeyboardControlLabel trimmed)

-- | User-facing operation shape before choosing HTML.
data InteractionIntent
    = MutatingAction
    | NavigationAction
    | FormFieldEntry
    | NativeDisclosure
    | ModalDialogInteraction
    | CustomCompositeWidget
    deriving (Eq, Show, Enum, Bounded)

-- | Keyboard contract implied by the rendered shape.
data KeyboardContract
    = ButtonKeyboardContract
    | LinkKeyboardContract
    | FieldKeyboardContract
    | DetailsSummaryKeyboardContract
    | DialogKeyboardContract
    | CustomWidgetKeyboardContract
    deriving (Eq, Show, Enum, Bounded)

-- | Whether an element participates in sequential tab navigation.
data FocusPlacement
    = NativeSequentialFocus
      -- ^ Native interactive element; no tabindex attribute should be added.
    | ProgrammaticFocusOnly
      -- ^ Receives focus from a skip link or script; render tabindex="-1".
    | CustomSequentialFocusRoot
      -- ^ Rare custom-widget root; render tabindex="0" and document behavior.
    deriving (Eq, Show, Enum, Bounded)

-- | Region-level operability state.
--
-- This is deliberately separate from 'FocusPlacement': tabindex="-1" affects
-- one element only; it does not make a whole subtree inert.
data RegionOperability
    = RegionInteractive
    | RegionHiddenFromAll
      -- ^ Use the hidden attribute or do not render the region.
    | RegionInertWithFallback
      -- ^ Use inert where supported and still account for descendant controls
      -- in older/browser-assistive-technology combinations.
    | RegionDescendantsDisabledOrRemoved
      -- ^ Explicitly disable, hide, or remove focusable descendants.
    deriving (Eq, Show, Enum, Bounded)

-- | Local focus-ring policy for review checks.
data FocusVisibility
    = NativeFocusVisible
    | CustomFocusVisible
    | FocusHiddenAntiPattern
    deriving (Eq, Show, Enum, Bounded)

keyboardContractForIntent :: InteractionIntent -> KeyboardContract
keyboardContractForIntent MutatingAction = ButtonKeyboardContract
keyboardContractForIntent NavigationAction = LinkKeyboardContract
keyboardContractForIntent FormFieldEntry = FieldKeyboardContract
keyboardContractForIntent NativeDisclosure = DetailsSummaryKeyboardContract
keyboardContractForIntent ModalDialogInteraction = DialogKeyboardContract
keyboardContractForIntent CustomCompositeWidget = CustomWidgetKeyboardContract

isNativeKeyboardContract :: KeyboardContract -> Bool
isNativeKeyboardContract CustomWidgetKeyboardContract = False
isNativeKeyboardContract _ = True

focusPlacementTabIndex :: FocusPlacement -> Maybe Text
focusPlacementTabIndex NativeSequentialFocus = Nothing
focusPlacementTabIndex ProgrammaticFocusOnly = Just "-1"
focusPlacementTabIndex CustomSequentialFocusRoot = Just "0"

regionNeedsDescendantFocusHandling :: RegionOperability -> Bool
regionNeedsDescendantFocusHandling RegionInteractive = False
regionNeedsDescendantFocusHandling RegionHiddenFromAll = False
regionNeedsDescendantFocusHandling RegionInertWithFallback = True
regionNeedsDescendantFocusHandling RegionDescendantsDisabledOrRemoved = True
Helper implementation examples

Render a non-semantic programmatic focus target only when the target is truly a fragment container with no stronger element available. Do not use this helper for skip links, validation summaries, or post-swap section headings: put tabindex="-1" on the real <main>, the labeled summary section, or the heading itself.

renderNonSemanticProgrammaticFocusTarget :: FocusableTargetId -> Html -> Html
renderNonSemanticProgrammaticFocusTarget targetId inner = [hsx|
    <div id={focusableTargetIdText targetId} tabindex="-1">
        {inner}
    </div>
|]

Render a native disclosure before reaching for custom accordion behavior. The summary is an interactive control, so its visible label is smart-constructed.

renderNativeDisclosure :: KeyboardControlLabel -> Html -> Html
renderNativeDisclosure summaryLabel body = [hsx|
    <details>
        <summary>{keyboardControlLabelText summaryLabel}</summary>
        {body}
    </details>
|]

When a custom disclosure is unavoidable, make the trigger a real button. The actual show/hide behavior belongs to local JavaScript or HTMX; this helper only captures the keyboard-operable trigger contract.

renderCustomDisclosureButton :: KeyboardControlLabel -> FocusableTargetId -> Bool -> Html
renderCustomDisclosureButton label controlsId expanded = [hsx|
    <button type="button"
            aria-expanded={boolAttributeValue expanded}
            aria-controls={focusableTargetIdText controlsId}>
        {keyboardControlLabelText label}
    </button>
|]

boolAttributeValue :: Bool -> Text
boolAttributeValue True = "true"
boolAttributeValue False = "false"

Render the keyboard link slot for a clickable row or card. Use a real target and an entity-title label when possible. If the entity has no title, construct an explicit fallback label at the call site; do not fall back to an empty link or a generic "Open".

renderFocusableContainerLinkSlot :: NavigationTarget -> KeyboardControlLabel -> Html
renderFocusableContainerLinkSlot target label = [hsx|
    <a class="visually-hidden-focusable" href={navigationTargetText target}>
        {keyboardControlLabelText label}
    </a>
|]

Render a plain-text inline action as a real button. The class is intentionally project-owned: local CSS may remove button chrome, but must preserve a visible focus style.

renderPlainTextButton :: KeyboardControlLabel -> Html
renderPlainTextButton label = [hsx|
    <button type="button" class="plain-text-button">
        {keyboardControlLabelText label}
    </button>
|]

Small review marker for examples and local audits. Do not ship this as user UI; use it to make an unresolved keyboard exception grepable while refactoring.

renderKeyboardChecklistWarning :: Text -> Html
renderKeyboardChecklistWarning message = [hsx|
    <p class="text-warning" data-keyboard-operability-warning="true">
        {message}
    </p>
|]
Usage examples

A non-landmark fragment can expose a programmatic focus target for an HTMX focus marker without adding an extra tab stop. Do not use this helper for the page's skip-link target; main-content belongs on the real <main> rendered by AccessibilityLandmarks.

resultsPanelTarget :: Maybe FocusableTargetId
resultsPanelTarget = focusableTargetIdFromText "member-results-heading"

renderResultsPanelFocusTargetExample :: Html -> Html
renderResultsPanelFocusTargetExample content = case resultsPanelTarget of
    Nothing -> mempty
    Just targetId -> renderNonSemanticProgrammaticFocusTarget targetId content

A normal disclosure should use native <details>/<summary> when possible:

advancedFiltersExample :: Html
advancedFiltersExample = case keyboardControlLabel "Advanced filters" of
    Nothing -> mempty
    Just label -> renderNativeDisclosure label [hsx|
        <label for="status-filter">Status</label>
        <select id="status-filter" name="status">
            <option>Open</option>
            <option>Closed</option>
        </select>
    |]

A custom disclosure trigger remains a button, not a clickable div:

customFilterTriggerExample :: Html
customFilterTriggerExample =
    case (keyboardControlLabel "Advanced filters", focusableTargetIdFromText "advanced-filters") of
        (Just label, Just targetId) -> renderCustomDisclosureButton label targetId False
        _ -> mempty
Standalone checks
_checkButtonContract :: KeyboardContract
_checkButtonContract = keyboardContractForIntent MutatingAction

_checkLinkContract :: KeyboardContract
_checkLinkContract = keyboardContractForIntent NavigationAction

_checkProgrammaticTabIndex :: Maybe Text
_checkProgrammaticTabIndex = focusPlacementTabIndex ProgrammaticFocusOnly

_checkRejectsBlankFocusId :: Maybe FocusableTargetId
_checkRejectsBlankFocusId = focusableTargetIdFromText " "

_checkRejectsWhitespaceFocusId :: Maybe FocusableTargetId
_checkRejectsWhitespaceFocusId = focusableTargetIdFromText "main content"

_checkRejectsBlankControlLabel :: Maybe KeyboardControlLabel
_checkRejectsBlankControlLabel = keyboardControlLabel " "

_checkInertNeedsDescendantHandling :: Bool
_checkInertNeedsDescendantHandling = regionNeedsDescendantFocusHandling RegionInertWithFallback

Sectioned Containers

Raw source: Patterns/Views/SectionedContainer.lhs

Pattern intent and mechanism

A single layout container cannot simultaneously provide comfortable reading padding for text and edge-to-edge breakout for tables. Trying to do both in one wrapper forces negative-margin hacks and produces inconsistent alignment between headers, controls, and tabular data.

This pattern replaces the monolithic wide container with two explicit primitives---contentPadded and fullBleed---and a small Section API that composes them so views declare roles instead of wrestling with containers.

Why this matters:

  • Content focus. Text, buttons, and headings stay aligned to a single reading edge (the navbar brand). The eye is not pulled left and right by alternating indents.
  • Visual calm. When a table or card grid sits inside the same padded container as the text above it, the content appears to "jump" inward at the section boundary. Separating the bands keeps each content type at its own natural width without visual friction.
  • Real estate. Tables, cards, and dividers gain the full viewport width for their data. Background colours and horizontal rules extend to the edge, so the component looks intentional rather than accidentally clipped.
  • Predictable alignment. First and last table cells regain padding via CSS so column content still aligns with the text band above. The reader perceives one consistent grid even though the table itself is edge-to-edge.

The visual result is a page built from alternating bands:

PaddingLeft- and right-aligned contentPaddingPaddingH1PaddingPaddingControlsPaddingStylingTable / Cards / …StylingPaddingFooterPadding

Reading the diagram

Each row is a horizontal band. The outer columns ("padding") are the page edge padding (--page-edge-padding); the centre column is the content.

  • Blue rows (content-padded): the content sits inside the padding. Text, buttons, and headings align with the navbar brand.
  • Orange row (full-bleed): the content breaks out of the padding so tables, cards, dividers, or other components extend edge-to-edge. The outer "styling" cells show that background colours and borders continue into the padded zone, while the first and last cells regain padding via CSS so content still aligns with the blue bands above and below. The diagram shows "Table / Cards / …" as examples; other full-bleed components follow the same rule.
  • BodyRole (PaddedBody | FullBleedBody) makes the choice explicit at call sites. Composite helpers (renderIndexSection*, renderEmbeddedTableSection) enforce the invariant that headers and controls are always padded while the body may bleed.

The same padding schema applies to navbar and footer as well. The top "Left- and right-aligned content" legend and the bottom "Footer" row show that every page element uses the same edge padding, so the brand logo top-left and the user avatar top-right align with the H1 text below them. The pattern's Section API controls the middle content rows; navbar and footer follow the same padding convention for visual consistency.

Project-specific notes and rollout guidance

Migration from a single wide container

If the host project currently uses one container (e.g. sectionWide) for both headers and tables:

  1. Introduce contentPadded, fullBleed, and the Section API in parallel.
  2. Do not change legacy wrappers yet. Instead, add *Raw variants of existing table helpers that render markup without a container wrapper.
  3. Replace call sites incrementally:
    • Old: sectionWide (legacyTableHelper entities fields renderRow)
    • New: renderIndexSection indexHeader FullBleedBody (legacyTableHelperRaw entities fields renderRow)
  4. Only after every call site is migrated, remove the legacy wrapper and the old container CSS.

Why *Raw suffixes

The *Raw convention marks a helper that produces bare markup and relies on its caller to supply the container. It is grepable and makes the intermediate migration state explicit.

Local exceptions

Some components own their own breakout via project-specific CSS (for example, a matrix view with its own responsive rules). In those cases, compose the section manually with contentPadded for the header and omit the fullBleed wrapper for the body. Document the exception locally; do not complicate the generic pattern for a single special case.

Layout context assumption

The breakout CSS assumes a specific DOM shape:

.main-content > .container-fluid > .full-bleed

The direct-child selector (>) scopes the breakout to the main page layout while preventing nested .full-bleed elements inside additional containers from overreaching. If the host project uses HTMX or another framework that wraps swapped content in an intermediate container, the DOM shape changes and the selector must be relaxed to a descendant selector:

.main-content > .container-fluid .full-bleed

This scopes the breakout to the .main-content > .container-fluid context while allowing nested HTMX swap containers inside it. That is a legitimate local deviation; keep the direct-child rule as the default and document the override locally.

If the host project nests sections inside additional padded containers without such a framework, the breakout will overreach. Scope the .full-bleed breakout rule to the actual page layout context.

Supporting snippets

CSS custom properties

:root {
  --page-edge-padding:         0.75rem;  /* mobile  */
  --page-edge-padding-desktop: 1.5rem;   /* 576px+  */
}

Padded band

.content-padded {
  padding-left:  var(--page-edge-padding);
  padding-right: var(--page-edge-padding);
}

@media (min-width: 576px) {
  .content-padded {
    padding-left:  var(--page-edge-padding-desktop);
    padding-right: var(--page-edge-padding-desktop);
  }
}

/* When already inside a padded container-fluid, avoid double padding. */
.main-content > .container-fluid > .content-padded {
  padding-left: 0;
  padding-right: 0;
}

Full-bleed band

.full-bleed {
  padding-left: 0;
  padding-right: 0;
}

/* Break out of the parent container-fluid padding.
   Direct-child selector prevents nested .full-bleed
   inside additional containers from overreaching. */
.main-content > .container-fluid > .full-bleed {
  width: calc(100% + 2 * var(--page-edge-padding));
  margin-left:  calc(-1 * var(--page-edge-padding));
  margin-right: calc(-1 * var(--page-edge-padding));
}

@media (min-width: 576px) {
  .main-content > .container-fluid > .full-bleed {
    width: calc(100% + 2 * var(--page-edge-padding-desktop));
    margin-left:  calc(-1 * var(--page-edge-padding-desktop));
    margin-right: calc(-1 * var(--page-edge-padding-desktop));
  }
}

Cell padding alignment

So that the first and last columns of an edge-to-edge table still line up with padded content above:

.full-bleed table td:first-child,
.full-bleed table th:first-child {
  padding-left: var(--page-edge-padding);
}

.full-bleed table td:last-child,
.full-bleed table th:last-child {
  padding-right: var(--page-edge-padding);
}

@media (min-width: 576px) {
  .full-bleed table td:first-child,
  .full-bleed table th:first-child {
    padding-left: var(--page-edge-padding-desktop);
  }

  .full-bleed table td:last-child,
  .full-bleed table th:last-child {
    padding-right: var(--page-edge-padding-desktop);
  }
}

Card padding in full-bleed

When a table collapses to cards on mobile, cards are rendered inside the same .full-bleed wrapper. The card container itself carries no side padding so that row backgrounds can run edge-to-edge; instead, padding is added to the rows inside the card body so their content aligns with the page edge:

/* Remove Bootstrap default side padding so row backgrounds run edge-to-edge. */
.full-bleed .entity-card,
.full-bleed .entity-card .card-body {
  padding-left: 0;
  padding-right: 0;
}

/* Add page-edge padding to rows so content aligns with the page edge. */
.full-bleed .entity-card .card-body > .row {
  padding-left: var(--page-edge-padding);
  padding-right: var(--page-edge-padding);
}

@media (min-width: 576px) {
  .full-bleed .entity-card .card-body > .row {
    padding-left: var(--page-edge-padding-desktop);
    padding-right: var(--page-edge-padding-desktop);
  }
}

Footer padding

A page footer should align with the same edge padding as the content above it:

.footer {
  padding-left: var(--page-edge-padding);
  padding-right: var(--page-edge-padding);
}

@media (min-width: 576px) {
  .footer {
    padding-left: var(--page-edge-padding-desktop);
    padding-right: var(--page-edge-padding-desktop);
  }
}
Core reusable pattern infrastructure
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE OverloadedStrings #-}

module Patterns.Views.SectionedContainer where

import IHP.ViewPrelude

-- | Determines whether a section body runs in padded or full-bleed mode.
-- PaddedBody:   content stays within the page edge padding
--               (text, buttons, empty states, filter controls).
-- FullBleedBody: content extends edge-to-edge
--               (tables, cards, dividers).
data BodyRole = PaddedBody | FullBleedBody

-- | Content-padded container: consistent side padding for text, buttons,
-- headings. Aligns with the navbar brand's left edge.
contentPadded :: Html -> Html
contentPadded inner = [hsx|<div class="content-padded">{inner}</div>|]

-- | Full-bleed container: zero side padding for tables, cards, dividers.
-- Relies on CSS to break out of the parent container and to restore
-- first/last cell padding so column content aligns with padded bands.
fullBleed :: Html -> Html
fullBleed inner = [hsx|<div class="full-bleed">{inner}</div>|]

-- | Render a complete index section: header, optional controls, and body.
-- Header and controls are always padded. The body is padded or bleeds
-- according to the given 'BodyRole'.
--
-- This is the primary entry point for index pages that carry filters or
-- other control widgets.
renderIndexSectionWithControls :: Html -> Html -> BodyRole -> Html -> Html
renderIndexSectionWithControls header controls bodyRole bodyHtml =
    contentPadded header
    <> contentPadded controls
    <> case bodyRole of
        PaddedBody    -> contentPadded bodyHtml
        FullBleedBody -> fullBleed bodyHtml

-- | Render a complete index section without controls (header + body only).
renderIndexSection :: Html -> BodyRole -> Html -> Html
renderIndexSection header bodyRole bodyHtml =
    renderIndexSectionWithControls header mempty bodyRole bodyHtml

-- | Render an embedded table section for Show pages.
-- Header and optional controls are always padded; the table body is always
-- full-bleed. This is a convenience wrapper around
-- 'renderIndexSectionWithControls' with 'FullBleedBody' fixed.
renderEmbeddedTableSection :: Html -> Html -> Html -> Html
renderEmbeddedTableSection header controls bodyHtml =
    renderIndexSectionWithControls header controls FullBleedBody bodyHtml
Helper implementation examples

The table helpers in the following example used to wrap their output in a single wide container. After introducing the Section API, they expose *Raw variants that produce bare markup, leaving container choice to the caller.

-- | Render an entity list as a desktop table and mobile cards.
-- No container wrapper: the caller supplies padding or full-bleed.
renderEntityTableAndCardsRaw :: forall entity.
    [entity]
    -> [Field entity]
    -> (RenderMode -> entity -> Html)
    -> Html
renderEntityTableAndCardsRaw entities fields renderRowFn =
    [hsx|
        <div class="table-responsive d-none d-md-block">
            <table class="table">
                <thead>
                    <tr>
                        {forEach (visibleFields fields) renderFieldHeader}
                        <th class="table-actions"></th>
                    </tr>
                </thead>
                <tbody>{forEach entities (renderRowFn TableMode)}</tbody>
            </table>
        </div>

        <div class="d-block d-md-none">
            {forEach entities (renderRowFn CardMode)}
        </div>
    |]

The *Raw suffix marks a helper that omits its own container wrapper. The caller is responsible for wrapping the result in contentPadded or fullBleed as appropriate. This makes the helper reusable in both index sections and embedded show-page sections without duplicating container logic.

The corresponding non-Raw legacy wrapper simply prepends the old wide container:

renderEntityTableAndCards :: forall entity.
    [entity] -> [Field entity] -> (RenderMode -> entity -> Html) -> Html
renderEntityTableAndCards entities fields renderRowFn =
    sectionWide (renderEntityTableAndCardsRaw entities fields renderRowFn)
Usage examples

Index page with filter controls and a full-bleed table

exampleIndexSection :: Html
exampleIndexSection =
    renderIndexSectionWithControls
        indexHeader
        indexFilters
        FullBleedBody
        itemsTable
  where
    indexHeader = [hsx|<h1>Items</h1>|]

    indexFilters = [hsx|
        <div class="d-flex gap-2 mb-3">
            <button class="btn btn-primary">Active</button>
            <button class="btn btn-outline-primary">Archived</button>
        </div>
    |]

    itemsTable = [hsx|
        <div class="table-responsive">
            <table class="table">
                <thead><tr><th>Name</th></tr></thead>
                <tbody><tr><td>Example</td></tr></tbody>
            </table>
        </div>
    |]

Show page with an embedded table section

exampleShowSection :: Html
exampleShowSection =
    renderEmbeddedTableSection
        subitemsHeader
        newSubitemButton
        subitemsTable
  where
    subitemsHeader = [hsx|<h2>Subitems</h2>|]

    newSubitemButton = [hsx|
        <a href="/subitems/new" class="btn btn-primary">New Subitem</a>
    |]

    subitemsTable = [hsx|
        <div class="table-responsive">
            <table class="table">
                <thead><tr><th>Title</th></tr></thead>
                <tbody><tr><td>Example</td></tr></tbody>
            </table>
        </div>
    |]

Local exception: component with its own breakout

When a component already manages its own edge-to-edge layout via project-specific CSS, wrap only the header in contentPadded and emit the body directly:

-- | This matrix component owns its full-bleed breakout via local CSS.
-- Do not wrap it in 'fullBleed'; that would double-break out.
renderMatrixSection :: Html -> Html -> Html
renderMatrixSection headerHtml matrixHtml =
    contentPadded headerHtml <> matrixHtml
Standalone checks

This module compiles standalone with IHP's view prelude. In a project that uses the pattern, verify:

  • The GHC build passes after replacing a legacy wrapper call with the Section API.
  • In a browser: on desktop, the first table column aligns with the H1 text; the last column aligns with the right edge of padded content.
  • On mobile, cards render without horizontal overflow.
  • Filter buttons sit inside the padded band, not touching the screen edge.

Recipes

Recipes

Raw source: Recipes/README.md

Recipes are feature-level orchestration guides that link ordng patterns in the order they should be applied. A recipe answers a concrete question such as "How do I harden an IHP auth surface?" or "How do I bootstrap a new project?"

Recipes are not patterns. Patterns are compilable building blocks; recipes are step-by-step instructions that reference patterns. Recipes are not maps.

Maps are D2 diagrams. They answer "Where am I and where should I go?" — they have branches but no numbered steps. A map can be system-level (e.g., Patterns/map.md: new project vs. refactor vs. browse) or topic-local (e.g., Patterns/Bootstrap/map.md: drift vs. upstream moved). In both cases the map navigates you to the right entry point.

Recipes are cross-topic numbered checklists. They answer "What must I do in what order?" — they have steps but no branches. Once a map has pointed you to a feature, the recipe carries you through it.

Choosing a recipe

If you are not sure which recipe you need, read the system-level map at Patterns/map.md first. It navigates to the right entry point.

If you already know your feature, start with the catalog.

Catalog

Recipes are ordered by the canonical greenfield sequence. A recipe may be thin or partially complete while its underlying patterns are still being extracted; the order is still stable.

Order Recipe File Scope Status
1 Bootstrap Bootstrap.md Bring a freshly generated IHP project onto the ordng baseline Complete
2 Domain Modeling DomainModeling.md Design the first 2–3 core entities (including one whose enum carries roles/permissions) Thin
3 Page Layout & Composition PageLayoutAndComposition.md Establish page structure, sections, and component boundaries Thin
4 Auth and Permissions AuthAndPermissions.md Build or refactor an IHP auth and authorization surface end-to-end Complete
5 Forms & Validation FormsAndValidation.md Build form lifecycle, validation, and submission handling Thin
6 Reactive UI ReactiveUI.md Add server-driven reactive interactions (inline editing, typeahead, modals) Substantial
7 Accessibility Hardening AccessibilityHardening.md Audit and harden semantic structure, forms, and reactive UI across topics Thin
8 External Provider Integration ExternalProviderIntegration.md Add optional third-party provider/API search or enrichment with explicit provider boundaries Complete

Recipe structure

Every recipe follows a stable shape:

  1. Purpose — when to use this recipe.
  2. Prerequisites — patterns the agent should know before starting.
  3. Recipe order — numbered steps applied in sequence.
  4. Decision summary — table mapping concerns to patterns with status.

Steps are ordered: security boundaries first, cosmetic refinements later. Each step links to the relevant pattern and states the concrete rule to apply.

When to create a new recipe

Create a recipe when:

  • multiple patterns must be applied in a specific order to achieve a feature,
  • the order matters (security before cosmetics),
  • the feature recurs across projects (auth, bootstrapping, deployment).

Do not create a recipe when the guidance is temporary coordination (use docs/plans/ instead).

A recipe may orchestrate a single pattern if that pattern is the primary entry point for a recurring feature. For example, a bootstrap recipe that applies only AgenticHandoff is thin but legitimate because bootstrapping is a recurring project-level goal with a canonical first step.

Accessibility Hardening

Raw source: Recipes/AccessibilityHardening.md

Purpose

Audit and harden an ordng-style IHP/HTMX application's accessibility surface across rendered structure, forms, and reactive UI.

Accessibility guidance is distributed across topic patterns. This recipe is the cross-topic application order: it tells an agent what to check first and which pattern topic owns each finding.

When to use this recipe

  • Before release, after Page Layout & Composition, Forms & Validation, and Reactive UI have introduced real screens.
  • While refactoring an existing application with mixed semantic HTML, form, and HTMX issues.
  • After extracting a new accessibility-relevant pattern, to verify that the surrounding feature flow still works.

Prerequisites

Familiarity with these ordng patterns is assumed:

Also read the cross-topic index in Patterns/README.md.

Recipe order

Apply in order. Establish semantic document structure before fixing local widgets; verify dynamic HTMX behavior after the static structure is sound.

1. Inventory rendered surfaces

List the screens and fragments that users can actually reach:

  • anonymous and authenticated layouts,
  • index, show, edit, and dashboard pages,
  • form-heavy flows,
  • HTMX fragments that replace themselves or update several regions,
  • modals, dropdowns, typeaheads, and permission-gated controls.

For each surface, record whether it needs a semantic-structure check, a form check, a dynamic-interaction check, or an audit run.

2. Establish page landmarks and heading outline

Apply AccessibilityLandmarks for page landmarks:

  • one <main> landmark contains only primary page content,
  • repeated chrome such as <nav>, <footer>, and modal mounts stays outside <main>,
  • layout or scroll containers do not have to be landmarks,
  • repeated navigation landmarks have explicit labels.

Then apply AccessibilityHeadingOutline:

  • each page has one page-level H1 and a gap-free visible heading outline,
  • non-section prompts are styled as non-heading elements, not fake headings,
  • rendered Markdown or .lhs fragments are normalized at the embedding boundary.

Run only the page-shell keyboard smoke check here:

  • repeated chrome can be skipped through a visible-on-focus skip link,
  • the skip target is the real <main id="main-content" tabindex="-1">.

Use SectionedContainer for page band structure, but keep landmark semantics independent from visual layout mechanics.

3. Use native elements and names for their real job

Apply AccessibleControlNames.

Audit controls and navigation with keyboard activation in mind:

  • actions are <button> elements or form submissions and activate with Enter and Space,
  • links are real navigation targets with meaningful href and activate with Enter,
  • avoid clickable non-native elements when a native button, link, field, <details>/<summary>, or dialog primitive fits,
  • custom widget roots use tabindex="0" only with a documented keyboard model,
  • never use positive tabindex values,
  • table data uses table semantics,
  • clickable cards or rows expose a real keyboard link target; do not wrap arbitrary field HTML in an anchor when nested links may exist,
  • card/row link names use the entity title where possible, with an explicit fallback label for untitled entities,
  • inline clickable text is rendered as a neutral-styled real button, not a span or div,
  • icon-only controls have localized accessible names,
  • the name says the actual operation and target context, not the visual shape or a near-miss verb,
  • controls that already have visible text or a <label for> relationship do not get redundant aria-label attributes,
  • decorative icons inside named controls are hidden from assistive technology.

For HTMX actions, keep hx-* on a button when the operation mutates state or performs an action. Do not use anchors only to borrow link styling.

Then apply KeyboardOperability for the full keyboard walkthrough:

  • Tab and Shift+Tab follow the visible interaction order,
  • controls activate with their native keyboard contract,
  • focused controls keep a visible focus indicator,
  • inline HTMX edit swaps use native autofocus on the swapped input or a marker-driven focus target, not global selector-based focus guessing,
  • programmatic focus targets use tabindex="-1" on the meaningful target element,
  • custom widget roots use tabindex="0" only with a documented keyboard model,
  • positive tabindex values are absent.
4. Harden forms

Apply AccessibilityFieldStructure for field labels, descriptions, and grouping. Apply AccessibilityValidationFeedback for validation summaries and failed-response focus targets.

For each form surface:

  • every field has a visible <label for> unless a genuine label-less exception is documented,
  • field id, name, label, help text, and error text stay in one helper/config boundary,
  • grouped controls use fieldset / legend or role="group" / aria-labelledby where the group meaning matters,
  • help text and error text are connected to the relevant field through aria-describedby,
  • validation errors are visible in the rendered response and invalid fields set aria-invalid="true",
  • redundant aria-label is removed when a visible label already names the field,
  • HTMX validation replacements preserve stable field, help, group-label, and error IDs and swap the ARIA attributes with the visible error markup,
  • failed validation responses choose one focus target: validation summary, first invalid field, or preserved current focus,
  • post-validation focus behavior is checked in the concrete host flow,
  • disabled-until-valid submit controls remain visually distinct, legible, and recognizably related to the enabled state.

Security-sensitive fill boundaries still belong to Auth and Permissions and Forms & Validation; this step checks whether the rendered form can be understood and operated.

5. Harden HTMX interactions

Apply the extracted HTMX patterns where they fit:

Apply AccessibilityFocusAndLiveFeedback for explicit focus targets and local live feedback after HTMX swaps.

For every hx-swap="outerHTML" replacement, verify focus continuity in the actual browser flow. Add JavaScript focus restoration only after the host flow shows a real loss of focus or orientation. When restoration is needed, use a server-rendered focus marker inside the swapped fragment instead of guessing from CSS classes.

For asynchronous feedback, prefer local indicators and stable local live-region nodes close to the triggering control. Update live-region text through a targeted or OOB swap; do not rely on inserting an already-populated polite live region. Do not turn an HTMX response into a page-wide accessibility patch unless the whole page state changes.

6. Keep semantic state and color legible

Apply SemanticValueAxes when categorical values drive visual treatment. Apply AccessibilitySemanticColor when the concern is CSS token choice, contrast repair, icon inheritance, or state-specific color drift.

Rules to preserve:

  • domain meaning, salience, and actionability are separate axes,
  • disabled options keep their domain meaning visible,
  • all-disabled controls collapse to static display instead of dead controls,
  • color is not the only selected-state signal,
  • contrast is checked for normal, hover, focus, active, and disabled states,
  • unavailable-state dimming depends on visual tone, label presence, and actionability,
  • disabled controls remain exposed as controls, while pure alignment placeholders are dimmed non-controls hidden from assistive technology,
  • icons inherit the control color unless a local semantic reason overrides it,
  • defended brand colors stay fixed; repair the paired foreground/background,
  • muted tokens remain visually muted after clearing contrast,
  • translucent hover-fill repairs are swept across sibling variants,
  • signal/inverted backgrounds reassert their paired foreground over semantic descendants.

Do not turn this recipe into a generic CSS guide. Generic contrast mechanics belong to WCAG/MDN; ordng records the IHP/HSX pattern decisions and their semantic-class mapping.

7. Run reproducible audits

Automated audits are useful only when their context is recorded.

For each Lighthouse, axe, or equivalent run, record:

  • build: development or production,
  • browser profile: clean/incognito vs extension-loaded,
  • auth state: anonymous or authenticated,
  • viewport/device mode,
  • exact failing selectors from exported JSON, not just the report headline.

Accessibility, SEO, and Best-Practices audits can usually run against the dev server because the rendered markup is the same. Performance audits must run against a production build and should be interpreted separately from accessibility findings.

Decision summary

Concern Applied via Status
Cross-topic accessibility order AccessibilityHardening recipe Thin
Page landmarks AccessibilityLandmarks Pattern extracted
Heading outline AccessibilityHeadingOutline Pattern extracted
Action-vs-link semantics and accessible control names AccessibleControlNames Pattern extracted
Keyboard operability and focus walkthroughs KeyboardOperability Pattern extracted
Form labels, groups, descriptions, field-level error wiring AccessibilityFieldStructure Pattern extracted
Validation summaries and failed-response focus targets AccessibilityValidationFeedback Pattern extracted
General HTMX focus continuity and live-region feedback AccessibilityFocusAndLiveFeedback Pattern extracted
Inline enum accessibility InlineDropdown, PermissionAwareEnumControl Pattern extracted
Nested controls in clickable containers NestedControlClickIsolation Pattern extracted
Semantic state and disabled/read-only rendering SemanticValueAxes Pattern extracted
Semantic color and contrast token decisions AccessibilitySemanticColor Pattern extracted
Audit reproducibility Recipe guidance Documented

Auth and Permissions

Raw source: Recipes/AuthAndPermissions.md

Purpose

Help an agent build or refactor an IHP application's authentication and authorization surface end-to-end. The recipe links to existing ordng patterns in the order they should be applied and marks mandatory security boundaries separately from cosmetic or optional refinements.

When to use this recipe

  • Starting a new IHP project with user accounts, roles, or session handling.
  • Hardening an existing IHP auth surface after security review.
  • Refactoring a codebase where auth decisions leak across layers (controller, view, HTMX partial).

In a greenfield project, apply this recipe after Domain Modeling has established the first core entities (including the entity whose enum carries roles or permissions).

Prerequisites

Familiarity with these ordng patterns is assumed before following the recipe:

Recipe order

Apply steps in order. Do not skip ahead to cosmetic steps before the security boundaries below them are established.

1. Inventory auth surfaces

List every controller action that touches authentication, authorization, or user state:

  • signup, login, logout, password reset, email confirmation,
  • role-gated actions,
  • ownership checks (edit/delete own records),
  • destructive actions (delete account, demote admin),
  • HTMX partials that render permission-aware controls.

For each action, note: - does it read public user input (param, fill)? - does it write to user or session state? - does it send email or generate tokens? - does it render controls that depend on role or ownership?

2. Narrow public fill boundaries

Apply NarrowPublicFillBoundary.

For every fill @'[...] call that touches user-facing create or update actions, classify fields:

  • Public — unauthenticated user may supply (name, email, password input).
  • Self-editable — authenticated owner may change (nickname, language).
  • Privileged — only admin or guarded action may touch (role, account state).
  • Server-controlled — application sets, user never does (counters, lock flags, timestamps, foreign keys to internal records).

Create one named builder per boundary. Set server-controlled fields after the narrow fill, never inside it. Give each builder an explicit type signature (_ => User -> User when generated names make fully written signatures fragile).

3. Enforce method allowlists for state-changing actions

Every state-changing action must allow only the HTTP method(s) it expects. Form-backed create and update actions typically accept only POST; logout may accept POST or DELETE. Reject everything else before any database read or write.

action UpdateUserAction { userId = userId } = do
    unless (Wai.requestMethod request == "POST") do
        redirectTo (EditUserAction userId)

Apply this guard to all actions identified in step 1 that modify state. Actions that only render a form (EditUserAction) remain GET-safe.

Authentication gate. Before any write operation, ensure the actor is authenticated. In IHP, ensureIsUser at the start of authenticated actions provides this guard. Unauthenticated requests should not reach database mutations.

Guarded deletes: Destructive actions may require two separate decisions: actor privilege from ScopedRolePermissionRegistry or ownership gates, and domain actionability from entity state (for example, whether user-facing child records still block deletion). Compute both in the controller, enforce both on POST, and pass named decisions to the view only for UX state. When the delete also removes cleanup children, apply TransactionalParentDelete for the transaction boundary.

4. Verify secrets never render in HTML

passwordHash, stored tokens, stored secrets, and hashes must never appear in form value attributes or rendered HTML. Out-of-band delivery tokens (reset links, confirmation URLs) are intentional presentation boundaries and are allowed.

Audit every form that pre-fills values from a database record. Whitelist fields explicitly and exclude secrets.

5. Apply expiring user token flows

Apply ExpiringUserTokenFlow to password reset, email confirmation, and any other out-of-band token flow.

Lifecycle: generate → store → deliver → verify → consume → invalidate.

Key checks: - token stored raw (Maybe Text) or as a deterministic digest/HMAC suitable for lookup; never hashed with hashPassword, - verification in both edit action (renders form) and update action (accepts new value), - generic response for unknown email addresses, - POST-only for reset request and password update, - invalidate token immediately after successful use, - log mail delivery failures internally, preserve generic user-facing response.

6. Add session lockout or rate limiting

Apply Patterns/Actions/CustomSessionLockout.lhs.

IHP standard auth provides lockout when using the standard schema and SessionsControllerConfig. Custom login actions must enforce equivalent behavior:

  • check lockout before password verification,
  • generic failure responses for unknown email, wrong password, locked account,
  • increment failed attempts on invalid login,
  • set lockedAt after threshold,
  • reset failed attempts after successful login,
  • define unlock strategy: time-based, admin reset, or both
  • avoid race-prone read-modify-write counters; use atomic SQL updates with placeholders.
7. Establish actor predicate vocabulary

Apply ActorPredicateVocabulary.

  • Define isLoggedIn, isMember, isAdmin :: Maybe User -> Bool as primitive predicates, not derived from each other.
  • Classify every privilege enum constructor explicitly in each privilege predicate; use a module-local -Werror=incomplete-patterns tripwire.
  • Use whenLoggedIn, whenAdmin for HSX render gates.
  • Reserve whenAdmin for global admin chrome, not per-resource decisions.

This is the bottom-most auth layer. It sits beneath scoped roles and ownership gates.

8. Enforce case-insensitive identity boundaries

Apply CaseInsensitiveIdentity.

  • Signup: reject case-variant duplicates using fetchCount with filterWhereCaseInsensitive.
  • Login: query case-insensitively; handle 0, 1, or N matches safely.
  • Use fetchCount first when legacy duplicates may exist.
  • Add database-level case-insensitive unique index after deduplicating legacy data (required for race-safe uniqueness).
9. Introduce scoped role permissions

Apply ScopedRolePermissionRegistry.

  • Keep the pure permission registry IO-free.
  • Load the actor's role/subscription for the concrete target scope via an IO wrapper.
  • Use opaque constructors so callers cannot pair a role from scope A with scope B.
  • Compare the concrete value-level scope id at authorization sites; phantom types alone are not enough.
  • Keep admin/global privilege as an actor property, not a fake local role.
  • Mark deprecated compatibility gates during rollout but do not let them define the final shape.

Rollout: migrate controller gates first, then view models.

10. Compose actor-target ownership gates

Apply ActorTargetAuthGate where ownership matters (participation editing, self-edit paths).

Actor authority comes from the actor's own context. Target records are used for target identity and ownership checks, not for deriving actor authority.

Ownership composes with scoped role permission: rolePermission || isOwnRecord.

11. Apply last-guardian invariant protection

Apply LastGuardianProtection to destructive actions that could orphan a scope (demoting last OrgLead, deleting last admin).

This is an invariant, not a role permission. It belongs after authorization but before the destructive write.

12. Propagate permission decisions into view models

Controllers compute permission booleans or row permission maps. Views consume named decision records or named booleans. Views should not query subscriptions or rebuild policy.

For show pages and concrete row contexts, precompute decisions in the controller and pass them to the view.

For list views, lightweight per-row pure policy calls are acceptable when a precomputed map would inflate the view model without improving safety. This exception should be explicit.

13. Group permission booleans into semantic config records

Apply PermissionGroupedConfigRecord when helpers carry three or more permission booleans.

  • Group permission booleans into a dedicated semantic subrecord (EventsSectionPermissions, EventRowPermissions).
  • Keep derived presentation state (isEngaged, badge styling) separate from the permission record.
  • Do not use RecordWildCards on the permission subrecord at call sites.
14. Verify permission-aware HTMX replacements

Apply PermissionAwareEnumControl, NestedControlClickIsolation, and SemanticValueAxes.

  • Initial render and HTMX replacement must use the same permission-aware wrapper.
  • Partial responses should return the stable wrapper node, not just inner content.
  • Controllers freeze the relevant permission context before rendering the partial.
  • Blocked actions need HTMX-safe responses: the UI must not silently look successful.
  • Disabled action controls are still controls and keep nested-click isolation.
  • Static semantic indicators are display, not controls, and stay transparent to row/card navigation.
  • Pure action buttons (New, Edit, Delete) do not carry DomainSemantic. If the operation is blocked in this state but still meaningful next to live siblings, render a real disabled action control. If the viewer cannot invoke the operation at all and the slot only preserves row/card alignment, render a static unavailable placeholder (aria-hidden="true") or omit it. Disabled pure actions and static unavailable placeholders still carry Actionability; route their affordance and dimming through SemanticValueAxes (including label presence), not through a color-class-specific CSS shortcut. Use AccessibleControlNames for the named disabled-control versus aria-hidden placeholder split.
  • Permission decisions choose actionability, not domain colour. DomainSemantic stays tied to the value; denied or blocked states use Actionability plus the semantic-color/cascade rules from AccessibilitySemanticColor.

IHP Guide responsibilities by reference

These are framework-level protections and required application responsibilities. Do not restate them as ordng patterns; cite the IHP Security Guide and verify them during recipe application.

  • IHP_SESSION_SECRET / IHP_SESSION_SECRET_FILE configured in production.
  • IHP_BASEURL=https://... so session cookies get Secure.
  • Security headers (Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) via reverse proxy or middleware.
  • Content Security Policy once asset/script needs are clear.
  • Parameterized raw SQL (? placeholders); never concatenate user input.
  • No preEscapedToHtml / preEscapedTextValue on user-provided content.
  • Upload validation if auth work touches file uploads.

Decision summary

Concern Applied via Status
Public fill boundary NarrowPublicFillBoundary Pattern extracted
Expiring token flow ExpiringUserTokenFlow Pattern extracted
POST-only enforcement Cross-cutting note in NarrowPublicFillBoundary Extracted
Secrets in HTML Cross-cutting note in NarrowPublicFillBoundary Extracted
Scoped role permissions ScopedRolePermissionRegistry Refined
Permission-grouped config PermissionGroupedConfigRecord Pattern extracted
Actor-target ownership ActorTargetAuthGate Existing, cross-referenced
Last-guardian invariant LastGuardianProtection Existing, cross-referenced
Permission-aware HTMX PermissionAwareEnumControl, NestedControlClickIsolation, SemanticValueAxes Existing
Session lockout CustomSessionLockout Pattern extracted
Case-insensitive identity CaseInsensitiveIdentity Pattern extracted
Actor predicate vocabulary ActorPredicateVocabulary Pattern extracted

Bootstrap

Raw source: Recipes/Bootstrap.md

Purpose

Bring a freshly generated IHP project onto the ordng baseline in a repeatable, reviewable order.

This recipe links the ordng Bootstrap patterns in the sequence they should be applied. It covers the agentic baseline, the Nix flake delta, and the optional deployment handoff. It does not cover initial IHP installation or generator invocation — run ihp-new first, then return here.

When to use this recipe

  • Starting a new IHP project that will follow the ordng pattern library.
  • Re-baselining an existing project after generator drift or a major upstream IHP version change. (For upstream-update workflow, see Patterns/Bootstrap/IhpUpstreamUpdates.md first.)
  • Auditing whether a project still matches the documented bootstrap baseline.

Prerequisites

Familiarity with these ordng patterns is assumed before following the recipe:

Recipe order

Apply steps in order. Do not modify generated code before the agentic baseline is established — the baseline determines how subsequent changes are reviewed and recorded.

1. Establish the agentic baseline

Apply AgenticHandoff.

Create the minimal files an agent needs to work in the repository:

  • AGENTS.md — canonical agent runtime configuration. Keep it thin; point to normal repository docs for canonical project knowledge.
  • CLAUDE.md — compatibility shim only. Move any genuinely useful guidance into AGENTS.md or normal docs, then keep this file minimal.
  • docs/ordng/README.md — local ordng usage anchor. State what the project adopts from ordng, what it overrides, and where local patterns live.
  • docs/ordng/log.md — important rollout decisions already made. Keep it for decisions and notable rollout steps, not routine work notes.
  • docs/ordng/plan.md — active rollout plan when ordng patterns are being introduced or extracted.
  • docs/ordng/technical-debts.md — known deviations from the ordng baseline, with rationale and intended resolution.

If the project uses Pi or a compatible harness, also ensure: - ~/.pi/agent/paths.json resolves <paths.repos.ordng> and <paths.repos.ihp-deployment-manager> when relevant. - Skills are loaded by task (/skill:haskell-implementation, /skill:nix, etc.).

Do not duplicate harness-level guidance in the project repository. The agent harness owns runtime, review integration, model/provider choices, and related bootstrap conventions.

2. Apply the flake.nix delta

Apply FlakeDelta.

Compare the generated flake.nix against a fresh ihp-new output and apply the recurring ordng delta:

  • Set ihp.appName to the real project name.
  • Keep cabal-install in ihp.haskellPackages.
  • Remove the generator's local NixOS deployment scaffolding if the project follows the companion deployment path.
  • Leave generated .envrc and start scripts untouched unless a separate reusable need has been identified.

Verify that the project still builds after the change:

direnv reload
# or the project's normal build verification

Do not add project-local pre-start hooks, unstable dotenv tweaks, or generator-provided CI defaults to the documented ordng baseline unless they have stabilized across multiple projects.

3. Set up deployment handoff (optional)

Apply DeploymentHandoff.

If the project follows the fully opinionated ordng deployment path:

  • Remove generator-produced Config/nix deployment files so the app repo does not present a second, stale deployment home.
  • In the project's AGENTS.md, add a Deployment workflow section that points to the canonical companion docs:
    • <paths.repos.ihp-deployment-manager>/AGENTS.md
    • <paths.repos.ihp-deployment-manager>/DEPLOYMENT_README.md
  • Keep only genuinely local deployment facts in the project repository:
    • machine names and host mapping
    • project-specific deploy entrypoints or app names
    • explicit confirmation rules if they differ locally

If the project deploys differently, omit this step and document the real local workflow instead.

4. Record local conventions

Document any project-specific deviations from the ordng baseline:

  • Local naming conventions that differ from the generic pattern vocabulary.
  • Deliberate omissions (e.g. "we do not use ScopedRolePermissionRegistry because we have a flat admin/user split").
  • Additional local patterns that are not yet generalizable enough for ordng.

Keep these records in docs/ordng/ or the project's canonical decisions document, not in AGENTS.md.

5. Verify the bootstrap

Run the smallest authoritative checks that prove the baseline is sound:

  • direnv reload or equivalent environment check succeeds.
  • The project builds and the dev server starts.
  • ghci can load at least one representative module.
  • If .lhs patterns were modified, run the explicit pattern check from Patterns/README.md.

Do not perform a full integration test suite at this stage; the goal is to confirm that the bootstrap delta itself is correct, not that the application is feature-complete.

Decision summary

Concern Applied via Status
Agentic baseline AgenticHandoff Pattern extracted
Flake.nix delta FlakeDelta Pattern extracted
Deployment handoff DeploymentHandoff Pattern extracted
Upstream update workflow IhpUpstreamUpdates Documented, not a .lhs pattern

Domain Modeling

Raw source: Recipes/DomainModeling.md

Purpose

Design the domain schema, entity relations, and data-access patterns for a new ordng-style IHP project.

This recipe orchestrates the ordng schema and data patterns in the order they should be considered. It is thinner than the Bootstrap and Auth recipes because the underlying pattern library in Patterns/Domain/ is still growing.

When to use this recipe

  • Starting a new IHP project directly after the Bootstrap baseline is established.
  • Re-modeling a slice of an existing schema to use typed relations or node-based content structures.
  • Auditing whether a project's schema follows ordng identity and relation conventions.

Prerequisites

Familiarity with these ordng patterns is assumed:

Recipe order

1. Inventory the domain slice

Start from one small domain slice and its concrete usage paths — not from the ordng pattern list. For that slice, list:

  • entities and their natural identity
  • text-bearing content that may need to become reusable or independently relatable
  • relations that carry semantics beyond a simple foreign key
  • internal structures (ordered blocks, nested content) inside entities
  • taxonomies or categorizations that may grow in parallel
  • at least one entity with a role enum that the upcoming Auth and Permissions recipe can map to permissions
2. Select the first entities strategically

The choice of the first 2–3 entities matters beyond the immediate domain need. Pick a slice that:

  • includes one entity with a role enum for the upcoming auth/permission gates,
  • supports enough layout variety (index tables, detail views, relations, filters) to make the following Page Layout and Reactive UI recipes immediately useful,
  • is small enough to model end-to-end without turning into upfront architecture theatre.

The broader the usage surface of these initial entities, the less "Trockenschwimmen" in the subsequent recipes.

3. Choose schema patterns per entity and relation

For each entity and relation from step 1, consult the schema patterns:

  • Text-bearing content → consider TextBearingContentAsNode if the content should become reusable, addressable, or independently relatable.
  • Named, reusable relations → consider TypedNodeToNodeRelation or TypedDomainEntityRelation when the relation semantics should be named, queried, or carry relation-specific fields.
  • Internal ordering/structure → consider InternalContentStructure when an entity contains ordered or nested sub-components.
  • Multiple categorizations → consider MultipleParallelTaxonomies when an entity is classified in more than one independent dimension.

Keep direct foreign keys for central operational structure. Do not introduce nodes or typed relations by default; justify them per field and relation.

4. Apply identity conventions

Follow the conventions from Patterns/Domain/README.md:

  • Domain entities get their own id.
  • Pure relation tables use a compound primary key unless the relation itself becomes an addressable domain object.
  • Add an id to a relation table only when it needs independent referencing, versioning, commenting, or authorization.
5. Set up role hierarchy (if applicable)

If the domain slice includes membership roles, apply SingleSelectRoleHierarchy:

  • Store one highest role per membership.
  • Imply lower permissions via Enum ordering.
  • Document lateral-role edge cases (roles that are not in the linear chain).
6. Index and performance check

Before expanding to the next domain slice:

  • Index relation tables by source, target, and type.
  • Avoid deep live traversals on routine request paths.
  • Prefer dedicated read projections for frequently rendered views.

Validate one coherent example end-to-end before broad rollout.

Decision summary

Concern Applied via Status
Text-bearing content TextBearingContentAsNode Schema pattern extracted
Typed relations TypedNodeToNodeRelation, TypedDomainEntityRelation Schema pattern extracted
Internal structure InternalContentStructure Schema pattern extracted
Parallel taxonomies MultipleParallelTaxonomies Schema pattern extracted
Role hierarchy SingleSelectRoleHierarchy Pattern extracted
Query / transaction shapes (planned) Awaiting extraction

External Provider Integration

Raw source: Recipes/ExternalProviderIntegration.md

Purpose

Add an optional external provider — often first described in product work as an external API integration — to an ordng-style IHP/HTMX application without treating the provider as an ordinary helper call. External providers bring credentials, quota or cost, latency, schema drift, and operational failure. This recipe keeps those boundaries explicit.

Use this recipe when a feature queries a third-party catalogue, search service, or enrichment provider from a request/response UI flow.

Common search terms: external API, third-party API, API client, external service, OAuth, API key, JSON API, quota, timeout, fallback search, autocomplete.

Provider-specific work remains required. Before implementing, the downstream project must identify or add:

  • aeson, lens, network-uri, and wreq in the downstream IHP flake.nix,
  • environment variables or another credential source,
  • HTTPS provider URLs, OAuth/API-key details, and JSON schema from provider docs,
  • IHP routes, controller actions, and component/view names,
  • endpoint method, login, and request-forgery policy.

Do not use this recipe as a permission design for importing or persisting provider data. Domain role permissions belong to Auth and Permissions once provider results become a local domain action.

Prerequisites

Familiarity with these ordng patterns is assumed:

Recipe order

Apply in order. Establish the provider boundary before wiring UI triggers; fail closed before spending provider resources.

1. Inventory the provider boundary

Before writing code, identify:

  • provider documentation pages used as source of truth,
  • credentials, environment variable names, and auth flow,
  • HTTPS provider token and resource URLs,
  • quota, cost, or rate limits,
  • expected request latency,
  • response format, JSON schema, and required fields,
  • IHP routes, controller actions, and component/view names,
  • whether the endpoint is public or logged-in only,
  • whether the first feature only displays results or also imports/persists them.

If the feature imports or persists provider data, split that domain action into a separate permission and persistence design. This recipe covers display/search integration only.

2. Introduce integration result semantics

Apply IntegrationResultBoundary.

  • Empty successful payloads stay inside IntegrationSucceeded.
  • Disabled, timed-out, and failed provider operations use IntegrationUnavailable.
  • Timeout is a first-class result.
  • Async cancellation and shutdown exceptions are re-thrown, not converted into provider unavailability.
3. Create the provider module boundary

Apply ExternalProviderModuleBoundary.

  • Put credentials, auth, HTTP, JSON decoding, provider wire response types, and translation into one provider module.
  • Export only app-facing values and public provider functions.
  • Keep provider wire response types private.
  • Do not derive Show for credentials.
  • Treat fully absent optional credentials as disabled.
  • Treat partial or blank credential configuration as visible misconfiguration.
4. Build provider HTTP requests with wreq

Apply WreqJsonProviderRequest inside the provider module when the external provider exposes JSON over HTTP.

  • Add aeson, lens, network-uri, and wreq to the downstream IHP project's ihp.haskellPackages in flake.nix.
  • Build wreq Options locally from an explicit request record.
  • Validate provider URLs as HTTPS before any request that carries credentials, bearer auth, or credential-bearing parameters.
  • Disable redirects, or enforce a redirect policy that preserves HTTPS, same host, and strips credentials before following.
  • Use getWith or postWith inside a timeout wrapper that redacts wreq exception details before constructing IntegrationFailed.
  • Check for a 2xx status before JSON decoding; non-2xx provider responses are integration failures even when the body matches the expected schema.
  • Apply asJSON only after the status check and extract responseBody into a provider wire type.
  • Do not export raw Response, Options, bearer tokens, auth helpers, full URLs, or parameters.

HTTP, network, status, and decode failures are provider failures, not successful no-hit results.

5. Decode provider responses explicitly

Inside the provider module:

  • define provider wire response types close to their FromJSON instances,
  • use required decoding for required provider fields,
  • use optional decoding only for genuinely optional provider fields,
  • translate provider wire values into app-facing result values through an explicit function.

Decode failures are provider failures, not successful no-hit results.

Apply ExternalSearchQueryPolicy for autocomplete-style free-text search.

  • Strip whitespace.
  • Treat blank and too-short queries as successful no-op searches.
  • Cap long queries before they leave the application.
  • Run normalization before provider auth, token retrieval, or HTTP search.

Do not apply this shape unchanged to exact-ID, URL, barcode, or other lookup surfaces where short values may be meaningful.

7. Gate the controller endpoint

Apply ExternalProviderEndpointGate.

  • Check method first.
  • Check actor presence when the endpoint can spend provider resources.
  • Verify CSRF, same-origin, or equivalent request-forgery protection for browser-submitted requests.
  • Fail before token, auth, search, or fetch calls.

This is a resource-use gate, not a domain role permission check.

Apply SpeculativeExternalSearchFeed when external results enrich local typeahead/search.

  • Visible input drives local search.
  • Hidden listener observes the visible input with from:#input.
  • Hidden listener uses hx-include to send the visible input value.
  • External provider results target a dedicated container.
  • Do not use changed or load on the hidden listener by default.
  • External provider responses remain fragments for their own target and never replace local results.
9. Map provider results at the presentation boundary

The component or view decides how visible each integration result should be:

  • IntegrationSucceeded [] may render nothing or a no-result block.
  • IntegrationUnavailable (IntegrationDisabled _) may render nothing in local development.
  • Runtime failure and timeout should remain observable enough for the surface: subtle UI block, log entry, CLI failure, or job status.

Do not render raw exception detail to end users.

10. Record deliberate exclusions

Document what is intentionally not part of the first provider slice:

  • domain-role permissions for import or persistence,
  • provider token caching,
  • rate limiting,
  • stale-response cancellation,
  • provider-result selection workflows.

Add those only when the host project has real evidence that they are needed.

Decision summary

Concern Applied via Status
Provider failure semantics IntegrationResultBoundary Pattern extracted
Free-text query shaping ExternalSearchQueryPolicy Pattern extracted
Provider module seam ExternalProviderModuleBoundary Pattern extracted
Provider HTTP requests WreqJsonProviderRequest Pattern extracted
Endpoint resource gate ExternalProviderEndpointGate Pattern extracted
Local/external HTMX separation SpeculativeExternalSearchFeed Pattern extracted
Local search base interaction Typeahead, FilterTarget, optional Indicator Pattern extracted
Domain import permissions AuthAndPermissions when needed Out of scope
Token cache/rate limit/stale cancellation Future evidence-driven patterns Out of scope

Forms & Validation

Raw source: Recipes/FormsAndValidation.md

Purpose

Build form lifecycle, validation, and submission handling for an ordng-style IHP application.

This recipe covers field rendering, validation rules, multi-model forms, file upload, and post-submit response patterns. It is currently thin because the Patterns/Forms/ topic is still in early extraction.

When to use this recipe

  • After Auth and Permissions has established the authorization surface, because form fill boundaries and action guards depend on it.
  • Before or alongside Reactive UI, since many reactive controls are forms or form-like interactions.

Prerequisites

Familiarity with these ordng patterns is assumed:

Recipe order

1. Inventory form surfaces

List every form in the application:

  • Create, edit, and search forms.
  • Forms that touch multiple models.
  • Forms with dynamic or conditional fields.
  • File upload flows.
  • HTMX-backed inline forms.

For each form, note: - which fields are public, self-editable, privileged, or server-controlled (reuse the boundary classification from NarrowPublicFillBoundary), - which fields need custom validation beyond IHP defaults, - whether the form spans more than one database record.

2. Narrow public fill boundaries

Apply NarrowPublicFillBoundary to every fill @'[...] call in form-backed actions.

This step is shared with the auth recipe because form security is not a separate concern — it is the same boundary applied in a form context.

3. Structure fields accessibly

Apply AccessibilityFieldStructure to shared HSX field helpers.

  • Keep field id, name, label, help text, and error text in one helper/config boundary.
  • Prefer visible <label for>; avoid redundant aria-label on already labeled fields.
  • Connect help and error text with aria-describedby.
  • Use fieldset / legend for grouped controls.
  • Preserve stable IDs across HTMX validation replacements.
4. Render validation feedback accessibly

Apply AccessibilityValidationFeedback when failed submissions render field errors.

  • Keep field-level errors wired through AccessibilityFieldStructure.
  • Add a validation summary for forms with multiple possible invalid fields.
  • Link summary entries to stable field IDs.
  • Choose one failed-response focus target.
  • For HTMX validation fragments, swap the visible errors, ARIA attributes, and focus marker together.
5. Set up image upload (if applicable)

Apply ImageUpload.

  • Validation: format, size, dimension checks.
  • Preprocessing: resize, format conversion.
  • Storage: path naming, cleanup on failure.
  • Post-submit response: success/failure feedback pattern.
6. Document validation-rule conventions

(Awaiting pattern extraction: Patterns/Forms/Validation/)

Until dedicated validation-rule patterns exist, document locally:

  • custom validation rules and where they live (controller, helper, or type),
  • how domain-level validation errors are translated into field and form errors.
7. Plan multi-model and dynamic forms

(Awaiting pattern extraction: Patterns/Forms/Composed/)

For forms that edit more than one record or show/hide fields dynamically:

  • Keep one form component as the source of truth.
  • Decide whether dynamic fields use HTMX replacement or client-side toggling.
  • Document the fallback to full-page New/Edit when component-driven flow fails.

Decision summary

Concern Applied via Status
Public fill boundary NarrowPublicFillBoundary Pattern extracted
Image upload ImageUpload Pattern extracted
Accessible field structure AccessibilityFieldStructure Pattern extracted
Accessible validation feedback AccessibilityValidationFeedback Pattern extracted
Field primitives (planned) Awaiting extraction
Validation rules (planned) Awaiting extraction
Multi-model forms (planned) Awaiting extraction
Post-submit response (planned) Awaiting extraction

Page Layout & Composition

Raw source: Recipes/PageLayoutAndComposition.md

Purpose

Establish page structure, visual building blocks, and component boundaries for an ordng-style IHP application.

This recipe covers the layout layer: how pages are divided into bands and sections, where presentation helpers live, and how component-driven flows are composed. It is currently thin because the underlying Patterns/Views/ and Patterns/Composition/ topics are still being extracted.

When to use this recipe

  • After Domain Modeling has established the first core entities, so that tables, sections, and containers can render real data.
  • Before Forms & Validation or Reactive UI add interactive elements.
  • Re-structuring an existing IHP application that has grown monolithic containers or unclear helper boundaries.

Prerequisites

Familiarity with these ordng patterns is assumed:

Recipe order

1. Define page band structure

Apply SectionedContainer.

Replace monolithic wide containers with alternating content-padded and full-bleed bands. Assign an explicit BodyRole to each band so that padding, background, and width rules are declarative rather than implicit.

Typical band roles:

  • content-padded — text, forms, detail views.
  • full-bleed — tables, index lists, embedded data grids.
  • footer-padded — closing actions or secondary links.
2. Extract presentation-specific helpers

Follow the function-placement guidance in Patterns/Composition/README.md:

  • Keep view rendering thin.
  • Push domain logic out of views into typed helpers or query modules.
  • Use unique, descriptive names so helpers remain discoverable in human and agent workflows.
3. Establish component boundaries

Document where the project's component-driven flows live:

  • Web/ vs Application/Helper/* API boundary.
  • Form components as source of truth.
  • Inline swapping vs full-page fallback boundaries.
  • Page-versus-component responsibility split.

These decisions are project-specific conventions; the ordng patterns provide the decision framework, not the concrete split.

4. Keep the semantic core small

Follow the composition guidance on small semantic cores and volatile outer layers. The central domain model should change slowly; presentation-specific helpers, styling, and HTMX wiring are the outer layer and can evolve faster.

Decision summary

Concern Applied via Status
Page band structure SectionedContainer Pattern extracted
Component boundaries Composition/README.md guidance Documented
Helper placement Composition/README.md guidance Documented
Template primitives (planned) Awaiting extraction
Composed view patterns (planned) Awaiting extraction

Reactive UI

Raw source: Recipes/ReactiveUI.md

Purpose

Add server-driven reactive interactions to an ordng-style IHP application: inline editing, search-as-you-type, modal flows, permission-aware controls, and association management.

This recipe is the most substantial after Bootstrap and Auth because the Patterns/Htmx/ topic is already well extracted. It assumes the page structure from Page Layout & Composition and the form foundations from Forms & Validation are in place.

When to use this recipe

  • Adding HTMX interactions after Forms & Validation and Page Layout & Composition are in place.
  • Building a new feature that requires inline editing, filtered lists, or entity lookup.
  • Migrating from React to HTMX+HSX (see also the migration patterns in Patterns/Htmx/Migration/).

Prerequisites

Familiarity with these ordng patterns is assumed:

Foundation primitives:

Composed interactions:

Cross-cutting:

Recipe order

Apply in order. Establish primitives before composed interactions; establish composed interactions before permission-aware or multi-step flows.

1. Set up shared typed selectors

Apply Shared/Types.

Replace raw selector strings (#foo, [data-bar]) with typed selectors (HxSelector) across all HTMX attributes. This prevents drift between controller-rendered targets and view-embedded hx-target values.

2. Add request-in-flight feedback

Apply Indicator to every HTMX trigger that may take perceptible time.

  • Use a stable hx-indicator selector.
  • Keep the indicator visually close to the trigger.
  • Avoid page-level spinners for local operations.
3. Establish target/swap boundaries

Apply FilterTarget for self-swap replacement boundaries.

Every HTMX replacement needs a stable DOM node with a stable id. The response must include the wrapper node, not just inner content.

4. Preserve focus and local status feedback

Apply AccessibilityFocusAndLiveFeedback where HTMX swaps can change user orientation.

  • Verify hx-swap="outerHTML" flows with keyboard only before adding focus restoration.
  • When focus restoration is needed, render an explicit focus marker in the swapped fragment.
  • Use stable local live-region nodes for async result counts, status changes, or OOB updates that happen away from the focused control; update their text through the HTMX response instead of inserting already-populated regions.
5. Isolate nested controls

Apply NestedControlClickIsolation wherever clickable rows, cards, or list items contain nested buttons, checkboxes, or dropdowns.

  • Stop event propagation at the nested control boundary.
  • Do not let a row click swallow a delete button or inline dropdown.
6. Add inline enum controls

Apply InlineDropdown or InlineButtonGroup for in-place enum editing.

  • Use InlineDropdown when option count is medium-to-high or space is tight.
  • Use InlineButtonGroup when all options should be visible immediately.
  • Apply the clearable variant (Maybe a lift) for nullable fields.
  • Apply EnumFromParam at the action boundary to parse posted text back into typed values.

Apply Typeahead for entity lookup and attach flows.

  • Wire Indicator for search-in-flight feedback.
  • Ensure the result list replaces only the typed target, not the whole form.
8. Add conditional presence toggles

Apply ConditionalHtmxComponent for two-state empty/present flows.

  • Subscribe/unsubscribe toggles.
  • Create/delete presence indicators.
  • Any flow that swaps between a CTA and an editable state.

The swapEmpty and swapPresent slots can hold any other HTMX pattern (InlineDropdown, InlineButtonGroup, etc.).

9. Wire modals for multi-step flows

Apply Modal for flows that need a stable layout-level mount point.

  • Use a single #modal-container in the page skeleton.
  • Load modal body via HTMX from different screens.
  • Modal + typeahead is the foundation for AssociationTable.
10. Add OOB updates where needed

Apply OobUpdates when one HTMX response must update multiple DOM regions.

  • Totals, counters, or derived values outside the primary swap target.
  • Keep OOB scoped; avoid turning every response into a full-page patch.
11. Apply permission-aware controls

Apply PermissionAwareEnumControl wherever enum fields are rendered in permission-sensitive contexts.

  • Initial render and HTMX response must return the same permission-aware wrapper, never a raw interactive control.
  • Controller freezes the permission context before rendering the partial.
  • Blocked actions need HTMX-safe responses (no silent success appearance).
  • Disabled controls keep NestedControlClickIsolation.
12. Add section-level filtering

Apply TargetedPartialSectionFilter for tab-like or dropdown filters that replace a page section in place.

  • The HTMX response must include the section wrapper with its stable id.
  • The full-page fallback (non-HTMX request) renders the whole page.
  • Optionally use SemanticValueAxes for filter-button presentation.
13. Add association table management

Apply AssociationTable for many-to-many attach/detach flows.

  • Builds on typeahead + modal.
  • Optionally uses OOB for counter updates.
  • Confirm destructive detach actions.
14. Keep semantic values consistent

Apply SemanticValueAxes as a cross-cutting design token layer.

  • Separate domain colour, salience, and actionability.
  • Render categorical values (enum, badge, status) consistently whether they are clickable, read-only, or disabled.

Decision summary

Concern Applied via Status
Typed selectors Shared/Types Pattern extracted
Request feedback Indicator Pattern extracted
Target/swap boundary FilterTarget Pattern extracted
Focus continuity and local live feedback AccessibilityFocusAndLiveFeedback Pattern extracted
Nested control isolation NestedControlClickIsolation Pattern extracted
Inline enum editing InlineDropdown, InlineButtonGroup Pattern extracted
Typeahead search Typeahead Pattern extracted
Conditional presence ConditionalHtmxComponent Pattern extracted
Modal flows Modal Pattern extracted
Multi-region updates OobUpdates Pattern extracted
Permission-aware controls PermissionAwareEnumControl Pattern extracted
Section filtering TargetedPartialSectionFilter Pattern extracted
Association management AssociationTable Pattern extracted
Semantic value rendering SemanticValueAxes Pattern extracted