loopable / web /src /ui /blocks.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
092334a verified
Raw
History Blame Contribute Delete
3.98 kB
// ---------------------------------------------------------------------------
// ui / blocks.ts β€” EXIT wave 2 (W2-6/W2-9). The block RULES, pure and
// React-free so `verify_ui.py` can run them under bare node.
//
// The two rules in this file are the two owner constants the presentation layer
// is most able to break quietly, which is exactly why they live here rather
// than inline in a component: a rule inside JSX can only be checked by looking
// at a screenshot, and both of these fail in ways a screenshot looks fine for.
//
// 1. **No silent caps.** `capDisclosure` renders from the SERVER's numbers.
// 2. **One drill grammar.** `cellDescriptor` resolves a row to a descriptor
// or to nothing β€” never to a half-populated one.
// ---------------------------------------------------------------------------
import { int } from "./fmt";
import { isRowDrillRule } from "./types";
import type { DecompDescriptor, DrillDescriptor, DrillRule, EntityKind, PageRow } from "./types";
export interface CapDisclosure {
text: string;
/** True when rows were actually withheld β€” the disclosure is louder then. */
capped: boolean;
}
/**
* "Showing N of M", or nothing at all.
*
* β›” THE NUMBERS ARE THE SERVER'S AND ARE NEVER DERIVED FROM `rows.length`.
* That is the whole point and it is subtle: a disclosure computed from the
* array it is describing agrees with a truncated list BY CONSTRUCTION, so it
* would read "Showing 25 of 25" over a list the server cut from 812. It cannot
* be wrong, which means it is not a check β€” it is decoration that looks like
* one ([[no-unverifiable-aggregates]]: a silent `[:N]` cap is a defect).
*
* Y1 rule 6 ships BOTH numbers even when they are equal, so `shown === total`
* still renders β€” "Showing 12 of 12" is the sentence that tells a reader
* nothing was withheld, and its absence is not the same statement.
*/
export function capDisclosure(shown?: number, total?: number): CapDisclosure | null {
if (typeof shown !== "number" || typeof total !== "number") return null;
if (!Number.isFinite(shown) || !Number.isFinite(total)) return null;
return { text: `Showing ${int(shown)} of ${int(total)}`, capped: total > shown };
}
/**
* A row + a block-level drill rule β†’ the descriptor to open, or `undefined`.
*
* Y1 rule 5's placements (b) and (c):
* (b) an ENTITY rule names the columns holding the id and the label;
* (c) a ROW rule names the column holding a complete `DecompDescriptor`.
*
* ⚠ A DESCRIPTOR IS COMPLETE OR IT DOES NOT EXIST. A half-populated one opens a
* panel onto the wrong window β€” a period drill with no `date_from` would show
* the reader a number attributed to a scope nobody computed. Returning
* `undefined` costs a click; returning a partial costs the number's credibility.
*/
export function cellDescriptor(
row: PageRow,
rule: DrillRule | undefined
): DrillDescriptor | undefined {
if (!rule) return undefined;
if (isRowDrillRule(rule)) {
const d = row[rule.row_key];
if (!d || typeof d !== "object") return undefined;
const dd = d as DecompDescriptor;
if (dd.kind !== "decomp") return undefined;
// The window is what makes a decomp descriptor answerable at all.
if (!dd.date_from || !dd.date_to) return undefined;
return dd;
}
const id = row[rule.id_key];
if (id == null || id === "") return undefined;
const label = rule.label_key ? row[rule.label_key] : undefined;
return {
kind: rule.kind as EntityKind,
id: id as string | number,
label: String(label ?? id),
};
}
/** The empty-state sentence. NEVER the bare words "no data": an empty table
* that does not say what empty MEANS reads as a broken page, and sends people
* looking in the wrong place. The server supplies the meaning (`empty`); this
* is the fallback when it did not. */
export function emptyText(empty?: string): string {
const s = (empty ?? "").trim();
return s || "Nothing matched this scope.";
}