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.
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.
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.
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
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.
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.
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:
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.
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.
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:
only the semantic content layer is polymorphized into reusable
nodes,
relation types are modeled explicitly as reusable semantic
objects,
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
domain entities stay explicit,
reusable nodes carry the semantic units that should not be trapped
in one table,
typed relations connect nodes and domain entities in a way that
remains semantically inspectable and programmatically reliable.
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:
manually, by copying, adapting, and checking the pattern without
agent support,
interactively with an agent, step by step, whether through normal
prompting, a plan file, or plan mode,
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
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.
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.
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.
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:
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.
Steps become checkpoints. Completed steps are never
re-executed. Crash → resume at the failed step.
Events as approval gates. A step can
awaitEvent("human-approved-step-5") — staged autonomy: N
steps autonomous, pause for review, continue.
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:
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
— Related but Separate
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).
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/**/*.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
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:
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.
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.
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.
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:
Pattern intent and mechanism — what the pattern
does and how
Project-specific notes and rollout guidance —
host-project context
Supporting snippets — non-Haskell artifacts the
pattern depends on (CSS, SQL, JS, etc.)
Core reusable pattern infrastructure — the portable
abstraction
Helper implementation examples — concrete
implementations using the infrastructure
Usage examples — call-site examples
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.
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.
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:
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.
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.
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.
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.
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
Bootstrap — extract the recurring
post-ihp-new project baseline while the local setup deltas
are current and easy to inspect.
Domain & Data — make domain types, query
patterns, and transactional shapes explicit before extracting
higher-level application patterns.
Views — first pattern extracted (SectionedContainer);
remaining view-building blocks referenced by HTMX patterns still to
document.
Forms — high practical value; form handling in IHP
is where the most improvisation happens.
Actions — repetitive and useful, but lower urgency
than the topics above.
Haskell — extract language idioms and structural
conventions that recur across topics (config records, explicit bindings,
permission grouping).
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.
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:
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.
Preserve the difference between successful empty external-provider
results and provider unavailability, including disabled, timed-out, and
failed operations
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.
{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Actions.ActorTargetAuthGatewhereimportPreludeimportIHP.ViewPrelude-- | Re-export the pure policy vocabulary.importPatterns.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->BoolactorCanActOnTarget 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.
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:
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.
Login: Query case-insensitively; expect 0, 1, or N
matches.
Safety: Use fetchCount first when
legacy duplicates may exist; only proceed with single-match logic when
count == 1.
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).
-- | For edit actions: exclude the current user so keeping one's own email-- does not falsely trigger a collision.isEmailUniqueCaseInsensitiveExcludingUser :: (?modelContext ::ModelContext)=>IdUser->Text->IOValidatorResultisEmailUniqueCaseInsensitiveExcludingUser currentUserId email =do count <- query @User|> filterWhereCaseInsensitive (#email, email)|> filterWhereNot (#id, currentUserId)|> fetchCountpure$if count >0thenFailure"This email address is already in use"elseSuccess
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 (MaybeUser)findUserForLogin email =do count <- query @User|> filterWhereCaseInsensitive (#email, email)|> fetchCountcase count of0->pureNothing1-> 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)pureNothing-- or implement disambiguation strategy
Core
reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}modulePatterns.Actions.CaseInsensitiveIdentitywhereimportPreludeimportData.Text (Text)importData.Time.Clock (UTCTime)-- | Result type for uniqueness validation.dataValidatorResult=Success|FailureText-- | 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->IOInt) -- ^ Count function (inject for testing)-> (Text->IO (MaybeUser)) -- ^ Fetch-one function->IO (MaybeUser)findUserForLogin email countFn fetchFn =do n <- countFn emailcase n of0->pureNothing1-> fetchFn email _ ->do warn "Login ambiguity: multiple users for case-variant email"pureNothing-- Conservative: treat as not found
Helper
implementation examples
-- Example User type for compilation.dataUser=User { userId ::Int , userEmail ::Text , createdAt ::UTCTime }-- Example validation integration.validateEmailUniqueness :: (Text->IOInt) -- ^ Case-insensitive count->Text->IOValidatorResultvalidateEmailUniqueness countFn email =do n <- countFn emailpure$if n >0thenFailure"This email address is already in use"elseSuccess
Usage
examples
Signup controller integration (IHP):
action CreateUserAction=dolet 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 \caseLeft 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 \caseLeft 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 emailcase mUser ofNothing->do Log.info ("Login failed: no unique user found" ::Text) genericLoginFailureJust user ->doif verifyPassword user passwordthen login userelsedo Log.info ("Login failed: wrong password" ::Text) genericLoginFailure
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.CREATEUNIQUEINDEX idx_users_email_lower ON users(LOWER(email));-- Alternative: functional unique index with COALESCE for NULL safetyCREATEUNIQUEINDEX idx_users_email_lowerON users(COALESCE(LOWER(email), ''));
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:
Check lockout status before password
verification.
Return generic failure responses for all error
cases (unknown email, wrong password, locked account) to prevent
enumeration.
Increment failed attempts via atomic SQL update to
avoid races on concurrent login attempts.
Set lockedAt timestamp when threshold reached.
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):
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.
Lockout check (requires lockedAt field on User
record):
isUserLockedOut ::User->UTCTime->BoolisUserLockedOut user now =case get #lockedAt user ofJust lockedAt -> diffUTCTime now lockedAt < lockoutDurationMinutesNothing->False
Atomic failed-login recording (raw SQL to avoid races):
recordFailedLogin :: (?modelContext ::ModelContext) =>IdUser->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) =>IdUser->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):
{-# LANGUAGE OverloadedStrings #-}modulePatterns.Actions.CustomSessionLockoutwhereimportPreludeimportData.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)-- | Configuration: lock after this many failed attempts.maxFailedLoginAttempts ::IntmaxFailedLoginAttempts =5-- | Configuration: lockout window in seconds (here: 30 minutes).lockoutDurationMinutes ::NominalDiffTimelockoutDurationMinutes =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->BoolisUserLockedOut user now =case lockedAtField user ofJust lockedAt -> diffUTCTime now lockedAt < lockoutDurationMinutesNothing->False-- Minimal type class for demonstration (IHP provides actual field accessors).classHasLockedAt a where lockedAtField :: a ->MaybeUTCTime
Helper
implementation examples
-- Example User type with HasLockedAt instance for compilation.dataUser=User { userId ::Int , lockedAt ::MaybeUTCTime }instanceHasLockedAtUserwhere lockedAtField = lockedAt-- Example: check lockout for a user.exampleCheck ::User->UTCTime->StringexampleCheck user now =if isUserLockedOut user nowthen"Account locked"else"Account active"
Usage
examples
Schema migration (PostgreSQL):
ALTERTABLE users ADDCOLUMNIFNOTEXISTSfailed_login_attemptsINTDEFAULT0;ALTERTABLE users ADDCOLUMNIFNOTEXISTS locked_at TIMESTAMPNULL;-- Optional: index for admin queries on locked usersCREATEINDEX idx_users_locked_at ON users(locked_at) WHERE locked_at ISNOTNULL;
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 throughcase (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' <- getCurrentTimeif isUserLockedOut u' now'thendo Log.info ("Login rejected: account locked (race)" ::Text) genericLoginFailureelsedo-- 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.
Password reset, email confirmation, account recovery, and invite
acceptance share the same lifecycle:
Generate a random token.
Store token state with a timestamp.
Deliver the token out-of-band (usually by email).
Verify token and expiry when the user presents it.
Perform the guarded operation.
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.
Explicit export list with a boundary smart constructor:
moduleWeb.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->PasswordResetTokenmkPasswordResetToken =PasswordResetToken
Request action (POST-only, generic response):
action RetrievePasswordAction=do unless (requestMethod request =="POST") do redirectTo NewSessionActioncase paramOrNothing @Text"email"ofNothing->pure () -- generic response belowJust email ->do user <- query @User|> filterWhereCaseInsensitive (#email, email)|> fetchOneOrNothingcase user ofNothing->pure () -- generic response belowJust 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 ofRight () ->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 userWithTokenpure ()-- Same message whether the email exists or not. setSuccessMessage iPasswordResetRequestReceived redirectTo ForgotPasswordAction
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)|> fetchOneOrNothingcase maybeUser ofNothing-> redirectWithErrorJust user ->do now <- getCurrentTimelet resetToken = mkPasswordResetToken token unless (passwordResetTokenIsValid user resetToken now) do redirectWithErrorlet 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 #-}modulePatterns.Actions.ExpiringUserTokenFlowwhereimportPreludeimportData.Text (Text)importData.Time.Clock ( UTCTime (..) , NominalDiffTime , diffUTCTime )importData.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.newtypeResetToken=ResetToken { unwrapResetToken ::Text }deriving (Eq)-- | Generic account record with token lifecycle fields.-- In IHP this is the generated database record.dataAccount=Account { accountId ::Int , email ::Text , passwordHash ::Text , resetToken ::MaybeText , resetTokenCreatedAt ::MaybeUTCTime }deriving (Eq)
Helper
implementation examples
Maximum token age and validation logic.
-- | Tokens expire after this many seconds.tokenMaxAge ::NominalDiffTimetokenMaxAge =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->BooltokenIsValid account (ResetToken presented) now =case (resetToken account, resetTokenCreatedAt account) of (Just stored, Just createdAt) ->let age = diffUTCTime now createdAtin 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->AccountclearResetToken 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->TexthashPassword pw ="hashed:"<> pw
Usage
examples
A concrete token and account for demonstration.
-- | Example account with an active reset token.exampleAccount ::AccountexampleAccount =Account { accountId =1 , email ="ada@example.com" , passwordHash ="oldhash" , resetToken =Just"abc123" , resetTokenCreatedAt =Just exampleTokenTime }-- | A token that matches the stored value.exampleValidToken ::ResetTokenexampleValidToken =ResetToken"abc123"-- | A token that does not match.exampleInvalidToken ::ResetTokenexampleInvalidToken =ResetToken"wrong"-- | Fixed point in time for the example checks.exampleNow ::UTCTimeexampleNow =UTCTime (fromGregorian 202411) 0-- | The token was created five minutes before 'exampleNow'.exampleTokenTime ::UTCTimeexampleTokenTime =UTCTime (fromGregorian 202411) (-300)
Token validation checks.
-- | True: the token matches and is within the expiry window.validTokenCheck ::BoolvalidTokenCheck = tokenIsValid exampleAccount exampleValidToken exampleNow-- | False: the token value does not match.invalidTokenCheck ::BoolinvalidTokenCheck = tokenIsValid exampleAccount exampleInvalidToken exampleNow
Password update with hash-and-invalidate.
-- | Simulated successful reset: hash the new password and clear the token.examplePasswordUpdate ::Account->Text->AccountexamplePasswordUpdate account newPassword =let hashed = hashPassword newPasswordin (clearResetToken account) { passwordHash = hashed }-- | The account after a successful reset. Token is gone; password is hashed.resetAccount ::AccountresetAccount = examplePasswordUpdate exampleAccount "newsecret"-- | True: the token was cleared after use.tokenIsCleared ::BooltokenIsCleared = resetToken resetAccount ==Nothing-- | True: the new password was hashed.passwordWasHashed ::BoolpasswordWasHashed = 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.
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:
check the HTTP method first,
check whether an actor is required and present,
check CSRF, same-origin, or equivalent request-forgery protection
for browser-submitted requests,
fail with an explicit response before token, auth, search, or fetch
calls,
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.
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:
A tiny local helper can also omit the explicit signature during
exploration, but canonical application code should make the boundary
grepable once it settles.
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 ofJust _ ->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.
{-# LANGUAGE OverloadedStrings #-}modulePatterns.Actions.ExternalProviderEndpointGatewhereimportPreludeimportData.ByteString (ByteString)-- | Configuration for an endpoint that can spend external provider resources.dataExternalProviderEndpointGate=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.dataExternalProviderActorRequirement=ExternalProviderPublicEndpoint|ExternalProviderRequiresActorderiving (Show, Eq)-- | Whether request-forgery protection must have accepted the request.dataExternalProviderRequestForgeryRequirement=ExternalProviderRequestForgeryNotRequired|ExternalProviderRequiresRequestForgeryProtectionderiving (Show, Eq)-- | Result of the framework's CSRF, same-origin, or equivalent boundary.dataExternalProviderRequestForgeryState=ExternalProviderRequestForgeryAccepted|ExternalProviderRequestForgeryRejectedderiving (Show, Eq)-- | Minimal request shape needed by the reusable gate decision.dataExternalProviderRequest=ExternalProviderRequest { externalProviderRequestMethod ::ByteString , externalProviderRequestForgeryState ::ExternalProviderRequestForgeryState }deriving (Show, Eq)-- | Gate failure before any provider operation is attempted.dataExternalProviderGateFailure=ExternalProviderMethodNotAllowedByteStringByteString|ExternalProviderLoginRequiredByteString|ExternalProviderRequestRejectedByteStringderiving (Show, Eq)
The gate decision is pure. Framework-specific controller code maps
failures to respondAndExit, redirects, JSON, CLI output, or
another boundary response.
A typical external autocomplete gate accepts only POST, requires a
logged-in actor, and requires request-forgery protection to have
accepted the request.
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:
strip surrounding whitespace,
reject blank values and values shorter than the configured
minimum,
cap values at the configured maximum,
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 ....
{-# LANGUAGE OverloadedStrings #-}modulePatterns.Actions.ExternalSearchQueryPolicywhereimportPreludeimportData.Text (Text)importqualifiedData.TextasTimportPatterns.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.dataExternalSearchQueryPolicy=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.
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.
{-# LANGUAGE BlockArguments #-}{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE ScopedTypeVariables #-}modulePatterns.Actions.IntegrationResultBoundarywhereimportPreludeimportControl.Exception ( SomeAsyncException , SomeException , displayException , fromException , throwIO , try )importData.Text (Text)importqualifiedData.TextasTimportSystem.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.dataIntegrationError=IntegrationDisabledText|IntegrationTimedOutTextInt|IntegrationFailedTextTextderiving (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.dataIntegrationResult a=IntegrationSucceeded a|IntegrationUnavailableIntegrationErrorderiving (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 actioncase result ofRight 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.
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.
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.
{-# LANGUAGE ImplicitParams #-}{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Actions.LastGuardianProtectionwhereimportPreludeimportIHP.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).dataLastGuardianConfig entity =LastGuardianConfig { remainingGuardians :: entity ->IOInt }-- | 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.dataGuardianResult=GuardiansRemain|LastGuardianDeniedTextderiving (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 ->IOGuardianResultcheckRemainingGuardians LastGuardianConfig { remainingGuardians } entity =do count <- remainingGuardians entityif count >0thenpureGuardiansRemainelsepure (LastGuardianDenied"last guardian cannot be removed")
Usage
examples
A project membership system where each project needs at least one
Lead.
dataMembership=Membership { membershipId ::Int , membershipProjectId ::Int , membershipUserId ::Int , membershipRole ::Text }deriving (Eq, Show)-- | Count remaining leads for this project, excluding the current member.countRemainingLeads ::Membership->IOIntcountRemainingLeads membership =do-- Simplified: real implementation uses IHP's query DSLpure1-- placeholderlastLeadConfig ::LastGuardianConfigMembershiplastLeadConfig =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->IOGuardianResultcanDemoteMemberUI = 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.
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:
Public construction path — fields supplied by an
unauthenticated or weakly authenticated request (for example,
signup).
Self-edit profile path — fields a normal
authenticated user may change about themselves.
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->UserbuildSignupUser 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->UserbuildEditUser 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->UserbuildAdminUserUpdate 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:
{-# LANGUAGE OverloadedStrings #-}modulePatterns.Actions.NarrowPublicFillBoundarywhereimportPreludeimportData.Text (Text)-- | Forward pipe for readable record-update chains.(|>) :: a -> (a -> b) -> b(|>) x f = f xinfixl0|>-- | Generic privilege level for examples.dataPrivilege=NormalUser|Adminderiving (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.dataUser=User { firstName ::Text , lastName ::Text , nickname ::MaybeText , email ::Text , passwordHash ::Text , language ::Text , privilege ::Privilege , failedLoginAttempts ::Int , lockedAt ::MaybeText , profilePicId ::MaybeInt }deriving (Eq, Show)-- | A fresh user with only server-controlled defaults.-- Application code uses the framework's @newRecord@ instead.newUser ::UsernewUser =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->MaybeText->Text->Text->Text->User->UserbuildSignupUser 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->MaybeText->Text->Text->User->UserbuildEditUser 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->MaybeText->Text->Text->Privilege->User->UserbuildAdminUserUpdate 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->UserapplyServerControlledDefaults 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->MaybeText->Text->Text->Text->UserexampleSignupUser 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->MaybeText->Text->Text->UserexampleEditUser 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->MaybeText->Text->Text->Privilege->UserexampleAdminUpdate 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 ::UsersignedUpUser = exampleSignupUser "Ada""Lovelace"Nothing"ada@x""secret""en"-- | True: the server-controlled fields were not left at uninitialized values.privilegeIsNormal ::BoolprivilegeIsNormal = privilege signedUpUser ==NormalUserfailedAttemptsAreZero ::BoolfailedAttemptsAreZero = 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.
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:
Actor context — who the current user is and what
they already own.
Policy — a capability summary derived from the
actor context.
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.
{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Actions.PurePolicyControllerGatewhereimportPreludeimportData.Text (Text)importIHP.ViewPrelude-- | Actor context: who the current user is and what they already own.-- | This is the input to policy computation.dataActorContext 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.dataPolicy=ReadOnly-- ^ may view, may not modify|Editor-- ^ may view and modify own content|FullAccess-- ^ may do anything, including admin operationsderiving (Eq, Show)-- | Generic action type for a resource.-- | Every operation that needs a gate is a constructor.dataAction=ViewAction|CreateAction|UpdateAction|DeleteActionderiving (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 ->PolicycomputePolicy 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->BoolcanDoAction FullAccess _ =TruecanDoAction EditorViewAction=TruecanDoAction EditorUpdateAction=TruecanDoAction EditorCreateAction=TruecanDoAction EditorDeleteAction=FalsecanDoAction ReadOnlyViewAction=TruecanDoAction ReadOnly _ =False
Ownership-aware permission check.
-- | A type class for resources that have an owner id.-- | Keeps the permission check generic over resource types.classOwnable 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->BoolcanDoActionWithOwnership actor resource policy action =let isOwner = actorId actor == ownerId resourceincase action ofDeleteAction-> isOwner || canDoAction policy actionUpdateAction-> (isOwner || policy ==FullAccess) && canDoAction policy action _ -> canDoAction policy action
examplePolicy ::PolicyexamplePolicy = computePolicy actor Lead-- | True: Contributor policy allows viewing.canView ::BoolcanView = canDoAction examplePolicy ViewAction-- | False: Contributor policy does not allow deleting.canDelete ::BoolcanDelete = canDoAction examplePolicy DeleteAction
Ownership overrides a weak policy.
ownedProject ::ProjectownedProject =Project { projectId =1 , projectOwnerId =42 , projectTitle ="Example" }notOwnedProject ::ProjectnotOwnedProject =Project { projectId =2 , projectOwnerId =99 , projectTitle ="Other" }-- | True: owner can delete their own project even with ReadOnly policy.ownerCanDelete ::BoolownerCanDelete = canDoActionWithOwnership actor ownedProject ReadOnlyDeleteAction-- | False: non-owner cannot delete with ReadOnly policy.strangerCannotDelete ::BoolstrangerCannotDelete = canDoActionWithOwnership actor notOwnedProject ReadOnlyDeleteAction
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.
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 notPurePolicyControllerGate,
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 notActorTargetAuthGate, 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 notPermissionAwareEnumControl,
which renders UI controls. The registry produces booleans; views may
feed them into controls or disabled action buttons.
The pattern has two layers:
Role context — the actor, the value-level scope
key, and the actor's role in that scope.
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.
Add mkScopedRoleContext or
mkScopedRoleContextIO and switch the first controller gate
to canInScopeFor.
Verify the gate still rejects cross-scope role reuse.
Add deprecated pragmas to the old non-scope gates.
Only after all controller gates use the scope-bound context, compute
permission booleans in show/list controllers and pass them to
views.
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->ScopeIdProjectScope->User->TargetRecord->IO ()ensureCanEditTargetRecord ctx targetScopeId user membership =let hasRolePermission = canInScopeFor ctx targetScopeId EditRecordByRole isOwner = get #userId membership == get #id userin 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.
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->ScopeIdProjectScope->MaybeSubscription->Maybe (ScopedRolePermissionContextProjectScope)mkScopedRoleContext user scopeId maybeSub =case maybeSub ofNothing->JustScopedRolePermissionContext { scopedActorUser = user , scopedScopeId = scopeId , scopedRoleInScope =RoleInNothing }Just sub ->let userMatches = get #userId sub == get #id user scopeMatches = get #scopeId sub == scopeIdinif userMatches && scopeMatchesthenJustScopedRolePermissionContext { scopedActorUser = user , scopedScopeId = scopeId , scopedRoleInScope =RoleIn (Just (get #subscriptionRole sub)) }elseNothing
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->ScopeIdProjectScope->IO (MaybeSubscription))->User->ScopeIdProjectScope->IO (MaybeProjectRolePermissionContext)mkProjectRoleContextFromIO fetchSub user scopeId =do maybeSub <- fetchSub user scopeIdpure (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.
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 ::MaybeScopedRole->BoolisEngagedSubscriber (JustViewer) =FalseisEngagedSubscriber Nothing=FalseisEngagedSubscriber _ =True
Core
reusable pattern infrastructure
Simple variant: plain context with a comment denoting scope.
{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.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 ) whereimportPreludeimportData.Text (Text)importIHP.ViewPrelude-- | Example actor record. In real code this is the IHP-generated User record.dataUser=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.dataScopedPermission=CreateEntity|ManageEntity|ManageScope|EditRecordByRole|WriteStatusderiving (Eq, Show)-- | Generic role type for the simple variant.-- | In application code this is usually a domain enum.dataScopedRole=Lead|Member|Viewerderiving (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.dataRolePermissionContext=RolePermissionContext { actorUser ::User , actorScopedRole ::MaybeScopedRole }
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.newtypeScopeId scope =ScopeIdIntderiving (Eq, Show)-- | A role tied to a specific scope kind. The role is carried together-- | with a value-level 'ScopeId' in 'ScopedRolePermissionContext'.newtypeRoleIn scope =RoleIn (MaybeScopedRole)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.dataScopedRolePermissionContext 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->BoolactorIsAdmin ctx = userIsAdmin (actorUser ctx)-- | Check if the user's scoped role is any of the given roles.hasRole ::RolePermissionContext-> [ScopedRole] ->BoolhasRole ctx roles =maybeFalse (`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->Boolcan 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 ->BoolactorIsAdminScoped 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->BoolcanInScope 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 ->BoolscopeMatches 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->BoolcanInScopeFor ctx targetScopeId permission = scopeMatches ctx targetScopeId && canInScope ctx permissionhasRoleInScope ::ScopedRolePermissionContext scope -> [ScopedRole] ->BoolhasRoleInScope ctx roles =letRoleIn maybeRole = scopedRoleInScope ctxinmaybeFalse (`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 ->BoolhasAnyRoleInScope ctx = actorIsAdminScoped ctx||case scopedRoleInScope ctx ofRoleIn (Just _) ->TrueRoleInNothing->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 ::MaybeScopedRole->BoolisEngagedSubscriber (JustViewer) =FalseisEngagedSubscriber Nothing=FalseisEngagedSubscriber _ =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->MaybeScopedRole->BoolcanCreateEntityAsUser user role = can (RolePermissionContext user role) CreateEntitycanManageEntityAsUser ::User->MaybeScopedRole->BoolcanManageEntityAsUser user role = can (RolePermissionContext user role) ManageEntitycanManageScopeAsUser ::User->MaybeScopedRole->BoolcanManageScopeAsUser user role = can (RolePermissionContext user role) ManageScopecanWriteStatusAsUser ::User->MaybeScopedRole->BoolcanWriteStatusAsUser user role = can (RolePermissionContext user role) WriteStatuscanEditRecordByRoleAsUser ::User->MaybeScopedRole->BoolcanEditRecordByRoleAsUser user role = can (RolePermissionContext user role) EditRecordByRole
Usage
examples
Application-specific scope tag.
-- | Concrete scope declared in application code.dataProjectScopetypeProjectRolePermissionContext=ScopedRolePermissionContextProjectScope
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->ScopeIdProjectScope->MaybeScopedRole) -- ^ pure map lookup->User->ScopeIdProjectScope-- ^ scopeId->ProjectRolePermissionContextmkProjectRoleContextFromMap 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->ScopeIdProjectScope->MaybeScopedRole->ProjectRolePermissionContextmkProjectRoleContext 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->ScopeIdProjectScope->MaybeScopedRolelookupProjectRole (User42 _) (ScopeId7) =JustMemberlookupProjectRole (User99 _) _ =NothinglookupProjectRole _ _ =NothingprojectCtx ::ProjectRolePermissionContextprojectCtx = mkProjectRoleContextFromMap lookupProjectRole (User42False) (ScopeId7)-- | True: Member may create entities.canCreate ::BoolcanCreate = canInScopeFor projectCtx (ScopeId7) CreateEntity-- | False: Member may not manage the scope itself.cannotManageScope ::BoolcannotManageScope =not (canInScopeFor projectCtx (ScopeId7) ManageScope)
Admin bypass.
adminCtx ::ProjectRolePermissionContextadminCtx = mkProjectRoleContextFromMap lookupProjectRole (User99True) (ScopeId99)-- | True: admin bypasses all role checks.adminCanManageScope ::BooladminCanManageScope = canInScopeFor adminCtx (ScopeId99) ManageScope
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:
Authorize the parent delete before the
transaction.
Reject blocking ACTION children that are fachlich
independent from the parent or require separate user confirmation.
Begin transaction for the cleanup and parent
delete.
Delete cleanup children in dependency order: start
with leaves and work toward the parent. Each delete must succeed.
Delete the parent only after all cleanup children
are removed.
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:
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:
construct a provider-specific request record,
build wreqOptions from that record,
run getWith or postWith inside a redacted
timeout wrapper,
reject non-2xx statuses before decoding,
apply asJSON,
extract responseBody into a provider wire type,
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.
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:
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.
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-newflake.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.
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.
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.
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.
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.
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:
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
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.
Apply the updated canon to the ordng IHP host
application.
Feed discoveries from that implementation pass back into the
canon.
Only then update downstream projects.
For the ordng Bootstrap canon update:
Compare upstream changes against current Bootstrap patterns.
Compare against fresh ihp-new output when generator
assumptions are affected; only the generated code is needed, no
build.
Classify each meaningful delta.
Update Bootstrap docs and pattern material.
Run the smallest authoritative checks for the touched pattern
surface.
Record what was learned, adopted, rejected, or promoted into
canon.
For the ordng IHP host application pass:
Roll out the updated Bootstrap canon to the ordng
IHP host application.
Run the smallest authoritative checks for the touched application
surface.
If the rollout exposes missing reusable structure, update Bootstrap
docs and pattern material before continuing.
Record host-application changes in ordng's CHANGELOG.md.
For a downstream project update:
Confirm that the ordng canon has already been
updated and checked against the ordng IHP host
application.
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.
Compare the project against the updated ordng
canon.
Classify each meaningful local delta.
Align the project.
Run the smallest authoritative checks for the touched surface.
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.
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.
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.
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.
Translation seam — a small function that maps
provider wire types into application-facing values.
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:
moduleApplication.Helper.ExampleProvider ( AppFacingResult(..) , searchExampleProvider ) where
{-# LANGUAGE BlockArguments #-}{-# LANGUAGE OverloadedStrings #-}modulePatterns.Composition.ExternalProviderModuleBoundary ( AppFacingResult(..) , searchExternalProvider ) whereimportPreludeimportData.Aeson (FromJSON(..), withObject, (.:), (.:?))importData.Text (Text)importqualifiedData.TextasTimportPatterns.Actions.IntegrationResultBoundaryproviderName ::TextproviderName ="ExampleProvider"-- | Credentials identify the application or integration account at the provider.-- Do not derive 'Show' for records that contain secrets.dataProviderCredentials=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.dataAppFacingResult=AppFacingResult { appFacingTitle ::Text , appFacingAuthors :: [Text] , appFacingYear ::MaybeText , appFacingExternalUrl ::MaybeText }deriving (Show, Eq)
Provider response types are internal wire types. In a host project
they are not exported from the provider module.
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.
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.
A decode failure in a host provider module should become provider
unavailability through IntegrationResultBoundary; it should
not become an empty successful payload.
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/:
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:
what this pattern shows
compared schemas
how to read the diagram
modeling decision
tradeoffs
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.
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.
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.
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.
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.
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 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_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.
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.
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.
Related
Patterns
This is analogous to 02-TypedNodeToNodeRelation.md:
the same typed-relation idea is reused here for taxonomic
classification.
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.
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->BoolcanActAs held required =fromEnum held >=fromEnum required
For write gating, check the held role against the target role:
canAssignRole ::Role->Role->BoolcanAssignRole 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.
canActAs ::Role->Role->BoolcanActAs held required =fromEnum held >=fromEnum required
Core
reusable pattern infrastructure
{-# LANGUAGE OverloadedStrings #-}modulePatterns.Domain.Data.SingleSelectRoleHierarchywhereimportPrelude-- | 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.dataRole=Audience|Participant|OrgTeam|Marketing|OrgLeadderiving (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->BoolcanActAs 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->BoolcanAssignRole 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.dataMembership=Membership { membershipId ::Int , membershipProjectId ::Int , membershipUserId ::Int , membershipRole ::Role }deriving (Eq, Show)-- | Alice is OrgLead on Project 1.alice ::Membershipalice =Membership { membershipId =1 , membershipProjectId =1 , membershipUserId =42 , membershipRole =OrgLead }-- | True: OrgLead can act as Participant.aliceCanParticipate ::BoolaliceCanParticipate = canActAs (membershipRole alice) Participant-- | True: OrgLead can assign OrgTeam to someone else.aliceCanAssignOrgTeam ::BoolaliceCanAssignOrgTeam = canAssignRole (membershipRole alice) OrgTeam-- | False: Participant cannot assign OrgLead.participantCannotAssignOrgLead ::BoolparticipantCannotAssignOrgLead = canAssignRole ParticipantOrgLead
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.dataExtendedMembership=ExtendedMembership { extendedRole ::Role , isExternalPartner ::Bool }deriving (Eq, Show)-- | An ExternalPartner must be OrgLead. The flag is checked separately.canBeExternalPartner ::ExtendedMembership->BoolcanBeExternalPartner 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.
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
AccessibilityFieldStructure.lhs
— labels, stable field IDs, help/error descriptions, invalid/required
projection, and fieldset/legend grouping for accessible HSX field
helpers.
AccessibilityValidationFeedback.lhs
— validation summaries, failed-response focus targets, and HTMX
validation focus markers for accessible failed form submissions.
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.
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:
Use the framework field generator first. Only hand-roll when the
rendered shape cannot be expressed by the normal IHP helper.
A <label for> is valid only for a single
labelable control with a matching stable id.
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.
For hand-rolled fields, keep id, name,
label, help text, and error text in one helper/config boundary.
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.
Connect help and error text with aria-describedby on
the field.
When a field has a validation error, render the error visibly and
set aria-invalid="true" on the control.
Mark required fields in the native control (required)
and in visible copy when the local design needs it; do not rely on color
alone.
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.
hand-rolled or raw-HTMX inputs, selects, textareas, and composite
widgets, which are the primary risk surface.
For every hand-rolled field, identify its field identity
(id + name) and visible label.
For every composite widget, verify that no
<label for> points at a button group, non-labelable
element, absent element, or conditional hidden input.
Remove redundant aria-label where
<label for> already provides the name.
Add aria-describedby IDs for help and error text owned
by the field.
Ensure invalid fields project both visible error text and
aria-invalid.
Check grouped controls for fieldset /
legend or role="group" +
aria-labelledby rather than a visual heading alone.
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.
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.
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.
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.
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:
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:
Field-level errors come first: every invalid field keeps stable
id, error id, aria-describedby,
and aria-invalid via
AccessibilityFieldStructure.
A form with multiple possible errors should render a summary before
the fields or at the top of the replaced form fragment.
Summary entries link to the stable field IDs, not to generated row
positions or ephemeral component IDs.
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.
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.
Focus targets that are not naturally focusable, such as a summary
section, need tabindex="-1".
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.
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:
Find form actions that return a failed full-page render, failed HTMX
fragment, or modal/body replacement.
Verify that field helpers already use AccessibilityFieldStructure
for field-level aria-describedby and
aria-invalid.
For forms with more than one possible invalid field, render a
summary with links to stable field IDs.
Decide the focus strategy per form and encode it in the response. Do
not let every caller invent its own focus convention.
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.
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>
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.
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.
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 ::MaybeText_checkSummaryFocus = validationFocusTargetId sampleFeedback-- First-invalid focus targets the first field error._checkFirstInvalidFocus ::MaybeText_checkFirstInvalidFocus = validationFocusTargetId sampleFeedback { validationFocusStrategy =FocusFirstInvalidField }-- No errors means no focus marker._checkNoErrorFocus ::MaybeText_checkNoErrorFocus = validationFocusTargetId sampleFeedback { validationFieldErrors = [] , validationFormErrors = [] }sampleFeedback ::ValidationFeedbacksampleFeedback =ValidationFeedback { validationSummaryId = sampleSummaryId , validationSummaryHeading = sampleSummaryHeading , validationFieldErrors = [sampleFieldError] , validationFormErrors = [] , validationFocusStrategy =FocusValidationSummary }sampleFieldError ::FieldValidationErrorsampleFieldError =FieldValidationError { validationFieldId = sampleFieldId , validationFieldLabel = sampleFieldLabel , validationFieldMessage = sampleFieldMessage }sampleSummaryId ::FieldIdsampleSummaryId = must "summary id" (fieldIdFromText "validation-summary")sampleFieldId ::FieldIdsampleFieldId = must "field id" (fieldIdFromText "artistName")sampleSummaryHeading ::NonEmptyTextsampleSummaryHeading = must "summary heading" (nonEmptyText "Please fix these fields")sampleFieldLabel ::NonEmptyTextsampleFieldLabel = must "field label" (nonEmptyText "Artist name")sampleFieldMessage ::NonEmptyTextsampleFieldMessage = must "field message" (nonEmptyText "Artist name is required")must ::Text->Maybe a -> amust _ (Just value) = valuemust label Nothing= P.error ("Invalid sample "<> Text.unpack label)
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.).
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:
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.
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.
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.
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:
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:
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:
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:
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:
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.
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.
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.
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.
Adding Moderator to Role now breaks the
build until every classifier handles it explicitly.
Render gates for HSX:
whenLoggedIn ::MaybeUser->Html->HtmlwhenLoggedIn mUser html =if isLoggedIn mUser then html elsememptywhenAdmin ::MaybeUser->Html->HtmlwhenAdmin mUser html =if isAdmin mUser then html elsemempty
Core
reusable pattern infrastructure
{-# OPTIONS_GHC -Werror=incomplete-patterns #-}{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE GeneralizedNewtypeDeriving #-}modulePatterns.Haskell.ActorPredicateVocabularywhereimportPreludeimportData.Text (Text)importData.Maybe (isJust)importData.String (IsString(..))-- | Stub Html type for compilation (IHP provides the real Html type)newtypeHtml=HtmlTextderiving (Eq, Show, Semigroup, Monoid)instanceIsStringHtmlwhere fromString =Html. fromString-- | Stub User type for compilationdataRole=Admin|Memberderiving (Eq, Show)dataUser=User { userRole ::Role }-- | Presence: authentication axisisLoggedIn ::MaybeUser->BoolisLoggedIn = isJust-- | Privilege: authorization axis - memberisMember ::MaybeUser->BoolisMember =maybeFalse (roleIsMember . userRole)where roleIsMember Member=True roleIsMember Admin=False-- | Privilege: authorization axis - adminisAdmin ::MaybeUser->BoolisAdmin =maybeFalse (roleIsAdmin . userRole)where roleIsAdmin Admin=True roleIsAdmin Member=False
-- | Render gate: presencewhenLoggedIn ::MaybeUser->Html->HtmlwhenLoggedIn mUser html =if isLoggedIn mUser then html elsemempty-- | Render gate: admin privilegewhenAdmin ::MaybeUser->Html->HtmlwhenAdmin mUser html =if isAdmin mUser then html elsemempty
Helper
implementation examples
-- Example: navigation chrome that only shows for adminsadminNavLink ::MaybeUser->HtmladminNavLink mUser = whenAdmin mUser "[Admin Panel]"-- Example: account link that only shows for logged-in usersaccountLink ::MaybeUser->HtmlaccountLink mUser = whenLoggedIn mUser "[Account]"
Usage
examples
Controller providing currentUser to view:
action DashboardAction=do-- currentUserOrNothing :: Maybe User from IHP Auth render DashboardView { currentUser = currentUserOrNothing }
-- 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.
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:
Positional arguments — what you start with in host
code.
Config record — group related parameters into a
single record type.
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):
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 =letConfig { label = label, action = action, rows = rows, value = value } = configin...
Explicit record pattern (canonical):
renderWithConfig Config { label = label, action = action, rows = rows, value = value } =...
{-# LANGUAGE OverloadedStrings #-}modulePatterns.Haskell.ExplicitRecordBindingwhereimportPreludeimportData.Text (Text, pack)-- | A generic config record with two type parameters.-- | The field names are prefixed to stay unique in local scope.dataConfig a action =Config { itemLabel :: a ->Text , itemAction ::MaybeText-> action , itemRows :: [Row a] , itemValue ::Maybe a }-- | A row of values with an associated tag.newtypeRow a =Row [(a, Tag)]deriving (Eq, Show)-- | A tag for visual or semantic classification.dataTag=Primary|Secondary|Neutralderiving (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 ->TextrenderFromConfig 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 ->TextrenderWithoutAction Config { itemLabel = itemLabel, itemRows = itemRows, itemValue = itemValue } ="Config with "<>pack (show (length itemRows)) <>" rows and selected "<>maybe"none" itemLabel itemValue
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.
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:
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->HtmlitemsSection entity items today canCreateItem canManageItem canWriteItemStatus = [hsx| ... |]
After: named permission subrecord inside the config.
-- | A row config with only edit and delete permissions.exampleRowConfig ::ItemConfigTextexampleRowConfig =ItemConfig { itemData ="Jazz Night" , itemPermissions =ItemPermissions { canEditItem =True , canDeleteItem =False } }-- | The rendered row shows "[edit only]".rowOutput ::TextrowOutput = renderRow exampleRowConfig
Grepable permission flow: the subrecord constructor line is the
boundary.
-- | True: the section output contains the permission count.sectionHasTwoActions ::BoolsectionHasTwoActions = sectionOutput =="Upcoming Items (2 actions permitted)"-- | True: the row output reflects the narrower permission subset.rowIsEditOnly ::BoolrowIsEditOnly = 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.
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:
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.
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.
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
Interaction purpose: inline enum-like selection
with self-swapping replacement boundary; base pattern for non-clearable
fields, with a clearable specialization for nullable values
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
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
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.
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)
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.
{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Htmx.Composed.ConditionalHtmxComponentwhereimportPreludeimportData.Text (Text)importIHP.ViewPreludeimportIHP.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@.dataConditionalHtmxConfig 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 ->HtmlconditionalHtmxComponent ConditionalHtmxConfig { swapId, swapEntity, swapEmpty, swapPresent } =let content =case swapEntity ofNothing-> swapEmptyJust entity -> swapPresent entityin [hsx| <div id={swapId}> {content} </div> |]
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.
{-# LANGUAGE DuplicateRecordFields #-}{-# LANGUAGE LambdaCase #-}{-# LANGUAGE ScopedTypeVariables #-}{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Htmx.Composed.InlineButtonGroupwhereimportPreludeimportData.Text (Text)importIHP.RouterSupport (HasPath (..))importIHP.ViewPreludeimportPatterns.Htmx.Primitives.NestedControlClickIsolation-- | Visual intent for option buttons.dataButtonSemantic=Typical|Variant|Affirmative|Negative|Neutralderiving (Eq, Show)-- | A row of options with their visual semantics.newtypeButtonRow a =ButtonRow [(a, ButtonSemantic)]deriving (Eq, Show)-- | Configuration for an inline button group.dataInlineButtonGroupConfig a action =InlineButtonGroupConfig { label :: a ->Text , action ::MaybeText-> 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.
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 ->HtmlrenderStaticInlineButtonGroup 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 semanticinif 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 ->HtmlpermissionAwareButtonGroup canEdit config =if canEditthen renderInlineButtonGroup configelse 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:
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.
{-# LANGUAGE DuplicateRecordFields #-}{-# LANGUAGE LambdaCase #-}{-# LANGUAGE ScopedTypeVariables #-}{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}-- DuplicateRecordFields: two config records share field names in this module.modulePatterns.Htmx.Composed.InlineDropdownwhereimportPreludeimportData.Text (Text)importIHP.RouterSupport (HasPath (..))importIHP.ViewPreludeimportPatterns.Htmx.Primitives.NestedControlClickIsolation-- | Visual intent for option buttons.-- Applications usually map this to their design system classes.dataButtonSemantic=Typical|Variant|Affirmative|Negative|Neutralderiving (Eq, Show)-- | A row of options with their visual semantics.newtypeButtonRow a =ButtonRow [(a, ButtonSemantic)]deriving (Eq, Show)-- | Configuration for a non-clearable inline dropdown.dataInlineDropdownConfig a action =InlineDropdownConfig { label :: a ->Text , action ::MaybeText-> action , rows :: [ButtonRow a] , value ::Maybe a , trigger ::Maybe a ->Html , isDisabled :: a ->Bool }-- | Configuration for a self-swapping clearable inline dropdown.dataClearableInlineDropdownConfig a action =ClearableInlineDropdownConfig { label :: a ->Text , action ::MaybeText-> 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.dataClearOption 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)incasereverse (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 ->BoolisClearableOptionSelected selectedValue option = selectedValue ==Just option-- | Configuration for clearable option label rendering.-- | Extracted to demonstrate explicit record binding.-- | See Patterns.Haskell.ExplicitRecordBinding for the convention.dataOptionLabelConfig a =OptionLabelConfig { clear ::ClearOption a , label :: a ->Text , value ::Maybe a }-- | Label a lifted option.clearableOptionLabel ::OptionLabelConfig a ->Maybe a ->TextclearableOptionLabel OptionLabelConfig { clear, label, value } option =case option ofNothing-> clearLabel clear valueJust 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 ->MaybeTextclearableActionValue option =case option ofNothing->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.
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 ->ButtonSemanticselectedSemantics rows selectedValue =case matches of semantic : _ -> semantic [] ->Typicalwhere matches :: [ButtonSemantic] matches = [ semantic |ButtonRow pairs <- rows, (value, semantic) <- pairs, Just value == selectedValue ]optionClasses ::ButtonSemantic->Bool->TextoptionClasses 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 <> activeClsemanticButtonColor ::ButtonSemantic->TextsemanticButtonColor semantic =case semantic ofTypical->"primary"Variant->"info"Affirmative->"success"Negative->"danger"Neutral->"light"semanticButtonClass ::ButtonSemantic->TextsemanticButtonClass semantic ="btn-"<> semanticButtonColor semantic
Usage
examples
A small enum-like value supplies labels and input values.
dataRole=Reader|Editor|Ownerderiving (Eq, Show)instanceInputValueRolewhere inputValue role =case role ofReader->"reader"Editor->"editor"Owner->"owner"roleLabel ::Role->TextroleLabel role =case role ofReader->"Reader"Editor->"Editor"Owner->"Owner"roleRows :: [ButtonRowRole]roleRows = [ ButtonRow [ (Reader, Typical) , (Editor, Variant) , (Owner, Affirmative) ] ]
Base case: non-clearable inline dropdown for a required enum
field.
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:
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.
-- 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 #-}modulePatterns.Htmx.Composed.PermissionAwareEnumControlwhereimportPreludeimportData.Maybe (listToMaybe)importData.Text (Text)importIHP.ViewPreludeimportPatterns.Htmx.Primitives.SemanticValueAxes (DomainSemantic (..), semanticColor)-- | A row of enum options with their semantic styling.newtypeButtonRow a =ButtonRow [(a, DomainSemantic)]deriving (Eq, Show)-- | Permission decision for rendering an enum control.dataPermissionDecision=Permitted|DeniedTextderiving (Eq, Show)-- | Visual shape requested by a call site.dataEnumControlShape=CompactControl|ExpandedControlderiving (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.dataPermissionAwareEnumControlConfig 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 ->HtmlrenderPermissionAwareEnumControl shape config@PermissionAwareEnumControlConfig { permissionContext, permissionDecision } =case permissionDecision permissionContext ofPermitted-> renderInteractive shape configDenied _ -> renderStatic shape configrenderPermissionAwareCompact ::Eq a =>PermissionAwareEnumControlConfig context a ->HtmlrenderPermissionAwareCompact = renderPermissionAwareEnumControl CompactControlrenderPermissionAwareExpanded ::Eq a =>PermissionAwareEnumControlConfig context a ->HtmlrenderPermissionAwareExpanded = renderPermissionAwareEnumControl ExpandedControl
Interactive shapes are supplied by existing controls.
The permission predicate is domain-level and shared by every
renderer.
canEditItem ::ItemPermissionContext->PermissionDecisioncanEditItem ItemPermissionContext { actor =Just user, actorRole, targetUserId }| userIsAdmin user =Permitted| userId user == targetUserId =Permitted| actorRole ==JustManager=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.
Interactive renderers usually come from InlineDropdown and
InlineButtonGroup. The example keeps them small placeholders so the
pattern remains standalone.
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:
a visible input drives the local search,
a hidden HTMX listener observes that input via
from:#input,
the hidden listener includes the visible input value with
hx-include,
the external provider response swaps into a dedicated target,
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.
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.
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:
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.
Stable section wrapper: the section is wrapped in a
node with a stable id so HTMX can swap it reliably on every
update.
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:
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.
{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Htmx.Composed.TargetedPartialSectionFilterwhereimportPreludeimportData.Text (Text)importIHP.ViewPreludeimportIHP.RouterSupport (HasPath (..))importPatterns.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.dataSectionFilterLinkConfig=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.dataSectionWrapperConfig=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->HtmlrenderSectionFilterLink SectionFilterLinkConfig { sectionId = sectionId, filterUrl = filterUrl, isSelected = isSelected, linkLabel = linkLabel } =if isSelectedthen [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->HtmlrenderSectionWrapper SectionWrapperConfig { wrapperId = wrapperId, wrapperContent = wrapperContent } = [hsx| <div id={wrapperId} class="targeted-partial-section"> {wrapperContent} </div>|]
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):
CREATETABLE utility_capacities (id UUID DEFAULT uuid_generate_v4() PRIMARYKEYNOTNULL, utility_id UUID NOTNULL, supply REALNOTNULL, unit TEXT NOTNULL, utility_name TEXT NOTNULL, rfsu_date DATENOTNULL);
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 =newDate(2015,02,01);const end =newDate(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 Classconst 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 dataLabelsconst orderedDataKeys = dataLabels.map(label => {// Reverse map from label back to the original key in dataKeysreturn dataKeys.find(key => idMap[key.toUpperCase()] ? (idMap[key.toUpperCase()] === label) : key === label); });let newDataset =assignDefaultValues(dataLabels, flattenedData);// cf.d3/docs/d3-shape/stack.mdconst 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 => (newDate(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> ) ) }
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 Dataconst 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 QIPsconst 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.
dataClientPoolEntry=ClientPoolEntry { cpProviderName ::Text , cpProviderId ::Int32 , cpClientName ::Text , cpClientId ::Int32 , cpUserCount ::Int64 , cpCreatedAt ::Day } deriving stock (Show, Generic)deriving anyclass (DecodeRow)clientPool ::Session.Session (VectorClientPoolEntry)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.
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:
Prefer native focus preservation when it works. Do not add a global
focus hook merely because HTMX is present.
When the swap destroys the focused element and orientation is lost,
the server response names one focus target inside the swapped
fragment.
Focus targets that are not naturally focusable need
tabindex="-1".
Live feedback stays local to the changed region or triggering
component.
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.
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.
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.
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.
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:
Inventory hx-swap="outerHTML", self-swaps, OOB updates,
and modal/list/detail replacements.
Test with keyboard only: trigger the interaction, observe whether
focus is still visible and whether the next Tab position makes
sense.
If focus is lost or the next position is disorienting, render one
explicit focus marker in the response fragment.
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.
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.
For blank queries or clear actions, explicitly clear the live-region
text in the HTMX response.
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:
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.
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 #-}modulePatterns.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 ) whereimportData.Char (isSpace)importData.Text (Text)importqualifiedData.TextasTextimportIHP.RouterSupport (HasPath (..))importIHP.ViewPreludeimportqualifiedPreludeasPimportPatterns.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.newtypeDomId=DomIdTextderiving (Eq, Show)domIdFromText ::Text->MaybeDomIddomIdFromText raw| Text.null trimmed =Nothing| Text.any isSpace trimmed =Nothing|otherwise=Just (DomId trimmed)where trimmed = Text.strip rawdomIdText ::DomId->TextdomIdText (DomId value) = value-- | Focus behavior requested by the server-rendered HTMX response.dataFocusAfterSwap=PreserveBrowserFocus|FocusAfterSwapTargetDomIdderiving (Eq, Show)-- | Live-region politeness for asynchronous HTMX feedback.dataLiveRegionPoliteness=Polite|Assertivederiving (Eq, Show, Enum, Bounded)-- | Whether assistive technology should announce the whole live-region content.dataLiveRegionAtomicity=Atomic|NotAtomicderiving (Eq, Show, Enum, Bounded)-- | Local live feedback rendered with or near the changed HTMX region.dataLiveFeedback=LiveFeedback { liveFeedbackId ::DomId , liveFeedbackPoliteness ::LiveRegionPoliteness , liveFeedbackAtomicity ::LiveRegionAtomicity , liveFeedbackMessage ::MaybeText }deriving (Eq, Show)-- | Accessibility metadata carried by an HTMX replacement fragment.dataHtmxAccessibilityFeedback=HtmxAccessibilityFeedback { htmxFocusAfterSwap ::FocusAfterSwap , htmxLiveFeedback ::MaybeLiveFeedback }deriving (Eq, Show)htmxFocusTargetAttribute ::TexthtmxFocusTargetAttribute ="data-htmx-focus-target"htmxLiveRegionSelectorAttribute ::TexthtmxLiveRegionSelectorAttribute ="data-htmx-live-region"renderPoliteness ::LiveRegionPoliteness->TextrenderPoliteness Polite="polite"renderPoliteness Assertive="assertive"renderAtomicity ::LiveRegionAtomicity->TextrenderAtomicity Atomic="true"renderAtomicity NotAtomic="false"renderLiveRegionRole ::LiveRegionPoliteness->TextrenderLiveRegionRole 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->DomIdexampleDomId value =case domIdFromText value ofJust domId -> domIdNothing-> 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.
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->HtmlrenderLiveFeedbackRegion = 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->HtmlrenderVisuallyHiddenLiveFeedbackRegion = renderLiveFeedbackRegionWithClass "visually-hidden"renderLiveFeedbackRegionWithClass ::Text->LiveFeedback->HtmlrenderLiveFeedbackRegionWithClass 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.
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.
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.
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.
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:
Fail-fast, clearable —
parseEnum :: 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.
Fail-fast, required —
parseRequiredEnum :: Maybe Text -> Either Text a. Use
for non-nullable enum fields without a clear option. Missing or empty
input returns Left.
Silent default —
parseEnumOrDefault :: 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) =>MaybeText->EitherText (Maybe a)parseEnum Nothing=RightNothingparseEnum (Just"") =RightNothingparseEnum (Just txt) =case find (\e -> inputValue e == txt) (allEnumValues @a) ofNothing->Left ("Unknown value: "<> txt)Just value ->Right (Just value)
Required fail-fast shape:
parseRequiredEnum :: (Enum a, InputValue a) =>MaybeText->EitherText aparseRequiredEnum Nothing=Left"Missing value"parseRequiredEnum (Just"") =Left"Missing value"parseRequiredEnum (Just txt) =case find (\e -> inputValue e == txt) (allEnumValues @a) ofNothing->Left ("Unknown value: "<> txt)Just value ->Right value
Silent-default shape (rare, use with explicit justification):
parseEnumOrDefault :: (Enum a, InputValue a) => a ->MaybeText-> aparseEnumOrDefault defaultValue Nothing= defaultValueparseEnumOrDefault defaultValue (Just"") = defaultValueparseEnumOrDefault 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 #-}modulePatterns.Htmx.Primitives.EnumFromParamwhereimportPreludeimportData.Maybe (fromMaybe, listToMaybe)importData.Text (Text)importIHP.ViewPrelude-- | Fail-fast parser for nullable enum fields.parseEnum ::forall a. (Enum a, InputValue a) =>MaybeText->EitherText (Maybe a)parseEnum Nothing=RightNothingparseEnum (Just txt) | txt ==""=RightNothingparseEnum (Just txt) =case find (\e -> inputValue e == txt) (allEnumValues @a) ofNothing->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) =>MaybeText->EitherText aparseRequiredEnum Nothing=Left"Missing value"parseRequiredEnum (Just txt) | txt ==""=Left"Missing value"parseRequiredEnum (Just txt) =case find (\e -> inputValue e == txt) (allEnumValues @a) ofNothing->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 ->MaybeText-> aparseEnumOrDefault defaultValue Nothing= defaultValueparseEnumOrDefault defaultValue (Just txt) | txt ==""= defaultValueparseEnumOrDefault 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.
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.
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:
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 #-}modulePatterns.Htmx.Primitives.FilterTargetwhereimportPreludeimportData.Text (Text)importIHP.RouterSupport (HasPath (..))importIHP.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.importPatterns.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 ::TexthxFilterResultsId ="filter-results"-- | Canonical selector for a dedicated filtered-results container.hxFilterResultsSelector ::HxSelectorhxFilterResultsSelector = hxIdSelector hxFilterResultsId-- | Canonical selector for self-targeting replacement.---- Use this together with `hx-swap="outerHTML"` when one component boundary-- should replace itself.hxFilterSelfTargetSelector ::HxSelectorhxFilterSelfTargetSelector =HxThis
Helper
implementation examples
Not applicable in this pattern.
Usage
examples
Dedicated result container:
-- | Placeholder action for demonstration purposes only.dataFilterAction=FilterActioninstanceHasPathFilterActionwhere pathTo FilterAction="/filter"-- | Placeholder result type for the examples below.dataSearchResult=SearchResultText-- | Dedicated result-container variant:-- the form triggers the request, but a separate results node is replaced.filterControlsWithDedicatedTarget :: [SearchResult] ->HtmlfilterControlsWithDedicatedTarget 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] ->HtmlfilterPanelWithSelfTarget 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] ->HtmlrenderDedicatedFilterResultsFragment = renderFilteredResults-- | Self-targeting response:-- return the whole replacement node.renderSelfTargetingFilterPanel :: [SearchResult] ->HtmlrenderSelfTargetingFilterPanel 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>|]
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 200msease-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 #-}modulePatterns.Htmx.Primitives.IndicatorwhereimportPreludeimportIHP.RouterSupport (HasPath (..))importIHP.ViewPreludeimportPatterns.Htmx.Shared.Types-- | CSS class name used by indicator elements.-- HTMX default indicator CSS keys off this class.hxIndicatorCssClass ::TexthxIndicatorCssClass ="htmx-indicator"-- | Default indicator asset used by this example.-- Applications should replace with their own canonical asset path.hxIndicatorAssetPath ::TexthxIndicatorAssetPath ="/bars.svg"-- | Default accessibility label for indicator imagery.hxIndicatorAltText ::TexthxIndicatorAltText ="Loading"-- | Default inline text for text-based indicator examples.hxIndicatorText ::TexthxIndicatorText ="Loading..."-- | Wrapper class for search inputs that host a local indicator.hxSearchInputWithIndicatorClass ::TexthxSearchInputWithIndicatorClass ="search-input-with-indicator"-- | CSS id for the indicator element in the id-based example.hxIndicatorSpinnerId ::TexthxIndicatorSpinnerId ="my-spinner"-- | Typed selector for the indicator element.hxIndicatorSpinnerSelector ::HxSelectorhxIndicatorSpinnerSelector = hxIdSelector hxIndicatorSpinnerId-- | Typed selector for the results target element.hxIndicatorResultsSelector ::HxSelectorhxIndicatorResultsSelector = hxIdSelector "results"-- | Canonical local selector for component-scoped indicator targeting.-- Renders to `closest .search-input-with-indicator`.hxSearchInputWithIndicatorSelector ::HxSelectorhxSearchInputWithIndicatorSelector =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):
-- | Placeholder action for demonstration purposes only.---- In real IHP applications, use your auto-routed controller actions instead.dataSomeAction=SomeActioninstanceHasPathSomeActionwhere pathTo SomeAction="/some-action"
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.
{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Htmx.Primitives.ModalwhereimportPreludeimportData.Text (Text)importIHP.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.importPatterns.Htmx.Shared.Types-- | CSS id for the canonical HTMX modal mount point.hxModalMountId ::TexthxModalMountId ="modal-container"-- | Canonical selector for targeting the whole shared mount.---- Use this when the response returns the whole modal shell.hxModalTargetSelector ::HxSelectorhxModalTargetSelector = 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->HxSelectorhxModalContentSelector 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->HtmllayoutSnippet 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->HtmlopenModalButton 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 ::HtmlclearModalMount = [hsx|<div id={hxModalMountId}></div>|]
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
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.
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.
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 #-}modulePatterns.Htmx.Primitives.NestedControlClickIsolationwhereimportPreludeimportData.Text (Text)importIHP.RouterSupport (HasPath (..))importIHP.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->HtmlisolateNestedControlClick 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.
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->HtmlwrapIfInteractive 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.
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.
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:
The target region already exists in the current DOM with a stable
id.
The HTMX response includes response-only elements marked with
hx-swap-oob.
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):
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.
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:
Choose DomainSemantic from the domain value only. Never
derive it from user role, selection state, permissions, or view
placement.
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?
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.
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.
Derive visual tone after the three semantic axes are chosen. A
common Bootstrap mapping is: Primary + current value →
Filled, unselected alternatives → Outlined,
and Secondary → Outlined.
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.
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 #-}modulePatterns.Htmx.Primitives.SemanticValueAxeswhereimportData.Maybe (fromMaybe, listToMaybe)importData.Text (Text, pack)importPrelude-- | Ontological meaning of a discrete value. Describes what the value-- means in the domain, independent of UI state or user role.dataDomainSemantic=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 applicablederiving (Eq, Show, Enum, Bounded)-- | Contextual importance of this parameter or control occurrence.-- Orthogonal to 'DomainSemantic' and 'Actionability'.dataSalience=Primary-- ^ Focal or decision-relevant in this view|Secondary-- ^ Background, auxiliary, or transparency-only in this viewderiving (Eq, Show, Enum, Bounded)-- | Interaction contract of this rendered occurrence.dataActionability=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 technologyderiving (Eq, Show, Enum, Bounded)-- | Renderer-level selection state.dataSelectionState=Selected|Unselectedderiving (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.dataSemanticPresentation=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.dataLabelPresence=IconOnlyControl|LabeledControlderiving (Eq, Show, Enum, Bounded)-- | Visual dimming to apply for unavailable affordance while preserving hue.dataDimLevel=NoDim|LabeledFilledDisabledDim|LabeledOutlinedDisabledDim|IconOnlyDisabledDimderiving (Eq, Show, Enum, Bounded)-- | Element-level affordance derived from actionability, visual tone, and label presence.dataElementAffordance=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'.dataVisualTone=Filled|Outlinedderiving (Eq, Show, Enum, Bounded)-- | Result of looking up the semantic meaning for the selected value.dataSemanticLookupResult=NoSelectedValue|SelectedSemanticDomainSemantic|MissingSelectedSemanticderiving (Eq, Show)-- | Map a semantic meaning to a Bootstrap colour name.semanticColor ::DomainSemantic->TextsemanticColor 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->VisualTonesalienceTone PrimarySelected=FilledsalienceTone PrimaryUnselected=OutlinedsalienceTone SecondarySelected=OutlinedsalienceTone SecondaryUnselected=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->TextsemanticButtonClass SemanticPresentation { domainSemantic = semantic, salience = sal, selectionState = selection } =let color = semanticColor semanticincase salienceTone sal selection ofFilled->"btn-"<> colorOutlined->"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->ElementAffordanceactionAffordanceForTone tone labelPresence action =case action ofActionable->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->DimLeveldisabledDimLevel _ IconOnlyControl=IconOnlyDisabledDimdisabledDimLevel FilledLabeledControl=LabeledFilledDisabledDimdisabledDimLevel OutlinedLabeledControl=LabeledOutlinedDisabledDim-- | Optional CSS hook for unavailable-state dimming.dimLevelClass ::DimLevel->TextdimLevelClass 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->ElementAffordancesemanticElementAffordance 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 ->SemanticLookupResultselectedSemanticFor _ Nothing=NoSelectedValueselectedSemanticFor options (Just selected) =case listToMaybe [ semantic | (value, semantic) <- options, value == selected ] ofJust semantic ->SelectedSemantic semanticNothing->MissingSelectedSemantic-- | Conservative rendering fallback for selected-value lookup.-- No value is neutral; missing semantics are suspicious and render as Clarify.semanticForLookupResult ::SemanticLookupResult->DomainSemanticsemanticForLookupResult NoSelectedValue=NeutralsemanticForLookupResult (SelectedSemantic semantic) = semanticsemanticForLookupResult MissingSelectedSemantic=Clarify-- | Convenience adapter for existing selected flags.selectionStateFromBool ::Bool->SelectionStateselectionStateFromBool True=SelectedselectionStateFromBool 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->TextsemanticPrefixedClass prefix SemanticPresentation { domainSemantic = semantic, salience = sal, selectionState = selection } =let intensity =case salienceTone sal selection ofFilled->"filled"Outlined->"outlined" color = semanticColor semanticin prefix <>" "<> prefix <>"-"<> intensity <>"-"<> color
A read-only semantic indicator that preserves colour and salience but
removes all interaction:
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:
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:
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:
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:
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.
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:
reuse application types and adapt pattern snippets, or
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 #-}modulePatterns.Htmx.Shared.TypeswhereimportPreludeimportData.Text (Text)importIHP.ViewPrelude-- | Typed selectors for HTMX attributes such as `hx-target` and `hx-indicator`.dataHxSelector=HxIdText|HxClassText|HxThis|HxClosestText|HxFindText|HxNextText|HxPreviousText|HxRawTextderiving (Show, Eq)-- | Render an `HxSelector` for use in HTMX attributes.renderHxSelector ::HxSelector->TextrenderHxSelector (HxId elementId) ="#"<> elementIdrenderHxSelector (HxClass className) ="."<> classNamerenderHxSelector HxThis="this"renderHxSelector (HxClosest selector) ="closest "<> selectorrenderHxSelector (HxFind selector) ="find "<> selectorrenderHxSelector (HxNext selector) ="next "<> selectorrenderHxSelector (HxPrevious selector) ="previous "<> selectorrenderHxSelector (HxRaw selector) = selector-- | Smart constructor for the most common case: targeting an element by id.hxIdSelector ::Text->HxSelectorhxIdSelector =HxId
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.
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:
Identify the one page-level H1.
For every other heading, ask whether it names a real section.
Replace non-section headings with semantic prose or component
labels.
Preserve visual treatment with a class when the typography is
intentional.
For generated fragments, normalize headings at the embedding
boundary rather than editing every source file to match one host
page.
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>
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 #-}modulePatterns.Views.AccessibilityHeadingOutlinewhereimportqualifiedData.TextasTextimportIHP.ViewPrelude-- | Heading level constrained to HTML's h1..h6 range.newtypeHeadingLevel=HeadingLevel { unHeadingLevel ::Int }deriving (Eq, Show)-- | Smart constructor for heading levels. Values outside h1..h6 are clamped.headingLevel ::Int->HeadingLevelheadingLevel n =HeadingLevel (clampHeadingLevel n)-- | Clamp a numeric heading level to HTML's h1..h6 range.clampHeadingLevel ::Int->IntclampHeadingLevel n| n <1=1| n >6=6|otherwise= n-- | The next structural heading level below the given level.childHeadingLevel ::HeadingLevel->HeadingLevelchildHeadingLevel (HeadingLevel n) = headingLevel (n +1)-- | Render a heading at the requested structural level.renderHeading ::HeadingLevel->Text->HtmlrenderHeading (HeadingLevel level) label =case level of1-> [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->HtmlrenderDeHeadedPrompt 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->TextshiftHtmlHeadings (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 accreplaceHeadingTag ::Int->Int->Text->TextreplaceHeadingTag sourceLevel targetLevel text = Text.replace ("</h"<> levelText sourceLevel <>">") ("</h"<> levelText targetLevel <>">") (Text.replace ("<h"<> levelText sourceLevel) ("<h"<> levelText targetLevel) text)levelText ::Int->TextlevelText 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:
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:
Find the outer layout wrapper used by normal pages.
Identify global chrome: header, navbar, footer, modal mounts, toast
mounts, live-region mounts, and other cross-page infrastructure.
Put exactly the primary page content in one
<main id="main-content" tabindex="-1">.
Keep layout-only wrappers as <div> unless they
also have a semantic region role.
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.
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.
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 #-}modulePatterns.Views.AccessibilityLandmarkswhereimportIHP.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.dataPageLandmarkConfig=PageLandmarkConfig { mainId ::Text , mainClasses ::Text , skipLinkLabel ::MaybeText }-- | Conventional baseline used by the examples.defaultPageLandmarkConfig ::PageLandmarkConfigdefaultPageLandmarkConfig =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->HtmlrenderPageLandmarks 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->HtmlrenderMainLandmark 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->MaybeText->HtmlrenderSkipLink _ Nothing=memptyrenderSkipLink targetId (Just label) = [hsx| <a class="skip-link" href={"#" <> targetId}>{label}</a>|]-- | Render a named navigation landmark.renderNamedNavigation ::Text->Html->HtmlrenderNamedNavigation label inner = [hsx| <nav aria-label={label}>{inner}</nav>|]-- | Render a named navigation landmark while preserving local styling classes.renderNamedNavigationWithClasses ::Text->Text->Html->HtmlrenderNamedNavigationWithClasses 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:
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:
Identify the semantic role of the color: brand, neutral-muted,
warning, danger, success, selected, disabled, or supporting chrome.
Decide whether the foreground or background side of the pair is
fixed. Brand anchors usually are; generic neutrals often are not.
Pick the minimum token change that restores contrast without
changing the semantic role.
Put foreground color in one place for the component semantic.
Make icons inherit with currentColor /
color: inherit.
Check normal, hover, focus, active, selected, disabled, and static
unavailable placeholder states.
For translucent hover fills, check the foreground against the
composited color that users actually see.
If a token is used both as outline text and hover fill, verify both
uses.
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.
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.
In every loud or inverted signal context, reassert the signal
foreground over all semantically colored descendant text.
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:
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:
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.
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 #-}modulePatterns.Views.AccessibilitySemanticColorwhereimportData.Text (Text)importPrelude-- | 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.dataContrastAnchor=BrandBackgroundFixed|BrandForegroundFixed|BackgroundFixed|ForegroundFixed|NoFixedAnchorderiving (Eq, Show)-- | Which side should be adjusted first when contrast fails.dataContrastAdjustment=AdjustForeground|AdjustBackground|AdjustEitherSidederiving (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->ContrastAdjustmentchooseContrastAdjustment BrandBackgroundFixed=AdjustForegroundchooseContrastAdjustment BrandForegroundFixed=AdjustBackgroundchooseContrastAdjustment BackgroundFixed=AdjustForegroundchooseContrastAdjustment ForegroundFixed=AdjustBackgroundchooseContrastAdjustment NoFixedAnchor=AdjustEitherSide-- | Whether one token is used for one visual job or deliberately does two jobs.dataTokenDuty=SingleDutyToken|DualDutyStrongTokenderiving (Eq, Show)-- | Whether the surrounding surface is ordinary or a loud/inverted signal-- context. This is independent of the parent pair's contrast threshold.dataSignalContext=NormalColorContext|LoudSignalContext|InvertedSignalContextderiving (Eq, Show)-- | Whether a signal context should let descendants keep their normal semantic-- foregrounds or reassert the signal foreground.dataSignalForegroundPolicy=KeepDescendantSemanticForegrounds|ReassertSignalForegroundderiving (Eq, Show)-- | Loud or inverted signal contexts should reassert their foreground over-- semantic descendants. Normal contexts can leave descendants alone.requiresSignalForegroundReassertion ::SignalContext->SignalForegroundPolicyrequiresSignalForegroundReassertion NormalColorContext=KeepDescendantSemanticForegroundsrequiresSignalForegroundReassertion LoudSignalContext=ReassertSignalForegroundrequiresSignalForegroundReassertion 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.dataContrastPolicy=MustMeetNormalTextAA|MustMeetLargeTextAAWithSecondCuederiving (Eq, Show)-- | Semantic role of a rendered color token.dataColorSemanticRole=BrandIdentity|NeutralMuted|NeutralDefault|PositiveSignal|NegativeSignal|WarningSignal|DisabledAffordancederiving (Eq, Show, Enum, Bounded)-- | CSS class stems used by local renderers.semanticColorClassStem ::ColorSemanticRole->TextsemanticColorClassStem 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.dataSemanticColorPair=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.dataSemanticControlPalette=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->BoolusesSingleForegroundToken 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->TextsemanticButtonClass role ="btn-"<> semanticColorClassStem role
A palette check can flag foreground drift before a browser audit
checks actual contrast ratios:
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.
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:
Every icon-only control needs an accessible name. Use
aria-label or visually hidden text on the control
itself.
Name the operation, not the visual treatment. Reopening a picker is
"Choose another member", not "Edit user".
Include target context when several same-shaped controls coexist.
Use "Close artist input" rather than a generic "Close" for a repeated
field dismiss button.
Generic verbs are acceptable only when the surrounding component
makes the target unambiguous, for example the single close button in a
modal.
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.
Decorative icons inside a named control are hidden with
aria-hidden="true". Their color follows the control through
currentColor; see AccessibilitySemanticColor.
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.
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.
Centralize names in shared render helpers. Table actions, header
actions, close buttons, media buttons, and picker controls should not
each hand-roll naming.
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:
Find icon-only <button> and
<a> elements in tables, cards, headers, media
controls, dropdown toggles, and picker widgets.
For each control, ask what operation it actually performs and
whether the current name says that operation.
Replace vague or stale names with localized operation labels that
include the target context when needed.
Remove redundant aria-label from controls that already
have visible text or a proper <label for>
relationship.
Mark inner decorative icons as aria-hidden="true".
Move naming into the shared helper that creates the repeated
control.
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:
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 #-}modulePatterns.Views.AccessibleControlNames ( AccessibleName(..) , NameSpecificity(..) , ControlElement(..) , ControlOperation(..) , LocalizedControlLabel , localizedControlLabel , requiredLocalizedControlLabel , controlLabelText , NavigationTarget , navigationTarget , navigationTargetText , elementForOperation , ariaLabelText , isDecorativeName , isSpecificEnough , nameForAffordance , nameForActionability , renderIconOnlyButton , renderVisibleTextButton , renderNavigationLink , renderUnavailableIconPlaceholder ) whereimportqualifiedData.TextasTextimportIHP.ViewPreludeimportqualifiedPreludeasPimportPatterns.Htmx.Primitives.SemanticValueAxes ( Actionability(..) , ElementAffordance(..) , LabelPresence(..) , VisualTone(..) , actionAffordanceForTone , dimLevel , dimLevelClass )-- | Where a rendered control's accessible name comes from.dataAccessibleName=FromVisibleLabel-- ^ Visible text or a <label for> relationship already names it.|FromAriaLabelLocalizedControlLabel-- ^ 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.dataNameSpecificity=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.dataControlElement=NativeButton|NativeLink|NonControlPlaceholderderiving (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.newtypeNavigationTarget=NavigationTarget { navigationTargetText ::Text }deriving (Eq, Show)-- | Operation shape before rendering.dataControlOperation=MutatingAction|NavigationActionNavigationTarget|AlignmentOnlyPlaceholderderiving (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="".newtypeLocalizedControlLabel=LocalizedControlLabel { controlLabelText ::Text }deriving (Eq, Show)-- | Build a non-empty localized control label after trimming whitespace.localizedControlLabel ::Text->MaybeLocalizedControlLabellocalizedControlLabel rawLabel =let stripped = Text.strip rawLabelinif Text.null strippedthenNothingelseJust (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->LocalizedControlLabelrequiredLocalizedControlLabel rawLabel =case localizedControlLabel rawLabel ofJust label -> labelNothing-> 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->MaybeNavigationTargetnavigationTarget rawTarget =let stripped = Text.strip rawTarget lowered = Text.toLower strippedinif Text.null stripped|| stripped =="#"||"javascript:"`Text.isPrefixOf` loweredthenNothingelseJust (NavigationTarget stripped)-- | Choose the native element from the operation, not from visual styling.elementForOperation ::ControlOperation->ControlElementelementForOperation MutatingAction=NativeButtonelementForOperation (NavigationAction _) =NativeLinkelementForOperation AlignmentOnlyPlaceholder=NonControlPlaceholder-- | Controls with visible text or label relationships should not receive a-- duplicate aria-label.ariaLabelText ::AccessibleName->MaybeTextariaLabelText FromVisibleLabel=NothingariaLabelText (FromAriaLabel label) =Just (controlLabelText label)ariaLabelText Decorative=Nothing-- | Decorative content is hidden from assistive technology.isDecorativeName ::AccessibleName->BoolisDecorativeName Decorative=TrueisDecorativeName _ =False-- | Generic names are acceptable only when the component context is-- unambiguous. Repeated row/header/action controls should be contextual.isSpecificEnough ::NameSpecificity->Bool->BoolisSpecificEnough GenericName isSingleObviousTarget = isSingleObviousTargetisSpecificEnough 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->AccessibleNamenameForAffordance 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->AccessibleNamenameForActionability 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:
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.
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:
Prefer native keyboard-operable elements:
<button>, <a href>, form fields,
<details>/<summary>, and framework-backed
modal/dialog primitives.
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.
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.
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.
Keep DOM order aligned with visual reading and interaction
order.
Keep focus visible. Do not remove outlines unless the replacement is
at least as visible and works in every state.
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.
Avoid tabindex="0" except for genuine custom-widget
focus roots; document their keyboard behavior when used.
Never use positive tabindex values. They create a parallel tab order
that diverges from the DOM.
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.
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:
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.
Follow the whole page with Tab and
Shift+Tab. The order should match the visible order and
should not enter inert placeholders.
Activate every focused control with keyboard only. Buttons need
Enter and Space; links need Enter
and real href navigation.
Check dropdowns, typeaheads, modals, and inline HTMX controls in
their open, closed, loading, and swapped states.
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.
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.
For inline edit controls, replace clickable text spans with
neutral-styled buttons and preserve the focus ring.
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.
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:
{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE QuasiQuotes #-}modulePatterns.Views.KeyboardOperability ( FocusableTargetId , focusableTargetIdFromText , focusableTargetIdText , KeyboardControlLabel , keyboardControlLabel , keyboardControlLabelText , InteractionIntent(..) , KeyboardContract(..) , FocusPlacement(..) , RegionOperability(..) , FocusVisibility(..) , keyboardContractForIntent , isNativeKeyboardContract , focusPlacementTabIndex , regionNeedsDescendantFocusHandling , renderNonSemanticProgrammaticFocusTarget , renderNativeDisclosure , renderCustomDisclosureButton , renderFocusableContainerLinkSlot , renderPlainTextButton , renderKeyboardChecklistWarning ) whereimportData.Char (isSpace)importData.Text (Text)importqualifiedData.TextasTextimportIHP.ViewPreludeimportPatterns.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.newtypeFocusableTargetId=FocusableTargetId { focusableTargetIdText ::Text }deriving (Eq, Show)focusableTargetIdFromText ::Text->MaybeFocusableTargetIdfocusableTargetIdFromText raw =let trimmed = Text.strip rawinif Text.null trimmed || Text.any isSpace trimmedthenNothingelseJust (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>.newtypeKeyboardControlLabel=KeyboardControlLabel { keyboardControlLabelText ::Text }deriving (Eq, Show)keyboardControlLabel ::Text->MaybeKeyboardControlLabelkeyboardControlLabel raw =let trimmed = Text.strip rawinif Text.null trimmedthenNothingelseJust (KeyboardControlLabel trimmed)-- | User-facing operation shape before choosing HTML.dataInteractionIntent=MutatingAction|NavigationAction|FormFieldEntry|NativeDisclosure|ModalDialogInteraction|CustomCompositeWidgetderiving (Eq, Show, Enum, Bounded)-- | Keyboard contract implied by the rendered shape.dataKeyboardContract=ButtonKeyboardContract|LinkKeyboardContract|FieldKeyboardContract|DetailsSummaryKeyboardContract|DialogKeyboardContract|CustomWidgetKeyboardContractderiving (Eq, Show, Enum, Bounded)-- | Whether an element participates in sequential tab navigation.dataFocusPlacement=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.dataRegionOperability=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.dataFocusVisibility=NativeFocusVisible|CustomFocusVisible|FocusHiddenAntiPatternderiving (Eq, Show, Enum, Bounded)keyboardContractForIntent ::InteractionIntent->KeyboardContractkeyboardContractForIntent MutatingAction=ButtonKeyboardContractkeyboardContractForIntent NavigationAction=LinkKeyboardContractkeyboardContractForIntent FormFieldEntry=FieldKeyboardContractkeyboardContractForIntent NativeDisclosure=DetailsSummaryKeyboardContractkeyboardContractForIntent ModalDialogInteraction=DialogKeyboardContractkeyboardContractForIntent CustomCompositeWidget=CustomWidgetKeyboardContractisNativeKeyboardContract ::KeyboardContract->BoolisNativeKeyboardContract CustomWidgetKeyboardContract=FalseisNativeKeyboardContract _ =TruefocusPlacementTabIndex ::FocusPlacement->MaybeTextfocusPlacementTabIndex NativeSequentialFocus=NothingfocusPlacementTabIndex ProgrammaticFocusOnly=Just"-1"focusPlacementTabIndex CustomSequentialFocusRoot=Just"0"regionNeedsDescendantFocusHandling ::RegionOperability->BoolregionNeedsDescendantFocusHandling RegionInteractive=FalseregionNeedsDescendantFocusHandling RegionHiddenFromAll=FalseregionNeedsDescendantFocusHandling RegionInertWithFallback=TrueregionNeedsDescendantFocusHandling 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.
Render a native disclosure before reaching for custom accordion
behavior. The summary is an interactive control, so its visible label is
smart-constructed.
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.
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".
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.
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.
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.
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:
Introduce contentPadded, fullBleed, and
the Section API in parallel.
Do not change legacy wrappers yet. Instead, add *Raw
variants of existing table helpers that render markup without a
container wrapper.
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:
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:
{-# LANGUAGE QuasiQuotes #-}{-# LANGUAGE OverloadedStrings #-}modulePatterns.Views.SectionedContainerwhereimportIHP.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).dataBodyRole=PaddedBody|FullBleedBody-- | Content-padded container: consistent side padding for text, buttons,-- headings. Aligns with the navbar brand's left edge.contentPadded ::Html->HtmlcontentPadded 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->HtmlfullBleed 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->HtmlrenderIndexSectionWithControls header controls bodyRole bodyHtml = contentPadded header<> contentPadded controls<>case bodyRole ofPaddedBody-> contentPadded bodyHtmlFullBleedBody-> fullBleed bodyHtml-- | Render a complete index section without controls (header + body only).renderIndexSection ::Html->BodyRole->Html->HtmlrenderIndexSection 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->HtmlrenderEmbeddedTableSection 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)->HtmlrenderEntityTableAndCardsRaw 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:
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->HtmlrenderMatrixSection 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 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.
Add optional third-party provider/API search or enrichment with
explicit provider boundaries
Complete
Recipe structure
Every recipe follows a stable shape:
Purpose — when to use this recipe.
Prerequisites — patterns the agent should know
before starting.
Recipe order — numbered steps applied in
sequence.
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.
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.
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:
InlineDropdown
for keyboard-safe inline enum selection with selected-state
semantics,
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
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:
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?
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.
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.
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.
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
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.