Card designs with [needs.card_layouts]

Added in version 0.31.2.

A need card is what ubCode renders a need directive as, in the built site and in the editor preview alike. Its shape is a card design: a named, declarative description of which regions the card has and what each one holds.

ubCode ships ten designs, and a project can define its own. Both are the same kind of thing, written in the same vocabulary and interpreted by the same code — so a design you write is not a second-class citizen of the renderer, and complete is not privileged over review.

A need chooses its design with :layout:, or a project chooses one for it with [needs.fields.layout] (default and predicates). An unknown name renders the default design and reports build.need_layout_unknown — it never ends the build.

The regions

A card has five regions, and every design is a statement about them:

Region

What it holds

header

The type badge, the title and the id, on one line. A design either has it (header = true) or does not.

meta

The collapsible table of the need’s fields and link rows. A design chooses which tier of fields it shows and can filter them; meta = false means no meta section at all.

content

The need’s body. Always present.

footer

An ordered list of elements below the content.

side

An ordered list of elements in a column beside the card, on the left or the right.

Both footer and side range over the same element set (each element can also be written as an object — see The object form):

Element

What it draws

id

The need’s id as an in-page permalink — what keeps a header-less card linkable.

title

The need’s title, drawn visibly where you put it — the design’s title carrier. Legal only in a design with header = false, and only once across all its regions (see One title per card).

type

The need’s type name.

layout_echo

layout: <resolved design> — what complete prints.

style_echo

style: <resolved tokens> — what complete prints.

field:<name>

<name>: <value> for one named need field.

image:<field>

An image whose source is the value of one named need field (see Images on a card).

Every element is omit-when-empty, and so is every region: an element with nothing to draw draws nothing, and a region whose elements all draw nothing emits no markup at all. That rule is what makes the side column of a design like clean_l free for a need that has no image.

A prefixed element’s payload is always a field name. image:diagram draws the image the need’s diagram field points to — the payload is never itself a literal path or URL, and a payload that names no defined field is a configuration diagnostic.

The object form

Every element string is canonical sugar for an object (a TOML inline table). The two spellings are equivalent — the same resolved design, the same rendered bytes, and the same configuration hash:

  • "image:diagram"{ type = "image", field = "diagram" }

  • "field:owner"{ type = "field", field = "owner" }

  • "id"{ type = "id" }, and the same for title, type, layout_echo and style_echo

The string forms stay valid forever and remain the default way to write an element. The object form exists for the things a bare string cannot say: an element’s options.

[needs.card_layouts.spec_card]
footer = [{ type = "field", field = "owner", label = "Owned by" }]

[needs.card_layouts.spec_card.side]
elements = [{ type = "image", field = "diagram", height = "40px" }]

type is required, and names one of the seven element kinds. field is required for field and image elements and not valid on any other kind — and it is always a field name, exactly as the prefixed strings’ payload is (see the invariant stated above), never a literal path or URL.

The options, only these and only where stated:

Option

On

Value

height

image

The drawn image’s height, emitted as the <img>’s height attribute: a number with an optional px, em, rem, % or pt unit — a bare number means pixels — at most 16 characters. The value is always a TOML string: write height = "40", not height = 40.

width

image

The drawn image’s width. Same grammar (and same string spelling) as height.

label

field

Replaces the field name in the rendered name: value pair: 1–64 characters from letters, digits, spaces and _().,/-, starting and ending alphanumeric, with _ only between alphanumerics — a word-trailing underscore is reference syntax in a Sphinx-Needs build, so user_name is fine and Owned_ by is not.

Anything else — an unknown key, a missing or misplaced field, an option on the wrong element kind, a value failing its grammar — is a configuration diagnostic, and the element is ignored exactly as an unknown element string is.

The grammars are deliberately narrow, and shared verbatim with the sphinx-needs needs_card_layouts companion: a label cannot carry a markup-active character, so one value renders the same pair in both engines.

The built-in designs

Name

What it renders

clean

The default: header, a meta section of the fields the need actually carries, then the content. Emits no layout class at all, so a clean card is byte-identical to a card with no :layout:.

complete

clean’s chrome over the full effective field set (schema and configuration defaults included), plus a footer echoing the resolved layout and style. Those two names move out of the meta section, because the footer is now their home.

focus

Content only — no header, no meta section — plus a slim footer carrying the card’s permalink.

debug

clean’s chrome over every field the index can resolve, internal and empty values included, with the meta section forced open.

clean_l, clean_r

clean plus a column drawing the need’s image field, on the left or the right, beside the whole card.

clean_lp, clean_rp

The same column, beside the header and meta section only; the content takes the card’s full width below it.

focus_l, focus_r

focus’s content-only card with the id in a narrow vertical strip instead of a footer.

Two further sphinx-needs names are accepted as identities: test renders clean, and focus_f renders focus. Both are stated as such in the editor’s completion list.

Note

A built-in design cannot be redefined. A [needs.card_layouts] table claiming one of the twelve names above is refused with a configuration diagnostic, and the built-in keeps its meaning — so :layout: complete means the same thing in every project.

