FamilyHQ Sign out Sign in

AI briefing - field practice

One agent's field notes from building and breaking real sites and apps on this engine - a companion to the reference briefings, not a specification.

What this is, and what it is not

These are one agent's field notes from building and breaking real sites and apps on this engine. They are a companion to the engine's reference briefings, not a specification: nothing here defines behaviour, and nothing here was written by the engine.

Where these notes conflict with the engine's reference docs, the reference docs win, and the conflict is a bug in these notes. Report it rather than working around it - a stale line here is worse than no line, because it will be trusted.

This copy was generated for engine 0.10.34. The last section, Where this came from, names the sources, the agent and the dates.

How the sections are marked

Some of what the field learns is true of one engine version and false of the next, and some of it is true whatever engine a site runs. They are worth different amounts to you, so they are marked:

Marking What it means
Version-dated Behaviour that differs by engine version, kept as before/after columns. Not resolved down to "current behaviour": a half-migrated estate is the normal state, and the agent on an older site is the one who needs the left-hand column. Check the engine a site runs before acting on one of these.
Version-independent A field scar. It cost somebody real time, it does not depend on a version, and it is the most useful part of this page.
unmarked General practice - judgement and habit rather than mechanism.

The engine version a running site reports is not necessarily this one. The briefing set is served from the site's own docroot, so a site installed from an older release serves an older copy of this page.

Part one: sites and content

Pages, layout, theme, HTML and styling.

The toolkit, and what each part is for

The engine gives more than Markdown. Reach for these before writing HTML.

Front matter, with source prefixes

tt_page_var values may be literals, or carry a source prefix:

This is documented in docs/frontmatter.md and is easy to miss. json: is the single most useful feature for content-heavy or repeating pages and it removes the temptation to generate pages offline.

tt_page_var:
  gallery: json:/data/paintings.json

Template Toolkit in the page body

Front-matter variables are available in the body. The body becomes HTML first and TT runs second, over the rendered HTML - see "Things that look equivalent and are not" for the two things that follow from it, both of which bite when a variable feeds an image or a ::: fence.

loop carries everything a list needs:

Wrap-around navigation without storing neighbours in the data:

[% FOREACH w IN works %]
[%- prev = loop.first ? works.last.id : loop.prev.id -%]
[%- next = loop.last  ? works.first.id : loop.next.id -%]
...
[% END %]

Components

A ::: name key="value" fence renders through components/name.tt, where:

