Revealing Sextile: the comprehensibility plan

Status: approved 2026-08-15; Phases 0-3, 5 and 6 complete 2026-08-15, Phase 4 (the Sphinx documentation set) complete 2026-08-16. This file is the working reference for the rework; the architect session owns it and the developer session executes against it.

Documentation is done under Sphinx. The legacy docs/*.md and packages/sextile/docs/*.md were superseded rather than ported line for line; what remains of the latter is either a pointer or an app-internal design note.

Final status

150 commits since approval; the gate (ruff, mypy --strict with tests, pytest) is green at every one. Framework src grew 10448 -> 11744 as behaviour moved out of the apps into it; each app shrank (calendar 381 -> 312, stardot 2331 -> 2174, weather 4424 -> 4312). Tests 3043 -> 3112.

  • Phase 0 – done. Stale doc claims fixed; test_public_surface.py pins each public module’s __all__ against public-surface.md; docs/prose-rewrite/ deleted; glossary stub started.

  • Phase 1 – done. One Sextile with request.app; PageLayout.build(request) defaults title/home/number; one-call shapes (menu_page/notice_page/prose_page/ farewell_page); request.neighbours and standard_pages; StateKey/request.state; PageRouter/@router.page; per-route label=.

  • Phase 2 – done. The rename families above landed one commit each, no shims.

  • Phase 3 – done. Module splits and duplicate collapses as recorded in the Phase 3 section.

  • Phase 4 – done 2026-08-16. The Sphinx documentation set: a seven-step tutorial, fifteen how-to recipes, a reference (surface, glossary, keys, cli, layout, content, the wire and display semantics), an explanation (why-sextile, a 28-entry decision log, the rendering pipeline, graphics and mosaic fonts), three worked-example application pages, and the workspace notes pulled into the tree. Frames are drawn live by the sextile-frame directive; the -n -W docs build is now part of the gate.

  • Phase 5 – done. Docstrings contract-first; CLAUDE.md to 117 lines with the document-level rules.

  • Phase 6 – done. Calendar the canonical example (312 src lines); one factory shape; explicit home/index where a title frame exists; title_page; shared render/serve CLI assembly; the three apps converged on the calendar’s shape. Deviations from the plan:

    1. title_page gained a shortcuts parameter beyond the stated signature, to carry stardot’s 1->main shortcut; default () keeps the plain call plain.

    2. fetch added to sextile.testing (three suites had the same local helper).

    3. SequencePart.empty widened to str | Sequence[str] for multi-line empty states; two callers.

    4. serve now configures logging and render now guards a missing --page, both closing gaps the CLI lift exposed against stardot’s behaviour.

    5. Cosmetic: stardot’s subcommand help lists render/serve before ingest/archive.

What Sphinx inherits (Phase 4)

The rewrite starts from a truthful base, not a blank one:

  • Docstrings are contract-first and autodoc-ready – what a thing is, what goes in, what comes out, what a subclass overrides – and are the framework’s primary documentation.

  • glossary.md is the rename ledger: every renamed term, old to new.

  • public-surface.md lists follow each module’s __all__, enforced by test_public_surface.py, so an autodoc surface can be generated from them.

  • The legacy docs/*.md have been kept truthful sentence by sentence but not rewritten; treat them as source material to supersede, not to port.

Next

  • Done 2026-08-18: weather-viewdata extracted to its own repository (deployed services live outside the workspace; see docs/explanation/design-decisions.md); the in-tree honesty apps are now calendar-viewdata and stardot-viewdata, so the line counts above read as historical.

  • Done: frames render as HTML with the Bedstead font – viewdata.display, viewdata.html render_html, sextile render --form html, and the sextile-frame directive drawing live frames (now with a :form: option for bytes/grid text). Display semantics measured against Beebium in docs/reference/display-semantics.md.

  • Done: the Sphinx documentation set (Phase 4), and the decision log (docs/explanation/design-decisions.md, per decision 7).

  • Still open – the sextile.viewdata facade question in docs/open-questions.md: whether the wire/drawing internals want a single public facade or stay a set of submodules.

  • Noted while writing the docs: a form’s footer is drawn once, with the field empty, so TypeAhead’s per-match digit hint (1-2 when two match) only ever displays the empty-field capacity; making it track live matches would mean repainting the footer row on each keystroke.

  • Later cleanup: packages/sextile/docs/{design,layout,navigation,rendering}.md survive as fuller internal narrative whose rationale is only partly homed in the tree; navigation has no explanation-page home yet.

Diagnosis

The engine is good: routing, session, command parsing, wire encoding, the attribute planner, the fill algorithm, mosaic fonts, forms at 1200 baud, testing.calling. What stands between a newcomer and it:

  1. A coined vocabulary about forty words long (Held, Room, Offer, Placement, Once, Every, Flowing, Drawn, Arrival, Parting, Lines(said=), Suggest(look_up=), …), several used in two senses (Run, rows_for, blocks, ROOM/Room, Control meaning attribute).

  2. One idea, several spellings: five ways to register a page; five ways to ask a page’s title (describe, heading, heading_for, page_info, on_describe); six spellings for service state (Held, .checking, .of, .found_in, .find, held_in); three routes to centre text; two notice-page builders (farewell_page, _plain_notice).

  3. A tax on every handler: app = Sextile.of(request), title=app.heading_for(request.address), home=app.index, .build(request.address); plus _menu/_notice helpers, neighbour wiring, and the framework’s own pages routed by hand, all repeated in every app.

  4. Docs that argue instead of instruct (deferred: Sphinx rewrite).

The target

from sextile import Sextile, Request, Page, Menu, Notice

app = Sextile()

@app.page("1", title="Main menu")
async def main(request: Request) -> Page:
    return Menu(request, items=[app.item("news"), app.item("sport")])

@app.page("11", title="News")
async def news(request: Request) -> Page:
    return Notice(request, "Nothing yet.")

One import line; title, home key and page number defaulted from the registration and the app. PageLayout, parts, furniture and custom drawables remain underneath for the hard cases.

Phases

Phase

What

Size

0

Ground truth: stale claims fixed; name-level public-surface test via __all__; delete docs/prose-rewrite/; glossary stub

S

1

Make easy things easy (API)

L

2

Naming sweep, one family per commit, no shims

M

3

Module structure and duplicate implementations

M

4

Documentation (DEFERRED to Sphinx rewrite)

5

Docstrings contract-first; CLAUDE.md to ~100 lines with document-level rules

M

6

Applications converge; calendar becomes the canonical example

M

Order: 0, then 1, then 2, then 3; 5 interleaves; 6 is partly forced by 1-2.

Phase 0: ground truth

  • Fix stale claims: graphics.md “lettering not built”; rendering.md pagination and NFKD; writing-an-application.md override-describe, “three pages come built”, sextile.compass import; open-questions.md “both apps write their own menu builder”; drawing.py:12-17 “when it comes”; canvas.py:409 paginator; CLAUDE.md “nothing checks the surface”; stardot README footer format and “eight questions”; sextile README “two applications” and app.alias example.

  • test_public_surface.py checks names: every public module gets __all__; the test diffs __all__ against public-surface.md. Missing today: Shortcut, HOME_KEY, farewell_page, Spacing, Style, DoesNotFit, Where, BLOCKS_ACROSS/DOWN, read_font, FOOTER_ROW.

  • Delete docs/prose-rewrite/.

  • Glossary stub: one line per term; renames recorded here.

Phase 1: make easy things easy

1.1 One application class; request.app: Sextile; delete Sextile.of. Fold Application into Sextile (a small Protocol if a seam is wanted). 1.2 PageLayout(...).build(request): defaults title (registered, upper), home (app.index), page number. Masthead pages pass address=None. 1.3 First-class page shapes on top of the parts model: Menu(request, items=,     preamble=, title=, home=, empty=), Notice(request, *lines, ...), Prose(request, *paragraphs), one farewell/notice implementation, app.item(name) replacing MenuItem.for_page(app, name). 1.4 request.neighbours.previous/next; PageLayout(neighbours=,     item_noun=); standard_pages(history="92", contents="93",     keywords="94"); readership pages as routable handlers taking the visits log from state. 1.5 One state mechanism: KEY = StateKey[T]("name"), request.state[KEY], lifespan yields {KEY: value}; request.service -> request.state. Spike the Protocol-vs-class runtime check; if not uniform, drop it. 1.6 Two registration forms: Sextile(pages=[PageRoute(...)]) and @app.page; an APIRouter-style collector (pages = Pages();     @pages.page(...)) replaces @page + routes_in; retire routes_on, subclass style, and post-construction duplicates; merge PageInfo into PageRoute. 1.7 Titles: title_for(address) and label_for(address) with one hook; per-route label=. 1.8 Small: done — testing.text_of(page, index) (one helper, all local reimplementations gone); flowing as the default (a bare Drawable in parts means Flowing). Deferred: RowWriter column offset and run trimming to a budget -> Phase 3 (with the drawing-triplicate collapse); drop digit from non-numbering formatters -> Phase 2 (with the formatter renames).

Phase 2: names (recommendations; “your call” items settled by the user)

Family

Now

Proposed

Layout wrappers

Once Every Flowing Break

OnFirstFrame OnEveryFrame (default) FrameBreak

Layout values

Room Offer Placement Filled Summary Edge.FOOT

Space Claim Placed FilledFrame (private) FrameContext Edge.BOTTOM

Furniture

Prompt; “chrome”

Footer; “furniture”

Custom part

Drawn

Custom

Shortcut

says arrow

label with_arrow

PageLayout

follows item

next_page item_noun

Formatters

Lines(said=) Formatter/RowFormatter Figures

Lines(entries) positional; SequencePart/RowPart; KeyValues

Deferred from 1.8: drop the digit parameter from formatters that do not number their rows, folded in with the formatter renames above. | Forms | Suggest(look_up=, field=, typing=, empty=) Fields(complete=, note=, sends=, advice=) Field.takes | TypeAhead(lookup=, field_colour=, text_colour=, no_match=) FieldSet(on_submit=, footnote=, submit_label=, footer_items=) Field.accepts | | Request | Arrival(preceding, following) Parting service | Neighbours(previous, next) IdleTimeout state | | Application | lately_read/most_read/who_has_called ask() advertised()/pages() | recent_page/popular_page/callers_page request_page() routes() | | Wire | Control is_control_code | Attribute is_attribute_code | | Two senses | canvas.Run/composition.Run; lettering.rows_for/cells_for; Style.held | Span/Run; rows_needed/cells_needed; hold_graphics | | Modules | parting.py countdown.py lettering.py | hangup.py idle_warning.py mosaic_text.py | | Testing | calling Caller.key Caller.shown | connect press screen | | Keys | CONVENTIONAL_NEXT_FRAME moving(back=, on=) arrows_lead_where keys.BACK vs HOME_KEY | HASH frame_move_keys(has_previous=, has_next=) with_arrow_aliases; one constant per meaning |

Phase 3: modules and duplicates – DONE

Splits landed: application.py – middleware types to middleware.py, the seven built-in page methods to free functions in sextile.handlers (gathering from request.app), which kept builtin/ free of the application object; layout.py -> a layout/ package of parts, furniture, page with viewdata/footer.py moved in as layout/footer.py; forms.py -> forms/{base,type_ahead,fields}.py; session/session.py -> navigation, screen and the coordinator; composition.py -> the attribute planner into viewdata/attributes.py; encoding.py -> the wire half (kept, now internal) and viewdata/measure.py (cell_count, fitted). Merges landed: addressing -> page; declarations -> routing; compass -> viewdata/compass.py. requests stayed (Starlette name); held was already gone. __init__.py now exports the commonest layout and formatting shapes, so hello world and a menu import from sextile in one line. Duplicates collapsed: the centring and double-height twins deleted (the Composition-based drawing.centred/centred_double survive), the mosaic twin resolved by bar drawing through RowWriter.mosaic; footer._cutfitted; the two _to_footer_row and repaint._to_row unified as repaint.to_row with the last-row wrap as its edge case; incremental_bytes expressed through typed_bytes; the two Frame escape loops into _encoded_cells; the colour ranges owned by controls (colour_of); drawing.SOLID dropped for SOLID_BLOCKS. (charting.ACROSS_A_CELL/DOWN_A_CELL had already gone in phase 2 batch 5.) Deferred items resolved: RowWriter column offset became starting_at, which reads the colour and mode in force; run trimming to a budget became RowWriter.runs(cells=). The place(canvas, room: Space) parameter is now space, the int room split into cells/extent/width first. composition.Align.LEFT/RIGHT became Align.START/END, axis-neutral, rather than a separate VAlign, because Where is int | Align on both axes. The digit parameter moved to NumberedRowSequencePart, drawn for a numbered part through a private hook, so every other part lost it.

Not done, by decision: the page methods went to sextile.handlers functions rather than a pages/ package, and the not_found/timed_out/failed notices stayed in application.py – they are the application’s own words, and 480 lines and one class is fine. handlers.py stayed rather than merging into pages/.

Phase 5: docstrings and CLAUDE.md

Contract first; rationale to a # comment or the design doc; no history in docstrings; no application concept even in examples. Worst lists: core: Middleware type, Application.respond, index, Arrival, forms.py module/Form, layout.Drawn, held.py, formatting.Lines; rendering: canvas.Run, composition.py module/Align, wrap_within, RowWriter.background/.plain, drawing.py module, countdown.py module, repaint.typed_bytes. Models: routing.Converter, layout.fill, charset.py, blocks.read_bitmap, encoding.ScreenControl, Frame.row_bytes. CLAUDE.md to ~100 lines; add document-level rules (genre first; code before prose; headings are tasks or nouns; no bold sentences; one home per idea; API names backticked and present in the surface).

Phase 6: applications

Calendar = canonical example (~180 lines); one factory shape; one home/index convention; a TitleFrame helper; shared render/serve CLI assembly.

Decisions (recommendation first)

  1. Handlers keep returning Page; layouts take the request.

  2. Keep typed state keys, one constructor.

  3. Teach decorators first; the PAGES table shown as the same thing.

  4. Wrapper names: OnFirstFrame/OnEveryFrame; flow the default.

  5. The Canvas twins of Composition features are duplicates: delete.

  6. Docs tooling: Sphinx (decided by the user).

  7. Design rationale becomes a decision log, not ADRs.

Guardrails

Do not touch routing, session command handling, wire encoding, the attribute planner, the fill algorithm or the fonts except to rename. No new features. Do not delete framework code because nothing calls it; only because something else does the same job. Two invariants and the surface test gate every commit. uv run pytest, uv run ruff check ., uv run mypy green at every commit.