diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for bureaucromancy
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+              GNU GENERAL PUBLIC LICENSE
+                Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                     Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+              END OF TERMS AND CONDITIONS
+
+     How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Main where
+
+import Network.Wai.Handler.Warp
+import Network.Wai
+import Network.HTTP.Types
+import System.Environment (getArgs)
+
+import Text.HTML.Form.WebApp
+import Text.HTML.Form.Query
+import Text.HTML.Form
+
+import Data.Text.Lazy.Encoding (encodeUtf8)
+import qualified Data.Text.Encoding as Txt
+import Data.Text.Lazy (fromStrict)
+import qualified Data.Text as Txt
+import qualified Text.HTML.DOM as HTML
+import Data.Maybe (fromJust, fromMaybe)
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let (filename, ident) = case args of
+        n:anchor:_ -> (n, anchor)
+        [n] -> (n, "0")
+        [] -> ("form.html", "0")
+  doc <- HTML.readFile filename
+  let form = ensureButtons $ fromJust $ parseDocument doc $ Txt.pack ident
+  runEnv 2018 $ servePage form
+
+servePage :: Form -> Application
+servePage form req respond = do
+    ret <- renderPage form (pathInfo req) (queryString req)
+    case ret of
+        Just (Right txt) ->
+            respond $ responseLBS status200 [] $ encodeUtf8 $ fromStrict txt
+        Just (Left qs) -> respond $ responseLBS status200 [] $
+            encodeUtf8 $ fromStrict $ Txt.pack $ renderQueryString'
+            [(utf8 k, utf8 $ fromMaybe "" v) | (k, v) <- qs]
+        Nothing -> respond $ responseLBS status404 [] "Unknown input or operation!"
+
+utf8 = Txt.unpack . Txt.decodeUtf8
diff --git a/bureaucromancy.cabal b/bureaucromancy.cabal
new file mode 100644
--- /dev/null
+++ b/bureaucromancy.cabal
@@ -0,0 +1,136 @@
+cabal-version:      3.0
+-- The cabal-version field refers to the version of the .cabal specification,
+-- and can be different from the cabal-install (the tool) version and the
+-- Cabal (the library) version you are using. As such, the Cabal (the library)
+-- version used must be equal or greater than the version stated in this field.
+-- Starting from the specification version 2.2, the cabal-version field must be
+-- the first thing in the cabal file.
+
+-- Initial package description 'bureaucromancy' generated by
+-- 'cabal init'. For further documentation, see:
+--   http://haskell.org/cabal/users-guide/
+--
+-- The name of the package.
+name:               bureaucromancy
+
+-- The package version.
+-- See the Haskell package versioning policy (PVP) for standards
+-- guiding when and how versions should be incremented.
+-- https://pvp.haskell.org
+-- PVP summary:     +-+------- breaking API changes
+--                  | | +----- non-breaking API additions
+--                  | | | +--- code changes with no API change
+version:            0.1.0.0
+
+-- A short (one-line) description of the package.
+synopsis:           Parse webforms & render to interactive hypertext
+
+-- A longer description of the package.
+-- description:
+
+-- URL for the project homepage or repository.
+homepage:           https://argonaut-constellation.org/
+
+-- The license under which the package is released.
+license:            GPL-3.0-only
+
+-- The file containing the license text.
+license-file:       LICENSE
+
+-- The package author(s).
+author:             Adrian Cochrane
+
+-- An email address to which users can send suggestions, bug reports, and patches.
+maintainer:         adrian@openwork.nz
+
+-- A copyright notice.
+-- copyright:
+category:           Web
+build-type:         Simple
+
+-- Extra doc files to be distributed with the package, such as a CHANGELOG or a README.
+extra-doc-files:    CHANGELOG.md
+
+-- Extra source files to be distributed with the package, such as examples, or a tutorial module.
+-- extra-source-files:
+
+common warnings
+    ghc-options: -Wall
+
+library
+    -- Import common warning flags.
+    import:           warnings
+
+    -- Modules exported by the library.
+    exposed-modules:  Text.HTML.Form, Text.HTML.Form.Query,
+        Text.HTML.Form.WebApp, Text.HTML.Form.WebApp.Ginger,
+        Text.HTML.Form.WebApp.Ginger.Hourglass, Text.HTML.Form.WebApp.Ginger.TZ,
+        Text.HTML.Form.Colours, Text.HTML.Form.Validate
+
+    -- Modules included in this library but not exported.
+    -- other-modules:
+
+    -- LANGUAGE extensions used by modules in this package.
+    -- other-extensions:
+
+    -- Other library packages from which modules are imported.
+    build-depends:    base ^>=4.16.4.0, ginger, file-embed-lzma, file-embed, mtl,
+            bytestring, text, xml-conduit, network-uri, regex-tdfa, containers,
+            filepath, directory, hourglass >= 0.2.12 && < 0.3, tz >= 0.1 && < 0.2,
+
+    -- Directories containing source files.
+    hs-source-dirs:   src
+
+    -- Base language which the package is written in.
+    default-language: Haskell2010
+
+executable bureaucromancy
+    -- Import common warning flags.
+    import:           warnings
+
+    -- .hs or .lhs file containing the Main module.
+    main-is:          Main.hs
+
+    -- Modules included in this executable, other than Main.
+    -- other-modules:
+
+    -- LANGUAGE extensions used by modules in this package.
+    -- other-extensions:
+
+    -- Other library packages from which modules are imported.
+    build-depends:
+        base ^>=4.16.4.0,
+        bureaucromancy, warp, wai, http-types, text, html-conduit
+
+    -- Directories containing source files.
+    hs-source-dirs:   app
+
+    -- Base language which the package is written in.
+    default-language: Haskell2010
+
+test-suite bureaucromancy-test
+    -- Import common warning flags.
+    import:           warnings
+
+    -- Base language which the package is written in.
+    default-language: Haskell2010
+
+    -- Modules included in this executable, other than Main.
+    -- other-modules:
+
+    -- LANGUAGE extensions used by modules in this package.
+    -- other-extensions:
+
+    -- The interface type and version of the test suite.
+    type:             exitcode-stdio-1.0
+
+    -- Directories containing source files.
+    hs-source-dirs:   test
+
+    -- The entrypoint to the test suite.
+    main-is:          Main.hs
+
+    -- Test dependencies.
+    build-depends:
+        base ^>=4.16.4.0,
+        bureaucromancy
diff --git a/src/Text/HTML/Form.hs b/src/Text/HTML/Form.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form.hs
@@ -0,0 +1,502 @@
+{-# LANGUAGE OverloadedStrings, TypeSynonymInstances, FlexibleInstances #-}
+-- | Parse webforms out of webpages
+module Text.HTML.Form (Form(..), Input(..), OptionGroup(..), Option(..),
+    FileSelector(..), defaultFileData, ImageData(..), defaultImageData,
+    TextArea(..), defaultTextArea, parseElement, parseDocument, ensureButtons) where
+
+import Data.Text (Text)
+import qualified Data.Text as Txt
+import Text.XML.Cursor
+import Text.XML (Document, Name(..), Node(..))
+
+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
+import Data.List (singleton)
+import Text.Read (readMaybe)
+import Data.Function (on)
+
+import Network.URI (parseURIReference, URI, nullURI)
+import Text.Regex.TDFA (Regex, defaultCompOpt, defaultExecOpt)
+import Text.Regex.TDFA.Text (compile)
+
+-- | A collection of controls intended to be handle by a particular URL endpoint.
+data Form = Form {
+    -- | The URL which should receive valid input from this form.
+    action :: URI,
+    -- | How to encode the data to be received by the URL.
+    enctype :: Text,
+    -- | Which HTTP method to use.
+    method :: Text,
+    -- | Whether to validate the form data before submitting it to the endpoint.
+    validate :: Bool,
+    -- | Where to display the response.
+    target :: Text,
+    -- | Which character sets to encode the data in.
+    acceptCharset :: [Text],
+    -- | Whether to offer autocompletions for all controls.
+    autocomplete :: Bool,
+    -- | The name of this form.
+    formName :: Text,
+    -- | The purpose of this form, typically using an external vocabulary.
+    rel :: Text,
+    -- | What data should be sent to the endpoint.
+    inputs :: [Input]
+}
+
+-- | Individual piece of data to send to a webservice.
+data Input = Input {
+    -- Core attributes
+    -- | Human-legible yet brief description of this input.
+    label :: Text,
+    -- | Human-legible longer-form description of this input.
+    description :: Node,
+    -- | How this control should be presented to the user, supporting all the HTML5 input types.
+    -- Support for more types may be added in the future, with any unsupported types
+    -- fallingback to text entry.
+    inputType :: Text,
+    -- | In which query parameter should we store the text direction?
+    dirname :: Text,
+    -- | In which query parameter should we store this value?
+    inputName :: Text,
+    -- State
+    -- | The user-provided value or caller-provided default to upload to the server.
+    value :: Text,
+    -- | Whether to autocomplete this input, if its enabled on the form.
+    inputAutocomplete :: Text,
+    -- | Whether this input has initial focus.
+    autofocus :: Bool,
+    -- | Whether (for certain types) to upload the data for this input.
+    checked :: Bool,
+    -- | Whether to temporarily-disallow users from editting this value.
+    disabled :: Bool,
+    -- | Whether to permanantly-disallow users from editting this value.
+    readonly :: Bool,
+    -- Input behaviour
+    -- | Whether to allow entering multiple values.
+    multiple :: Bool,
+    -- | If this control is used to submit the form, where to upload it.
+    formAction :: Maybe URI,
+    -- | If this control is used to submit the form, which text encoding to use in the upload.
+    formEnctype :: Maybe Text,
+    -- | If this control is used to submit the form, which HTTP method to use.
+    formMethod :: Maybe Text,
+    -- | If this control is used to submit the form, whether to enforce validation.
+    formValidate :: Bool,
+    -- | If this control is used to submit the form, where to render the response.
+    formTarget :: Maybe Text,
+    -- | Suggests which keyboard to use for the input.
+    inputMode :: Text,
+    -- | Autocompletion values provided by caller.
+    list :: [OptionGroup],
+    -- Validation
+    -- | The minimum & maximum values for the value of this input.
+    range :: (Maybe Text, Maybe Text),
+    -- | In which period from start do valid values occur?
+    step :: Maybe Text,
+    -- | The minimum & maximum lengths for the value of this input.
+    lengthRange :: (Maybe Int, Maybe Int),
+    -- | Optional regex to enforce on the value of this input.
+    pattern :: Maybe Regex,
+    -- | Whether this control must have a value for it to be considered valid.
+    required :: Bool,
+    -- Presentation
+    -- | Sample value, often visual clarity of its role incurs inaccessibility.
+    -- Make sure to communicate what's implied here elsewhere.
+    placeholder :: Text,
+    -- sort by tabindex?
+    -- | Longform clarifications.
+    title :: Text,
+    -- | How wide the control should be.
+    size :: Maybe Int,
+    -- | Additional data for inputs of type "file".
+    fileData :: FileSelector,
+    -- | Additional data for inputs of type "image".
+    imageData :: ImageData,
+    -- | Additional data for inputs of type "textarea".
+    textArea :: TextArea
+}
+-- | A labelled-group of options, that can be collectively disabled.
+data OptionGroup = OptGroup {
+    -- | A brief human-legible description of the options on this group.
+    optsLabel :: Text,
+    -- | Whether these options can be selected.
+    optsDisabled :: Bool,
+    -- | The options in this group.
+    subopts :: [Option]
+}
+-- | A possible value for an input.
+data Option = Option {
+    -- | Human-legible text identifying this option.
+    optLabel :: Text,
+    -- | Machine-legible text identifying this option.
+    optValue :: Text,
+    -- | Whether the option is selected.
+    optSelected :: Bool,
+    -- | Whether the option can be selected.
+    optDisabled :: Bool
+}
+-- | Data specific to "file" inputs.
+data FileSelector = FileSelector {
+    -- | The MIMEtypes of the files which can be validly entered into this control.
+    fileAccept :: [Text],
+    -- | Whether options for capturing from a camera should be offered.
+    fileCapture :: Text
+}
+-- | Empty values for file data.
+defaultFileData :: FileSelector
+defaultFileData = FileSelector [] ""
+-- | Data specific to "image" inputs.
+data ImageData = ImageData {
+    -- | Text describing the image, in case the reader can't view it.
+    imgAlt :: Maybe Text,
+    -- | How much screenspace the image takes up.
+    imgSize :: (Maybe Int, Maybe Int),
+    -- | The link to the image.
+    imgSrc :: Maybe URI
+}
+-- | Empty values for image data.
+defaultImageData :: ImageData
+defaultImageData = ImageData Nothing (Nothing, Nothing) Nothing
+-- | Data specific to textarea inputs.
+data TextArea = TextArea {
+    -- | Whether to enable autocorrect.
+    autocorrect :: Bool,
+    -- | Number of rows to display.
+    rows :: Maybe Int,
+    -- | Whether to enable spellcheck.
+    spellcheck :: Maybe Bool,
+    -- | Whether to enable text-wrap.
+    textwrap :: Maybe Bool
+}
+-- | Empty values for textarea data.
+defaultTextArea :: TextArea
+defaultTextArea = TextArea True Nothing Nothing Nothing
+
+-- | Helper for looking up attributes on a selected element, with fallback.
+attr :: Text -> Cursor -> Text -> Text
+attr n el def | [ret] <- n `laxAttribute` el = ret
+    | otherwise = def
+-- | Helper for looking up attributes on a selected element, with fallback & callback.
+attr' :: Text -> Cursor -> (Text -> a) -> Text -> a
+attr' n el cb def = cb $ attr n el def
+-- | Variant of `attr'` which passes which unpacks the callback's argument to a string.
+attr'' :: Text -> Cursor -> (String -> a) -> Text -> a
+attr'' n el cb def = attr' n el (cb . Txt.unpack) def
+-- | Helper for checking whether an attribute is present.
+hasAttr :: Name -> Cursor -> Bool
+hasAttr n = not . null . hasAttribute n
+-- | Helper for looking up an attribute on a selected element if present.
+mAttr :: Text -> Cursor -> Maybe Text
+mAttr n = listToMaybe . laxAttribute n
+-- | Parse a form from the selected HTML element.
+parseElement :: Cursor -> Maybe Form
+parseElement el | _:_ <- laxElement "form" el = Just Form {
+        action = attr'' "action" el (fromMaybe nullURI . parseURIReference) ".",
+        enctype = attr "enctype" el "",
+        method = attr "method" el "GET",
+        validate = null $ hasAttribute "novalidate" el,
+        target = attr "target" el "_self",
+        acceptCharset = attr' "accept-charset" el Txt.words "utf-8",
+        autocomplete = hasAttr "autocomplete" el,
+        formName = attr "name" el "",
+        rel = attr "rel" el "",
+        inputs = mapMaybe parseInput $ queryInputs el
+      }
+    | otherwise = Nothing
+
+-- | Helper to retrieve the root node of a document.
+root :: Axis
+root = singleton . last . orSelf ancestor
+-- | Case-insensitive element selection.
+laxElements :: [Text] -> Axis
+laxElements ns = checkName (\x -> or [
+    on (==) Txt.toCaseFold n $ nameLocalName x | n <- ns])
+-- | Retrieve all the inputs associated with a form element.
+queryInputs :: Cursor -> [Cursor]
+queryInputs form = (allInputs >=> inForm) form
+  where
+    allInputs = root >=> descendant >=> laxElements [
+        "input", "textarea", "button", "select"]
+    inForm = check (\x ->
+        laxAttribute "form" x == laxAttribute "id" form ||
+        nestedInForm x)
+    nestedInForm x = listToMaybe ((ancestor >=> laxElement "form") x) == Just form
+-- | Parse an input from the selected element.
+parseInput :: Cursor -> Maybe Input
+parseInput el | _:_ <- laxElement "input" el = Just Input {
+        label = fromMaybe
+                -- Additional fallbacks are primarily for buttons
+                (attr "name" el $ attr "value" el $ attr "alt" el $
+                attr "type" el "text") $ fmap text label',
+        description = fromMaybe (mkEl $ attr "title" el "") $ fmap node $
+            elByID (attr "aria-describedby" el "") `orElse` label',
+        inputType = attr "type" el "text",
+        value = attr "value" el "",
+        inputAutocomplete = attr "autocomplete" el "on",
+        autofocus = hasAttr "autofocus" el,
+        checked = hasAttr "checked" el,
+        -- NOTE: No remaining harm in displaying hidden inputs,
+        -- might be informative...
+        disabled = hasAttr "disabled" el || attr "type" el "" == "hidden",
+        readonly = hasAttr "readonly" el || attr "type" el "" == "hidden",
+        multiple = hasAttr "multiple" el,
+        dirname = attr "dirname" el "",
+        inputName = attr "name" el "",
+        formAction = if hasAttr "formaction" el
+            then attr' "formaction" el (parseURIReference . Txt.unpack) ""
+            else Nothing,
+        formEnctype = mAttr "formenctype" el,
+        formMethod = mAttr "formmethod" el,
+        formValidate = not $ hasAttr "formnovalidate" el,
+        formTarget = mAttr "formtarget" el,
+        inputMode = attr "inputmode" el "text",
+        list = fromMaybe [] $ fmap parseOptions (elByID =<< mAttr "list" el),
+        range = (mAttr "min" el, mAttr "max" el),
+        step = mAttr "step" el,
+        lengthRange = (attr'' "minlength" el readMaybe "",
+            attr'' "maxLength" el readMaybe ""),
+        pattern = attr' "pattern" el
+            (rightToMaybe . compile defaultCompOpt defaultExecOpt) ".*",
+        required = hasAttr "required" el,
+        placeholder = attr "placeholder" el "",
+        title = attr "title" el "",
+        size = attr'' "size" el readMaybe "",
+        fileData = FileSelector {
+            fileAccept = attr' "accept" el Txt.words "*",
+            fileCapture = attr "capture" el ""
+        },
+        imageData = ImageData {
+            imgAlt = mAttr "alt" el,
+            imgSize = (attr'' "width" el readMaybe "",
+                attr'' "height" el readMaybe ""),
+            imgSrc = attr'' "src" el (parseURIReference) ""
+        },
+        textArea = defaultTextArea
+      }
+    | _:_ <- laxElement "textarea" el = Just Input {
+        inputType = "<textarea>",
+        label = fromMaybe (attr "name" el "") $ fmap text label',
+        description = fromMaybe (mkEl $ attr "title" el "") $ fmap node $
+            elByID (attr "aria-describedby" el "") `orElse` label',
+        value = text el,
+
+        inputAutocomplete = attr "autocomplete" el "on",
+        autofocus = hasAttr "autofocus" el,
+        size = attr'' "cols" el readMaybe "",
+        dirname = attr "dirname" el "",
+        disabled = hasAttr "disabled" el,
+        lengthRange = (attr'' "minLength" el readMaybe "",
+            attr'' "maxLength" el readMaybe ""),
+        inputName = attr "name" el "",
+        placeholder = attr "placeholder" el "",
+        readonly = hasAttr "readonly" el,
+        required = hasAttr "required" el,
+        title = attr "title" el "",
+        inputMode = attr "inputMode" el "text",
+        textArea = TextArea {
+            autocorrect = attr "autocorrect" el "on" /= "off",
+            rows = attr'' "rows" el readMaybe "",
+            spellcheck = attr' "spellcheck" el (\x -> case x of
+                "true" -> Just True
+                "false" -> Just False
+                "default" -> Nothing
+                _ -> Nothing) "default",
+            textwrap = attr' "wrap" el (\x -> case x of
+                "hard" -> Just True
+                "soft" -> Just False
+                "off" -> Nothing
+                _ -> Just False) "soft"
+        },
+
+        checked = False,
+        multiple = True,
+        formAction = Nothing,
+        formEnctype = Nothing,
+        formMethod = Nothing,
+        formValidate = False,
+        formTarget = Nothing,
+        list = [],
+        range = (Nothing, Nothing),
+        step = Nothing,
+        pattern = Nothing,
+        fileData = defaultFileData,
+        imageData = defaultImageData
+    }
+    | _:_ <- laxElement "button" el = Just Input {
+        -- Fallingback to the input itself as its label allow for
+        -- the full richness of its children to be rendered!
+        label = fromMaybe (text el) $ fmap text label',
+        description = fromMaybe (node el) $ fmap node $
+            elByID $ attr "aria-describedby" el "",
+
+        autofocus = hasAttr "autofocus" el,
+        disabled = hasAttr "disabled" el,
+        formAction = if hasAttr "formaction" el
+            then attr' "formaction" el (parseURIReference . Txt.unpack) ""
+            else Nothing,
+        formEnctype = mAttr "formenctype" el,
+        formMethod = mAttr "formmethod" el,
+        formValidate = not $ hasAttr "formnovalidate" el,
+        formTarget = mAttr "formtarget" el,
+        inputName = attr "name" el "",
+        -- Popover buttons should be handled by HTML engine, not form engine.
+        inputType = attr "type" el "submit",
+        value = attr "value" el "",
+        title = attr "title" el "",
+        -- Placeholder makes sense as a place to put the label...
+        placeholder = Txt.concat $ (descendant >=> content) el,
+
+        dirname = "",
+        inputAutocomplete = "",
+        checked = False, -- Switch to true for the activated button!
+        readonly = False,
+        multiple = False,
+        inputMode = "",
+        list = [],
+        range = (Nothing, Nothing),
+        step = Nothing,
+        lengthRange = (Nothing, Nothing),
+        pattern = Nothing,
+        required = False,
+        size = Nothing,
+        fileData = defaultFileData,
+        imageData = defaultImageData,
+        textArea = defaultTextArea
+    }
+    | _:_ <- laxElement "select" el = Just Input {
+        inputType = "<select>",
+        label = fromMaybe (attr "name" el "") $ fmap Txt.concat $
+            fmap filterSelect label',
+        description = fromMaybe (mkEl $ attr "title" el "") $ fmap node $
+            elByID $ attr "aria-describedby" el "",
+
+        inputAutocomplete = attr "autocomplete" el "on",
+        autofocus = hasAttr "autofocus" el,
+        disabled = hasAttr "disabled" el,
+        multiple = hasAttr "multiple" el,
+        inputName = attr "name" el "",
+        required = hasAttr "required" el,
+        size = attr'' "size" el readMaybe "",
+        list = parseOptions el,
+        title = attr "title" el "",
+
+        dirname = "",
+        value = "", -- Sourced from list...
+        checked = False,
+        readonly = False,
+        formAction = Nothing,
+        formEnctype = Nothing,
+        formMethod = Nothing,
+        formValidate = False,
+        formTarget = Nothing,
+        inputMode = "",
+        range = (Nothing, Nothing),
+        step = Nothing,
+        lengthRange = (Nothing, Nothing),
+        pattern = Nothing,
+        placeholder = "",
+        fileData = defaultFileData,
+        imageData = defaultImageData,
+        textArea = defaultTextArea
+      }
+    | otherwise = Nothing
+  where
+    elByAttr k v = listToMaybe $ (root >=> descendant >=> attributeIs k v) el
+    elByID = elByAttr "id"
+    label' = elByAttr "for" (attr "id" el "") `orElse`
+            listToMaybe $ (ancestor >=> laxElement "label") el
+    filterSelect = descendant >=>
+        checkNot (orSelf ancestor >=> laxElement "select") >=>
+        content
+-- | Parse the options beneath a selected element.
+parseOptions :: Cursor -> [OptionGroup]
+parseOptions el = [parseGroup opt
+    | opt <- (descendant >=> laxElements ["option", "optgroup"] >=>
+        checkNot (parent >=> laxElement "optgroup")) el]
+  where
+    parseGroup opt
+        | _:_ <- laxElement "option" opt =
+            OptGroup "" False [parseOption opt False]
+        | _:_ <- laxElement "optgroup" opt = OptGroup {
+            optsLabel = attr "label" opt "",
+            optsDisabled = hasAttr "disabled" opt,
+            subopts = [parseOption o $ hasAttr "disabled" opt | o <- child opt]
+          }
+        | otherwise = OptGroup "" True [] -- Shouldn't happen!
+    parseOption opt disabledOverride = Option {
+        optLabel = attr "label" opt $ text opt,
+        optValue = attr "value" opt $ text opt,
+        optSelected = hasAttr "selected" opt,
+        optDisabled = hasAttr "disabled" opt || disabledOverride
+      }
+
+-- | Parse a named or numerically-indexed form from an HTML document.
+parseDocument :: Document -> Text -> Maybe Form
+parseDocument doc n
+    | Just n' <- readMaybe $ Txt.unpack n, n' < length (forms doc') =
+        parseElement (forms doc' !! n')
+    | el:_ <- (forms >=> attributeIs "name" n) doc' = parseElement el
+    | otherwise = Nothing
+  where
+    forms = orSelf descendant >=> laxElement "form"
+    doc' = fromDocument doc
+
+-- | Helper to select elements which fail a test.
+checkNot :: Boolean b => (Cursor -> b) -> Axis
+checkNot test = check (not . bool . test)
+-- | Helper to maybe-get the right side of an either.
+rightToMaybe :: Either a b -> Maybe b
+rightToMaybe (Left _)  = Nothing
+rightToMaybe (Right x) = Just x
+instance Eq Cursor where
+    a == b = node a == node b
+-- | Helper to return the 1st Just from its 2 arguments.
+orElse :: Maybe a -> Maybe a -> Maybe a
+orElse ret@(Just _) _ = ret
+orElse _ ret = ret
+infixr 0 `orElse`
+-- | Helper to retrieve the concatenated text under a selected element.
+text :: Cursor -> Text
+text = Txt.concat . (descendant >=> content)
+-- | Concise synonym for an XML text node.
+mkEl :: Text -> Node
+mkEl = NodeContent
+
+-- | Add submit & reset buttons to a form if they were missing!
+ensureButtons :: Form -> Form
+ensureButtons = ensureButton "submit" "Submit" . ensureButton "reset" "Reset"
+  where
+    ensureButton typ label' form
+        | any (\x -> inputType x == typ) $ inputs form = form
+        | otherwise = form { inputs = inputs form ++ [button typ label'] }
+    button typ label' = Input {
+        label = label',
+        description = mkEl "",
+        autofocus = False,
+        disabled = False,
+        formAction = Nothing,
+        formMethod = Nothing,
+        formEnctype = Nothing,
+        formValidate = True,
+        formTarget = Nothing,
+        inputName = "",
+        inputType = typ,
+        value = "",
+        title = "",
+        placeholder = "",
+        dirname = "",
+        inputAutocomplete = "",
+        checked = False, -- Switch to true for the activated button!
+        readonly = False,
+        multiple = False,
+        inputMode = "",
+        list = [],
+        range = (Nothing, Nothing),
+        step = Nothing,
+        lengthRange = (Nothing, Nothing),
+        pattern = Nothing,
+        required = False,
+        size = Nothing,
+        fileData = defaultFileData,
+        imageData = defaultImageData,
+        textArea = defaultTextArea
+      }
diff --git a/src/Text/HTML/Form/Colours.hs b/src/Text/HTML/Form/Colours.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form/Colours.hs
@@ -0,0 +1,83 @@
+-- | A decent colour-pallet for users to select between,
+-- for where we can't provide free-form colour selection.
+module Text.HTML.Form.Colours(tailwindColours, Colour) where
+
+-- Finally a good use for Tailwind!
+-- Otherwise it strikes me as little more than an illegible alternative to
+-- the `style` attribute!
+
+-- | Stores a CSS hexcode, with preceding "#"
+type Colour = String
+-- | The Tailwind colour pallet: https://v1.tailwindcss.com/docs/customizing-colors#default-color-palette
+tailwindColours :: [(String, [(Int, Colour)])]
+tailwindColours = [
+    "Slate"~>[50~>"#f8fafc", 100~>"#f1f5f9", 200~>"#e2e8f0", 300~>"#cbd5e1",
+            400~>"#94a3b8", 500~>"#64748b", 600~>"#475569", 700~>"#334155",
+            800~>"#1e293b", 900~>"#0f172a", 950~>"#020617"],
+    "Gray"~>[50~>"#f9fafb", 100~>"#f3f4f6", 200~>"#e5e7eb", 300~>"#d1d5db",
+            400~>"#9ca3af", 500~>"#6b7280", 600~>"#4b5563", 700~>"#374151",
+            800~>"#1f2937", 900~>"#111827", 950~>"#030712"],
+    "Zinc"~>[50~>"#fafafa", 100~>"#f4f4f5", 200~>"#e4e4e7", 300~>"#d4d4d8",
+            400~>"#a1a1aa", 500~>"#71717a", 600~>"#52525b", 700~>"#3f3f46",
+            800~>"#27272a", 900~>"#18181b", 950~>"#09090b"],
+    "Neutral"~>[50~>"#fafafa", 100~>"#f5f5f5", 200~>"#e5e5e5", 300~>"#d4d4d4",
+            400~>"#a3a3a3", 500~>"#737373", 600~>"#404040", 700~>"#404040",
+            800~>"#262626", 900~>"#171717", 950~>"#0a0a0a"],
+    "Stone"~>[50~>"#fafaf9", 100~>"#f5f5f4", 200~>"#e7e5e4", 300~>"#d6d3d1",
+            400~>"#a8a29e", 500~>"#78716c", 600~>"#57534e", 700~>"#44403c",
+            800~>"#292524", 900~>"#1c1917", 950~>"#0c0a09"],
+    "Red"~>[50~>"#fef2f2", 100~>"#fee2e2", 200~>"#fecaca", 300~>"#fca5a5",
+            400~>"#f87171", 500~>"#ef4444", 600~>"#dc2626", 700~>"#b91c1c",
+            800~>"#991b1b", 900~>"#7f1d1d", 950~>"#450a0a"],
+    "Orange"~>[50~>"#fff7ed", 100~>"#ffedd5", 200~>"#fed7aa", 300~>"#fdba74",
+            400~>"#fb923c", 500~>"#f97316", 600~>"#ea580c", 700~>"#c2410c",
+            800~>"#9a3412", 900~>"#7c2d12", 950~>"#431407"],
+    "Amber"~>[50~>"#fffbeb", 100~>"#fef3c7", 200~>"#fde68a", 300~>"#fcd34d",
+            400~>"#fbbf24", 500~>"#f59e0b", 600~>"#d97706", 700~>"#b45309",
+            800~>"#92400e", 900~>"#78350f", 950~>"#451a03"],
+    "Yellow"~>[50~>"#fefce8", 100~>"#fef9c3", 200~>"#fef08a", 300~>"#fde047",
+            400~>"#facc15", 500~>"#eab308", 600~>"#ca8a04", 700~>"#a16207",
+            800~>"#854d0e", 900~>"#713f12", 950~>"#422006"],
+    "Lime"~>[50~>"#f7fee7", 100~>"#ecfccb", 200~>"#d9f99d", 300~>"#bef264",
+            400~>"#a3e635", 500~>"#84cc16", 600~>"#65a30d", 700~>"#4d7c0f",
+            800~>"#3f6212", 900~>"#365314", 950~>"#1a2e05"],
+    "Green"~>[50~>"#f0fdf4", 100~>"#dcfce7", 200~>"#bbf7d0", 300~>"#86efac",
+            400~>"#4ade80", 500~>"#22c55e", 600~>"#16a34a", 700~>"#15803d",
+            800~>"#166534", 900~>"#14532d", 950~>"#052e16"],
+    "Emerald"~>[50~>"#ecfdf5", 100~>"#d1fae5", 200~>"#a7f3d0", 300~>"#6ee7b7",
+            400~>"#34d399", 500~>"#10b981", 600~>"#059669", 700~>"#047857",
+            800~>"#065f46", 900~>"#064e3b", 950~>"#022c22"],
+    "Teal"~>[50~>"#f0fdfa", 100~>"#ccfbf1", 200~>"#99f6e4", 300~>"#5eead4",
+            400~>"#2dd4bf", 500~>"#14b8a6", 600~>"#0f766e", 700~>"#0f766e",
+            800~>"#115e59", 900~>"#134e4a", 950~>"#042f2e"],
+    "Cyan"~>[50~>"#ecfeff", 100~>"#cffafe", 200~>"#a5f3fc", 300~>"#67e8f9",
+            400~>"#22d3ee", 500~>"#06b6d4", 600~>"#0891b2", 700~>"#0e7490",
+            800~>"#155e75", 900~>"#164e63", 950~>"#083344"],
+    "Sky"~>[50~>"#f0f9ff", 100~>"#e0f2fe", 200~>"#bae6fd", 300~>"#7dd3fc",
+            400~>"#38bdf8", 500~>"#0ea5e9", 600~>"#0284c7", 700~>"#0369a1",
+            800~>"#075985", 900~>"#0c4a6e", 950~>"#082f49"],
+    "Blue"~>[50~>"#eff6ff", 100~>"#dbeafe", 200~>"#bfdbfe", 300~>"#93c5fd",
+            400~>"#60a5fa", 500~>"#3b82f6", 600~>"#2563eb", 700~>"#1d4ed8",
+            800~>"#1e40af", 900~>"#1e3a8a", 950~>"#172554"],
+    "Indigo"~>[50~>"#eef2ff", 100~>"#e0e7ff", 200~>"#c7d2fe", 300~>"#a5b4fc",
+            400~>"#818cf8", 500~>"#6366f1", 600~>"#4f46e5", 700~>"#4338ca",
+            800~>"#3730a3", 900~>"#312e81", 950~>"#1e1b4b"],
+    "Violet"~>[50~>"#f5f3ff", 100~>"#ede9fe", 200~>"#ddd6fe", 300~>"#c4b5fd",
+            400~>"#a78bfa", 500~>"#8b5cf6", 600~>"#7c3aed", 700~>"#6d28d9",
+            800~>"#5b21b6", 900~>"#4c1d95", 950~>"#2e1065"],
+    "Purple"~>[50~>"#faf5ff", 100~>"#f3e8ff", 200~>"#e9d5ff", 300~>"#d8b4fe",
+            400~>"#c084fc", 500~>"#a855f7", 600~>"#9333ea", 700~>"#7e22ce",
+            800~>"#6b21a8", 900~>"#581c87", 950~>"#3b0764"],
+    "Fuchsia"~>[50~>"#fdf4ff", 100~>"#fae8ff", 200~>"#f5d0fe", 300~>"#f0abfc",
+            400~>"#e879f9", 500~>"#d946ef", 600~>"#c026d3", 700~>"#a21caf",
+            800~>"#86198f", 900~>"#701a75", 950~>"#4a044e"],
+    "Pink"~>[50~>"#fdf2f8", 100~>"#fce7f3", 200~>"#fbcfe8", 300~>"#f9a8d4",
+            400~>"#f472b6", 500~>"#ec4899", 600~>"#db2777", 700~>"#be185d",
+            800~>"#9d174d", 900~>"#831843", 950~>"#500724"],
+    "Rose"~>[50~>"#fff1f2", 100~>"#ffe4e6", 200~>"#fecdd3", 300~>"#fda4af",
+            400~>"#fb7185", 500~>"#f43f5e", 600~>"#e11d48", 700~>"#be123c",
+            800~>"#9f1239", 900~>"#881337", 950~>"#4c0519"]
+  ]
+
+(~>) :: a -> b -> (a, b)
+a ~> b = (a, b)
diff --git a/src/Text/HTML/Form/Query.hs b/src/Text/HTML/Form/Query.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form/Query.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Convert query data between parsed form data, multi-maps, & URI query strings.
+module Text.HTML.Form.Query(renderQueryString, renderQueryString', renderQuery',
+        applyQuery, applyQuery') where
+
+import Text.HTML.Form (Form(..), Input(..), OptionGroup(..), Option(..))
+import Network.URI (escapeURIString, isUnescapedInURIComponent)
+import Data.List (intercalate)
+import Data.Text (unpack)
+import qualified Data.Text as Txt
+
+-- | Serialize a form to a URI query string.
+renderQueryString :: Form -> String
+renderQueryString = renderQueryString' . renderQuery'
+-- | Serialize a key-value multi-map to a URI query string.
+renderQueryString' :: [(String, String)] -> String
+renderQueryString' query = intercalate "&" [
+    escape key ++ '=':escape val | (key, val) <- query
+  ]
+
+-- | Serialize a form to a key-value multi-map.
+renderQuery' :: Form -> [(String, String)]
+renderQuery' form = concatMap renderInput' $ inputs form
+-- | Serialize an input to a key-value multi-map.
+renderInput' :: Input -> [(String, String)]
+renderInput' Input { inputType = inputType' }
+    | inputType' `elem` ["submit", "reset", "button", "file"] = []
+renderInput' Input { checked = False, inputType = inputType' }
+    | inputType' `elem` ["radio", "checkbox"] = []
+renderInput' Input { inputType = "<select>",
+        inputName = k, value = "", list = opts, multiple = False
+    } | val:_ <- [optValue opt | grp <- opts, opt <- subopts grp, optSelected opt]
+        = [(unpack k, unpack val)]
+      | otherwise = []
+renderInput' Input { inputType = "<select>",
+        inputName = k, list = opts, multiple = True
+    } = [(unpack k, unpack $ optValue opt) |
+            grp <- opts, opt <- subopts grp, optSelected opt]
+renderInput' Input { inputName = k, value = v } = [(unpack k, unpack v)]
+
+-- | escape a URI string.
+escape :: String -> String
+escape = escapeURIString isUnescapedInURIComponent
+
+-- | Adjust an input to store the appropriate values encoded in a key-value multi-map.
+applyQuery :: Input -> [(String, String)] -> Input
+applyQuery input@Input { inputName = n } qs
+    | inputType input `notElem` ["submit", "reset", "button", "checkbox", "radio"],
+        Just val' <- unpack n `lookup` qs = input { value = Txt.pack val' }
+    | otherwise = input
+-- | Adjust all inputs in a form to store the values encoded in a key-value multi-map.
+applyQuery' :: Form -> [(String, String)] -> Form
+applyQuery' form qs = form { inputs = flip applyQuery qs `map` inputs form }
diff --git a/src/Text/HTML/Form/Validate.hs b/src/Text/HTML/Form/Validate.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form/Validate.hs
@@ -0,0 +1,130 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Does the form contain valid data according to specified rules?
+-- Can we normalize it to be more likely to do so?
+module Text.HTML.Form.Validate(isInputValid, isInputValid', isFormValid, isFormValid',
+        inputErrorMessage, inputErrorMessage', normalizeInput, normalizeForm) where
+
+import Text.HTML.Form
+import qualified Data.Text as Txt
+import Text.Read (readMaybe)
+import Data.Hourglass
+import Network.URI (parseAbsoluteURI)
+import Data.Maybe (isJust, isNothing)
+import Text.Regex.TDFA ((=~), matchTest)
+
+-- | Are all inputs in a form valid according to their rules?
+isFormValid :: Form -> Bool
+isFormValid = all isInputValid . inputs
+
+-- | Are all inputs in a form valid according to their rules, once normalized?
+isFormValid' :: Form -> Bool
+isFormValid' = all isInputValid' . inputs
+
+-- | Is the given input valid?
+isInputValid :: Input -> Bool
+isInputValid = null . inputErrorMessage
+
+-- | Is the given input once normalized valid?
+isInputValid' :: Input -> Bool
+isInputValid' = null . inputErrorMessage'
+
+-- | Describe why a form input is invalid, or return the empty string.
+inputErrorMessage :: Input -> String
+inputErrorMessage Input { inputType = "hidden" } = "" -- Don't validate hiddens!
+inputErrorMessage self@Input { required = True }
+    | inputType self == "checkbox", not $ checked self = "Required!"
+    -- Not validating "radio", needs different API...
+    | value self == "" = "Required!"
+inputErrorMessage Input { value = "" } = "" -- Skip further checks for empty!
+inputErrorMessage self@Input { pattern = Just re }
+    | not $ re `matchTest` value self = "Invalid format!"
+inputErrorMessage Input { lengthRange = (Just min', _), value = val }
+    | Txt.length val < min' = "Must be at least " ++ show min' ++ " characters!"
+inputErrorMessage Input { lengthRange = (_, Just max'), value = val }
+    | Txt.length val > max' = "Must be at most " ++ show max' ++ " characters!"
+inputErrorMessage Input { range = (Just min', _), value = val }
+    | Just x <- readMaybe' val :: Maybe Float, Just y <- readMaybe' min', x < y =
+        "Must be at least " ++ Txt.unpack min' ++ "!"
+inputErrorMessage Input { range = (_, Just max'), value = val }
+    | Just x <- readMaybe' val :: Maybe Float, Just y <- readMaybe' max', x > y =
+        "Must be at most " ++ Txt.unpack max' ++ "!"
+inputErrorMessage Input { range = (Just min', _), step = Just step', value = val }
+    | Just x <- readMaybe' val :: Maybe Integer, Just y <- readMaybe' min',
+        Just z <- readMaybe' step', z /= 0, rem (x - y) z == 0 =
+            ("Must be in increments of " ++ Txt.unpack step' ++ " from "
+            ++ Txt.unpack min' ++ "!")
+inputErrorMessage Input { range = (Just min', _), value = val }
+    | Just x <- parseTime $ Txt.unpack val, Just y <- parseTime $ Txt.unpack min',
+        x < y = "Must be at least " ++ Txt.unpack min' ++ "!"
+inputErrorMessage Input { range = (_, Just max'), value = val }
+    | Just x <- parseTime $ Txt.unpack val, Just y <- parseTime $ Txt.unpack max',
+        x > y = "Must be at most " ++ Txt.unpack max' ++ "!"
+inputErrorMessage Input {
+        range = (Just min', _), step = Just step',
+        inputType = ty, value = val
+      }
+    | ty == "date", Just x <- parseTime $ Txt.unpack val,
+        Just y <- parseTime $ Txt.unpack min', Just z <- readMaybe' step',
+        timeDiff x y `rem` toSeconds mempty { durationSeconds = 24*z } == Seconds 0 =
+            ("Must be in increments of " ++ Txt.unpack step' ++ " days from " ++
+            Txt.unpack min' ++ "!")
+    | ty == "month" = "" -- Not prepared to properly validate this...
+    | Just x <- parseTime $ Txt.unpack val, Just y <- parseTime $ Txt.unpack min',
+        Just z <- readMaybe' step', timeDiff x y `rem` Seconds z == Seconds 0 =
+            ("Must be in increments of " ++ Txt.unpack step' ++ "s from " ++
+            Txt.unpack min' ++ "!")
+
+-- Validation specific to input types
+inputErrorMessage self@Input { inputType = "color" }
+    | ("#[0-9a-fA-F]{6}" :: String) =~ value self = "Invalid colour value!"
+inputErrorMessage self@Input { inputType = "date" } = isTime' self
+inputErrorMessage self@Input { inputType = "datetime" } = isTime' self
+inputErrorMessage self@Input { inputType = "datetime-local" } = isTime' self
+-- This validation is less strict than many sites expect, but don't over-validate...
+inputErrorMessage self@Input { inputType = "email" }
+    | '@' `Txt.elem` value self =
+        "Obviously invalid email address, needs an '@'!"
+inputErrorMessage self@Input { inputType = "month" } = isTime' self
+inputErrorMessage Input { inputType = "number", value = val }
+    | isNothing (readMaybe' val :: Maybe Float) = "Invalid number!"
+inputErrorMessage Input { inputType = "range", value = val }
+    | isNothing (readMaybe' val :: Maybe Float) = "Invalid number!"
+inputErrorMessage self@Input { inputType = "time" } = isTime' self
+inputErrorMessage self@Input { inputType = "url" }
+    | isURL $ value self = "Invalid web address!"
+inputErrorMessage self@Input { inputType = "week" } = isTime' self
+inputErrorMessage _ = ""
+
+-- | Describe why an input, once normalized, is invalid? Or returns empty string.
+inputErrorMessage' :: Input -> [Char]
+inputErrorMessage' = inputErrorMessage . normalizeInput
+
+-- | Helper to parse the time stored in an input.
+parseTime :: String -> Maybe DateTime
+parseTime = fmap localTimeUnwrap . localTimeParse ISO8601_DateAndTime
+-- | Does the input store a time?
+isTime :: Input -> Bool
+isTime = isJust . parseTime . Txt.unpack . value
+-- | Emit an error message if an input doesn't store a valid time.
+isTime' :: Input -> String
+isTime' x | isTime x = ""
+    | otherwise = "Invalid time format!"
+-- | Parse a Text into any type that can be parsed from strings.
+readMaybe' :: Read a => Txt.Text -> Maybe a
+readMaybe' = readMaybe . Txt.unpack
+-- | Does the input store a valid URL?
+isURL :: Txt.Text -> Bool
+isURL = isNothing . parseAbsoluteURI . Txt.unpack
+
+-- | Implicitly tweak the input to make it more likely to be valid.
+normalizeInput :: Input -> Input
+normalizeInput self@Input { inputType = "url", value = val }
+    | not $ ':' `Txt.elem` val = self { -- Is there a better check?
+            value = "https://" `Txt.append` val
+        }
+-- Other aspects we wish to normalize?
+normalizeInput self = self
+
+-- | Implicitly tweak all of a form's inputs to make them more likely to be valid.
+normalizeForm :: Form -> Form
+normalizeForm self = self { inputs = map normalizeInput $ inputs self }
diff --git a/src/Text/HTML/Form/WebApp.hs b/src/Text/HTML/Form/WebApp.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form/WebApp.hs
@@ -0,0 +1,265 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Renders forms to an HTML menu, for the sake of highly-constrained browser engines.
+-- Like those dealing with TV remotes.
+module Text.HTML.Form.WebApp (renderPage, Form(..), Query) where
+
+import Data.ByteString as BS
+import Data.ByteString.Char8 as B8
+import Data.Text as Txt
+import Data.Text.Encoding as Txt
+import Data.List as L
+import Data.Maybe (fromMaybe)
+import Text.Read (readMaybe)
+import Network.URI (unEscapeString)
+import System.IO (readFile')
+import System.FilePath ((</>), normalise)
+import System.Directory (XdgDirectory(..), getXdgDirectory, doesFileExist,
+        doesDirectoryExist, listDirectory, getHomeDirectory)
+
+import Text.HTML.Form (Form(..), Input(..))
+import Text.HTML.Form.WebApp.Ginger (template, template', resolveSource, list')
+import Text.HTML.Form.Query (renderQueryString, renderQuery', applyQuery')
+import Text.HTML.Form.Validate (isFormValid')
+import Text.HTML.Form.WebApp.Ginger.Hourglass (timeData, modifyTime', timeParseOrNow,
+        gSeqTo, gPad2)
+import Text.HTML.Form.WebApp.Ginger.TZ (tzdata, continents)
+
+import Text.Ginger.GVal as V (GVal(..), ToGVal(..), orderedDict, (~>), fromFunction, list)
+import Text.Ginger.Html (html)
+import Data.Hourglass (Elapsed(..), Seconds(..), timeGetElapsed, localTimeToGlobal)
+import Text.HTML.Form.Colours (tailwindColours)
+
+-- | The query string manipulated by this serverside webapp.
+type Query = [(ByteString, Maybe ByteString)]
+-- | Converts URI path & query to rendered hyper-linked HTML representing menus
+-- for selecting values to upload to the server as prescribed by the given form.
+-- These values are returned to caller on the Left-branch.
+renderPage :: Form -> [Text] -> Query -> IO (Maybe (Either Query Text))
+renderPage form (n:path) query
+    | Just ix <- readMaybe $ Txt.unpack n, ix < Prelude.length (inputs form) =
+        renderInput form ix (inputs form !! ix) path query
+renderPage form [] _ = return $ Just $ Right $ Txt.concat [
+    "<a href='/0/?", Txt.pack $ renderQueryString form, "'>Start!</a>"]
+renderPage _ _ _ = return Nothing
+
+-- | Is this input type amongst the date-time family?
+isCalType :: Text -> Bool
+isCalType = flip L.elem ["date", "datetime-local", "datetime", "month", "time", "week"]
+-- | Render an input to the corresponding HTML, or form data to submit.
+renderInput :: Form -> Int -> Input -> [Text] -> [(ByteString, Maybe ByteString)] ->
+    IO (Maybe (Either Query Text))
+renderInput form ix input [""] qs = renderInput form ix input [] qs
+renderInput form ix input@Input { inputType = ty, inputName = name } ["year", p] qs
+    | isCalType ty,
+      Just t <- modifyTime' (Txt.pack $ "year/" ++ Txt.unpack p) $ get name qs = do
+        t' <- timeParseOrNow t
+        template' "cal/year-numpad.html" form ix input (set name (Txt.pack t) qs) $
+            \prop -> case prop of
+                "T" -> timeData t'
+                _ -> toGVal ()
+renderInput form ix input@Input { inputType = ty, inputName = name } ["zone", p] qs
+    | isCalType ty = do
+        t <- timeParseOrNow $ get name qs
+        let Elapsed (Seconds t') = timeGetElapsed $ localTimeToGlobal t
+        template' "cal/timezone.html" form ix input qs $ \prop -> case prop of
+            "T" -> timeData t
+            "zones" -> tzdata t' $ unEscapeString $ Txt.unpack p
+            "continents" -> continents
+            _ -> toGVal ()
+renderInput form ix input@Input { multiple = True } [p] qs
+    | '=':v' <- Txt.unpack p,
+            (utf8 $ inputName input, Just $ utf8' v') `Prelude.elem` qs =
+        renderInput form ix input [] $
+            unset (inputName input) (Txt.pack $ unEscapeString v') qs
+    | '=':v' <- Txt.unpack p = renderInput form ix input [] $
+        (utf8 $ inputName input, Just $ utf8' $ unEscapeString v'):qs
+renderInput form ix input [p] qs
+    | '=':v' <- Txt.unpack p = renderInput form ix input [] $
+        set (inputName input) (Txt.pack $ unEscapeString v') qs
+    | ':':v' <- Txt.unpack p = renderInput form ix input [] $
+        set (inputName input)
+            (Txt.pack (get (inputName input) qs ++ v')) qs
+    | "-" <- Txt.unpack p, v'@(_:_) <- get (inputName input) qs =
+        renderInput form ix input [] $ set (inputName input)
+            (Txt.pack $ Prelude.init v') qs
+    | "-" <- Txt.unpack p = renderInput form ix input [] qs
+    | '+':x' <- Txt.unpack p, Just x <- readMaybe x' :: Maybe Double,
+            Just y <- readMaybe $ get (inputName input) qs =
+        renderInput form ix input [] $
+            set (inputName input) (Txt.pack $ show $ x + y) qs
+    | '+':x' <- Txt.unpack p, Just _ <- readMaybe x' :: Maybe Double =
+        renderInput form ix input [] $ set (inputName input) (Txt.pack x') qs
+renderInput form ix input [x, p] qs
+    | '=':v' <- Txt.unpack p = renderInput form ix input [x] $
+        set (inputName input) (Txt.pack $ unEscapeString v') qs
+    | ':':v' <- Txt.unpack p = renderInput form ix input [x] $
+        set (inputName input)
+            (Txt.pack (get (inputName input) qs ++ v')) qs
+    | "-" <- Txt.unpack p, v'@(_:_) <- get (inputName input) qs =
+        renderInput form ix input [x] $ set (inputName input)
+            (Txt.pack $ Prelude.init v') qs
+    | "-" <- Txt.unpack p = renderInput form ix input [x] qs
+    | '+':z' <- Txt.unpack p, Just z <- readMaybe z' :: Maybe Double,
+            Just y <- readMaybe $ get (inputName input) qs =
+        renderInput form ix input [x] $
+            set (inputName input) (Txt.pack $ show $ z + y) qs
+    | '+':x' <- Txt.unpack p, Just _ <- readMaybe x' :: Maybe Double =
+        renderInput form ix input [x] $ set (inputName input) (Txt.pack x') qs
+renderInput form ix input@Input {inputType="checkbox", inputName=k', value=v'} [] qs
+    | (utf8 k', Just $ utf8 v') `Prelude.elem` qs =
+        template "checkbox.html" form ix input $ unset k' v' qs
+    | v' == "", (utf8 k', Nothing) `Prelude.elem` qs =
+        template "checkbox.html" form ix input [
+            q | q@(k, v) <- qs, not (k == utf8 k' && v == Nothing)]
+    | otherwise =
+        template "checkbox.html" form ix input $ (utf8 k', Just $ utf8 v'):qs
+renderInput form ix input@Input {inputType="radio", inputName=k', value=v'} [] qs =
+    template "checkbox.html" form ix input $ set k' v' qs
+renderInput form ix input@Input { inputType="<select>" } [] qs =
+    template "select.html" form ix input qs
+renderInput form ix input@Input { inputType="submit" } [] qs =
+    template' "submit.html" form ix input qs $ \x -> case x of
+        "isFormValid" -> toGVal $ formValidate input &&
+                isFormValid' (applyQuery' form $ strQuery qs)
+        _ -> toGVal ()
+renderInput _ _ input@Input { inputType="submit" } ["_"] qs =
+    return $ Just $ Left $ set (inputName input) (value input) qs
+renderInput form ix input@Input { inputType="image" } [] qs =
+    template "image-button.html" form ix input qs
+renderInput _ _ input@Input { inputType="image" } ["_"] qs =
+    return $ Just $ Left $ set (inputName input) (value input) qs
+renderInput form ix input@Input { inputType="reset" } [] qs =
+    template "reset.html" form ix input qs
+renderInput form ix input@Input { inputType="reset" } ["_"] _ =
+    template "reset.html" form ix input
+        [(utf8' k, Just $ utf8' v) | (k, v) <- renderQuery' form]
+renderInput form ix input@Input { inputType="file" } path qs = do
+    home <- getHomeDirectory
+    let filepath = normalise $ L.foldl (</>) home $ L.map Txt.unpack path
+    subfiles <- listDirectory filepath
+    (dirs, files) <- partitionM (doesDirectoryExist' filepath) subfiles
+    template' "files.html" form ix input qs $ \x -> case x of
+        "path" -> (list'$L.map buildBreadcrumb$L.inits$L.map Txt.unpack path) {
+            asText = Txt.pack filepath,
+            asHtml = html $ Txt.pack filepath
+          }
+        "files" -> toGVal files
+        "dirs" -> toGVal dirs
+        _ -> toGVal ()
+  where
+    buildBreadcrumb :: [String] -> GVal m
+    buildBreadcrumb [] = toGVal False
+    buildBreadcrumb path' = orderedDict [
+        "name" ~> L.last path',
+        "link" ~> ('/':show ix ++ '/':L.intercalate "/" path')
+      ]
+    doesDirectoryExist' parent file = doesDirectoryExist $ parent </> file
+renderInput form ix input@Input { inputType = "tel" } [] qs =
+    template "tel.html" form ix input qs
+renderInput form ix input@Input { inputType = "number" } [] qs =
+    template "number.html" form ix input qs
+renderInput form ix input@Input { inputType = "range" } [] qs =
+    template "number.html" form ix input qs
+renderInput form ix input@Input { inputType = ty, inputName = n } [op] qs
+    | "week" <- ty, "+date" <- op = renderInput form ix input ["+date7"] qs
+    | "week" <- ty, "-date" <- op = renderInput form ix input ["-date7"] qs
+    | isCalType ty, Just v <- modifyTime' op $ get n qs = do
+        -- TODO: Support other calendars
+        v' <- timeParseOrNow v
+        template' "gregorian.html" form ix input (set n (Txt.pack v) qs) $
+            \x -> case x of
+                "T" -> timeData v'
+                "seqTo" -> fromFunction $ return . gSeqTo
+                "pad2" -> fromFunction $ return . gPad2
+                _ -> toGVal ()
+    | isCalType ty = return Nothing
+renderInput f ix input@Input { inputType = ty, inputName = n } [] qs | isCalType ty = do
+    v' <- timeParseOrNow $ get n qs
+    template' "gregorian.html" f ix input qs $ \x -> case x of -- TODO: Ditto
+        "T" -> timeData v'
+        "seqTo" -> fromFunction $ return . gSeqTo
+        "pad2" -> fromFunction $ return . gPad2
+        _ -> toGVal ()
+renderInput form ix input@Input { inputType = "color" } [] qs =
+    template' "color.html" form ix input qs $ \x -> case x of
+        "colours" -> V.list $ L.map colourGVal tailwindColours
+        "shades" -> toGVal False
+        "subfolder" -> toGVal False
+        _ -> toGVal ()
+renderInput form ix input@Input { inputType = "color" } [c, ""] qs =
+    template' "color.html" form ix input qs $ \x -> case x of
+        "colours" -> V.list $ L.map colourGVal tailwindColours
+        "shades" -> case Txt.unpack c `lookup` tailwindColours of
+            Just shades -> V.list $ L.map shadeGVal shades
+            Nothing -> toGVal False
+        "subfolder" -> toGVal True
+        _ -> toGVal ()
+renderInput form ix input [keyboard] qs =
+    renderInput form ix input [keyboard, ""] qs
+renderInput form ix input [keyboard, ""] qs | Just (Just _) <- resolveSource path =
+    template path form ix input qs
+  where path = "keyboards/" ++ Txt.unpack keyboard ++ ".html"
+renderInput form ix input [keyboard, ""] qs = do
+    configpath <- getXdgDirectory XdgConfig "bureaucromancy"
+    exists <- doesFileExist $ configpath </> "keyboard"
+    namespace <- if exists then readFile' $ configpath </> "keyboard"
+        else return "latin1"
+    let path = "keyboards/" ++ namespace ++ "/" ++ Txt.unpack keyboard ++ ".html"
+    let path2 = "keyboards/" ++ namespace ++ ".html"
+    let keyboard'
+            | Just (Just _) <- resolveSource path = path
+            | Just (Just _) <- resolveSource path2 = path2
+            | otherwise = "keyboards/latin1.html"
+    template keyboard' form ix input qs
+renderInput form ix input [] qs = do
+    path <- getXdgDirectory XdgConfig "bureaucromancy"
+    exists <- doesFileExist $ path </> "keyboard"
+    keyboard <- if exists then readFile' $ path </> "keyboard"
+        else return "latin1"
+    let keyboard'
+            | Just (Just _) <- resolveSource ("keyboards/" ++ keyboard ++ ".html")
+                = keyboard
+            | otherwise = "latin1"
+    template ("keyboards/" ++ keyboard' ++ ".html") form ix input qs
+renderInput _ _ input _ _ =
+    return $ Just $ Right $ Txt.concat ["Unknown input type: ", inputType input]
+
+-- | Coerce Colour Pallet data into dynamically-typed Ginger data.
+colourGVal :: (ToGVal m a1, ToGVal m b, Eq a2, Num a2) => (a1, [(a2, b)]) -> GVal m
+colourGVal (key, hues) = orderedDict ["label"~>key, "value"~>lookup 500 hues]
+shadeGVal :: (ToGVal m a1, ToGVal m a2) => (a1, a2) -> GVal m
+shadeGVal (key, val) = orderedDict ["label"~>key, "value"~>val]
+
+-- | Convert Text to UTF8 ByteString data.
+utf8 :: Text -> ByteString
+utf8 = Txt.encodeUtf8
+-- | Convert String to UTF8 ByteString data.
+utf8' :: String -> ByteString
+utf8' = utf8 . Txt.pack
+-- | Set the given key in the query to the given value.
+set :: Text -> Text -> [(ByteString, Maybe ByteString)]
+    -> [(ByteString, Maybe ByteString)]
+set "" _ qs = qs -- Mostly for buttons!
+set k' v' qs = (utf8 k', Just $ utf8 v'):[q | q@(k, _) <- qs, k /= utf8 k']
+-- | Remove given key from the query.
+unset :: Text -> Text -> [(ByteString, Maybe ByteString)]
+    -> [(ByteString, Maybe ByteString)]
+unset k' v' qs = [q | q@(k, v) <- qs, not (k == utf8 k' && v == Just (utf8 v'))]
+-- | Retrieve the value corresponding to the given key in the query.
+get :: Text -> [(ByteString, Maybe ByteString)] -> String
+get k' qs
+    | Just (Just ret) <- utf8 k' `lookup` qs =
+        Txt.unpack $ Txt.decodeUtf8 ret
+    | otherwise = ""
+-- | Convert the query data to string-pairs, for use in Query submodule.
+strQuery :: [(ByteString, Maybe ByteString)] -> [(String, String)]
+strQuery qs = [(B8.unpack k, B8.unpack $ fromMaybe "" v) | (k, v) <- qs]
+
+-- | Monadically takes a predicate and a list, and returns the pair of lists
+-- of elements which do and do not satisfy the predicate, respectively.
+partitionM :: Monad f => (a -> f Bool) -> [a] -> f ([a], [a])
+partitionM _ [] = pure ([], [])
+partitionM f (x:xs) = do
+    res <- f x
+    (as,bs) <- partitionM f xs
+    pure ([x | res]++as, [x | not res]++bs)
diff --git a/src/Text/HTML/Form/WebApp/Ginger.hs b/src/Text/HTML/Form/WebApp/Ginger.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form/WebApp/Ginger.hs
@@ -0,0 +1,195 @@
+{-# LANGUAGE TemplateHaskell, OverloadedStrings, FlexibleContexts #-}
+-- | Shuttle parsed form data to Ginger's dynamically-typed datamodel.
+module Text.HTML.Form.WebApp.Ginger(template, template', resolveSource, list') where
+
+import Text.HTML.Form
+import Text.HTML.Form.Query (renderQueryString')
+
+import FileEmbedLzma
+import Data.FileEmbed
+import System.FilePath
+
+import Text.Ginger.Parse (parseGingerFile, SourcePos)
+import Text.Ginger.Run (runGinger, makeContextHtml, Run)
+import Text.Ginger.GVal as V (GVal(..), toGVal, ToGVal, orderedDict, (~>),
+        fromFunction, Function)
+import Text.Ginger.Html (Html, htmlSource, unsafeRawHtml)
+import Control.Monad.Writer.Lazy (Writer)
+
+import Data.Text as Txt
+import Data.Text.Encoding as Txt
+import Data.Text.Lazy as Txt (toStrict)
+import Data.ByteString.Char8 as B8
+import Network.URI (uriToString, escapeURIString, isUnescapedInURIComponent, nullURI)
+import Text.XML (Document(..), Element(..), Prologue(..), Node, def, renderText)
+
+import Data.List (nub)
+import Data.Maybe (fromMaybe, isJust)
+import qualified Data.Map as M
+
+import Text.HTML.Form.Validate (inputErrorMessage')
+import Text.HTML.Form.Query (applyQuery)
+
+-- | A key-value query string.
+type Query = [(ByteString, Maybe ByteString)]
+-- | Run the given template with the given Bureaucromancy data.
+template :: Monad m => String -> Form -> Int -> Input -> Query ->
+        m (Maybe (Either Query Text))
+template name form ix input query =
+    template' name form ix input query $ const $ toGVal ()
+-- | Run the given template with the given Bureaucromancy & Ginger data.
+template' :: Monad m => String -> Form -> Int -> Input -> Query ->
+        (Text -> GVal (Run SourcePos (Writer Html) Html)) ->
+        m (Maybe (Either Query Text))
+template' name form ix input query ctxt'
+    | Just (Right tpl) <- parseGingerFile resolveSource name =
+        return $ Just $ Right $ htmlSource $
+            flip runGinger tpl $ makeContextHtml ctxt
+    | Just (Left err) <- parseGingerFile resolveSource name =
+        return $ Just $ Right $ Txt.pack $ show err
+    | otherwise = return $ Just $ Right "Unexpected error!"
+  where
+    ctxt :: Text -> GVal (Run SourcePos (Writer Html) Html)
+    ctxt "Q" = query2gval query
+    ctxt "form" = form2gval form
+    ctxt "inputs" = list' $ Prelude.map (flip input2gval query) $
+        Prelude.zip [0..] $ inputs form
+    ctxt "input" = input2gval (ix, input) query
+    ctxt "xURI" = fromFunction xURI
+    ctxt x = ctxt' x
+    xURI [(_, uri)] = let uri' = Txt.unpack $ asText uri in
+        return$toGVal$Txt.pack $ escapeURIString isUnescapedInURIComponent uri'
+    xURI _ = return $ toGVal ()
+
+-- | Lookup the given template from a compiled-in directory.
+resolveSource :: FilePath -> Maybe (Maybe [Char])
+resolveSource ('/':path) = resolveSource path
+resolveSource path = Just $ fmap utf8 $
+    flip lookup $(makeRelativeToProject "tpl" >>= embedRecursiveDir) $
+    normalise $ '/':path
+
+-- | Convert a query into Ginger's datamodel.
+query2gval :: Monad m => Query -> GVal m
+query2gval qs =
+    (orderedDict [(Txt.decodeUtf8 k, (list1 vs){ asFunction = Just $ gElem vs })
+            | (k, vs) <- groupSort qs]) {
+        asText = Txt.pack q,
+        asHtml = unsafeRawHtml $ Txt.pack q
+    }
+  where
+    q = '?':renderQueryString' [(utf8 k, utf8 $ fromMaybe "" v) | (k, v) <- qs]
+    gElem :: Monad m => [ByteString] -> Function m
+    gElem xs [(_, x)] | Just x' <- asBytes x = return$toGVal$Prelude.elem x' xs
+    gElem _ _ = return $ toGVal ()
+
+-- | Convert a form to Ginger's datamodel.
+form2gval :: Form -> GVal m
+form2gval form = orderedDict [
+    "action"   ~> uriToString id (action form) "",
+    "enctype"  ~> enctype form,
+    "method"   ~> method form,
+    "validate" ~> validate form,
+    "target"   ~> target form,
+    "charset"  ~> acceptCharset form,
+    "autocomplete"~>autocomplete form,
+    "name"     ~> formName form,
+    "rel"      ~> rel form
+  ]
+
+-- | Convert an input to Ginger's datamodel.
+input2gval :: (Int, Input) -> Query -> GVal m
+input2gval (ix, input) query = orderedDict [
+    "index"       ~> ix,
+    "label"       ~> label input,
+    "error"       ~> inputErrorMessage' (applyQuery input
+            [(B8.unpack k, B8.unpack $ fromMaybe "" v) | (k, v) <- query]),
+    "description" ~> html (description input),
+    "inputType"   ~> inputType input,
+    "dirName"     ~> dirname input,
+    "name"        ~> inputName input,
+    "value"       ~> if inputType input `Prelude.elem` ["radio", "checkbox"]
+        then value input
+        else Txt.intercalate ", " [Txt.decodeUtf8 v | (k, Just v) <- query,
+            Txt.encodeUtf8 (inputName input) == k],
+    "autocomplete"~> inputAutocomplete input,
+    "autofocus"   ~> autofocus input,
+    "checked"     ~> (inputType input `Prelude.elem` ["radio", "checkbox"] &&
+        if value input== ""
+        then isJust $ Prelude.lookup (Txt.encodeUtf8 $ inputName input) query
+        else (Txt.encodeUtf8 $ inputName input,
+            Just $ Txt.encodeUtf8 $ value input) `Prelude.elem` query),
+    "disabled"    ~> disabled input,
+    "readonly"    ~> readonly input,
+    "multiple"    ~> multiple input,
+    ("form", orderedDict [
+        "action"  ~> (flip (uriToString id) "" <$> formAction input),
+        "enctype" ~> formEnctype input,
+        "method"  ~> formMethod input,
+        "validate"~> formValidate input,
+        "target"  ~> formTarget input
+    ]),
+    "inputmode"   ~> inputMode input,
+    ("list", list' $ Prelude.map (optgroup2gval [v |
+            (k, Just v) <- query, Txt.decodeUtf8 k == inputName input])
+        $ list input),
+    "min"         ~> fst (range input),
+    "max"         ~> snd (range input),
+    "step"        ~> step input,
+    "minlength"   ~> fst (lengthRange input),
+    "maxLength"   ~> snd (lengthRange input),
+    "required"    ~> required input,
+    "placeholder" ~> placeholder input,
+    "title"       ~> title input,
+    "size"        ~> size input,
+    "accept"      ~> fileAccept (fileData input),
+    "capture"     ~> fileCapture (fileData input),
+    "alt"         ~> imgAlt (imageData input),
+    "width"       ~> fst (imgSize $ imageData input),
+    "height"      ~> snd (imgSize $ imageData input),
+    "src"         ~> uriToString id (fromMaybe nullURI $ imgSrc $ imageData input) "",
+    "autocorrect" ~> autocorrect (textArea input),
+    "cols"        ~> size input,
+    "rows"        ~> rows (textArea input),
+    "spellcheck"  ~> spellcheck (textArea input),
+    "textwrap"    ~> textwrap (textArea input)
+  ]
+-- | Convert an XML node to Ginger's datamodel.
+html :: Node -> Html
+html node = unsafeRawHtml $ Txt.toStrict $ renderText def (
+    Document (Prologue [] Nothing []) (Element "div" M.empty [node]) []
+  )
+-- | Convert an option group to Ginger's datamodel.
+optgroup2gval :: [ByteString] -> OptionGroup -> GVal m
+optgroup2gval query optgroup = orderedDict [
+    "label"    ~> optsLabel optgroup,
+    "disabled" ~> optsDisabled optgroup,
+    ("opts", list' $ Prelude.map (opt2gval query) $ subopts optgroup)
+  ]
+-- | Convert an option to Ginger's datamodel.
+opt2gval :: [ByteString] -> Option -> GVal m
+opt2gval query opt = orderedDict [
+    "label"    ~> optLabel opt,
+    "value"    ~> optValue opt,
+    "selected" ~> (optValue opt `Prelude.elem` Prelude.map Txt.decodeUtf8 query),
+    "disabled" ~> optDisabled opt
+  ]
+
+-- | A ginger list which in most uses looks like its initial value.
+list1 :: ToGVal m a => [a] -> GVal m
+list1 vs@(v:_) = (toGVal v) {
+    asList = Just $ Prelude.map toGVal vs,
+    V.length = Just $ Prelude.length vs
+  }
+list1 [] = (toGVal True) { asList = Just [], V.length = Just 0 }
+-- | Type-constrained conversion of a list to Ginger's datamodel,
+-- serves to avoid type-inference issues.
+list' :: [GVal m] -> GVal m
+list' = toGVal
+
+-- | Aggregates values in a key-value list under their keys.
+groupSort :: Eq k => [(k, Maybe v)] -> [(k, [v])]
+groupSort q = [(k, [v | (k', Just v) <- q, k == k']) | k <- nub $ Prelude.map fst q]
+
+-- | Convert from UTF-8 bytestring to a string.
+utf8 :: ByteString -> String
+utf8 = Txt.unpack . Txt.decodeUtf8
diff --git a/src/Text/HTML/Form/WebApp/Ginger/Hourglass.hs b/src/Text/HTML/Form/WebApp/Ginger/Hourglass.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form/WebApp/Ginger/Hourglass.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Converts data between Ginger templates & HourGlass,
+-- whilst decomposing the datamodel further.
+module Text.HTML.Form.WebApp.Ginger.Hourglass(
+    timeData, modifyTime, modifyTime', timeParseOrNow, gSeqTo, gPad2) where
+
+import Text.Ginger.GVal as V (GVal(..), toGVal, ToGVal, orderedDict, (~>), toInt)
+import Text.Ginger.Html (unsafeRawHtml)
+import Data.Hourglass
+import Time.System (localDateCurrent)
+import qualified Data.Text as Txt
+import Text.Read (readMaybe)
+import System.IO.Unsafe (unsafePerformIO) -- For use with localDateCurrent
+
+-- | Converts HourGlass data to Ginger's datamodel.
+timeData :: LocalTime DateTime -> GVal a
+timeData datetime = orderedDict [
+    "year" ~> abs (dateYear date),
+    ("month", enumG $ dateMonth date),
+    "date" ~> dateDay date,
+    "meridiem" ~> case todHour $ dtTime $ localTimeUnwrap datetime of
+        x | x < 12 -> "AM" :: String
+        24 -> "AM"
+        _ -> "PM",
+    ("hour", enumG $ case todHour $ dtTime $ localTimeUnwrap datetime of
+        x | x <= 12 -> x
+        24 -> 12
+        x -> x - 11),
+    ("minute", enumG $ todMin $ dtTime $ localTimeUnwrap datetime),
+    ("second", enumG $ todSec $ dtTime $ localTimeUnwrap datetime),
+    ("nano", showG unwrapNanos $ todNSec $ dtTime $ localTimeUnwrap datetime),
+    ("zone", showG timezoneOffsetToMinutes $ localTimeGetTimezone datetime),
+    "daysInMonth" ~> (dateYear date `daysInMonth` dateMonth date),
+    ("monthStart", toGVal $ fromEnum $ getWeekDay date { dateDay = 1 })
+   ]
+  where
+    date = dtDate $ localTimeUnwrap datetime
+
+-- Converts an enum to Ginger's datamodel.
+enumG :: (Enum x, Show x) => x -> GVal a
+enumG = showG fromEnum
+-- | Converts showable data to Ginger's datamodel via a callback.
+showG :: (Show x, ToGVal m a) => (x -> a) -> x -> GVal m
+showG cb x = (toGVal $ cb x) {
+    asText = Txt.pack $ show x,
+    asHtml = unsafeRawHtml $ Txt.pack $ show x
+  }
+-- Retrieves the integral value from HourGlass Nanoseconds.
+unwrapNanos :: NanoSeconds -> Int
+unwrapNanos (NanoSeconds x) = fromEnum x
+
+-- | Interpret an operation upon a given time.
+modifyTime :: Txt.Text -> LocalTime DateTime -> Maybe (LocalTime DateTime)
+modifyTime "-hour" time = modLTime time $ flip timeAdd mempty { durationHours = -1 }
+modifyTime "+hour" time = modLTime time $ flip timeAdd mempty { durationHours = 1 }
+modifyTime "-minute" time = modLTime time $ flip timeAdd mempty { durationMinutes = -1 }
+modifyTime "+minute" time = modLTime time $ flip timeAdd mempty { durationMinutes = 1 }
+modifyTime "meridiem" time = case todHour $ dtTime $ localTimeUnwrap time of
+    12 -> modLTime time $ \time' -> time' {
+        dtTime = (dtTime time') { todHour = 24 }
+      }
+    x | x < 12 -> modLTime time $ \time' -> time' {
+        dtTime = (dtTime time') { todHour = x + 12 }
+      }
+    x -> modLTime time $ \time' -> time' {
+        dtTime = (dtTime time') { todHour = x - 12 }
+      }
+modifyTime "-second" time = modLTime time $ flip timeAdd mempty { durationSeconds = -1 }
+modifyTime "+second" time = modLTime time $ flip timeAdd mempty { durationSeconds = 1 }
+modifyTime "-nano" time = modLTime time $ flip timeAdd mempty { durationNs = -1 }
+modifyTime "+nano" time = modLTime time $ flip timeAdd mempty { durationNs = 1 }
+modifyTime "-zone" time = offsetTZ time (-30) -- TODO Include a timezone database...
+modifyTime "+zone" time = offsetTZ time 30
+modifyTime "now" _ = Just $ unsafePerformIO $ localDateCurrent
+modifyTime op time
+    | Just x' <- Txt.stripPrefix "year=" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' { dtDate = date { dateYear = toEnum x } }
+    | Just x' <- Txt.stripPrefix "month=" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' { dtDate = date { dateMonth = toEnum x } }
+    | Just x' <- Txt.stripPrefix "date=" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' { dtDate = date { dateDay = toEnum x } }
+    | Just x' <- Txt.stripPrefix "hour=" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' { dtTime = time_ {
+            todHour = toEnum x + (if isAM then 0 else 12)
+        } }
+    | Just x' <- Txt.stripPrefix "minute=" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' { dtTime = time_ { todMin = toEnum x } }
+    | Just x' <- Txt.stripPrefix "second=" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' { dtTime = time_ { todSec = toEnum x } }
+    | Just x' <- Txt.stripPrefix "nano=" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' { dtTime = time_ { todNSec = NanoSeconds x } }
+    | Just x' <- Txt.stripPrefix "zone=" op, Just x <- readMaybe $ Txt.unpack x' =
+        Just $ localTimeSetTimezone (TimezoneOffset x) time
+    | Just x' <- Txt.stripPrefix "year/:" op, Just x <- readMaybe $ Txt.unpack x' =
+        modLTime time $ \time' -> time' {
+            dtDate = date { dateYear = dateYear date * 10 + x }
+          }
+    | "year/-" <- op = modLTime time $ \time' -> time' {
+            dtDate = date { dateYear = dateYear date `div` 10 }
+          }
+    | "year/" <- op = Just time -- Noop, allow viewer.
+  where
+    date = dtDate $ localTimeUnwrap time
+    time_ = dtTime $ localTimeUnwrap time
+    isAM | todHour time_ == 24 = True
+        | todHour time_ < 12 = True
+        | otherwise = False
+-- Written this way to avoid GHC complaining about us pattern-matching too much! Blasphemy!
+modifyTime op time = case op of
+    "-year" -> addPeriod' time mempty { periodYears = -1 }
+    "+year" -> addPeriod' time mempty { periodYears = 1 }
+    "-month" -> addPeriod' time mempty { periodMonths = -1 }
+    "+month" -> addPeriod' time mempty { periodMonths = 1 }
+    "-date" -> addPeriod' time mempty { periodDays = -1 }
+    "+date" -> addPeriod' time mempty { periodDays = 1 }
+    "-date7" -> addPeriod' time mempty { periodDays = -7 }
+    "+date7" -> addPeriod' time mempty { periodDays = 7 }
+    _ -> Nothing
+-- | Helper for modifying HourGlass data.
+modLTime :: LocalTime a -> (a -> b) -> Maybe (LocalTime b)
+modLTime a = Just . flip fmap a
+-- | Helper for adding an offset to a HourGlass local time.
+addPeriod' :: LocalTime DateTime -> Period -> Maybe (LocalTime DateTime)
+addPeriod' time period = modLTime time $ \time' -> time' {
+    dtDate = dtDate time' `dateAddPeriod` period
+  }
+-- | Helper for adding an offset to the timezone of a local time as stored by HourGlass.
+offsetTZ :: Time t => LocalTime t -> Int -> Maybe (LocalTime t)
+offsetTZ time mins = Just $ localTimeSetTimezone
+    (TimezoneOffset $ timezoneOffsetToMinutes (localTimeGetTimezone time) + mins)
+    time
+
+-- | Helper for modifying time component of HourGlass data.
+modifyTime' :: Txt.Text -> String -> Maybe String
+modifyTime' op time
+    | Just ret <- modifyTime op $ unsafePerformIO $ timeParseOrNow time =
+        Just $ localTimePrint ISO8601_DateAndTime ret
+    | otherwise = Nothing
+-- | Parse a string to HourGlass data, falling back to the current time.
+timeParseOrNow :: String -> IO (LocalTime DateTime)
+timeParseOrNow txt = case localTimeParse ISO8601_DateAndTime txt of
+    Just ret -> return ret
+    Nothing -> localDateCurrent
+
+-- | A sequence to be called from Ginger templates.
+gSeqTo :: [(a, GVal m)] -> GVal m
+gSeqTo [(_, from), (_, to)]
+    | Just x <- toInt from, Just y <- toInt to = toGVal [x..y]
+gSeqTo [(_, from), (_, than), (_, to)]
+    | Just x <- toInt from, Just y <- toInt than, Just z <- toInt to = toGVal [x,y..z]
+gSeqTo _ = toGVal ()
+
+-- | A padding function to be called from Ginger templates,
+-- prepending 0 when needed to get 2 digits.
+gPad2 :: [(a, GVal m)] -> GVal m
+gPad2 [(_, x)] | Just y <- toInt x, y < 10 = toGVal $ '0':show x
+    | Just y <- toInt x = toGVal $ show y
+gPad2 _ = toGVal ()
diff --git a/src/Text/HTML/Form/WebApp/Ginger/TZ.hs b/src/Text/HTML/Form/WebApp/Ginger/TZ.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/HTML/Form/WebApp/Ginger/TZ.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Exposes data for a menu of timezones.
+module Text.HTML.Form.WebApp.Ginger.TZ(tzdata, continents) where
+
+import Text.Ginger.GVal as V (GVal, toGVal, orderedDict, (~>), list)
+import qualified Data.Map.Strict as M
+import Data.Time.Zones.All (tzNameLabelMap, tzByLabel)
+import Data.Time.Zones (diffForPOSIX)
+
+import Data.Int (Int64)
+import Data.List (nub)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BSC
+
+-- | Parses timezone data into a menu for Ginger templates.
+tzdata :: Int64 -> String -> GVal m
+tzdata now prefix = list [orderedDict [
+        "label" ~> label,
+        "value" ~> (diffForPOSIX tz' now `div` 60),
+        "offset" ~> formatOffset (diffForPOSIX tz' now `div` 60)
+    ] | (label, tz) <- M.toList tzNameLabelMap,
+        BSC.pack prefix `contains` label,
+        let tz' = tzByLabel tz]
+  where
+    contains "" = BSC.notElem '/'
+    contains "..." = BSC.notElem '/'
+    contains x = BS.isPrefixOf x
+-- | Serialize an offset to string, ensuring 0 is prepended to minutes when needed.
+formatOffset :: (Show a, Integral a) => a -> [Char]
+formatOffset offset
+    | minutes < 10 = show hours ++ ':':'0': show minutes
+    | otherwise = show hours ++ ':': show minutes
+  where
+    hours = offset `div` 60
+    minutes = abs $ offset `rem` 60
+
+-- | Retrieves continents list for Ginger templates.
+continents :: GVal m
+continents = list $ map toGVal $ nub $ "...":[prefix |
+        (label, _) <- M.toList tzNameLabelMap,
+        let (prefix, _) = BSC.breakEnd (== '/') label]
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,4 @@
+module Main (main) where
+
+main :: IO ()
+main = putStrLn "Test suite not yet implemented."
