1501 lines
53 KiB
HTML
1501 lines
53 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>List<Item> — Functional Blueprint</title>
|
||
<style>
|
||
:root {
|
||
--paper: #0a2645; /* blueprint blue */
|
||
--paper-2: #0d2f54; /* panel */
|
||
--paper-3: #103763; /* raised */
|
||
--ink: #dbe9f7; /* primary ink */
|
||
--ink-dim: #7ea6cd; /* secondary ink */
|
||
--ink-faint: #4f7099; /* tertiary */
|
||
--line: rgba(200, 226, 255, 0.14);
|
||
--line-strong: rgba(200, 226, 255, 0.3);
|
||
--amber: #ffb84d; /* highlight / annotation */
|
||
--amber-dim: rgba(255, 184, 77, 0.14);
|
||
--cyan: #8fd0ff;
|
||
--green: #7fdca0;
|
||
--red: #ff8f8f;
|
||
--mono:
|
||
ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Monaco, Consolas,
|
||
"Liberation Mono", monospace;
|
||
}
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
html,
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
body {
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
font-family: var(--mono);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
-webkit-font-smoothing: antialiased;
|
||
/* blueprint grid */
|
||
background-image:
|
||
linear-gradient(var(--line) 1px, transparent 1px),
|
||
linear-gradient(90deg, var(--line) 1px, transparent 1px),
|
||
linear-gradient(rgba(200, 226, 255, 0.05) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(200, 226, 255, 0.05) 1px, transparent 1px);
|
||
background-size:
|
||
120px 120px,
|
||
120px 120px,
|
||
24px 24px,
|
||
24px 24px;
|
||
background-position:
|
||
-1px -1px,
|
||
-1px -1px,
|
||
-1px -1px,
|
||
-1px -1px;
|
||
}
|
||
.sheet {
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
padding: 22px 20px 60px;
|
||
}
|
||
a {
|
||
color: var(--cyan);
|
||
text-decoration: none;
|
||
border-bottom: 1px dotted var(--ink-faint);
|
||
}
|
||
|
||
/* ── title block ─────────────────────────────────────────── */
|
||
.titleblock {
|
||
border: 1.5px solid var(--line-strong);
|
||
display: grid;
|
||
grid-template-columns: 1fr 130px 90px 110px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent);
|
||
}
|
||
.titleblock .cell {
|
||
border-left: 1px solid var(--line);
|
||
padding: 8px 12px;
|
||
}
|
||
.titleblock .cell:first-child {
|
||
border-left: none;
|
||
}
|
||
.tb-k {
|
||
color: var(--ink-faint);
|
||
font-size: 10px;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
}
|
||
.tb-v {
|
||
color: var(--ink);
|
||
font-size: 13px;
|
||
margin-top: 2px;
|
||
}
|
||
.title-main {
|
||
padding: 12px 14px 10px;
|
||
}
|
||
.title-main h1 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
letter-spacing: 0.02em;
|
||
font-weight: 600;
|
||
}
|
||
.title-main h1 .brk {
|
||
color: var(--amber);
|
||
}
|
||
.title-main .sub {
|
||
color: var(--ink-dim);
|
||
font-size: 12px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* ── legend ──────────────────────────────────────────────── */
|
||
.legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px 20px;
|
||
margin: 12px 0 18px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--line);
|
||
color: var(--ink-dim);
|
||
font-size: 11px;
|
||
}
|
||
.legend b {
|
||
color: var(--ink);
|
||
}
|
||
.legend .sw {
|
||
color: var(--amber);
|
||
}
|
||
|
||
h2.section {
|
||
font-size: 11px;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-dim);
|
||
font-weight: 600;
|
||
margin: 28px 0 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
h2.section::after {
|
||
content: "";
|
||
flex: 1;
|
||
height: 1px;
|
||
background: var(--line);
|
||
}
|
||
|
||
/* ── tabs ────────────────────────────────────────────────── */
|
||
.tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.tab {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--ink-dim);
|
||
background: transparent;
|
||
border: 1px solid var(--line);
|
||
padding: 5px 12px;
|
||
cursor: pointer;
|
||
letter-spacing: 0.06em;
|
||
transition: all 0.12s;
|
||
}
|
||
.tab:hover {
|
||
color: var(--ink);
|
||
border-color: var(--line-strong);
|
||
}
|
||
.tab.active {
|
||
color: var(--paper);
|
||
background: var(--amber);
|
||
border-color: var(--amber);
|
||
font-weight: 700;
|
||
}
|
||
.tab.full {
|
||
color: var(--cyan);
|
||
}
|
||
.tab.full.active {
|
||
color: var(--paper);
|
||
background: var(--cyan);
|
||
border-color: var(--cyan);
|
||
}
|
||
|
||
/* ── viewer layout ───────────────────────────────────────── */
|
||
.viewer {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
align-items: start;
|
||
}
|
||
@media (max-width: 820px) {
|
||
.viewer {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.panel {
|
||
border: 1.5px solid var(--line-strong);
|
||
background: var(--paper-2);
|
||
position: relative;
|
||
}
|
||
.panel .cap {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 7px 12px;
|
||
border-bottom: 1px solid var(--line);
|
||
font-size: 10px;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-dim);
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
.panel .cap .id {
|
||
color: var(--ink-faint);
|
||
}
|
||
.panel .body {
|
||
padding: 16px;
|
||
}
|
||
/* corner ticks (drawing crop marks) */
|
||
.panel::before,
|
||
.panel::after {
|
||
content: "";
|
||
position: absolute;
|
||
width: 8px;
|
||
height: 8px;
|
||
pointer-events: none;
|
||
}
|
||
.panel::before {
|
||
top: -1px;
|
||
left: -1px;
|
||
border-top: 1.5px solid var(--amber);
|
||
border-left: 1.5px solid var(--amber);
|
||
}
|
||
.panel::after {
|
||
bottom: -1px;
|
||
right: -1px;
|
||
border-bottom: 1.5px solid var(--amber);
|
||
border-right: 1.5px solid var(--amber);
|
||
}
|
||
|
||
/* ── specimen list ───────────────────────────────────────── */
|
||
.spec-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
color: var(--ink-dim);
|
||
font-size: 12px;
|
||
}
|
||
.search {
|
||
flex: 1;
|
||
border: 1px solid var(--line);
|
||
background: var(--paper);
|
||
padding: 5px 9px;
|
||
color: var(--ink);
|
||
position: relative;
|
||
}
|
||
.search .ph {
|
||
color: var(--ink-faint);
|
||
}
|
||
.search .q {
|
||
color: var(--amber);
|
||
}
|
||
.ctl {
|
||
border: 1px solid var(--line);
|
||
padding: 5px 9px;
|
||
white-space: nowrap;
|
||
position: relative;
|
||
}
|
||
.badge {
|
||
display: inline-block;
|
||
border: 1px solid var(--line-strong);
|
||
color: var(--cyan);
|
||
padding: 1px 7px;
|
||
font-size: 10px;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.listbox {
|
||
border: 1px solid var(--line);
|
||
position: relative;
|
||
background: var(--paper);
|
||
}
|
||
.listwrap {
|
||
display: flex;
|
||
}
|
||
.rows {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 11px;
|
||
border-bottom: 1px dashed var(--line);
|
||
position: relative;
|
||
}
|
||
.row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.row .chk {
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 1px solid var(--ink-faint);
|
||
flex: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 11px;
|
||
color: var(--amber);
|
||
}
|
||
.row .chk.on {
|
||
border-color: var(--amber);
|
||
background: var(--amber-dim);
|
||
}
|
||
.row .txt {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.row .txt .n {
|
||
color: var(--ink);
|
||
}
|
||
.row .txt .s {
|
||
color: var(--ink-faint);
|
||
font-size: 11px;
|
||
}
|
||
.row .chev {
|
||
color: var(--ink-dim);
|
||
}
|
||
.row.dim {
|
||
opacity: 0.32;
|
||
}
|
||
.row.dim .n {
|
||
text-decoration: line-through;
|
||
}
|
||
.row.sel {
|
||
background: var(--amber-dim);
|
||
}
|
||
.row.win {
|
||
background: rgba(143, 208, 255, 0.07);
|
||
}
|
||
.row.ghost .txt .n,
|
||
.row.ghost .txt .s {
|
||
color: transparent;
|
||
}
|
||
.row.ghost {
|
||
opacity: 0.4;
|
||
}
|
||
.skel {
|
||
height: 10px;
|
||
background: repeating-linear-gradient(
|
||
90deg,
|
||
var(--ink-faint),
|
||
var(--ink-faint) 6px,
|
||
transparent 6px,
|
||
transparent 12px
|
||
);
|
||
opacity: 0.5;
|
||
border-radius: 2px;
|
||
}
|
||
.skel.w1 {
|
||
width: 60%;
|
||
}
|
||
.skel.w2 {
|
||
width: 40%;
|
||
margin-top: 5px;
|
||
}
|
||
.row .actions {
|
||
display: flex;
|
||
gap: 0;
|
||
flex: none;
|
||
}
|
||
.row .actions .act {
|
||
padding: 9px 12px;
|
||
font-size: 11px;
|
||
border-left: 1px solid var(--line);
|
||
}
|
||
.row .actions .archive {
|
||
background: rgba(143, 208, 255, 0.12);
|
||
color: var(--cyan);
|
||
}
|
||
.row .actions .delete {
|
||
background: rgba(255, 143, 143, 0.14);
|
||
color: var(--red);
|
||
}
|
||
.row.swiped {
|
||
padding-right: 0;
|
||
}
|
||
.swipehint {
|
||
color: var(--ink-faint);
|
||
font-size: 10px;
|
||
margin-left: auto;
|
||
padding-right: 8px;
|
||
}
|
||
.scrollbar {
|
||
width: 8px;
|
||
flex: none;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border-left: 1px solid var(--line);
|
||
position: relative;
|
||
}
|
||
.scrollbar .thumb {
|
||
position: absolute;
|
||
left: 1px;
|
||
right: 1px;
|
||
background: var(--ink-faint);
|
||
opacity: 0.6;
|
||
}
|
||
.footer-load {
|
||
padding: 8px 11px;
|
||
border-top: 1px solid var(--line);
|
||
text-align: center;
|
||
color: var(--cyan);
|
||
font-size: 11px;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.empty-state {
|
||
padding: 26px 12px;
|
||
text-align: center;
|
||
color: var(--ink-faint);
|
||
}
|
||
.empty-state .big {
|
||
font-size: 22px;
|
||
color: var(--ink-dim);
|
||
}
|
||
.error-state {
|
||
padding: 22px 12px;
|
||
text-align: center;
|
||
color: var(--red);
|
||
}
|
||
.display-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
@media (max-width: 520px) {
|
||
.display-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
.mini {
|
||
border: 1px solid var(--line);
|
||
background: var(--paper);
|
||
}
|
||
.mini .mh {
|
||
padding: 4px 8px;
|
||
border-bottom: 1px solid var(--line);
|
||
font-size: 10px;
|
||
letter-spacing: 0.12em;
|
||
color: var(--ink-dim);
|
||
text-transform: uppercase;
|
||
}
|
||
.mini .mb {
|
||
padding: 8px;
|
||
}
|
||
.mini .mrow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 5px 4px;
|
||
border-bottom: 1px dashed var(--line);
|
||
font-size: 11px;
|
||
}
|
||
.mini .mrow:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
/* highlight callouts */
|
||
.hl {
|
||
outline: 1.5px dashed var(--amber);
|
||
outline-offset: 3px;
|
||
position: relative;
|
||
z-index: 2;
|
||
border-radius: 1px;
|
||
}
|
||
.hl::after {
|
||
content: attr(data-tag);
|
||
position: absolute;
|
||
top: -10px;
|
||
left: 8px;
|
||
background: var(--paper);
|
||
color: var(--amber);
|
||
font-size: 9px;
|
||
padding: 0 5px;
|
||
border: 1px solid var(--amber);
|
||
white-space: nowrap;
|
||
letter-spacing: 0.1em;
|
||
line-height: 1.5;
|
||
z-index: 3;
|
||
}
|
||
.hl.tag-r::after {
|
||
left: auto;
|
||
right: 8px;
|
||
}
|
||
.hl.tag-b::after {
|
||
top: auto;
|
||
bottom: -10px;
|
||
}
|
||
|
||
/* ── readout ─────────────────────────────────────────────── */
|
||
.readout .rhead {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
margin-bottom: 2px;
|
||
}
|
||
.readout .rhead .fn {
|
||
font-size: 16px;
|
||
color: var(--amber);
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.capbadge {
|
||
border: 1px solid var(--cyan);
|
||
color: var(--cyan);
|
||
font-size: 10px;
|
||
padding: 1px 6px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.capbadge.core {
|
||
border-color: var(--ink-faint);
|
||
color: var(--ink-dim);
|
||
}
|
||
.rverb {
|
||
color: var(--ink-faint);
|
||
font-size: 11px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.block {
|
||
margin: 0 0 14px;
|
||
}
|
||
.block .k {
|
||
font-size: 10px;
|
||
letter-spacing: 0.16em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-dim);
|
||
margin-bottom: 5px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.block .k::after {
|
||
content: "";
|
||
flex: 1;
|
||
height: 1px;
|
||
background: var(--line);
|
||
}
|
||
.block .v {
|
||
color: var(--ink);
|
||
}
|
||
.code {
|
||
color: var(--cyan);
|
||
white-space: pre-wrap;
|
||
}
|
||
.field {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.field .fk {
|
||
color: var(--ink);
|
||
}
|
||
.field .ft {
|
||
color: var(--ink-faint);
|
||
}
|
||
.field .fc {
|
||
color: var(--cyan);
|
||
}
|
||
.beh {
|
||
border-left: 2px solid var(--line-strong);
|
||
padding-left: 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.beh .sig {
|
||
color: var(--amber);
|
||
}
|
||
.beh .pe {
|
||
display: flex;
|
||
gap: 8px;
|
||
color: var(--ink-dim);
|
||
font-size: 12px;
|
||
}
|
||
.beh .pe .lbl {
|
||
color: var(--ink-faint);
|
||
width: 26px;
|
||
flex: none;
|
||
}
|
||
.beh .pe .ex {
|
||
color: var(--ink);
|
||
}
|
||
.warn {
|
||
color: var(--red);
|
||
}
|
||
.perf {
|
||
color: var(--amber);
|
||
}
|
||
ul.tight {
|
||
margin: 0;
|
||
padding-left: 16px;
|
||
}
|
||
ul.tight li {
|
||
margin: 2px 0;
|
||
}
|
||
.note {
|
||
color: var(--ink-faint);
|
||
font-style: italic;
|
||
font-size: 11px;
|
||
}
|
||
.sig-block {
|
||
background: var(--paper);
|
||
border: 1px solid var(--line);
|
||
padding: 10px 12px;
|
||
white-space: pre;
|
||
overflow-x: auto;
|
||
color: var(--ink);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
.sig-block .cm {
|
||
color: var(--ink-faint);
|
||
}
|
||
.sig-block .ty {
|
||
color: var(--cyan);
|
||
}
|
||
.fn-index {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
gap: 2px 12px;
|
||
font-size: 12px;
|
||
}
|
||
.fn-index .fi-n {
|
||
color: var(--amber);
|
||
}
|
||
.fn-index .fi-d {
|
||
color: var(--ink-dim);
|
||
}
|
||
|
||
/* ── companions ──────────────────────────────────────────── */
|
||
.svg-wrap {
|
||
overflow-x: auto;
|
||
}
|
||
svg {
|
||
display: block;
|
||
max-width: 100%;
|
||
height: auto;
|
||
margin: 0 auto;
|
||
}
|
||
svg text {
|
||
font-family: var(--mono);
|
||
fill: var(--ink);
|
||
}
|
||
.node rect {
|
||
fill: var(--paper-3);
|
||
stroke: var(--line-strong);
|
||
stroke-width: 1.5;
|
||
}
|
||
.node text {
|
||
font-size: 12px;
|
||
}
|
||
.node .sub {
|
||
fill: var(--ink-faint);
|
||
font-size: 9.5px;
|
||
}
|
||
.edge {
|
||
fill: none;
|
||
stroke-width: 1.4;
|
||
}
|
||
.elabel {
|
||
font-size: 10px;
|
||
paint-order: stroke;
|
||
stroke: var(--paper);
|
||
stroke-width: 3.5px;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
footer.credit {
|
||
margin-top: 34px;
|
||
padding-top: 14px;
|
||
border-top: 1px solid var(--line);
|
||
color: var(--ink-faint);
|
||
font-size: 11px;
|
||
line-height: 1.7;
|
||
}
|
||
footer.credit .k {
|
||
color: var(--ink-dim);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="sheet">
|
||
<!-- ── TITLE BLOCK ─────────────────────────────────────── -->
|
||
<div class="titleblock">
|
||
<div class="title-main">
|
||
<h1>
|
||
List<span class="brk"><</span>Item<span class="brk">></span> — Functional
|
||
Blueprint
|
||
</h1>
|
||
<div class="sub">
|
||
A scrollable, ordered set of items · rendered as an interactive schematic of its 7
|
||
functions
|
||
</div>
|
||
</div>
|
||
<div class="cell">
|
||
<div class="tb-k">Extends</div>
|
||
<div class="tb-v">Set<Item></div>
|
||
</div>
|
||
<div class="cell">
|
||
<div class="tb-k">Sheet</div>
|
||
<div class="tb-v">1 / 1</div>
|
||
</div>
|
||
<div class="cell">
|
||
<div class="tb-k">Composition</div>
|
||
<div class="tb-v">+5 caps</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── LEGEND ──────────────────────────────────────────── -->
|
||
<div class="legend">
|
||
<span
|
||
><span class="sw">◈</span> <b>highlighted region</b> — the part of the list this function
|
||
owns</span
|
||
>
|
||
<span><b>░</b> skeleton placeholder</span>
|
||
<span><b>⊆</b> subset</span>
|
||
<span><b>←</b> assignment / effect</span>
|
||
<span><span class="warn">⚠</span> failure mode</span>
|
||
<span><span class="perf">⚡</span> critical-performance threshold</span>
|
||
</div>
|
||
|
||
<!-- ── MAIN VIEWER ─────────────────────────────────────── -->
|
||
<h2 class="section">Functional viewer — select a function</h2>
|
||
<div class="tabs" id="tabs"></div>
|
||
|
||
<div class="viewer">
|
||
<div class="panel">
|
||
<div class="cap">
|
||
<span>Specimen · List<Item></span><span class="id" id="specId">FIG.00</span>
|
||
</div>
|
||
<div class="body" id="specimen"></div>
|
||
</div>
|
||
<div class="panel readout">
|
||
<div class="cap"><span>Readout · Contract</span><span class="id">§ list.als</span></div>
|
||
<div class="body" id="readout"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── COMPANION 1: COMPOSITION ────────────────────────── -->
|
||
<h2 class="section">Anatomy — where the 7 functions come from</h2>
|
||
<div class="panel">
|
||
<div class="cap">
|
||
<span>Composition map</span><span class="id">Set ◁ List ◁ capabilities</span>
|
||
</div>
|
||
<div class="body svg-wrap" id="compmap"></div>
|
||
</div>
|
||
|
||
<!-- ── COMPANION 2: STATE MACHINE ──────────────────────── -->
|
||
<h2 class="section">Lifecycle — LoadState machine</h2>
|
||
<div class="panel">
|
||
<div class="cap">
|
||
<span>Composed state machine · List + Async + Paginated + Pullable</span
|
||
><span class="id">loadState : LoadState</span>
|
||
</div>
|
||
<div class="body svg-wrap" id="statemachine"></div>
|
||
</div>
|
||
|
||
<footer class="credit">
|
||
<div>
|
||
<span class="k">SOURCE</span> blueprint-ontology / blueprints / list ·
|
||
formal model: <code>list.als</code>, <code>list.test.als</code>
|
||
</div>
|
||
<div>
|
||
<span class="k">READS AS</span> a <i>concept</i> (Jackson) / <i>contract</i> —
|
||
declarative, categorical, abstract. See BLUEPRINT-METAPHOR.md
|
||
</div>
|
||
<div>
|
||
<span class="k">SCOPE</span> standard composition:
|
||
<code>List + Async + Paginated + Pullable + Selectable + Filterable</code>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
|
||
<script>
|
||
/* ══════════════════════════════════════════════════════════════
|
||
MODEL — the List blueprint, function by function.
|
||
Static content lifted verbatim from the blueprint README.
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
const COMPOSED_SIG = `List<Item> + Async + Paginated + Pullable + Selectable + Filterable
|
||
items : seq Item ⟨comment⟩-- List: ordered, loaded subset
|
||
loadState : LoadState ⟨comment⟩-- Async · Paginated · Pullable
|
||
totalCount : Int ⟨comment⟩-- Paginated
|
||
selectionMode : SelectionMode ⟨comment⟩-- Selectable
|
||
selected : set Item ⟨comment⟩-- Selectable
|
||
visible : set Item ⟨comment⟩-- Filterable
|
||
|
||
⟨type⟩LoadState⟨/type⟩ = Idle | Loading | Refreshing | LoadingMore | Error
|
||
⟨type⟩SelectionMode⟨/type⟩ = None | Single | Multi`
|
||
|
||
const FUNCTIONS = [
|
||
{
|
||
id: "display",
|
||
name: "Display",
|
||
fig: "01",
|
||
caps: ["List", "Async"],
|
||
verb: "Render items in order; render skeleton placeholders during initial load; handle empty, loading, and error states distinctly.",
|
||
state: [
|
||
["items", "seq Item"],
|
||
["loadState", "LoadState"],
|
||
],
|
||
behaviors: [],
|
||
invariants: ["#1 items = ∅ is a valid state (empty list, not an error)"],
|
||
failures: [
|
||
"empty vs error conflation — network returns 0 items with 4xx; empty state treated as error, user sees wrong UI",
|
||
"skeleton / content mismatch — skeleton row dimensions differ from real item; layout shift on Loading → Idle",
|
||
],
|
||
perf: [
|
||
"skeleton placeholders render within 100 ms of navigation",
|
||
"real items replace the skeleton within 300 ms",
|
||
"skeleton dimensions must match real items to prevent layout shift",
|
||
],
|
||
notes: [],
|
||
},
|
||
{
|
||
id: "scroll",
|
||
name: "Scroll",
|
||
fig: "02",
|
||
caps: ["List"],
|
||
verb: "Allow the user to navigate the full item sequence; virtualize off-screen items.",
|
||
state: [["items", "seq Item"]],
|
||
behaviors: [],
|
||
invariants: ["items form a well-formed sequence (no duplicate indices) — l.items.isSeq"],
|
||
failures: [],
|
||
perf: [
|
||
"60 fps minimum — off-screen items must not be rendered (virtualization required)",
|
||
"items outside the render window must release their view references (memory)",
|
||
],
|
||
notes: [
|
||
"Scroll is navigation, not a mutating behavior — it moves the render window over items, it does not change them.",
|
||
],
|
||
},
|
||
{
|
||
id: "load",
|
||
name: "Load",
|
||
fig: "03",
|
||
caps: ["Async", "Paginated", "Pullable"],
|
||
verb: "Fetch the initial page; support refresh (restart) and pagination (append).",
|
||
state: [
|
||
["loadState", "LoadState"],
|
||
["totalCount", "Int"],
|
||
],
|
||
behaviors: [
|
||
{
|
||
sig: "refresh()",
|
||
pre: "loadState = idle",
|
||
eff: "loadState ← refreshing, reload items from page 1, selected ← ∅",
|
||
},
|
||
{
|
||
sig: "loadMore()",
|
||
pre: "#items < totalCount, loadState = idle",
|
||
eff: "loadState ← loadingMore, append next page to items",
|
||
},
|
||
],
|
||
invariants: [
|
||
"#6 #items ≤ totalCount",
|
||
"#7 loadState = loadingMore → #items < totalCount",
|
||
],
|
||
failures: [
|
||
"duplicate load — loadMore() called while loadState = loadingMore; duplicate items appended, items loses uniqueness",
|
||
"unknown total — streaming source, totalCount never known; use CursorPaginated with a hasMore sentinel instead",
|
||
],
|
||
perf: ["loadMore appends new items without layout shift or scroll-position jump"],
|
||
notes: [],
|
||
},
|
||
{
|
||
id: "filter",
|
||
name: "Filter",
|
||
fig: "04",
|
||
caps: ["Filterable"],
|
||
verb: "Restrict the visible set without mutating the underlying sequence.",
|
||
state: [["visible", "set Item"]],
|
||
behaviors: [{ sig: "filter(pred)", pre: "—", eff: "visible ← { x ∈ items | pred(x) }" }],
|
||
invariants: ["#8 visible ⊆ items — filter never introduces new items"],
|
||
failures: [
|
||
"filter after reload — predicate references old item shape; visible silently empties without error",
|
||
],
|
||
perf: ["result updates within one frame (≤ 16 ms) for in-memory data"],
|
||
notes: [
|
||
"Ephemeral: derives visible from items but does not mutate the sequence. Persisting a filter is a consumer concern.",
|
||
],
|
||
},
|
||
{
|
||
id: "sort",
|
||
name: "Sort",
|
||
fig: "05",
|
||
caps: ["Sortable"],
|
||
verb: "Reorder the sequence by a comparator without changing identity.",
|
||
state: [["items", "seq Item"]],
|
||
behaviors: [{ sig: "sort(cmp)", pre: "—", eff: "reorder items by comparator" }],
|
||
invariants: ["identity is preserved — sorting changes order, never membership"],
|
||
failures: [
|
||
"sort invalidates cursor (CursorPaginated) — cursor is stale; must reset to NoCursor and reload from page 1",
|
||
],
|
||
perf: [],
|
||
notes: [
|
||
"Ephemeral alongside filter: reorders the displayed sequence without persisting the change.",
|
||
],
|
||
},
|
||
{
|
||
id: "select",
|
||
name: "Select",
|
||
fig: "06",
|
||
caps: ["Selectable"],
|
||
verb: "Track user intent over a subset of items (none / single / multi).",
|
||
state: [
|
||
["selectionMode", "SelectionMode"],
|
||
["selected", "set Item"],
|
||
],
|
||
behaviors: [
|
||
{
|
||
sig: "select(x)",
|
||
pre: "x ∈ visible, selectionMode ≠ none",
|
||
eff: "selected ← selected ∪ {x}",
|
||
},
|
||
{ sig: "deselect(x)", pre: "x ∈ selected", eff: "selected ← selected \\ {x}" },
|
||
{ sig: "selectAll()", pre: "selectionMode = multi", eff: "selected ← visible" },
|
||
{ sig: "clearSelection()", pre: "—", eff: "selected ← ∅" },
|
||
],
|
||
invariants: [
|
||
"#3 selected ⊆ items",
|
||
"#4 selectionMode = none → selected = ∅",
|
||
"#5 selectionMode = single → #selected ≤ 1",
|
||
],
|
||
failures: [
|
||
"stale selection — refresh() clears items but not selected; selected ⊄ items, invariant #3 violated",
|
||
],
|
||
perf: [],
|
||
notes: [],
|
||
},
|
||
{
|
||
id: "act",
|
||
name: "Act",
|
||
fig: "07",
|
||
caps: ["Swipeable", "Reorderable"],
|
||
verb: "Expose per-item contextual actions (swipe, long-press) to the consumer.",
|
||
state: [],
|
||
behaviors: [
|
||
{
|
||
sig: "swipeAction(x, dir)",
|
||
pre: "x ∈ visible",
|
||
eff: "trigger consumer-defined contextual action",
|
||
},
|
||
{
|
||
sig: "reorder(from, to)",
|
||
pre: "capability: reorderable = true",
|
||
eff: "swap positions in items",
|
||
},
|
||
],
|
||
invariants: [],
|
||
failures: [
|
||
"reorder on filtered view — reorder operates on visible, not items; position mismatch between displayed and stored order",
|
||
],
|
||
perf: [],
|
||
notes: ["Open question: swipe direction — left | right only, or also up | down?"],
|
||
},
|
||
]
|
||
|
||
const SAMPLE = [
|
||
{ n: "Item A", s: "#a1f3 · ready" },
|
||
{ n: "Item B", s: "#b2e8 · ready" },
|
||
{ n: "Item C", s: "#c7d1 · ready" },
|
||
{ n: "Item D", s: "#d0a4 · ready" },
|
||
{ n: "Item E", s: "#e5b9 · ready" },
|
||
{ n: "Item F", s: "#f3c2 · ready" },
|
||
{ n: "Item G", s: "#01d7 · ready" },
|
||
{ n: "Item H", s: "#12e0 · ready" },
|
||
]
|
||
|
||
/* ══════════════════════════════════════════════════════════════
|
||
SPECIMEN RENDERERS — one static frame per function.
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
}
|
||
|
||
function rowHTML(it, opts = {}) {
|
||
const cls = ["row"]
|
||
if (opts.dim) cls.push("dim")
|
||
if (opts.sel) cls.push("sel")
|
||
if (opts.win) cls.push("win")
|
||
if (opts.ghost) cls.push("ghost")
|
||
if (opts.swiped) cls.push("swiped")
|
||
let hl = opts.hlTxt ? ` hl` : ""
|
||
let chk = ""
|
||
if (opts.check !== undefined) {
|
||
chk = `<span class="chk${opts.check ? " on" : ""}">${opts.check ? "✓" : ""}</span>`
|
||
}
|
||
let tail
|
||
if (opts.swiped) {
|
||
tail = `<span class="swipehint">◂ swiped</span><div class="actions"><span class="act archive">Archive</span><span class="act delete">Delete</span></div>`
|
||
} else {
|
||
tail = `<span class="chev${opts.hlChev ? " hl tag-r" : ""}"${opts.hlChev ? ' data-tag="◈ ACT"' : ""}>></span>`
|
||
}
|
||
return `<div class="${cls.join(" ")}">
|
||
${chk}
|
||
<div class="txt${hl}"${opts.hlTxt ? ` data-tag="${opts.hlTxt}"` : ""}><div class="n">${esc(it.n)}</div><div class="s">${esc(it.s)}</div></div>
|
||
${tail}
|
||
</div>`
|
||
}
|
||
|
||
function skelRow() {
|
||
return `<div class="row"><div class="txt"><div class="skel w1"></div><div class="skel w2"></div></div></div>`
|
||
}
|
||
|
||
function toolbar(cfg) {
|
||
// cfg: {search, searchQ, searchHl, sortHl, selectMeta, selectHl, badge}
|
||
let inner = ""
|
||
if (cfg.search !== false) {
|
||
const q = cfg.searchQ
|
||
? `<span class="q">${esc(cfg.searchQ)}</span>`
|
||
: `<span class="ph">search…</span>`
|
||
inner += `<div class="search${cfg.searchHl ? " hl" : ""}"${cfg.searchHl ? ' data-tag="◈ FILTER"' : ""}>⌕ ${q}</div>`
|
||
}
|
||
if (cfg.sort !== false) {
|
||
inner += `<div class="ctl${cfg.sortHl ? " hl tag-r" : ""}"${cfg.sortHl ? ' data-tag="◈ SORT"' : ""}>sort ▾ ${cfg.sortLabel || "name ↑"}</div>`
|
||
}
|
||
if (cfg.selectCtl) {
|
||
inner += `<div class="ctl${cfg.selectHl ? " hl tag-r" : ""}"${cfg.selectHl ? ' data-tag="◈ SELECT"' : ""}>☰ ${cfg.selectMeta || "select"}</div>`
|
||
}
|
||
return inner ? `<div class="spec-toolbar">${inner}</div>` : ""
|
||
}
|
||
|
||
function listbox(rowsHTML, cfg = {}) {
|
||
const scrollbar = cfg.scrollbar
|
||
? `<div class="scrollbar${cfg.scrollHl ? " hl tag-r" : ""}"${cfg.scrollHl ? ' data-tag="◈ SCROLL"' : ""}><div class="thumb" style="top:${cfg.thumbTop || 30}%;height:${cfg.thumbH || 36}%"></div></div>`
|
||
: ""
|
||
const footer = cfg.footer
|
||
? `<div class="footer-load${cfg.footerHl ? " hl tag-b" : ""}"${cfg.footerHl ? ' data-tag="◈ LOAD"' : ""}>${cfg.footer}</div>`
|
||
: ""
|
||
return `<div class="listbox"><div class="listwrap"><div class="rows">${rowsHTML}</div>${scrollbar}</div>${footer}</div>`
|
||
}
|
||
|
||
const RENDER = {
|
||
full() {
|
||
let rows = ""
|
||
rows += rowHTML(SAMPLE[0], { check: false, hlTxt: "◈ DISPLAY" })
|
||
rows += rowHTML(SAMPLE[1], { check: true, sel: true })
|
||
rows += rowHTML(SAMPLE[2], { check: false, hlChev: true })
|
||
rows += rowHTML(SAMPLE[3], { check: false })
|
||
rows += rowHTML(SAMPLE[4], { check: false })
|
||
const bar = `<div class="spec-toolbar">
|
||
<div class="search hl" data-tag="◈ FILTER">⌕ <span class="ph">search…</span></div>
|
||
<div class="ctl hl tag-r" data-tag="◈ SORT">sort ▾ name ↑</div>
|
||
<div class="ctl">☰ multi</div>
|
||
</div>`
|
||
const gutter = `<div class="hl" style="position:absolute;top:44px;left:6px;width:22px;height:150px" data-tag="◈ SELECT"></div>`
|
||
return (
|
||
bar +
|
||
`<div style="position:relative">` +
|
||
listbox(rows, {
|
||
scrollbar: true,
|
||
scrollHl: true,
|
||
thumbTop: 12,
|
||
thumbH: 44,
|
||
footer: "↻ loadMore ▾ · items 5 / 42",
|
||
footerHl: true,
|
||
}) +
|
||
gutter +
|
||
`</div>`
|
||
)
|
||
},
|
||
|
||
display() {
|
||
const empty = `<div class="mini"><div class="mh">Empty · items = ∅</div><div class="mb"><div class="empty-state"><div class="big">∅</div>no items — valid state,<br>not an error</div></div></div>`
|
||
const loading = `<div class="mini"><div class="mh">Loading</div><div class="mb">${skelRow()}${skelRow()}${skelRow()}</div></div>`
|
||
const error = `<div class="mini"><div class="mh">Error</div><div class="mb"><div class="error-state">⚠ failed to load<br><span style="color:var(--cyan)">↻ retry</span></div></div></div>`
|
||
const idle = `<div class="mini"><div class="mh">Idle</div><div class="mb">
|
||
<div class="mrow"><span style="color:var(--ink)">${SAMPLE[0].n}</span><span class="chev" style="margin-left:auto;color:var(--ink-dim)">></span></div>
|
||
<div class="mrow"><span style="color:var(--ink)">${SAMPLE[1].n}</span><span class="chev" style="margin-left:auto;color:var(--ink-dim)">></span></div>
|
||
<div class="mrow"><span style="color:var(--ink)">${SAMPLE[2].n}</span><span class="chev" style="margin-left:auto;color:var(--ink-dim)">></span></div>
|
||
</div></div>`
|
||
return `<div class="note" style="margin-bottom:10px">Four states, rendered distinctly. The skeleton mirrors item shape so the transition to Idle causes no layout shift.</div>
|
||
<div class="display-grid hl" data-tag="◈ DISPLAY">${empty}${loading}${error}${idle}</div>`
|
||
},
|
||
|
||
scroll() {
|
||
let rows = ""
|
||
rows += rowHTML(SAMPLE[0], { ghost: true })
|
||
rows += rowHTML(SAMPLE[1], { ghost: true })
|
||
rows += rowHTML(SAMPLE[2], { win: true })
|
||
rows += rowHTML(SAMPLE[3], { win: true })
|
||
rows += rowHTML(SAMPLE[4], { win: true })
|
||
rows += rowHTML(SAMPLE[5], { win: true })
|
||
rows += rowHTML(SAMPLE[6], { ghost: true })
|
||
rows += rowHTML(SAMPLE[7], { ghost: true })
|
||
return (
|
||
`<div class="note" style="margin-bottom:10px"><span style="color:var(--cyan)">▓ render window</span> — only these rows exist in the DOM. <span style="opacity:.5">Faded rows</span> are virtualized: not rendered, view references released.</div>` +
|
||
listbox(rows, { scrollbar: true, scrollHl: true, thumbTop: 30, thumbH: 40 })
|
||
)
|
||
},
|
||
|
||
load() {
|
||
let rows = ""
|
||
rows += rowHTML(SAMPLE[0], {})
|
||
rows += rowHTML(SAMPLE[1], {})
|
||
rows += rowHTML(SAMPLE[2], {})
|
||
rows += skelRow()
|
||
rows += skelRow()
|
||
return (
|
||
`<div class="spec-toolbar"><span class="badge">loadState = LoadingMore</span><span style="color:var(--ink-faint)">appending page 2…</span></div>` +
|
||
listbox(rows, { footer: "↻ loadMore ▾ · items 3 / 42", footerHl: true })
|
||
)
|
||
},
|
||
|
||
filter() {
|
||
let rows = ""
|
||
rows += rowHTML(SAMPLE[0], { dim: true })
|
||
rows += rowHTML(SAMPLE[1], { hlTxt: "◈ visible" }) // Item B matches "b"
|
||
rows += rowHTML(SAMPLE[2], { dim: true })
|
||
rows += rowHTML(SAMPLE[3], { dim: true })
|
||
rows += rowHTML(SAMPLE[4], { dim: true })
|
||
return (
|
||
toolbar({ searchQ: '"b"', searchHl: true, sort: false, badge: false }) +
|
||
`<div class="note" style="margin:2px 0 10px">visible = { x ∈ items | name matches "b" } · underlying items sequence is untouched.</div>` +
|
||
listbox(rows, {})
|
||
)
|
||
},
|
||
|
||
sort() {
|
||
// reordered view (descending)
|
||
let rows = ""
|
||
rows += rowHTML(SAMPLE[7], {})
|
||
rows += rowHTML(SAMPLE[6], {})
|
||
rows += rowHTML(SAMPLE[5], {})
|
||
rows += rowHTML(SAMPLE[4], {})
|
||
rows += rowHTML(SAMPLE[3], {})
|
||
return (
|
||
toolbar({ search: false, sortHl: true, sortLabel: "name ↓" }) +
|
||
`<div class="note" style="margin:2px 0 10px">Same members, new order (name ↓). Identity is unchanged — only the sequence is reordered.</div>` +
|
||
listbox(rows, {})
|
||
)
|
||
},
|
||
|
||
select() {
|
||
let rows = ""
|
||
rows += rowHTML(SAMPLE[0], { check: false })
|
||
rows += rowHTML(SAMPLE[1], { check: true, sel: true })
|
||
rows += rowHTML(SAMPLE[2], { check: false })
|
||
rows += rowHTML(SAMPLE[3], { check: true, sel: true })
|
||
rows += rowHTML(SAMPLE[4], { check: false })
|
||
return (
|
||
toolbar({
|
||
search: false,
|
||
sort: false,
|
||
selectCtl: true,
|
||
selectMeta: "MULTI · 2 selected",
|
||
selectHl: true,
|
||
}) +
|
||
`<div class="note" style="margin:2px 0 10px">selectionMode = Multi · selected = { Item B, Item D } ⊆ visible</div>` +
|
||
listbox(rows, {})
|
||
)
|
||
},
|
||
|
||
act() {
|
||
let rows = ""
|
||
rows += rowHTML(SAMPLE[0], {})
|
||
rows += rowHTML(SAMPLE[1], {})
|
||
rows += rowHTML(SAMPLE[2], { swiped: true })
|
||
rows += rowHTML(SAMPLE[3], {})
|
||
return (
|
||
`<div class="note" style="margin-bottom:10px">swipeAction(Item C, left) → reveals consumer-defined contextual actions. The list does not define them; it exposes the hook.</div>` +
|
||
`<div class="hl tag-b" data-tag="◈ ACT">` +
|
||
listbox(rows, {}) +
|
||
`</div>`
|
||
)
|
||
},
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════
|
||
READOUT RENDERER
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
function sigBlock() {
|
||
let h = esc(COMPOSED_SIG)
|
||
.replace(/⟨comment⟩(.*)/g, (m, p1) => `<span class="cm">${p1}</span>`)
|
||
.replace(/⟨type⟩(.*?)⟨\/type⟩/g, (m, p1) => `<span class="ty">${p1}</span>`)
|
||
return `<div class="sig-block">${h}</div>`
|
||
}
|
||
|
||
function readoutFull() {
|
||
const idx = FUNCTIONS.map(
|
||
(f) => `<div class="fi-n">${f.name}</div><div class="fi-d">${esc(f.verb)}</div>`,
|
||
).join("")
|
||
return `<div class="rhead"><span class="fn" style="color:var(--cyan)">FULL VIEW</span></div>
|
||
<div class="rverb">The standard composition and its seven functions. Select a function above to focus it.</div>
|
||
<div class="block"><div class="k">Composed signature</div>${sigBlock()}</div>
|
||
<div class="block"><div class="k">Core invariants</div>
|
||
<ul class="tight">
|
||
<li>items = ∅ is a valid state (empty list, not an error)</li>
|
||
<li>items are identity-unique — no duplicates by key</li>
|
||
<li>elems[items] = members — ordered form stays in sync with the set</li>
|
||
</ul>
|
||
</div>
|
||
<div class="block"><div class="k">Function index</div><div class="fn-index">${idx}</div></div>`
|
||
}
|
||
|
||
function readoutFn(f) {
|
||
let h =
|
||
`<div class="rhead"><span class="fn">${f.name}</span>` +
|
||
f.caps
|
||
.map((c) => `<span class="capbadge${c === "List" ? " core" : ""}">${c}</span>`)
|
||
.join("") +
|
||
`</div>`
|
||
h += `<div class="rverb">${esc(f.verb)}</div>`
|
||
|
||
if (f.state.length) {
|
||
h +=
|
||
`<div class="block"><div class="k">State touched</div>` +
|
||
f.state
|
||
.map(
|
||
([k, t]) =>
|
||
`<div class="field"><span class="fk">${k}</span><span class="ft">:</span><span class="fc">${esc(t)}</span></div>`,
|
||
)
|
||
.join("") +
|
||
`</div>`
|
||
}
|
||
if (f.behaviors.length) {
|
||
h +=
|
||
`<div class="block"><div class="k">Behavior${f.behaviors.length > 1 ? "s" : ""}</div>` +
|
||
f.behaviors
|
||
.map(
|
||
(b) => `<div class="beh"><div class="sig">${esc(b.sig)}</div>
|
||
<div class="pe"><span class="lbl">pre</span><span class="ex">${esc(b.pre)}</span></div>
|
||
<div class="pe"><span class="lbl">eff</span><span class="ex">${esc(b.eff)}</span></div></div>`,
|
||
)
|
||
.join("") +
|
||
`</div>`
|
||
}
|
||
if (f.invariants.length) {
|
||
h +=
|
||
`<div class="block"><div class="k">Invariant${f.invariants.length > 1 ? "s" : ""}</div><ul class="tight">` +
|
||
f.invariants.map((i) => `<li>${esc(i)}</li>`).join("") +
|
||
`</ul></div>`
|
||
}
|
||
if (f.failures.length) {
|
||
h +=
|
||
`<div class="block"><div class="k warn">⚠ Failure mode${f.failures.length > 1 ? "s" : ""}</div><ul class="tight warn">` +
|
||
f.failures.map((x) => `<li>${esc(x)}</li>`).join("") +
|
||
`</ul></div>`
|
||
}
|
||
if (f.perf.length) {
|
||
h +=
|
||
`<div class="block"><div class="k perf">⚡ Critical performance</div><ul class="tight perf">` +
|
||
f.perf.map((x) => `<li>${esc(x)}</li>`).join("") +
|
||
`</ul></div>`
|
||
}
|
||
if (f.notes.length) {
|
||
h += f.notes.map((n) => `<div class="note">— ${esc(n)}</div>`).join("")
|
||
}
|
||
return h
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════
|
||
TABS + WIRING
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
const tabsEl = document.getElementById("tabs")
|
||
const specEl = document.getElementById("specimen")
|
||
const readEl = document.getElementById("readout")
|
||
const specId = document.getElementById("specId")
|
||
|
||
const TABS = [{ id: "full", name: "Full view", fig: "00" }].concat(FUNCTIONS)
|
||
|
||
function select(id) {
|
||
document
|
||
.querySelectorAll(".tab")
|
||
.forEach((t) => t.classList.toggle("active", t.dataset.id === id))
|
||
if (id === "full") {
|
||
specEl.innerHTML = RENDER.full()
|
||
readEl.innerHTML = readoutFull()
|
||
specId.textContent = "FIG.00"
|
||
} else {
|
||
const f = FUNCTIONS.find((x) => x.id === id)
|
||
specEl.innerHTML = RENDER[id]()
|
||
readEl.innerHTML = readoutFn(f)
|
||
specId.textContent = "FIG." + f.fig
|
||
}
|
||
}
|
||
|
||
TABS.forEach((t) => {
|
||
const b = document.createElement("button")
|
||
b.className = "tab" + (t.id === "full" ? " full" : "")
|
||
b.dataset.id = t.id
|
||
b.textContent = t.id === "full" ? "▣ FULL VIEW" : t.name
|
||
b.onclick = () => select(t.id)
|
||
tabsEl.appendChild(b)
|
||
})
|
||
|
||
/* ══════════════════════════════════════════════════════════════
|
||
COMPANION 1 — COMPOSITION MAP (static SVG)
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
function buildCompMap() {
|
||
const caps = [
|
||
{ id: "core", label: "List : Set", sub: "items : seq Item", color: "var(--ink-dim)" },
|
||
{ id: "async", label: "Async", sub: "loadState", color: "var(--cyan)" },
|
||
{ id: "paginated", label: "Paginated", sub: "totalCount", color: "var(--cyan)" },
|
||
{ id: "pullable", label: "Pullable", sub: "refresh", color: "var(--cyan)" },
|
||
{ id: "filterable", label: "Filterable", sub: "visible", color: "var(--cyan)" },
|
||
{ id: "selectable", label: "Selectable", sub: "selected", color: "var(--cyan)" },
|
||
{ id: "sortable", label: "Sortable", sub: "cmp", color: "var(--cyan)" },
|
||
{ id: "swipeable", label: "Swipeable", sub: "+ Reorderable", color: "var(--cyan)" },
|
||
]
|
||
const funcs = ["Display", "Scroll", "Load", "Filter", "Sort", "Select", "Act"]
|
||
const links = [
|
||
["core", "Display"],
|
||
["core", "Scroll"],
|
||
["async", "Display"],
|
||
["async", "Load"],
|
||
["paginated", "Load"],
|
||
["pullable", "Load"],
|
||
["filterable", "Filter"],
|
||
["selectable", "Select"],
|
||
["sortable", "Sort"],
|
||
["swipeable", "Act"],
|
||
]
|
||
const W = 640,
|
||
lx = 24,
|
||
lw = 170,
|
||
rx = 446,
|
||
rw = 170,
|
||
bh = 34,
|
||
gap = 6,
|
||
top = 16
|
||
const lstep = bh + gap,
|
||
rstep = 52
|
||
const H = top + caps.length * lstep + 6
|
||
const capY = {},
|
||
funcY = {}
|
||
caps.forEach((c, i) => (capY[c.id] = top + i * lstep + bh / 2))
|
||
funcs.forEach((f, i) => (funcY[f] = top + i * rstep + bh / 2))
|
||
|
||
let s = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Composition map">`
|
||
// edges first
|
||
links.forEach(([a, b]) => {
|
||
const y1 = capY[a],
|
||
y2 = funcY[b]
|
||
const x1 = lx + lw,
|
||
x2 = rx
|
||
const mx = (x1 + x2) / 2
|
||
const col = a === "core" ? "var(--ink-faint)" : "var(--cyan)"
|
||
s += `<path class="edge" d="M${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}" stroke="${col}" opacity=".55"/>`
|
||
s += `<circle cx="${x2}" cy="${y2}" r="2.4" fill="${col}"/>`
|
||
})
|
||
// left boxes (capabilities)
|
||
caps.forEach((c) => {
|
||
const y = capY[c.id] - bh / 2
|
||
s +=
|
||
`<g class="node"><rect x="${lx}" y="${y}" width="${lw}" height="${bh}" rx="2" ${c.id === "core" ? 'stroke="var(--amber)"' : ""}/>` +
|
||
`<text x="${lx + 12}" y="${y + 15}">${c.label}</text>` +
|
||
`<text class="sub" x="${lx + 12}" y="${y + 27}">${c.sub}</text></g>`
|
||
})
|
||
// right boxes (functions)
|
||
funcs.forEach((f) => {
|
||
const y = funcY[f] - bh / 2
|
||
s +=
|
||
`<g class="node"><rect x="${rx}" y="${y}" width="${rw}" height="${bh}" rx="2" stroke="var(--amber)"/>` +
|
||
`<text x="${rx + 12}" y="${y + 21}" fill="var(--amber)">${f}</text></g>`
|
||
})
|
||
// column labels
|
||
s += `<text x="${lx}" y="${H - 2}" fill="var(--ink-faint)" font-size="10" letter-spacing="1.5">CAPABILITIES (compose onto host)</text>`
|
||
s += `<text x="${rx}" y="${H - 2}" fill="var(--ink-faint)" font-size="10" letter-spacing="1.5">FUNCTIONS</text>`
|
||
s += `</svg>`
|
||
return s
|
||
}
|
||
document.getElementById("compmap").innerHTML = buildCompMap()
|
||
|
||
/* ══════════════════════════════════════════════════════════════
|
||
COMPANION 2 — LOADSTATE MACHINE (static SVG)
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
function buildStateMachine() {
|
||
const nodes = {
|
||
loading: { x: 70, y: 150, w: 110, h: 40, label: "Loading" },
|
||
idle: { x: 300, y: 150, w: 96, h: 40, label: "Idle" },
|
||
refreshing: { x: 520, y: 56, w: 130, h: 40, label: "Refreshing" },
|
||
loadingmore: { x: 520, y: 250, w: 140, h: 40, label: "LoadingMore" },
|
||
error: { x: 770, y: 150, w: 96, h: 40, label: "Error" },
|
||
}
|
||
const COL = {
|
||
succeed: "var(--green)",
|
||
fail: "var(--red)",
|
||
refresh: "var(--amber)",
|
||
loadMore: "var(--cyan)",
|
||
init: "var(--ink-faint)",
|
||
}
|
||
const edges = [
|
||
{ from: "loading", to: "idle", label: "succeed()", kind: "succeed", off: 0 },
|
||
{ from: "loading", to: "error", label: "fail()", kind: "fail", off: 150 },
|
||
{ from: "idle", to: "refreshing", label: "refresh()", kind: "refresh", off: -16 },
|
||
{ from: "refreshing", to: "idle", label: "succeed()", kind: "succeed", off: -16 },
|
||
{ from: "idle", to: "loadingmore", label: "loadMore()", kind: "loadMore", off: 16 },
|
||
{ from: "loadingmore", to: "idle", label: "succeed()", kind: "succeed", off: 16 },
|
||
{ from: "refreshing", to: "error", label: "fail()", kind: "fail", off: 14 },
|
||
{ from: "loadingmore", to: "error", label: "fail()", kind: "fail", off: -14 },
|
||
{ from: "error", to: "refreshing", label: "refresh()", kind: "refresh", off: 70 },
|
||
]
|
||
const W = 900,
|
||
H = 330,
|
||
vbTop = -6
|
||
|
||
function center(n) {
|
||
return [n.x + n.w / 2, n.y + n.h / 2]
|
||
}
|
||
function edgePoint(n, tx, ty) {
|
||
const cx = n.x + n.w / 2,
|
||
cy = n.y + n.h / 2
|
||
let dx = tx - cx,
|
||
dy = ty - cy
|
||
if (dx === 0 && dy === 0) return [cx, cy]
|
||
const sx = dx !== 0 ? n.w / 2 / Math.abs(dx) : Infinity
|
||
const sy = dy !== 0 ? n.h / 2 / Math.abs(dy) : Infinity
|
||
const s = Math.min(sx, sy)
|
||
return [cx + dx * s, cy + dy * s]
|
||
}
|
||
|
||
let markers = ""
|
||
Object.entries(COL).forEach(([k, c]) => {
|
||
markers += `<marker id="ar-${k}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="${c}"/></marker>`
|
||
})
|
||
|
||
let s = `<svg viewBox="0 ${vbTop} ${W} ${H}" role="img" aria-label="LoadState machine"><defs>${markers}</defs>`
|
||
|
||
// initial marker → loading
|
||
{
|
||
const l = nodes.loading
|
||
s += `<circle cx="26" cy="${l.y + l.h / 2}" r="5" fill="var(--ink-faint)"/>`
|
||
const [ex, ey] = edgePoint(l, 26, l.y + l.h / 2)
|
||
s += `<path class="edge" d="M31 ${l.y + l.h / 2} L ${ex} ${ey}" stroke="${COL.init}" marker-end="url(#ar-init)"/>`
|
||
}
|
||
|
||
// edges
|
||
edges.forEach((e) => {
|
||
const A = nodes[e.from],
|
||
B = nodes[e.to]
|
||
const [acx, acy] = center(A),
|
||
[bcx, bcy] = center(B)
|
||
const [sx, sy] = edgePoint(A, bcx, bcy)
|
||
const [tx, ty] = edgePoint(B, acx, acy)
|
||
const mx = (sx + tx) / 2,
|
||
my = (sy + ty) / 2
|
||
let dx = tx - sx,
|
||
dy = ty - sy
|
||
const len = Math.hypot(dx, dy) || 1
|
||
const nx = -dy / len,
|
||
ny = dx / len // unit normal
|
||
const cx = mx + nx * e.off,
|
||
cy = my + ny * e.off // control point
|
||
const col = COL[e.kind]
|
||
s += `<path class="edge" d="M${sx} ${sy} Q ${cx} ${cy} ${tx} ${ty}" stroke="${col}" marker-end="url(#ar-${e.kind})"/>`
|
||
// label at quadratic midpoint t=0.5
|
||
const lx = 0.25 * sx + 0.5 * cx + 0.25 * tx,
|
||
ly = 0.25 * sy + 0.5 * cy + 0.25 * ty
|
||
s += `<text class="elabel" x="${lx}" y="${ly - 3}" fill="${col}" text-anchor="middle">${e.label}</text>`
|
||
})
|
||
|
||
// nodes
|
||
Object.values(nodes).forEach((n) => {
|
||
s +=
|
||
`<g class="node"><rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="3"/>` +
|
||
`<text x="${n.x + n.w / 2}" y="${n.y + n.h / 2 + 4}" text-anchor="middle">${n.label}</text></g>`
|
||
})
|
||
|
||
// legend
|
||
const leg = [
|
||
["succeed()", "succeed"],
|
||
["fail()", "fail"],
|
||
["refresh()", "refresh"],
|
||
["loadMore()", "loadMore"],
|
||
]
|
||
let lxp = 70
|
||
leg.forEach(([t, k]) => {
|
||
s +=
|
||
`<line x1="${lxp}" y1="${vbTop + 14}" x2="${lxp + 18}" y2="${vbTop + 14}" stroke="${COL[k]}" stroke-width="2"/>` +
|
||
`<text x="${lxp + 24}" y="${vbTop + 18}" fill="var(--ink-dim)" font-size="10">${t}</text>`
|
||
lxp += 24 + t.length * 6.6 + 26
|
||
})
|
||
|
||
s += `</svg>`
|
||
return s
|
||
}
|
||
document.getElementById("statemachine").innerHTML = buildStateMachine()
|
||
|
||
/* init */
|
||
select("full")
|
||
</script>
|
||
</body>
|
||
</html>
|