Defining a design

A design is a table under [needs.card_layouts], named with a CSS identifier ([A-Za-z_][A-Za-z0-9_-]* — the name becomes a class on the card):

# `complete`'s rows and echoes, with the meta section pinned closed and the
# owner echoed in the footer.
[needs.card_layouts.review]
extends = "complete"
collapse = "closed"
footer = ["layout_echo", "field:owner"]

extends names any design — one of the twelve built-in names (the ten designs and the two sphinx-needs identities) or another of your own — and every key you omit is inherited from it, the side column included:

# `clean_l`'s image column, with the meta section pinned closed.
[needs.card_layouts.illustrated]
extends = "clean_l"
collapse = "closed"

Without extends, an omitted key takes clean’s value.

Two keys can be switched off rather than merely left unstated, for a design whose base has one and does not want it:

[needs.card_layouts.plain]
extends = "clean_l"
meta = false   # no meta section
side = false   # no side column, despite the base having one

side = { elements = [] } means the same thing as side = false: a column with no elements is not a column, so it resolves to no side region at all rather than to an empty one. Both spellings are accepted, in ubCode and in a Sphinx-Needs project using needs_card_layouts.

meta and side are sub-tables:

[needs.card_layouts.spec_card]
header = true
footer = ["id"]

[needs.card_layouts.spec_card.meta]
# "stored" (the fields the need carries), "effective" (plus defaults),
# or "all" (plus internal fields — the `debug` tier).
fields = "effective"
# Show only these rows, in this order. Empty means "no restriction".
include = ["status", "priority", "owner"]
# Never show these. `exclude` beats `include`.
exclude = ["layout"]
# Show a row whose value is empty.
empties = false
links = true
links_back = true

[needs.card_layouts.spec_card.side]
elements = ["image:diagram"]
position = "right"   # or "left"
span = "full"        # or "partial" — beside the header and meta only

The full key list

Key

Default

Meaning

extends

(none)

The design whose values every omitted key inherits.

header

true

Whether the card has a header line.

meta

clean’s meta

The meta section’s selection, or false for no meta section.

content

true

Present for completeness. false is refused.

footer

[]

Elements below the content, in order.

side

(inherited)

The side column: elements, position, span — or false for a design with no column, even when the design it extends has one.

collapse

"honour"

"honour" (the need’s own :collapse: decides), "open" (pinned open — what debug does) or "closed".

meta.fields

"stored"

"stored", "effective" or "all".

meta.include

[]

An allow-list: non-empty restricts the rows to exactly these names, in this order. An included name is shown even if it is an internal field.

meta.exclude

[]

Names never shown. Beats include.

meta.empties

false

Whether a field with an empty value gets a row.

meta.links / meta.links_back

true

Whether outgoing / incoming link rows are shown.

side.elements

[]

The column’s elements, in order.

side.position

"right"

"left" or "right".

side.span

"full"

"full" (beside the whole card) or "partial" (beside the header and meta section only). On a hidden card both are measured from below the header — see How designs compose.

Images on a card

image:<field> draws an image whose source is the value of a need field. The value must be one of exactly two shapes:

  • an https URL, emitted verbatim. ubCode never fetches it, at build time or any other time.

  • a project-relative path — no scheme, no // prefix, no backslash and no .. segment. It resolves against the project’s source directory, whichever page the card is drawn on, and the file is published under _images/ exactly as an .. image:: argument is.

Anything else is refused and reported as build.need_card_image_invalid, and the element draws nothing. That is deliberately stricter than an authored .. image::: a card image’s value is a need field, and a need can arrive from an imported needs.json, i.e. from outside your project.

A path is resolved from the project’s source directory, not from the document drawing the card — a card is a property of the need, and the same need’s card is drawn by every page that shows it, so a document-relative path would mean different files on different pages. (A Sphinx-Needs project’s image("field:…") resolves the same way, so one value works in both.)

Spelling a design out in full — the field, the design, and a need that uses it:

[needs.fields.diagram]
nullable = true

[needs.card_layouts.diagrammed]
extends = "clean"

[needs.card_layouts.diagrammed.side]
elements = ["image:diagram"]
position = "left"
span = "full"
.. spec:: Session handshake
   :id: SP_1
   :diagram: assets/handshake.svg
   :layout: diagrammed

   The client opens the session with …

A need with no value for diagram renders a card byte-identical to clean.

This project configures a shorter version of the same thing, illustrated, which inherits clean_l’s column instead of restating it — see Trying it in this project.