Components live at lazysite/layouts/<layout>/components/*.tt. Components under lazysite/templates/components/ are available to any layout, and a layout's own component of the same name wins.

Front matter can also carry sections: as structured data, which a layout iterates and dispatches to the same components.

Themes carry the look, layouts carry the structure

Colours, type and spacing belong in theme.json as tokens, emitted as CSS custom properties. A component or layout should reference tokens, never literal colours. This is what makes a restyle a one-file change.

Other things worth remembering

Briefs: stop writing sidecars from 0.10.29

Version-dated - this describes behaviour that differs by engine version. The before/after columns below are kept on purpose. Check which engine the site runs before acting on it. Imported for engine 0.10.34.

SM245 retires the .brief sidecar. The record survives, in an engine-owned store at lazysite/briefs/<content-path>, owned by a contract plugin that ships disabled - the operator enables it per site and runs its Migrate action, which imports every existing sidecar idempotently and never removes one it could not import.

Was Is, from 0.10.29
write <file>.brief over WebDAV append_brief (MCP) / brief-append (API), under manage_content
read the sidecar read_brief / brief-read
you stamped the date and your name the store stamps the date and your verified identity
append-only by convention append-only, unchanged

append_brief takes {path, entry}.

A brief is not only for a page. The store keys on a path and does not check that the target is a file, or that it exists. Verified on 0.10.29: a folder (/docs), an asset (/favicon.ico), a layout, a theme stylesheet, the nav, a form submission store and the site root (/) are all accepted keys. So the place to record why a whole section exists is a brief on the folder, and the place to record what a site is for is a brief on /.

Two things it will not key: anything under lazysite/db/ (blocked), and a data table ROW (no path exists to name one). Whether to extend to those is an open decision with the operator, not settled practice.

The trap: writing a .brief over WebDAV still works mechanically after the change. It just writes an inert file - nothing lists it, nothing carries it, and nothing imports it after the one-shot migration. There is no error to tell you. So the rule is not "prefer the tool", it is stop authoring sidecars on any site running 0.10.29 with the plugin enabled.

The denies are unchanged: a stray .brief still 404s and never indexes, so nothing leaks during the transition.

Until 0.10.30, move_file does not carry a brief, and deleting a file leaves one behind. The store is path-keyed, so a moved file's entry stays under its OLD path until a reconcile - recorded as the accepted interim, and a deleted page's brief becomes debris that nothing can list or remove. Do not read an empty brief after a move as data loss; read the old path.

Fixed in 0.10.30, and verified on edge: the entry follows the file on move and goes on delete - over WebDAV as well as through the tools - a copy starts unbriefed, and list_briefs / delete_brief make strays visible and clearable. list_briefs returns path, size, mtime and an orphan flag; read_page reports has_brief from the store. Check which of these a site has before assuming either behaviour, the same way you check whether the plugin answers at all.

Two things worth knowing about the store once you can list it:

Nothing is deleted, and migration is per site, whenever that site is next revisited. There is no estate-wide sweep and no deadline: a site can sit on sidecars indefinitely and must keep working. So a half-migrated estate is the normal state for a long time, and arriving at a site you cannot assume either condition.

Check before you write a brief. The plugin is off until an operator enables it, and the tools do not answer until they do:

Filed 2026-08-24 (operator's instruction): a .brief write should be refused once the plugin is enabled, rather than silently landing an inert file. Until that ships, the check above is the only thing standing between you and a record nobody reads.

Choosing the right tool

Graphics-heavy sites

A visual site is not a different kind of site. Measured across this estate, raw HTML in Markdown tracks one thing only: whether the site has components.

community.dhcf.eu writes a three-number stats panel as 24 lines of hand HTML with an inline style hack. dito.tech renders the same shape from a 9-line component fed by a list. Same engine, same week.

Receiving a design

Designs often arrive as a zip of monolithic HTML from a design tool or another Claude. Do not publish it as-is, and do not paste it into a Markdown body - the engine refuses raw: true pages with an HTML content type, but nothing stops a monolith pasted into an ordinary page, so the guard will not save you.

Decompose in this order:

  1. Find the repeats. Any block appearing three or more times with the same class signature is a component. Do this before anything else; it determines the shape of everything after.
  2. Pull the copy out into Markdown and the structured bits into a JSON data file. Ask: could the client edit this text without seeing a tag?
  3. Collect the visual decisions - every colour, size and spacing value - and put them in theme.json as tokens. No literal colours survive this step.
  4. Rebuild the page as prose plus component fences.
  5. Check the round trip. The next design iteration should touch tokens and components only. If a redesign would force re-entering content, the decomposition is not finished.

Photograph-led galleries

Anti-patterns

Never label one statement as honest, or as precisely stated. "The honest position", "one honest qualification", "stated precisely" - each implies everything else on the page is dishonest or imprecise. The label undermines the whole to decorate a line. State the fact and let it stand; a qualification is just a sentence next to the claim it qualifies. (Keep precisely where it means accurately - "restrict the ports precisely" carries information.)

A db: binding has a row ceiling, and it changed in 0.10.30

Version-dated - this describes behaviour that differs by engine version. The before/after columns below are kept on purpose. Check which engine the site runs before acting on it. Imported for engine 0.10.34.

A binding with no explicit limit does not return every row. This bites only once a table passes 200 rows - which is exactly when a site has become worth something - so check which engine a site runs before trusting a list.

Binding 0.10.29 and earlier From 0.10.30
db:works, 250-row table 200 rows, silently 500-row ceiling; a capped render logs a WARN
.count on that page 200 - counted after the limit, so it agreed with the short list the true count, 250
db:works limit=501 nothing at all, no error clamps to the ceiling and serves rows, warning on the result
Showing the real total no way to [% items_total %] beside [% items.size %]

On 0.10.29 the page and its own count agreed with each other and were both wrong, with no signal anywhere - not on the page, not in the source, not in a log. That is the version to be careful on.

The 0.10.30 column is verified on edge: a 250-row table renders 200 with items_total reporting 250 and .count reporting 250, and limit=501 serves rows rather than nothing. Note the default is still 200 when no limit is given - 500 is the ceiling you may ask for, not the default you get.

From 0.10.30 the ceiling is one number, 500, stated once; an over-cap request clamps rather than emptying; and every list binding gets a companion <var>_total carrying the true count. The sanctioned spelling for an honest list is:

showing [% items.size %] of [% items_total %]

So:

Creating a page in a folder that does not exist yet

Version-dated - this describes behaviour that differs by engine version. The before/after columns below are kept on purpose. Check which engine the site runs before acting on it. Imported for engine 0.10.34.

Verified on 0.10.30, and the channel decides:

Channel Deep path with missing parents
MCP write_file / create_page creates the parents and the page
Manager save creates the parents
WebDAV PUT refused - "Parent collection missing - MKCOL the parent(s) first"

The WebDAV refusal is correct behaviour, not a gap: RFC 4918 9.7.1 requires it, and every WebDAV client in the world expects it. So MKCOL each level first when you are working over DAV, or use the MCP tools, which do it for you.

A brief may be appended to a path at any depth whether or not anything exists there yet, on any channel - that is what makes brief-first authoring possible.

WebDAV writes that leave something stale

Version-dated - this describes behaviour that differs by engine version. The before/after columns below are kept on purpose. Check which engine the site runs before acting on it. Imported for engine 0.10.34.

Proven defects as at 0.10.32, fix planned but NOT yet shipped. They share one shape: the manager path cleans up and the DAV path does not, so the same logical edit has two different outcomes depending on how you made it.

What you do over DAV What is left stale
MOVE or COPY a page Registries are never invalidated - the sitemap keeps advertising the old URL. The manager's own move clears it
DELETE a collection A 301 alias survives, pointing at a page that no longer exists
Write nav.conf Cached pages keep rendering the old nav until something else invalidates them

Until these land, after any of the three: regenerate the registries and invalidate the cache explicitly, or make the edit through the manager or MCP instead, which do it for you. And check the sitemap after a move rather than assuming - an old URL left advertised is the kind of thing a visitor finds before you do.

This is the same lesson as the parent-directory one, in the other direction: WebDAV is a file protocol and does exactly what a file protocol should. The engine's bookkeeping hangs off the tools that know about pages.

Things that look equivalent and are not

Version-independent - a field scar. It held before engine 0.10.34 and holds after it, on any site you connect to.

Each of these cost real time; none is obvious from reading.

Verify like this

Version-independent - a field scar. It held before engine 0.10.34 and holds after it, on any site you connect to.

Part two: apps and data

Workflow, where state lives, how data is stored and read back, and who is allowed to see it.

The question that separates a site from an app

If two people open this on two devices, do they see the same thing, and does what one of them does show up for the other?

If yes, it is an app and it needs a store. If no, it is a page. Most prototypes answer no without meaning to, because the browser makes it so easy not to notice - see State that only exists in one browser, below.

Probe the action before you design around the capability

Version-dated - this describes behaviour that differs by engine version. Check which engine the site runs before acting on it. Imported for engine 0.10.34.

describe-capabilities lists actions its own server will refuse. A capability reads true, the action appears in the actions map, and the call answers "served only to the manager UI over a cookie session". The map answers has this account been granted X, never can this surface reach X.

Confirmed manager-UI-only on 0.10.32: form-targets-*, the handler-* family, plugin-list / plugin-enable, users, principals, keys-list, protected-sections, preview, cache-invalidate. auth_default is refused by config-set as not settable. acl-set refuses lazysite/db/tables as a blocked path.

Make one call of the action the design depends on, before the design depends on it. A throwaway probe costs a round trip. Finding out at the first write costs the design - twice now that has been a form-based plan that had to become something else after the tables were already built.

Where state can live, in order of preference

A data table (lazysite/db/tables/<name>.yaml + rows)
The default for anything a person enters and expects to find again. Declared by a descriptor, applied with a migration, read on a page with tt_page_var: items: db:<table>. Survives devices, browsers and reinstalls, and can be read by more than one person.
A JSON data file in the docroot (json:/data/thing.json)
For content the author edits and visitors only read - a gallery, a price list, a set of questions. No write path, no per-user state. Cheap, editable in a text editor, and versioned with the site.
Front matter on the page
For one page's own settings. Not for data.
The browser (localStorage)
Only for genuinely local preferences - which tab was open, a draft not yet submitted. Never for the thing the app is for.

State that only exists in one browser

The most common defect in a prototype, and it is invisible while one person tests it on one machine.

localStorage keeps data in that browser, on that device, for that origin. Clearing site data loses it. A second device never had it. Two family members each get their own private copy of what looks like a shared record, and neither can tell.

When you meet a prototype that persists to localStorage, the requirement is almost never "keep using localStorage". It is: this was always meant to be shared, and the prototype could not express that. Say so explicitly rather than porting the mechanism.

Declaring a table

Types are text, integer, decimal, boolean, date, datetime, enum. A decimal must declare digits and places; an enum must declare values. key: names the field that identifies a row - leave it out and the store assigns an id.

title: Homework
key: id
public: true          # ONLY needed if ANONYMOUS visitors must see rows
fields:
  child:   { type: enum, values: [sasha, daniel], required: true }
  subject: { type: text, required: true, max: 40 }
  task:    { type: text, max: 300 }
  due:     { type: date }
  done:    { type: boolean, default: false }

Then data-migrate. Declaring is not creating; the migration is a separate, deliberate step, and re-running it is safe.

Things the descriptor cannot yet say

Know these before designing a schema, because each one becomes hand-work:

Reading it back

tt_page_var:
  items: db:homework sort=due asc limit=50

Then loop in the body. loop.count, loop.size, loop.first, loop.last, loop.prev and loop.next give counters and wrap-around navigation without storing any of it in the data.

Three properties worth knowing:

Page bindings filter. The data endpoint does not.

The two read paths do not have the same grammar, and the difference is silent.

Read path Filtering
db:t(col=v,order=x,limit=n) in front matter conditions AND-combine; .count(col=v) and .field(col,key=x) too
/cgi-bin/lazysite-data.pl?table=t order_by, order, limit, offset only - anything else is IGNORED, and the reply looks exactly like a filtered one

data-rows&table=t&chunk=AAA returns every row including the ones where chunk is not AAA. Never read an unfiltered result as a filtered one.

So a script that needs a subset reads the whole table and indexes it in memory, paging at the 500-row ceiling until a page comes back short. At a couple of thousand rows that is a few requests once per session and it is fine. What you cannot do is have front matter follow the script: tt_page_var has TT markers stripped, so a binding can never take a query parameter. A page that must show whichever row the script just picked is a script-driven page, and deciding that early saves building it twice.

Changing rows over the API

Version-dated - this describes behaviour that differs by engine version. Check which engine the site runs before acting on it. Imported for engine 0.10.34.

Reading is db: in the page. Writing is the control API, and four of its conventions cost a round trip each the first time you meet them. Verified against 0.10.29.

data-row-save is BOTH insert and update, and key is what decides which
To ADD a row, omit key entirely and carry the key field inside row. To EDIT one, pass key and leave the key field OUT of row. Passing key AND the key field together is refused as key_immutable - it reads as an attempt to rename a row, which is the one thing a save will not do. Moving a row to a new key is a delete and an add.
data-import wants multipart, not a body
The CSV must arrive as a multipart part named file. Posting the CSV as a raw request body fails validation, whatever the content type says.
data-import writes nothing without apply=1
Without it you get a PLAN - applied: 0, inserts: 25, ok: true - in a reply otherwise identical to a successful load. A load can look like it worked and have done nothing. Read applied, not ok.
data-table-save takes the descriptor in a key called descriptor
Not text, yaml, content, body or definition - each refused with the same "descriptor text required". data-table-source returns it under descriptor too, not source.
data-table-drop confirms by NAME
confirm takes the table's own name, not 1 and not true. Deliberate - it makes a drop impossible to fire by copying another call's confirm flag.
A drop leaves a safety export you may not be able to delete
Dropping writes every row to lazysite/db/rebuilds/<table>-dropped-<ts>.json first. That is good - a drop is recoverable. But lazysite/db/ is denied over WebDAV by design, so an agent holding manage_data can legitimately drop a table and then cannot clean up the file its own drop created. Plan for an operator to remove it, and say so when you hand over.

Two properties of these that are easy to misread:

Identity, and who may see what

An app usually has to answer "who is this?" before it can answer anything else.

Two separate controls guard a table, and this is the shape a gated app wants

Confirmed with the engine side 2026-08-24, and it is not obvious from the flag's name:

So an unpublished table renders normally for a signed-in user. Authenticated- only rendering is the default state of a table, not something to arrange. The render path carries the real visitor, never an operator, so a page shows the same rows to whoever is looking.

For an app whose data should never be public:

  1. Leave public: off - anonymous readers get nothing, not even the table's existence.
  2. Put a read list on lazysite/db/tables/<name> naming the users or group.
  3. Gate the pages too.

ACL lookup is longest-prefix, so one rule on lazysite/db/tables governs every table at once if a site-wide default is what you want.

Do not reach for public: true to make a gated page work. If rows are not appearing for a signed-in user, the read list or the page gate is the cause, and publishing the table would fix the symptom by removing the protection.

For a family app, expect at least three roles and design for them from the start: an adult who administers, an adult who participates, and a child who sees their own things and not their sibling's. A fourth is common - a person who appears in the data but never signs in (a grandparent, a coach).

One group, and exactly what it needs

A gated app usually wants one group, not a hierarchy. Ours is stock-admin, and working out what to put in it took longer than it should.

manage_data, and nothing else. A write through the data endpoint needs all three of a signed-in session, manage_data, and membership of the table's writable_by if it names any. writable_by can only ever TAKE write access away - it cannot grant a write to an account without the capability - so it is not a substitute, and a group with the name and not the capability looks correct until somebody presses Save.

Four things that are easy to get wrong:

Do not grant ui. That is what injects the admin bar on site pages, and on an app page its Edit link opens the Markdown of a page whose body is a script - the most destructive action available, offered as the most prominent one. It is per-user with no per-page control, so the only way to keep it off an application is to keep the capability off the people using it.

webdav / api / mcp are for partner tokens. A person signing in with a browser cookie does not use them, and granting them to a human group grants nothing and confuses the next reader.

@group in an ACL matches only signed-in browser users. Token, MCP and WebDAV partners carry no groups, so an agent that must keep working has to be named in the list explicitly. acl-set warns about this and the warning is easy to skim past.

Name a group you are in on writable_by while you are still building. The office group may not exist yet, and a descriptor naming only it locks the loader out of its own tables. Add the build agent's group, and put a comment in the descriptor saying to remove it at handover.

Beyond that, do not split rights the app does not split. An earlier draft here had a second group for corrections because they were going to happen in the manager row editor. Once corrections moved onto the app's own pages, the second group had nothing to do.

Forms are how people put data in

A person in a browser cannot call the control API, so a form is the only way they write anything. Three properties decide the design.

There is a db form handler - "Store in a data table". It takes a table and a required form field=column mapping. Values are checked against the declared types, so a submission that does not fit is refused and the visitor told, rather than thanked and stored wrong. Fields nobody maps are dropped, so a form gaining a field cannot start writing a column on its own.

A form can dispatch to several handlers at once - a file record and a table row from one submission, if both are wanted.

Forms only ever INSERT. The db target inserts and has no update branch, so a submission carrying an existing key is refused on the unique key rather than superseding the row. A form is a capture surface, never an edit surface. Corrections travel by the manager row editor, a CSV round trip, or an API row update; save_data_row with a key updates in place.

So design the capture surface and the correction surface separately. They are not the same surface and they do not want the same shape: a guided form is right for entering a hundred things with their context in front of you, and wrong for fixing one of them. A row editor is the reverse.

When an app may skip the forms system entirely

A custom data app may write to its tables directly from its own page script, through /cgi-bin/lazysite-data.pl. Operator's ruling, 2026-08-25, on the jpm-stock corrections build. It is an exception to "forms are native and hand-written form markup is not acceptable", and it is narrow.

The endpoint takes GET ?table=t to read, GET ?csrf=1 for a token, and POST ?table=t with X-CSRF-Token carrying {"row":{...}} to insert, {"key":"...","row":{...}} to update, {"key":"...","delete":1} to remove. A write needs all three of a signed-in session (a partner token is not one - the endpoint answers not signed in), the manage_data capability, and membership of writable_by if the descriptor names any. It applies the same type checks as any other write.

Take the exception when all of these hold:

Stay with a native form when any of them fails, and always when the writer could be a member of the public. A contact form, a booking, a sign-up: those are submissions from strangers and the forms system exists for exactly them.

Two things the exception costs, both of which want handling rather than accepting:

Say in the app's own docs that it took this exception and why. The next session will otherwise read the hand-built control as a rule being broken.

Watch for a design that only exists because the old store could not be edited. Append-only files force "write again, latest wins" and it looks like a requirement long after it has stopped being one. Given a table, the correction is an update, the superseding rule disappears from both ends, and so does the risk of a partial re-submission blanking what it did not re-state. Ask what a rule is protecting against before porting it.

Creating or editing a handler is an operator action. lazysite/forms/handlers.conf is denied to every partner grant, so a build plan that needs a new handler has an operator step in it. Say so up front rather than discovering it at kick-off.

One submission is one row

The trap. A form maps fields to columns, so one submission becomes one record. That is right when one form means one thing - a contact message, one answer, one booking.

It breaks when a page collects many things at once. A form with forty-five question fields cannot become forty-five rows; it becomes one row forty-five columns wide, and that table changes shape every time the questions change, which means it is a spreadsheet rather than a table.

When capture and storage disagree on shape, the honest options are:

Two limits worth designing around

Submissions must arrive between 3 seconds and 2 hours after the page rendered - an HMAC timestamp token, and unlike the rate limit it is not configurable per form. Any long data-entry page, or one left open over a lunch break, will cross it and the person loses what they typed. Design for partial submission, keep pages short, and put "submit as you go" in the on-page guidance rather than in a briefing nobody re-reads.

Rate limiting is five submissions per IP per hour by default, which is right for a public contact form and wrong for a team working through data-entry pages from one office address. From SM425 a submission whose session cookie verifies bypasses the anonymous limit, so a signed-in team needs no tuning - check the deployed build before reaching for rate_limit: off, which remains the mechanism for forms that are open but protected another way.

Importing from a feed

When rows arrive from another system on a cycle:

Identifiers must not be positional

If a checkbox is keyed child:hw0 - child plus the index of the item in a list - then deleting or reordering the list silently transfers the tick to a different task. The same applies to any state stored beside a list rather than in it.

Give every row a stable key and store state against it. Where a prototype uses positional ids, that is a defect to fix in the port, not a pattern to carry over.

Workflow: the shape most family and team apps take

Nearly all of them are the same four movements. Naming them early makes the schema obvious:

  1. Capture - something arrives: typed, dictated, photographed, imported.
  2. Structure - it becomes rows: a task with a date, a mark with a subject.
  3. Act - somebody ticks, assigns, reorders, completes.
  4. Reflect - a view that shows what happened and what is next.

Capture is where prototypes are strongest and production is weakest, because capture is the part that needs a person's context. Be careful promising automatic structuring of free text; specify what happens when it gets it wrong.

Handing out work to several people at once

The moment more than one person works a shared list, the app has to answer: who has this item, and how does nobody else get given it?

The unique key on a table is an atomic test-and-set, and it is the whole answer. Claiming an item is INSERTING its row. A second insert carrying the same key is refused on the unique constraint, so two people claiming at the same instant produce one success and one refusal.

claim(item):
  insert {key: item.id, allocated_to: me, allocated_at: now}
  refused? somebody beat me to it - reload, take the next, retry (bounded)

There is no window between checking and taking, because there is no check. The obvious alternative is the broken one:

read the table, pick a free item, write it back

Two people who read before either writes both see it free, and both take it. That code passes every test you will run alone.

Three things the design needs beyond the lock:

One row, not two. Keep the claim and the result in the SAME row, keyed on the item. Its existence is the allocation and its answer field is the outcome: no row means free, a row with no answer means somebody is on it, a row with an answer means done. A separate allocation table needs the two kept in step, and they will not be.

Claims must expire. Somebody will close the tab, or go home mid-item. Without an expiry that item is allocated forever and no one can reach it. Pick a span longer than a working session and shorter than a day, decide it from the timestamp at read time, and take an expired claim by deleting the stale row and inserting fresh - so the unique key keeps doing the locking.

Give a person their own item back. On arrival, look for a row allocated to this person with no answer, and resume it before handing out anything new. Otherwise someone who reloads collects a second item and strands the first.

Let anyone release. Own claims release without ceremony; someone else's should ask first and name them. Colleagues sharing a queue need to free an item when a person is off sick, and they should not be able to do it by mis-clicking.

A stop action must actually stop

Both mine did the opposite, and it is an easy shape to write by accident: a handler that finishes its job and then calls the same "what next" routine everything else calls. So put this back released the item and immediately claimed another one - the single action that means "I am leaving" handed the person more work.

Name the buttons for what they do to the SESSION, not to the record. Save and take the next, Save and stop, Put back and stop. Then make each one end where its name says.

And where an action navigates away, await the audit write before leaving. Fire-and-forget is right for a log everywhere else - a failing trail should never block someone's work - but a request in flight when the page unloads is cancelled, so the one action you most want a record of is the one that leaves none.

Exact totals need integer arithmetic

Any app where entered numbers must add up to a stated figure - allocations, splits, invoice lines, stock quantities - must not compare them as floats. 0.1 + 0.2 is not 0.3, and a screen whose entire purpose is making a total match will refuse a total that is visibly correct.

Multiply into the smallest unit and compare integers. Tenths for a quantity to one place, pence for money. Convert once at the edge, compare in the middle, format on the way out.

Say which way the total is wrong, too. 40.0 still to allocate and over by 2.5 are actionable; does not match means counting on your fingers.

Start from the record, not from a blank form

When an app exists to CORRECT existing data, pre-fill what the system already believes and let the person change it. The common case is that the record is right - the exercise is finding where it is not - so confirming should cost a glance and only a disagreement should cost typing.

Two conditions. Say on the page where the pre-filled value came from, or it reads as a suggestion the app invented rather than the thing under review. And fill only when there is no answer yet, so re-opening something already answered shows what was saved and never quietly overwrites it.

The cost is that Save is valid the moment the page loads, so a distracted person can confirm without reading. That is a real trade and worth naming to whoever asked for the app rather than deciding it quietly.

Where the intelligence runs

A prototype may call a model directly from the browser. A deployed app cannot: the key would be in the page. Any model call belongs behind the server, with:

Scheduling

Anything recurring lives in the stack, not in host cron - a sidecar or an in-process timer that ships with the app. A schedule the operator has to remember to install is a schedule that will be missing after the next move.

Widen the page, do not break out of it

An app screen is usually wider than the prose column a site layout is built for. The reflex is to break the content out - margin-left:50% with a translateX(-50%) - and it works, for exactly the element you apply it to.

Everything around it stays behind. The site bar, the nav and the footer keep the old width, so the page reads as a wide table wearing a narrow hat. That is worse than the narrow table you started with, and it looks like a bug because it is one.

The shipped chrome caps the page with body { max-width: 800px }. Raise that instead, from the page's own stylesheet - a page can style body like any other element, and it only affects that page:

body { max-width: min(1400px, calc(100vw - 2rem)); }

Then the bar, the nav, the content and the footer are one column again and all of them flow together. Pick ONE width for the whole section, or the header shifts as somebody moves between pages.

Having done that, keep the prose narrow inside it. A table may use the full width; a paragraph at 1400px is unreadable:

.head, .sub, .caption, label, textarea, .msg { max-width: 62rem; }

And size the columns rather than hoping. A column with no width of its own gives its space to whichever column carries the most words - a three-digit number will wrap onto two lines next to a sentence. width:1% with white-space:nowrap is the shrink-to-fit idiom: the cell gets exactly what its content needs. Give the prose column the slack, and keep a horizontal overflow-x:auto container around the table for the narrow-screen case.

Borrowing a theme

A gated app should not look like the public site, and it does not need a theme of its own to manage. Take the palette and the conventions from an existing theme and put them in the page, scoped to the app's own container.

Ours came from a documented intranet theme: quiet greys, a dark bar along the top, dense tables with small-caps headers. What made it worth borrowing was the description rather than the colours - a working surface, not a shop window - which decides a hundred small questions the same way.

Two things carry the signal on their own. A dark bar across the top says which side of the gate you are on before anything is read, and it does it without a label - the "Private" chip we put in it was telling signed-in staff what the login had already told them. And dense rows with quiet rules: a data-entry screen is read by someone comparing it against paper, not browsing.

Before calling an app done

Reading a client's prototype

A prototype is a requirement expressed in the only language available, not a design to copy. Extract, in this order:

  1. The data - every list, constant and record in the source. This is the schema, whether or not it is declared.
  2. The computations - averages, roll-ups, groupings. These are business rules and they are usually written down nowhere else.
  3. The vocabulary - the client's own words for things. Keep them exactly; they are the domain language and renaming them loses meaning.
  4. The interactions - what is clickable, what persists, what is only visual.
  5. What is defined but never shown. Dead constants are almost always an intention that ran out of time, and they belong in the requirements as a question rather than being silently dropped or silently built.

Then state plainly what the prototype could not express: sharing, permissions, history, backup, what happens when two people edit at once.

Where this came from

Imported on 2026-08-25 by tools/import-field-practice.pl, for engine 0.10.34. Written by the lazysite site agent (Claude Code) - the agent that builds and maintains sites on this engine - as a working record, and kept current in its own project trees:

Source Covers Last changed
/srv/projects/lazysite-sites/AUTHORING-PRACTICE.md sites and content 2026-08-25
/srv/projects/lazysite-apps/APP-PRACTICE.md apps and data 2026-08-25

Those paths are on the site agent's own machine and are not part of this engine. Updates come from re-running the import, which happens when a release is cut; an operator can also run it between releases. Nothing you edit on this page survives the next import, and the engine's own test suite fails the build if this copy stops matching its sources - so a correction belongs in the source files, not here.

If you have found something durable that is missing - a mechanism worth reaching for, a trap worth naming, a measurement worth keeping - send it to the operator for the source files rather than adding it to the site.