How designs compose

  • The layout class. A card carries ub-need-layout-<design> — except for clean (whose absence of a class is its identity) and except for a design whose only difference from clean is a side column that drew nothing. Layout classes compose freely with the :style: classes, the per-type accent and the hidden-card state.

  • ``:collapse:``. collapse = "honour" lets the need’s own value decide; "open" and "closed" pin it. A design with no meta section has no disclosure, so :collapse: is inert on it.

  • ``:hide:``. A hidden need renders its whole card — every region, this design included — inside a closed disclosure whose summary is the card’s header line. That summary sits outside the side column’s row, because a disclosure’s control has to span the card rather than sit in a narrow cell — so on a hidden card span = "full" runs beside the meta section, content and footer, and span = "partial" beside the meta section alone. A hidden card also emits its title once, in that summary, so a title element and the automatic span both stand down.

  • Nested needs. A need directive inside another need’s body renders a full card inside the outer card’s content, with its own design.

  • Before the index has seen the need. The editor preview renders a card in the default clean shape until the background index has indexed it — the raw :layout: you typed is never read, only the resolved one. So a design you have just written shows up a moment after the index catches up, not instantly.

One title per card

The built site’s search index takes a card’s title from the card’s title span, anywhere inside the card, and appends every run it finds. So a card must contain exactly one.

Three rules follow, and ubCode enforces all three rather than documenting them and hoping:

  • a design with header = false and no title element gets a title span automatically, so a header-less card still yields a full search record. Both stylesheets hide that automatic span visually — for every design, including one you defined — while leaving it in the accessibility tree, where a bare id is a poor label.

  • a title element replaces that automatic span. It is the design’s title carrier: it draws where you put it, in your own element order, and it is visible. (This is what upstream’s meta("title") does in a needs_layouts footer.)

  • a title element is legal only in a header-less design, and only once across all of a design’s regions. Anything else is refused at configuration time and the offending elements are dropped, because a doubled title corrupts that need’s search record silently — the search index appends every title run it finds inside a card.

A hidden need (:hide:) is the exception to the first two: its card’s <summary> is a header and already carries the title, so both the automatic span and an explicit title element stand down.

Differences from a Sphinx-Needs build

  • The vocabulary is shared, the mechanism is not. A Sphinx-Needs project can write the same designs with that project’s needs_card_layouts, which compiles them down to needs_layouts entries. ubCode does not implement needs_layouts itself — see Differences from a Sphinx build for why.

  • ``focus_f`` ≡ ``focus``, by design. Upstream’s bare focus drops even the id, which would leave a card with nothing to link to; ubCode’s focus carries the id footer, so it is upstream’s focus_f exactly, and upstream’s bare focus is the name with no ubCode equivalent.

  • ``debug`` opens. Upstream’s debug layout renders collapsed unconditionally; ubCode forces it open, because a debug view that opens closed shows nothing.

  • The vertical id strip differs in two named ways. Upstream’s focus_l / focus_r stylesheet uses writing-mode: sideways-lr / sideways-rl with text-orientation: upright. ubCode declares the widely-supported writing-mode: vertical-rl first and sideways-lr after it, so a browser without sideways-* still gets a vertical strip rather than an overlapping one; and it uses text-orientation: mixed rather than upright, which would stack a need id one character per line.

  • An unknown name warns. Upstream raises SphinxNeedLayoutException, a BaseException subclass, so one typo ends the build.

Trying it in this project

These documents configure card designs of their own, in this repository’s docs/ubproject.toml — the illustrated, review and plain tables are literally the examples above, not imitations of them. Open the repository in ubCode and they are there to try: write :layout: illustrated on a need in the editor preview, or run ubc build html over docs/.

Where they do and do not render, plainly. [needs.card_layouts] is a ubCode table. These published pages are built by Sphinx with a released sphinx-needs, which does not know the key yet and ignores it silently — so the project’s own designs are inert here, and live in ubCode. They become live in this build too once the docs’ sphinx-needs pin includes the companion needs_card_layouts feature.

What both engines already share is the permutation names, so the live cards below use those. They are real needs on this page, rendered by whichever engine built what you are reading.

clean_lclean plus a left image column, drawing the need’s image field (registered in docs/ubproject.toml, and the field upstream’s own clean_* layouts read):

../_images/card-layout-example.svg
My Requirement: A need with a side image M_CARD_SIDE
layout: clean_l
image: configuration/card-layout-example.svg
owner: docs

The column holds the image field’s picture. The path is resolved from the source directory, which is the one spelling both engines agree on.

The same design on a need that has no value for that field. The column draws nothing, so there is no column, no wrapper — and, because a side column is this design’s only difference from clean, not even a layout class to catch a stylesheet on:

My Requirement: A need whose image field is unset M_CARD_PLAIN
layout: clean_l

This card is byte-identical to a clean card. That is what makes the clean_* family safe to adopt: a need without a picture is unchanged.

focus_r — content only, with the id in a narrow vertical strip on the right instead of a footer:

No header and no meta section. The id beside this text is the card’s permalink, and the need’s title is still in the search index even though nothing here shows it.

Upgrade cost

Adding [needs.card_layouts] to a project changes its configuration hash, and ubCode keys both its index cache and its build manifest on that hash — so the first build after adding or editing a design re-indexes and re-renders once. That is the intended trade: it is also what makes an edit to a design’s body re-render every page that draws it.