npx skills add https://github.com/0x1notme/shaping-skills --skill breadboarding面包板将工作流描述转化为完整的可操作项及其关系图。输出总是一组表格,展示带有“连线输出”和“返回至”关系的编号UI和代码可操作项。这些表格是真相。Mermaid图是可选的、供人理解的可视化。
面包板有两个功能:
你不理解现有系统的具体细节。你有一个试图理解的工作流——解释某事如何发生或某事为何不发生。
输入:
输出:
注意: 如果工作流跨越多个应用程序(前端+后端),创建一个讲述完整故事的面包板。标记位置以显示它们属于哪个系统。
你有一个新系统,根据成型结果被草拟为部件(机制)的集合。你需要详细说明具体机制,并展示这些部件如何作为系统交互。
输入:
输出:
通常你两者都有:一个必须保持原样的现有系统,加上在成型中定义的新部件或更改。在这种情况下,将两者一起面包板——现有的可操作项和新的可操作项——展示它们如何连接。
位置是一个有边界的交互上下文。当你在一个位置时:
位置是感知上的,不是技术上的。 它与URL或组件无关——它与用户体验到的当前上下文有关。位置就是就你现在能做什么而言的“你所在之处”。
判断某物是否为不同位置的最简单测试:你能与后面的东西交互吗?
| 答案 | 含义 |
|---|---|
| 否 | 你在一个不同的位置 |
| 是 | 同一位置,有本地状态变化 |
| UI元素 | 阻塞? | 位置? | 原因 |
|---|---|---|---|
| 模态框 | 是 | 是 | 无法与后面的页面交互 |
| 确认弹出框 | 是 | 是 | 必须响应才能返回(模态框的极限情况) |
| 编辑模式(整个屏幕变换) | 是 | 是 | 所有可操作项都改变了 |
| 复选框显示额外字段 | 否 | 否 | 周围环境未变 |
| 下拉菜单 | 否 | 否 | 可以点击别处,非阻塞 |
| 工具提示 | 否 | 否 | 信息性的,非阻塞 |
当一个控件改变状态时,问:是所有东西都变了,还是只是一个子集变了而周围环境保持不变?
| 类型 | 发生什么 | 如何建模 |
|---|---|---|
| 本地状态 | UI子集改变,周围环境不变 | 同一位置,条件N → 依赖的U |
| 位置导航 | 整个屏幕变换,或出现阻塞覆盖层 | 不同的位置 |
当一个模式(如“编辑模式”)变换整个屏幕——不同的按钮,各处不同的可操作项——将其建模为独立的位置:
PLACE: CMS Page (Read Mode)
PLACE: CMS Page (Edit Mode)
在它们之间切换的状态标志(例如 editMode$)是一个导航机制,而不是数据存储。不要将其作为S包含在任何位置中。
对于任何UI可操作项,问:
如果对问题#3的回答是“所有东西都变了”或“在我响应之前无法与后面的东西交互”,那就是导航到一个不同的位置。
| 模式 | 用途 |
|---|---|
PLACE: Page Name | 标准页面/路由 |
PLACE: Page Name (Mode) | 页面的基于模式的变体 |
PLACE: Modal Name | 模态对话框 |
PLACE: Backend | API/数据库边界 |
当跨越多个系统时,用系统标签:PLACE: Checkout Page (frontend), PLACE: Payment API (backend)。
位置是数据模型中的一等元素。每个位置都有一个ID:
---|---|---
P1 | CMS Page (Read Mode) | 仅查看状态
P2 | CMS Page (Edit Mode) | 带有页面级控件的编辑状态
P2.1 | widget-grid (letters) | 子位置:P2内的字母编辑小部件
P3 | Letter Form Modal | 添加/编辑字母的表单
P4 | Backend | API解析器和数据库
位置ID支持:
→ P2 而不是其内部的可操作项当一个嵌套位置有很多内部可操作项,并且会使父级混乱时,你可以分离它:
_letter-browser_letter-browser --> letter-browser引用是一个UI可操作项——它代表父上下文中“这个小部件/组件在这里渲染”。
flowchart TB
subgraph P1["P1: CMS Page (Read Mode)"]
U1["U1: Edit button"]
U_LB["_letter-browser"]
end
subgraph letterBrowser["letter-browser"]
U10["U10: Search input"]
U11["U11: Letter list"]
N40["N40: performSearch()"]
end
U_LB --> letterBrowser
在可操作项表格中,将引用列为UI可操作项:
---|---|---|---
U1 | Edit button | click | → N1
_letter-browser | Widget reference | — | → P3
用虚线边框样式化位置引用以区分它们:
classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5
class U_LB placeRef
当一个组件具有不同的模式(读取 vs 编辑,查看 vs 编辑,折叠 vs 展开)时,将它们建模为独立的位置——它们对用户来说是不同的感知状态。
如果一个模式包含另一个模式的所有内容加上更多,则在扩展位置内部使用位置引用来展示这一点:
P3: letter-browser (Read) — 基础状态
P4: letter-browser (Edit) — 包含 _letter-browser (Read) + 新可操作项
引用展示了组合:“P3中的所有内容都出现在这里,加上这些新增内容。”
flowchart TB
subgraph P3["P3: letter-browser (Read)"]
U10["U10: Search input"]
U11["U11: Letter list"]
end
subgraph P4["P4: letter-browser (Edit)"]
U_P3["_letter-browser (Read)"]
U3["U3: Add button"]
U4["U4: Edit button"]
end
U_P3 --> P3
在P4的可操作项表格中,引用展示了继承:
---|---|---|---|---
_letter-browser (Read) | 继承P3的所有内容 | — | → P3 |
U3 | Add button | click | → N3 | NEW
U4 | Edit button | click | → N4 | NEW
子位置是位置的一个定义子集——一个包含相关可操作项的分组区域。在以下情况使用子位置:
表示法: 使用分层ID——P2.1, P2.2 等表示P2的子位置。
| # | Place | Description |
|---|-------|-------------|
| P2 | Dashboard | Main dashboard page |
| P2.1 | Sales widget | Subplace: sales metrics |
| P2.2 | Activity feed | Subplace: recent activity |
在可操作项表格中,使用子位置ID来展示包含关系:
| U3 | P2.1 | sales-widget | "Refresh" button | click | → N4 | — |
| U7 | P2.2 | activity-feed | activity list | render | — | — |
在Mermaid中: 将子位置子图嵌套在父级内部。使用相同的背景颜色(无特殊填充)——子位置是父级的一部分,不是独立的位置:
flowchart TB
subgraph P2["P2: Dashboard"]
subgraph P2_1["P2.1: Sales widget"]
U3["U3: Refresh button"]
end
subgraph P2_2["P2.2: Activity feed"]
U7["U7: activity list"]
end
otherContent[["... other dashboard content ..."]]
end
范围外内容的占位符: 当详细说明一个子位置时,添加一个占位符同级项以显示页面上还有更多内容:
otherContent[["... other page content ..."]]
这告诉读者:“我们正在放大P2.1,但P2包含更多我们未详细说明的内容。”
这些是数据模型中两种不同的关系:
| 关系 | 含义 | 捕获位置 |
|---|---|---|
| 包含关系 | 可操作项属于/存在于一个位置 | 位置列(集合成员关系) |
| 连线 | 可操作项触发/调用某物 | 连线输出列(控制流) |
包含关系是集合成员关系:U1 ∈ P1 表示U1是位置P1的成员。每个可操作项恰好属于一个位置。
连线是控制流:U1 → N1 表示U1触发N1。一个可操作项可以连线到任何东西——其他可操作项或位置。
位置列回答:“这个可操作项位于哪里?” 连线输出列回答:“这个可操作项触发什么?”
当一个可操作项导致导航(用户“去”某处)时,连线到位置本身,而不是其内部的可操作项:
✅ N1 Wires Out: → P2 (导航到编辑模式)
❌ N1 Wires Out: → U3 (连线到P2内部的可操作项)
这使得导航在表格中变得明确。位置是目的地;一旦到达,内部的具体可操作项就变得可用。
在Mermaid中,这变为:
N1 --> P2
子图ID匹配位置ID,因此连线连接到位置边界。
你可以操作的事物:
可操作项如何相互连接:
连线输出 —— 一个可操作项触发或调用的内容(控制流):
返回至 —— 一个可操作项的输出流向何处(数据流):
这种分离使数据流变得明确。连线输出展示控制流(什么触发什么)。返回至展示数据流(输出流向何处)。
这些表格是真相。每个面包板都会产生这些:
---|---|---
P1 | Search Page | 主搜索界面
P2 | Detail Page | 单个结果视图
---|---|---|---|---|---|---
U1 | P1 | search-detail | search input | type | → N1 | —
U2 | P1 | search-detail | loading spinner | render | — | —
U3 | P1 | search-detail | results list | render | — | —
U4 | P1 | search-detail | result row | click | → P2 | —
---|---|---|---|---|---|---
N1 | P1 | search-detail | activeQuery.next() | call | → N2 | —
N2 | P1 | search-detail | activeQuery subscription | observe | → N3 | —
N3 | P1 | search-detail | performSearch() | call | → N4, → N5, → N6 | —
N4 | P1 | search.service | searchOneCategory() | call | → N7 | → N3
N5 | P1 | search-detail | loading | write | store | → U2
N6 | P1 | search-detail | results | write | store | → U3
N7 | P1 | typesense.service | rawSearch() | call | — | → N4
---|---|---|---
S1 | P1 | results | 搜索结果数组
S2 | P1 | loading | 布尔加载状态
| 列 | 描述 |
|---|---|
| # | 唯一ID(位置用P1, P2...;UI用U1, U2...;代码用N1, N2...;存储用S1, S2...) |
| 位置 | 此可操作项属于哪个位置(包含关系) |
| 组件 | 哪个组件/服务拥有此可操作项 |
| 可操作项 | 你可以操作的具体事物 |
| 控件 | 触发事件:click, type, call, observe, write, render |
| 连线输出 | 它触发什么:→ N4, → P2(控制流,包括导航) |
| 返回至 | 输出流向何处:→ N3 或 → U2, U3(数据流) |
请参见下面的示例A以获取完整的工作示例。
步骤1:确定要分析的流程
选择一个特定的用户旅程。始终将其框定为操作员试图做某事:
步骤2:列出所有涉及的位置
遍历旅程,识别用户访问的每个不同位置或跨越的系统边界。
步骤3:跟踪代码以查找组件
从入口点(路由、API端点)开始,跟踪代码以找到该流程触及的每个组件。
步骤4:对于每个组件,列出其可操作项
阅读代码。识别:
步骤5:命名实际的事物,而不是抽象概念
如果你写“DATABASE”,停下来。实际的方法是什么?(userRepo.save())。每个可操作项名称必须是你在代码中可以指向的真实事物。
步骤6:填写控件列
对于每个可操作项,什么触发它?(click, type, call, observe, write, render)
步骤7:填写连线输出
对于每个可操作项,它触发什么?阅读代码——这个方法调用什么?这个按钮的处理程序调用什么?
步骤8:填写返回至
对于每个可操作项,它的输出流向何处?
—步骤9:将数据存储添加为可操作项
当代码写入一个属性,该属性后来被另一个可操作项读取时,将该属性添加为控件类型为 write 的代码可操作项。
步骤10:将框架机制添加为可操作项
包括像 cdr.detectChanges() 这样连接代码和UI渲染的事物。这些展示了状态变化如何实际到达UI。
步骤11:对照代码验证
再次阅读代码。确认每个可操作项都存在,并且连线匹配现实。
请参见下面的示例B以获取包含切片的完整工作示例。
步骤1:列出成型中的每个部件
获取成型中识别的每个机制/部件并写下来。
步骤2:将部件转化为可操作项
对于每个部件,识别:
步骤3:验证每个U都有一个支持的N
对于每个UI可操作项,检查:哪个代码可操作项提供其数据或控制其渲染?如果不存在,添加缺失的N。
步骤4:将位置分类为现有的或新的
对于每个UI可操作项,确定它位于:
步骤5:连线可操作项
为每个可操作项填写连线输出和返回至。遍历预期行为——什么调用什么?什么返回到哪里?
步骤6:连接到现有系统(如果适用)
如果有现有代码库:
步骤7:检查完整性
步骤8:将用户可见的输出视为U
用户看到的任何东西(包括电子邮件、通知)都是UI可操作项,并且需要一个N连线到它。
当向后跟踪流程时,不要遵循你记忆中的路径。扫描连线输出列中所有连线到你的目标的可操作项。
填写表格时,系统地阅读每一行。不要依赖你认为你知道的东西。
这些表格是真相的来源。你的记忆是不可靠的。
映射现有代码时,永远不要发明抽象概念。每个名称必须指向代码库中真实存在的东西。
可操作项是你可以操作的、在系统中具有有意义身份的事物。有几样东西看起来像可操作项,但实际上只是实现机制:
| 类型 | 示例 | 为什么它不是可操作项 |
|---|---|---|
| 视觉容器 | modal-frame wrapper | 你无法操作包装器——它只是一个位置边界 |
| 内部转换 | letterDataTransform() | 调用者的实现细节——不可单独操作 |
| 导航机制 | modalService.open() | 只是到达位置的“方式”——直接连线到目的地 |
这些在初稿中并不总是显而易见的。 在审查你的可操作项表格时,仔细检查每个代码可操作项并问:
“这实际上是一个可操作项,还是它只是详细说明了某事如何发生的机制?”
如果它只是“方式”——跳过它,直接连线到目的地或结果。
示例:
❌ N8 --> N22 --> P3 (N22是modalService.open——只是机制)
✅ N8 --> P3 (处理程序导航到模态框)
❌ N6 --> N20 --> S2 (N20是数据转换——N6的内部)
✅ N6 --> S2 (回调写入存储)
❌ U7: modal-frame (包装器——只是P3的边界)
✅ U8: Save button (可操作的)
处理程序导航到P3。回调写入存储。模态框就是P3。机制是隐式的。
面包板捕获两种不同的流:
| 流 | 它跟踪什么 | 连线 |
|---|---|---|
| 导航 | 从位置到位置的移动 | 连线输出 → 位置 |
| 数据 | 状态如何填充显示 | 返回至 → U |
它们是正交的。你可以有导航而没有数据变化,也可以有数据变化而没有导航。
审查面包板时,跟踪两种流:
显示数据的UI可操作项必须有东西为其提供数据——要么是数据存储(S),要么是返回数据的代码可操作项(N)。
❌ U6: letter list (没有传入连线——数据来自哪里?)
✅ S1 -.-> U6 (存储提供数据显示)
✅ N4 -.-> U6 (查询结果提供数据显示)
如果一个显示U没有数据源连线到它,要么:
当专注于导航时,这很容易被忽略。总是问:“这个U显示数据——这些数据来自哪里?”
如果一个代码可操作项既没有连线输出也没有返回至,那么有问题:
一个似乎没有连线到任何地方的N是可疑的。如果它有系统边界之外的副作用(浏览器URL、localStorage、外部API、分析),添加一个存储节点来表示该外部状态:
❌ N41: updateUrl() (连线到... 没有东西?)
✅ N41: updateUrl() → S15 (连线到浏览器URL存储)
这使得数据流变得明确。存储也可以有返回连线,展示外部状态如何流回:
flowchart TB
N42["N42: performSearch()"] --> N41["N41: updateUrl()"]
N41 --> S15["S15: Browser URL (?q=)"]
S15 -.->|back button / init| N40["N40: activeQuery$"]
要建模的常见外部存储:
Browser URL —— 查询参数、哈希片段localStorage / sessionStorage —— 持久化的客户端状态Clipboard —— 复制/粘贴操作Browser History —— 导航状态连线输出 = 控制流(什么触发什么) 返回至 = 数据流(输出流向何处)
这种分离使系统的行为变得明确。
路由是每个页面使用的通用机制。与其通过中央路由器可操作项绘制所有导航,不如在发生的地方内联显示 Router navigate() 并直接连线到目的地位置。
数据存储属于其数据被消费以启用某种效果的位置——而不是产生它的位置。来自其他位置的写入是“触及”该位置的状态。
要确定存储属于哪里:
示例:一个 changedPosts 数组由模态框写入(当用户确认更改时)但被PAGE_SAVE处理程序读取(当用户点击保存时)。存储属于PAGE_SAVE处理程序——这是它启用持久化操作的地方。
在将存储放入单独的DATA STORES部分之前,验证它是否确实被多个位置读取。如果它只在一个位置启用行为,它属于该位置内部。
在一个位置内,将存储放在启用行为的子组件中。如果一个存储被特定的处理程序读取,将其放在该处理程序的组件中——不要漂浮在位置级别。
数据库和解析器不是漂浮的基础设施——它们是一个拥有自己可操作项的位置。数据库表(S)属于后端位置内部,与读取和写入它们的解析器(N)一起。
本节提供了面包板中可以出现的所有内容的完整参考。
| 元素 | ID模式 | 它是什么 | 什么符合条件 |
|---|---|---|---|
| 位置 | P1, P2, P3... | 一个有边界的交互上下文 | 阻塞测试:无法与后面的东西交互 |
| 子位置 | P2.1, P2.2... | 位置内的一个定义子集 | 在较大位置内分组相关的可操作项 |
| 位置引用 | _PlaceName | 指向分离位置的UI可操作项 | 复杂嵌套位置单独定义 |
| UI可操作项 | U1, U2, U3... | 用户可以看到或交互的事物 | 输入框、按钮、显示、滚动区域 |
| 代码可操作项 | N1, N2, N3... | 代码中你可以操作的事物 | 方法、订阅、处理程序、框架机制 |
| 数据存储 | S1, S2, S3... | 持久化并被读/写的状态 | 保存数据的属性、数组、可观察对象 |
| 关系 | 语法 | 含义 | 示例 |
|---|---|---|---|
| 包含关系 | 位置列 | 可操作项属于位置 | U3 在位置 P2.1 中 |
| 连线输出 | → X | 控制流:触发/调用 | → N4, → P2 |
| 返回至 | → X(在返回至列中) |
| 关系 | 含义 | 捕获位置 |
|---|---|---|
| 包含关系 | 可操作项属于/存在于一个位置 | 位置列(集合成员关系) |
| 连线 | 可操作项触发/调用某物 | 连线输出列(控制流) |
包含关系是集合成员关系:U1 ∈ P1 表示U1是位置P1的成员。连线是控制流:U1 → N1 表示U1触发N1。
位置 (P):
位置引用 (_PlaceName):
_letter-browser, _user-profile-widget_letter-browser --> P3UI可操作项 (U):
代码可操作项 (N):
handleSubmit(), query$ subscription, detectChanges()数据存储 (S):
results 数组, loading 布尔值, changedPosts 列表Browser URL, localStorage, Clipboard —— 表示应用程序边界之外的状态| 检查 | 问题 | 如果没有... |
|---|---|---|
| 每个显示数据的U | 它有传入连线(通过连线输出或返回至)吗? | 添加数据源 |
| 每个N | 它有连线输出或返回至(或两者都有)吗? | 调查——可能是死代码或缺失连线 |
| 每个S | 有东西从中读取(返回至)吗? | 调查——可能未使用 |
| 导航机制 | 这个N只是到达某处的“方式”吗? | 直接连线到位置 |
| 有副作用的N | 这个N影响外部状态(URL、存储、剪贴板)吗? | 为外部状态添加存储 |
分块将子系统折叠成主图中的单个节点,细节单独显示。当面包板的某个部分具有以下特点时,使用分块来管理复杂性:
寻找那些跟踪连线时揭示“瓶颈点”的部分——许多可操作项通过单一输入和单一输出汇集。这些是分块的天然边界。
示例:一个 dynamic-form 组件接收表单定义,渲染许多字段(U7a-U7k),在更改时验证(N26),并发出单个 valid$ 信号。在主图中,这变为:
N24 -->|formDefinition| dynamicForm
dynamicForm -.->|valid$| U8
dynamicForm[["CHUNK: dynamic-form"]]
N24 -->|formDefinition| dynamicForm
dynamicForm -.->|valid$| U8
3. 创建一个单独的块图,用边界标记显示内部结构:
flowchart TB
input([formDefinition])
output(["valid$"])
subgraph chunk["dynamic-form internals"]
N25["N25: generateFormConfig()"]
U7a["U7a: field"]
N26["N26: form value changes"]
N27["N27: valid$ emission"]
end
input --> N25
N25 --> U7a
U7a --> N26
N26 --> N27
N27 --> output
classDef boundary fill:#b3e5fc,stroke:#0288d1,stroke-dasharray:5 5
class input,output boundary
4. 在主图中区分块的样式:
classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px
class dynamicForm chunk
| 类型 | 颜色 | 十六进制 |
|---|---|---|
| 块节点(主图) | 浅蓝色 | #b3e5fc |
| 边界标记(块图) | 浅蓝色,虚线 | #b3e5fc 带 stroke-dasharray:5 5 |
这些表格是真相。Mermaid图是可选的、供人理解的可视化。
flowchart TB
U1["U1: search input"] --> N1["N1: activeQuery.next()"]
N1 --> N2["N2: subscription"]
N2 --> N3["N3: performSearch"]
N3 --> N4["N4: searchOneCategory"]
N4 -.-> N3
N3 --> N5["N5: loading store"]
N3 --> N6["N6: results store"]
N5 -.-> U2["U2: loading spinner"]
N6 -.-> U3["U3: results list"]
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
class U1,U2,U3 ui
class N1,N2,N3,N4,N5,N6 nonui
| 线条样式 | Mermaid语法 | 用途 |
|---|---|---|
实线 (-->) | A --> B | 连线输出:调用、触发、写入 |
虚线 (-.->) | A -.-> B | 返回至:返回值、数据存储读取 |
带标签的 ... | `A -.-> | ... |
当数据流有中间步骤与面包板的范围无关时,通过直接从源连线到目的地并带有 ... 标签来缩写:
S4 -.->|...| U6
这表示“数据从S4流向U6,中间步骤省略。”在以下情况使用:
| 前缀 | 类型 | 示例 |
|---|---|---|
| P | 位置 | P1, P2, P3 |
| U | UI可操作项 | U1, U2, U3 |
| N | 代码可操作项 | N1, N2, N3 |
| S | 数据存储 | S1, S2, S3 |
| 类型 | 颜色 | 十六进制 |
|---|---|---|
| 位置(子图) | 白色/透明 | — |
| UI可操作项 | 粉色 | #ffb6c1 |
| 代码可操作项 | 灰色 | #d3d3d3 |
| 数据存储 | 淡紫色 | #e6e6fa |
| 块 | 浅蓝色 | #b3e5fc |
| 位置引用 | 粉色,虚线边框 | #ffb6c1 |
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
classDef store fill:#e6e6fa,stroke:#9370db,color:#000
classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px
classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5
使用位置ID作为子图ID,以便导航连线正确连接:
flowchart TB
subgraph P1["P1: CMS Page (Read Mode)"]
U1["U1: Edit button"]
N1["N1: toggleEditMode()"]
end
subgraph P2["P2: CMS Page (Edit Mode)"]
U2["U2: Save button"]
U3["U3: Add button"]
end
%% Navigation wires to Place ID
N1 --> P2
| 类型 | ID模式 | 标签模式 | 目的 |
|---|---|---|---|
| 位置 | P1, P2... | P1: Page Name | 用户访问的有边界的上下文 |
| 触发器 | — | TRIGGER: Name | 启动流程的事件( |
Breadboarding transforms a workflow description into a complete map of affordances and their relationships. The output is always a set of tables showing numbered UI and Code affordances with their Wires Out and Returns To relationships. The tables are the truth. Mermaid diagrams are optional visualizations for humans.
Breadboarding serves two functions:
You don't understand how an existing system works in its concrete details. You have a workflow you're trying to understand — explaining how something happens or why something doesn't happen.
Input:
Output:
Note: If the workflow spans multiple applications (frontend + backend), create ONE breadboard that tells the full story. Label places to show which system they belong to.
You have a new system sketched as an assembly of parts (mechanisms) per shaping. You need to detail out the concrete mechanism and show how those parts interact as a system.
Input:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 块 |
| — |
| 折叠的子系统 |
| 一条连线进,一条连线出,许多内部结构 |
| 占位符 | — | 范围外内容标记 | 展示上下文而不详细说明 |
| 数据流:输出流向 |
→ U6, → N3 |
| 缩写流 | ` | label | ` |
| 父子关系 | 分层ID | 子位置属于位置 | P2.1是P2的子级 |
Often you have both: an existing system that must remain as-is, plus new pieces or changes defined in a shape. In this case, breadboard both together — the existing affordances and the new ones — showing how they connect.
A Place is a bounded context of interaction. While you're in a Place:
Place is perceptual, not technical. It's not about URLs or components — it's about what the user experiences as their current context. A Place is "where you are" in terms of what you can do right now.
The simplest test for whether something is a different Place: Can you interact with what's behind?
| Answer | Meaning |
|---|---|
| No | You're in a different Place |
| Yes | Same Place, with local state changes |
| UI Element | Blocking? | Place? | Why |
|---|---|---|---|
| Modal | Yes | Yes | Can't interact with page behind |
| Confirmation popover | Yes | Yes | Must respond before returning (limit case of modal) |
| Edit mode (whole screen transforms) | Yes | Yes | All affordances changed |
| Checkbox reveals extra fields | No | No | Surroundings unchanged |
| Dropdown menu | No | No | Can click away, non-blocking |
| Tooltip | No | No | Informational, non-blocking |
When a control changes state, ask: did everything change, or just a subset while the surroundings stayed the same?
| Type | What happens | How to model |
|---|---|---|
| Local state | Subset of UI changes, surroundings unchanged | Same Place, conditional N → dependent Us |
| Place navigation | Entire screen transforms, or blocking overlay | Different Places |
When a mode (like "edit mode") transforms the entire screen — different buttons, different affordances everywhere — model as separate Places:
PLACE: CMS Page (Read Mode)
PLACE: CMS Page (Edit Mode)
The state flag (e.g., editMode$) that switches between them is a navigation mechanism , not a data store. Don't include it as an S in either Place.
For any UI affordance, ask:
If the answer to #3 is "everything changes" or "I can't interact with what's behind until I respond," that's navigation to a different Place.
| Pattern | Use |
|---|---|
PLACE: Page Name | Standard page/route |
PLACE: Page Name (Mode) | Mode-based variant of a page |
PLACE: Modal Name | Modal dialog |
PLACE: Backend | API/database boundary |
When spanning multiple systems, label with the system: PLACE: Checkout Page (frontend), PLACE: Payment API (backend).
Places are first-class elements in the data model. Each Place gets an ID:
---|---|---
P1 | CMS Page (Read Mode) | View-only state
P2 | CMS Page (Edit Mode) | Editing state with page-level controls
P2.1 | widget-grid (letters) | Subplace: letter editing widget within P2
P3 | Letter Form Modal | Form for adding/editing letters
P4 | Backend | API resolvers and database
Place IDs enable:
→ P2 instead of to an affordance insideWhen a nested place has lots of internal affordances and would clutter the parent, you can detach it:
_letter-browser_letter-browser --> letter-browserThe reference is a UI affordance — it represents "this widget/component renders here" in the parent context.
flowchart TB
subgraph P1["P1: CMS Page (Read Mode)"]
U1["U1: Edit button"]
U_LB["_letter-browser"]
end
subgraph letterBrowser["letter-browser"]
U10["U10: Search input"]
U11["U11: Letter list"]
N40["N40: performSearch()"]
end
U_LB --> letterBrowser
In affordance tables, list the reference as a UI affordance:
---|---|---|---
U1 | Edit button | click | → N1
_letter-browser | Widget reference | — | → P3
Style place references with a dashed border to distinguish them:
classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5
class U_LB placeRef
When a component has distinct modes (read vs edit, viewing vs editing, collapsed vs expanded), model them as separate places — they're different perceptual states for the user.
If one mode includes everything from another plus more, show this with a place reference inside the extended place:
P3: letter-browser (Read) — base state
P4: letter-browser (Edit) — contains _letter-browser (Read) + new affordances
The reference shows composition: "everything in P3 appears here, plus these additions."
flowchart TB
subgraph P3["P3: letter-browser (Read)"]
U10["U10: Search input"]
U11["U11: Letter list"]
end
subgraph P4["P4: letter-browser (Edit)"]
U_P3["_letter-browser (Read)"]
U3["U3: Add button"]
U4["U4: Edit button"]
end
U_P3 --> P3
In affordance tables for P4, the reference shows inheritance:
---|---|---|---|---
_letter-browser (Read) | Inherits all of P3 | — | → P3 |
U3 | Add button | click | → N3 | NEW
U4 | Edit button | click | → N4 | NEW
A subplace is a defined subset of a Place — a contained area that groups related affordances. Use subplaces when:
Notation: Use hierarchical IDs — P2.1, P2.2, etc. for subplaces of P2.
| # | Place | Description |
|---|-------|-------------|
| P2 | Dashboard | Main dashboard page |
| P2.1 | Sales widget | Subplace: sales metrics |
| P2.2 | Activity feed | Subplace: recent activity |
In affordance tables, use the subplace ID to show containment:
| U3 | P2.1 | sales-widget | "Refresh" button | click | → N4 | — |
| U7 | P2.2 | activity-feed | activity list | render | — | — |
In Mermaid: Nest the subplace subgraph inside the parent. Use the same background color (no distinct fill) — the subplace is part of the parent, not a separate Place:
flowchart TB
subgraph P2["P2: Dashboard"]
subgraph P2_1["P2.1: Sales widget"]
U3["U3: Refresh button"]
end
subgraph P2_2["P2.2: Activity feed"]
U7["U7: activity list"]
end
otherContent[["... other dashboard content ..."]]
end
Placeholder for out-of-scope content: When detailing one subplace, add a placeholder sibling to show there's more on the page:
otherContent[["... other page content ..."]]
This tells readers: "we're zooming in on P2.1, but P2 contains more that we're not detailing."
These are two different relationships in the data model:
| Relationship | Meaning | Where Captured |
|---|---|---|
| Containment | Affordance belongs to / lives in a Place | Place column (set membership) |
| Wiring | Affordance triggers / calls something | Wires Out column (control flow) |
Containment is set membership: U1 ∈ P1 means U1 is a member of Place P1. Every affordance belongs to exactly one Place.
Wiring is control flow: U1 → N1 means U1 triggers N1. An affordance can wire to anything — other affordances or Places.
The Place column answers: "Where does this affordance live?" The Wires Out column answers: "What does this affordance trigger?"
When an affordance causes navigation (user "goes" somewhere), wire to the Place itself , not to an affordance inside:
✅ N1 Wires Out: → P2 (navigate to Edit Mode)
❌ N1 Wires Out: → U3 (wiring to affordance inside P2)
This makes navigation explicit in the tables. The Place is the destination; specific affordances inside become available once you arrive.
In Mermaid, this becomes:
N1 --> P2
The subgraph ID matches the Place ID, so the wire connects to the Place boundary.
Things you can act upon:
How affordances connect to each other:
Wires Out — What an affordance triggers or calls (control flow):
Returns To — Where an affordance's output flows (data flow):
This separation makes data flow explicit. Wires Out show control flow (what triggers what). Returns To show data flow (where output goes).
The tables are the truth. Every breadboard produces these:
---|---|---
P1 | Search Page | Main search interface
P2 | Detail Page | Individual result view
---|---|---|---|---|---|---
U1 | P1 | search-detail | search input | type | → N1 | —
U2 | P1 | search-detail | loading spinner | render | — | —
U3 | P1 | search-detail | results list | render | — | —
U4 | P1 | search-detail | result row | click | → P2 | —
---|---|---|---|---|---|---
N1 | P1 | search-detail | activeQuery.next() | call | → N2 | —
N2 | P1 | search-detail | activeQuery subscription | observe | → N3 | —
N3 | P1 | search-detail | performSearch() | call | → N4, → N5, → N6 | —
N4 | P1 | search.service | searchOneCategory() | call | → N7 | → N3
N5 | P1 | search-detail | loading | write | store | → U2
N6 | P1 | search-detail | results | write | store | → U3
N7 | P1 | typesense.service | rawSearch() | call | — | → N4
---|---|---|---
S1 | P1 | results | Array of search results
S2 | P1 | loading | Boolean loading state
| Column | Description |
|---|---|
| # | Unique ID (P1, P2... for Places; U1, U2... for UI; N1, N2... for Code; S1, S2... for Stores) |
| Place | Which Place this affordance belongs to (containment) |
| Component | Which component/service owns this |
| Affordance | The specific thing you can act upon |
| Control | The triggering event: click, type, call, observe, write, render |
| Wires Out | What this triggers: → N4, → P2 (control flow, including navigation) |
| Returns To | Where output flows: → N3 or → U2, U3 (data flow) |
See Example A below for a complete worked example.
Step 1: Identify the flow to analyze
Pick a specific user journey. Always frame it as an operator trying to do something:
Step 2: List all places involved
Walk through the journey and identify each distinct place the user visits or system boundary crossed.
Step 3: Trace through the code to find components
Starting from the entry point (route, API endpoint), trace through the code to find every component touched by that flow.
Step 4: For each component, list its affordances
Read the code. Identify:
Step 5: Name the actual thing, not an abstraction
If you write "DATABASE", stop. What's the actual method? (userRepo.save()). Every affordance name must be something real you can point to in the code.
Step 6: Fill in Control column
For each affordance, what triggers it? (click, type, call, observe, write, render)
Step 7: Fill in Wires Out
For each affordance, what does it trigger? Read the code — what does this method call? What does this button's handler invoke?
Step 8: Fill in Returns To
For each affordance, where does its output flow?
—Step 9: Add data stores as affordances
When code writes to a property that is later read by another affordance, add that property as a Code affordance with control type write.
Step 10: Add framework mechanisms as affordances
Include things like cdr.detectChanges() that bridge between code and UI rendering. These show how state changes actually reach the UI.
Step 11: Verify against the code
Read the code again. Confirm every affordance exists and the wiring matches reality.
See Example B below for a complete worked example including slicing.
Step 1: List each part from the shape
Take each mechanism/part identified in shaping and write it down.
Step 2: Translate parts into affordances
For each part, identify:
Step 3: Verify every U has a supporting N
For each UI affordance, check: what Code affordance provides its data or controls its rendering? If none exists, add the missing N.
Step 4: Classify places as existing or new
For each UI affordance, determine whether it lives in:
Step 5: Wire the affordances
Fill in Wires Out and Returns To for each affordance. Trace through the intended behavior — what calls what? What returns where?
Step 6: Connect to existing system (if applicable)
If there's an existing codebase:
Step 7: Check for completeness
Step 8: Treat user-visible outputs as Us
Anything the user sees (including emails, notifications) is a UI affordance and needs an N wiring to it.
When tracing a flow backwards, don't follow the path you remember. Scan the Wires Out column for ALL affordances that wire to your target.
When filling in the tables, read each row systematically. Don't rely on what you think you know.
The tables are the source of truth. Your memory is unreliable.
When mapping existing code, never invent abstractions. Every name must point to something real in the codebase.
An affordance is something you can act upon that has meaningful identity in the system. Several things look like affordances but are actually just implementation mechanisms:
| Type | Example | Why it's not an affordance |
|---|---|---|
| Visual containers | modal-frame wrapper | You can't act on a wrapper — it's just a Place boundary |
| Internal transforms | letterDataTransform() | Implementation detail of the caller — not separately actionable |
| Navigation mechanisms | modalService.open() | Just the "how" of getting to a Place — wire to the destination directly |
These aren't always obvious on first draft. When reviewing your affordance tables, double-check each Code affordance and ask:
"Is this actually an affordance, or is it just detailing the mechanism for how something happens?"
If it's just the "how" — skip it and wire directly to the destination or outcome.
Examples:
❌ N8 --> N22 --> P3 (N22 is modalService.open — just mechanism)
✅ N8 --> P3 (handler navigates to modal)
❌ N6 --> N20 --> S2 (N20 is data transform — internal to N6)
✅ N6 --> S2 (callback writes to store)
❌ U7: modal-frame (wrapper — just the boundary of P3)
✅ U8: Save button (actionable)
The handler navigates to P3. The callback writes to the store. The modal IS P3. The mechanisms are implicit.
A breadboard captures two distinct flows:
| Flow | What it tracks | Wiring |
|---|---|---|
| Navigation | Movement from Place to Place | Wires Out → Places |
| Data | How state populates displays | Returns To → Us |
These are orthogonal. You can have navigation without data changes, and data changes without navigation.
When reviewing a breadboard, trace both flows:
A UI affordance that displays data must have something feeding it — either a data store (S) or a code affordance (N) that returns data.
❌ U6: letter list (no incoming wire — where does the data come from?)
✅ S1 -.-> U6 (store feeds the display)
✅ N4 -.-> U6 (query result feeds the display)
If a display U has no data source wiring into it, either:
This is easy to miss when focused on navigation. Always ask: "This U shows data — where does that data come from?"
If a Code affordance has no Wires Out AND no Returns To, something is wrong:
An N that appears to wire nowhere is suspicious. If it has side effects outside the system boundary (browser URL, localStorage, external API, analytics), add a store node to represent that external state:
❌ N41: updateUrl() (wires to... nothing?)
✅ N41: updateUrl() → S15 (wires to Browser URL store)
This makes the data flow explicit. The store can also have return wires showing how external state flows back in:
flowchart TB
N42["N42: performSearch()"] --> N41["N41: updateUrl()"]
N41 --> S15["S15: Browser URL (?q=)"]
S15 -.->|back button / init| N40["N40: activeQuery$"]
Common external stores to model:
Browser URL — query params, hash fragmentslocalStorage / sessionStorage — persisted client stateClipboard — copy/paste operationsBrowser History — navigation stateWires Out = control flow (what triggers what) Returns To = data flow (where output goes)
This separation makes the system's behavior explicit.
Routing is a generic mechanism every page uses. Instead of drawing all navigation through a central Router affordance, show Router navigate() inline where it happens and wire directly to the destination place.
A data store belongs in the Place where its data is consumed to enable some effect — not where it's produced. Writes from other Places are "reaching into" that Place's state.
To determine where a store belongs:
Example: A changedPosts array is written by a Modal (when user confirms changes) but read by a PAGE_SAVE handler (when user clicks Save). The store belongs with the PAGE_SAVE handler — that's where it enables the persistence operation.
Before putting a store in a separate DATA STORES section, verify it's actually read by multiple Places. If it only enables behavior in one Place, it belongs inside that Place.
Within a Place, put stores in the subcomponent where they enable behavior. If a store is read by a specific handler, put it in that handler's component — not floating at the Place level.
The database and resolvers aren't floating infrastructure — they're a Place with their own affordances. Database tables (S) belong inside the Backend Place alongside the resolvers (N) that read and write them.
This section provides a complete reference of everything that can appear in a breadboard.
| Element | ID Pattern | What It Is | What Qualifies |
|---|---|---|---|
| Place | P1, P2, P3... | A bounded context of interaction | Blocking test: can't interact with what's behind |
| Subplace | P2.1, P2.2... | A defined subset within a Place | Groups related affordances within a larger Place |
| Place Reference | _PlaceName | UI affordance pointing to a detached place | Complex nested place defined separately |
| UI Affordance | U1, U2, U3... | Something the user can see or interact with | Inputs, buttons, displays, scroll regions |
| Code Affordance | N1, N2, N3... | Something in code you can act upon | Methods, subscriptions, handlers, framework mechanisms |
| Data Store | S1, S2, S3... | State that persists and is read/written | Properties, arrays, observables that hold data |
| Chunk | — | A collapsed subsystem | One wire in, one wire out, many internals |
| Placeholder | — | Out-of-scope content marker | Shows context without detailing |
| Relationship | Syntax | Meaning | Example |
|---|---|---|---|
| Containment | Place column | Affordance belongs to Place | U3 in Place P2.1 |
| Wires Out | → X | Control flow: triggers/calls | → N4, → P2 |
| Returns To | → X (in Returns To column) | Data flow: output goes to | → U6, → N3 |
| Abbreviated flow | ` | label | ` |
| Parent-child | Hierarchical ID | Subplace belongs to Place | P2.1 is child of P2 |
| Relationship | Meaning | Where Captured |
|---|---|---|
| Containment | Affordance belongs to / lives in a Place | Place column (set membership) |
| Wiring | Affordance triggers / calls something | Wires Out column (control flow) |
Containment is set membership: U1 ∈ P1 means U1 is a member of Place P1. Wiring is control flow: U1 → N1 means U1 triggers N1.
Place (P):
Place Reference (_PlaceName):
_letter-browser, _user-profile-widget_letter-browser --> P3UI Affordance (U):
Code Affordance (N):
handleSubmit(), query$ subscription, detectChanges()Data Store (S):
results array, loading boolean, changedPosts listBrowser URL, localStorage, Clipboard — represent state outside the app boundary| Check | Question | If No... |
|---|---|---|
| Every U that displays data | Does it have an incoming wire (via Wires Out or Returns To)? | Add the data source |
| Every N | Does it have Wires Out or Returns To (or both)? | Investigate — may be dead code or missing wiring |
| Every S | Does something read from it (Returns To)? | Investigate — may be unused |
| Navigation mechanisms | Is this N just the "how" of getting somewhere? | Wire directly to Place instead |
| N with side effects | Does this N affect external state (URL, storage, clipboard)? | Add a store for the external state |
Chunking collapses a subsystem into a single node in the main diagram, with details shown separately. Use chunking to manage complexity when a section of the breadboard has:
Look for sections where tracing the wiring reveals a "pinch point" — many affordances that funnel through a single input and single output. These are natural boundaries for chunking.
Example: A dynamic-form component receives a form definition, renders many fields (U7a-U7k), validates on change (N26), and emits a single valid$ signal. In the main diagram, this becomes:
N24 -->|formDefinition| dynamicForm
dynamicForm -.->|valid$| U8
dynamicForm[["CHUNK: dynamic-form"]]
N24 -->|formDefinition| dynamicForm
dynamicForm -.->|valid$| U8
3. Create a separate chunk diagram showing the internals with boundary markers:
flowchart TB
input([formDefinition])
output(["valid$"])
subgraph chunk["dynamic-form internals"]
N25["N25: generateFormConfig()"]
U7a["U7a: field"]
N26["N26: form value changes"]
N27["N27: valid$ emission"]
end
input --> N25
N25 --> U7a
U7a --> N26
N26 --> N27
N27 --> output
classDef boundary fill:#b3e5fc,stroke:#0288d1,stroke-dasharray:5 5
class input,output boundary
4. Style chunks distinctly in the main diagram:
classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px
class dynamicForm chunk
| Type | Color | Hex |
|---|---|---|
| Chunk node (main diagram) | Light blue | #b3e5fc |
| Boundary markers (chunk diagram) | Light blue, dashed | #b3e5fc with stroke-dasharray:5 5 |
The tables are the truth. Mermaid diagrams are optional visualizations for humans.
flowchart TB
U1["U1: search input"] --> N1["N1: activeQuery.next()"]
N1 --> N2["N2: subscription"]
N2 --> N3["N3: performSearch"]
N3 --> N4["N4: searchOneCategory"]
N4 -.-> N3
N3 --> N5["N5: loading store"]
N3 --> N6["N6: results store"]
N5 -.-> U2["U2: loading spinner"]
N6 -.-> U3["U3: results list"]
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
class U1,U2,U3 ui
class N1,N2,N3,N4,N5,N6 nonui
| Line Style | Mermaid Syntax | Use |
|---|---|---|
Solid (-->) | A --> B | Wires Out: calls, triggers, writes |
Dashed (-.->) | A -.-> B | Returns To: return values, data store reads |
Labeled ... | `A -.-> | ... |
When a data flow has intermediate steps that aren't relevant to the breadboard's scope, abbreviate by wiring directly from source to destination with a ... label:
S4 -.->|...| U6
This says "data flows from S4 to U6, with intermediate steps omitted." Use this when:
| Prefix | Type | Example |
|---|---|---|
| P | Places | P1, P2, P3 |
| U | UI affordances | U1, U2, U3 |
| N | Code affordances | N1, N2, N3 |
| S | Data stores | S1, S2, S3 |
| Type | Color | Hex |
|---|---|---|
| Places (subgraphs) | White/transparent | — |
| UI affordances | Pink | #ffb6c1 |
| Code affordances | Grey | #d3d3d3 |
| Data stores | Lavender | #e6e6fa |
| Chunks | Light blue | #b3e5fc |
| Place references | Pink, dashed border | #ffb6c1 |
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
classDef store fill:#e6e6fa,stroke:#9370db,color:#000
classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px
classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5
Use the Place ID as the subgraph ID so navigation wiring connects properly:
flowchart TB
subgraph P1["P1: CMS Page (Read Mode)"]
U1["U1: Edit button"]
N1["N1: toggleEditMode()"]
end
subgraph P2["P2: CMS Page (Edit Mode)"]
U2["U2: Save button"]
U3["U3: Add button"]
end
%% Navigation wires to Place ID
N1 --> P2
| Type | ID Pattern | Label Pattern | Purpose |
|---|---|---|---|
| Place | P1, P2... | P1: Page Name | A bounded context the user visits |
| Trigger | — | TRIGGER: Name | An event that kicks off a flow (not navigable) |
| Component | — | COMPONENT: Name | Reusable UI+logic that appears in multiple places |
| System | — | SYSTEM: Name | When spanning multiple applications |
Key point: The subgraph ID (P1, P2) must match the Place ID from the Places table. This allows navigation wires like N1 --> P2 to connect to the Place boundary.
flowchart TB
subgraph frontend["SYSTEM: Frontend"]
U1["U1: submit button"]
N1["N1: handleSubmit()"]
end
subgraph backend["SYSTEM: Backend API"]
N10["N10: POST /orders"]
N11["N11: orderService.create()"]
end
U1 --> N1
N1 --> N10
N10 --> N11
When breadboarding a specific workflow, you can optionally add numbered step markers to help readers follow the sequence visually. This is useful when:
Format:
Add a Workflow Guide table before the diagram:
| Step | Action | Where to look |
|------|--------|---------------|
| **1** | Click "Edit" button | U1 → N1 → S1 |
| **2** | Edit mode activates | S1 → N2 → U3 |
| **3** | Click "Add" | U3 → N3 → N8 |
Add step marker nodes in the Mermaid diagram using stadium-shaped nodes:
flowchart TB
%% Step markers
step1(["1 - CLICK EDIT"])
step2(["2 - EDIT MODE ON"])
step3(["3 - CLICK ADD"])
%% Connect steps to relevant affordances with dashed lines
step1 -.-> U1
step2 -.-> N2
step3 -.-> U3
%% Style step markers green
classDef step fill:#90EE90,stroke:#228B22,color:#000,font-weight:bold
class step1,step2,step3 step
Formatting notes:
"1 - ACTION" format (number, space, hyphen, space, action)"1. ACTION" — the period triggers Mermaid's markdown list parser"1) ACTION" — parentheses can also cause parsing issues-.->)Slicing takes a breadboard and groups its affordances into vertical implementation slices. See Example B below for a complete slicing example.
Input:
Output:
A vertical slice is a group of UI and Code affordances that does something demo-able. It cuts through all layers (UI, logic, data) to deliver a working increment.
The opposite is a horizontal slice — doing work on one layer (e.g., "set up all the data models") that isn't clickable from the interface.
Every slice must have visible UI that can be demoed. A slice without UI is a horizontal layer, not a vertical slice.
Demo-able means:
The shape guides what counts as "meaningful progress" — you're not just grouping affordances arbitrarily, you're grouping them to demonstrate mechanisms working.
Aim for ≤9 slices. If you need more, the shape may be too large for one cycle.
A slice may contain affordances with Wires Out pointing to affordances in later slices. These wires exist in the breadboard but aren't implemented yet — they're stubs or no-ops until that later slice is built.
This is normal. The breadboard shows the complete system; slicing shows the order of implementation.
Step 1: Identify the minimal demo-able increment
Look at your breadboard and shape. Ask: "What's the smallest subset that demonstrates the core mechanism working?"
Usually this is:
This becomes V1.
Step 2: Layer additional capabilities as slices
Look at the mechanisms in your shape. Each slice should demonstrate a mechanism working:
Max 9 slices. If you have more, combine related mechanisms. Features that don't make sense alone should be in the same slice.
Step 3: Assign affordances to slices
Go through every affordance and assign it to the slice where it's first needed to demo that slice's mechanism:
| Slice | Mechanism | Affordances |
|---|---|---|
| V1 | Core display | U2, U3, N3, N4, N5, N6, N7 |
| V2 | Search | U1, N1, N2 |
| V3 | Pagination | U10, N11, N12, N13 |
Some affordances may have Wires Out to later slices — that's fine. They're implemented in their assigned slice; the wires just don't do anything yet.
Step 4: Create per-slice affordance tables
For each slice, extract just the affordances being added:
V2: Search Works
---|---|---|---|---|---
U1 | search-detail | search input | type | → N1 | —
N1 | search-detail | activeQuery.next() | call | → N2 | —
N2 | search-detail | activeQuery subscription | observe | → N3 | —
Step 5: Write a demo statement for each slice
Each slice needs a concrete demo that shows its mechanism working toward the R:
The demo should be something you can show a stakeholder that demonstrates progress.
Show the complete breadboard in every slice diagram, but use styling to distinguish scope:
| Category | Style | Description |
|---|---|---|
| This slice | Bright color | Affordances being added |
| Already built | Solid grey | Previous slices |
| Future | Transparent, dashed border | Not yet built |
flowchart TB
U1["U1: search input"]
U2["U2: loading spinner"]
N1["N1: activeQuery.next()"]
N2["N2: subscription"]
N3["N3: performSearch"]
U1 --> N1
N1 --> N2
N2 --> N3
N3 --> U2
%% V2 scope (this slice) = green
classDef thisSlice fill:#90EE90,stroke:#228B22,color:#000
%% Already built (V1) = grey
classDef built fill:#d3d3d3,stroke:#808080,color:#000
%% Future = transparent dashed
classDef future fill:none,stroke:#ddd,color:#bbb,stroke-dasharray:3 3
class U1,N1,N2 thisSlice
class U2,N3 built
This lets stakeholders see:
---|---|---|---
V1 | Widget with real data | F1, F4, F6 | "Widget shows letters from API"
V2 | Search works | F3 | "Type to filter results"
V3 | Infinite scroll | F5 | "Scroll down, more load"
V4 | URL state | F2 | "Refresh preserves search"
The Mechanism column references parts from the shape, showing which mechanisms each slice demonstrates.
This example shows breadboarding an existing system to understand how data flows through multiple entry points.
Workflow to understand: "How is admin_organisation_countries modified and read downstream? There are multiple entry points: manual edit, checkbox toggle, and batch job."
UI Affordances
---|---|---|---|---|---
U1 | SSO Admin | role_profiles checkboxes | render | — | —
U2 | SSO Admin | "Country Admin" checkbox | click | toggles selection | —
U3 | SSO Admin | admin_countries filter_horizontal | render | — | —
U4 | SSO Admin | Available countries list | render | — | —
U5 | SSO Admin | Selected countries list | render | — | —
U6 | SSO Admin | Add → / Remove ← | click | modifies selection | —
U7 | SSO Admin | Save button | click | → N3 | —
U20 | DWConnect | "Country admins" section | render | — | —
U21 | (unknown) | System email "From" field | render | — | —
Code Affordances
---|---|---|---|---|---
N1 | sso/accounts/admin | get_fieldsets() | call | → U3 (conditional) | —
N2 | sso/accounts/models | get_administrable_user_countries() | call | — | → U4
N3 | sso/accounts/admin | save_form() | call | → N4, → N5 | —
N4 | Django Admin | Form M2M save | call | → S2 | —
N5 | sso/forms/mixins | _update_user_m2m() | call | → S1, → N6 | —
N6 | sso/signals | user_m2m_field_updated signal | signal | → N10 | —
N7 | CLI/Scheduler | manage.py dwbn_cleanup | invoke | → N15 | —
N10 | sso-dwbn-theme | dwbn_user_m2m_field_updated() | receive | → N11 | —
N11 | sso-dwbn-theme | dwbn_user_m2m_field_updated_task() | call | → N12 | —
N12 | sso-dwbn-theme | Country Admin added AND zero admin countries? | conditional | → N20 | —
N15 | sso-dwbn-theme | admin_changes() | call | → N16 | —
N16 | sso-dwbn-theme | For each Country Admin: home center country missing? | loop | → N20 | —
N20 | sso-dwbn-theme | Get home center's country | call | → N21 | —
N21 | sso-dwbn-theme | admin_organisation_countries.add() | call | → S2 | —
N22 | sso-dwbn-theme | update_last_modified() | call | — | —
N30 | dwconnect2-backend | findCenterAdmins() | call | — | → U20
N31 | sso/api | get_object_data() | call | — | → external
Data Stores
---|---|---
S1 | role_profiles | M2M: which role profiles a user has
S2 | admin_organisation_countries | M2M: which countries a user administers
S3 | organisations | User's home center(s)
Mermaid Diagram
flowchart TB
subgraph stores["DATA STORES"]
S1["S1: role_profiles"]
S2["S2: admin_organisation_countries"]
S3["S3: organisations"]
end
subgraph ssoAdmin["PLACE: SSO Admin — User Change Page"]
subgraph permissions["Permissions fieldset"]
U1["U1: role_profiles checkboxes"]
U2["U2: 'Country Admin' checkbox"]
end
subgraph userAdmin["User admin fieldset (superuser only)"]
U3["U3: admin_countries filter_horizontal"]
U4["U4: Available countries"]
U5["U5: Selected countries"]
U6["U6: Add → / Remove ←"]
end
U7["U7: Save button"]
N1["N1: get_fieldsets()"]
N2["N2: get_administrable_user_countries()"]
N3["N3: save_form()"]
N4["N4: Form M2M save"]
N5["N5: _update_user_m2m()"]
N6["N6: user_m2m_field_updated signal"]
N1 -->|is_superuser| userAdmin
U3 --> U4
U3 --> U5
U6 --> U5
N2 -.-> U4
U2 --> U7
U6 --> U7
U7 --> N3
N3 --> N4
N3 --> N5
N5 --> N6
end
subgraph trigger["TRIGGER: Batch Cleanup"]
N7["N7: manage.py dwbn_cleanup"]
end
subgraph theme["sso-dwbn-theme"]
N10["N10: dwbn_user_m2m_field_updated()"]
N11["N11: dwbn_user_m2m_field_updated_task()"]
N12["N12: Country Admin added AND zero admin countries?"]
N15["N15: admin_changes()"]
N16["N16: For each Country Admin: home center country missing?"]
N20["N20: Get home center's country"]
N21["N21: admin_organisation_countries.add()"]
N22["N22: update_last_modified()"]
N6 --> N10
N10 --> N11
N11 --> N12
N7 --> N15
N15 --> N16
N12 -->|yes| N20
N16 -->|yes| N20
N20 --> N21
N21 --> N22
end
subgraph dwconnect["PLACE: DWConnect — Center Page"]
N30["N30: findCenterAdmins()"]
U20["U20: 'Country admins' section"]
N30 --> U20
end
subgraph api["TRIGGER: External API Request"]
N31["N31: get_object_data()"]
end
U21["U21: System email 'From' field"]
N4 --> S2
N5 --> S1
N21 --> S2
S1 -.-> N15
S3 -.-> N16
S3 -.-> N20
S2 -.-> U5
S2 -.-> N30
S2 -.-> N31
S2 -.-> U21
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
classDef store fill:#e6e6fa,stroke:#9370db,color:#000
classDef condition fill:#fffacd,stroke:#daa520,color:#000
classDef trigger fill:#98fb98,stroke:#228b22,color:#000
class U1,U2,U3,U4,U5,U6,U7,U20,U21 ui
class N1,N2,N3,N4,N5,N6,N10,N11,N15,N20,N21,N22,N30,N31 nonui
class N12,N16 condition
class N7 trigger
class S1,S2,S3 store
This section shows what comes FROM shaping — the requirements, existing patterns identified, and sketched parts. This is the INPUT that breadboarding receives.
Note: This example uses shaping terminology. In shaping, you define requirements (Rs), identify existing patterns to reuse, and sketch a solution as parts/mechanisms. Breadboarding takes this shaped solution and details out the concrete affordances and wiring.
The R (Requirements)
| ID | Requirement |
|---|---|
| R0 | Make content searchable from the index page |
| R2 | Navigate back to pagination state when returning from detail |
| R3 | Navigate back to search state when returning from detail |
| R4 | Search/pagination state survives page refresh |
| R5 | Browser back button restores previous search/pagination state |
| R9 | Search should debounce input (not fire on every keystroke) |
| R10 | Search should require minimum 3 characters |
| R11 | Loading and empty states should provide user feedback |
Existing System with Reusable Patterns (S-CUR)
The app already has a global search page that implements most of these Rs. During shaping, it was documented at the parts/mechanism level:
| Part | Mechanism |
|---|---|
| S-CUR1 | URL state & initialization |
| S-CUR1.1 | Router queryParams observable provides {q, category} |
| S-CUR1.2 | initializeState(params) sets query and category from URL |
| S-CUR1.3 | On page load, triggers initial search from URL state |
| S-CUR2 | Search input |
| S-CUR2.1 | Search input binds to activeQuery BehaviorSubject |
| S-CUR2.2 | activeQuery subscription with 90ms debounce |
| S-CUR2.3 | Min 3 chars triggers performNewSearch() |
| S-CUR3 | Data fetching |
| S-CUR3.1 | performNewSearch() sets loading state, calls search service |
| S-CUR3.2 | Search service builds Typesense filter, calls rawSearch() |
| S-CUR3.3 | rawSearch() queries Typesense, returns {found, hits} |
| S-CUR3.4 | Results written to detailResult data store |
| S-CUR4 | Pagination |
| S-CUR4.1 | Scroll-to-bottom triggers appendNextPage() via intercomService |
| S-CUR4.2 | appendNextPage() increments page, calls search |
| S-CUR4.3 | New hits concatenated to existing hits |
| S-CUR4.4 | sendMessage() re-arms scroll detection |
| S-CUR5 | Rendering |
| S-CUR5.1 | cdr.detectChanges() triggers template re-evaluation |
| S-CUR5.2 | Loading spinner, "no results", result count based on store |
| S-CUR5.3 | *ngFor renders tiles for each hit |
| S-CUR5.4 | Tile click navigates to detail page |
Sketched Solution: Parts that Adapt S-CUR
The new solution's parts explicitly reference which S-CUR patterns they adapt:
| Part | Mechanism | Adapts |
|---|---|---|
| F1 | Create widget (component, def, register) | — |
| F2 | URL state & initialization (read ?q=, restore on load) | S-CUR1 |
| F3 | Search input (debounce, min 3 chars, triggers search) | S-CUR2 |
| F4 | Data fetching (rawSearch() with filter) | S-CUR3 |
| F5 | Pagination (scroll-to-bottom, append pages, re-arm) | S-CUR4 |
| F6 | Rendering (loading, empty, results list, rows) | S-CUR5 |
This is where breadboarding happens. The shaped parts become concrete affordances with explicit wiring. The output is the affordance tables and diagram.
UI Affordances
---|---|---|---|---|---
U1 | letter-browser | search input | type | → N1 | —
U2 | letter-browser | loading spinner | render | — | —
U3 | letter-browser | no results msg | render | — | —
U4 | letter-browser | result count | render | — | —
U5 | letter-browser | results list | render | → U6, U7, U8, U9 | —
U6 | letter-row | row click | click | → LD | —
U7 | letter-row | date | render | — | —
U8 | letter-row | subject | render | — | —
U9 | letter-row | teaser | render | — | —
U10 | letter-browser | scroll | scroll | → N11 | —
U11 | browser | back button | click | → N9 | —
U12 | letter-browser | "See all X results" | click | → LP | —
LD | — | Letter Detail | place | — | —
LP | — | Full Page | place | — | —
Code Affordances
---|---|---|---|---|---
N1 | letter-browser | activeQuery.next() | call | → N2 | → U12
N2 | letter-browser | activeQuery subscription | observe | → N3 | —
N3 | letter-browser | performSearch() | call | → N4, → N6, → N7, → N8 | —
N4 | typesense.service | rawSearch() | call | — | → N3, → N12
N5 | letter-browser | parentId (config) | config | — | → N4
N6 | letter-browser | loading store | write | — | → N8
N7 | letter-browser | detailResult store | write | — | → N8, → N16
N8 | letter-browser | detectChanges() | call | → U2, → U3, → U4, → U5 | —
N9 | browser | URL ?q= | read | → N10 | —
N10 | letter-browser | initializeState() | call | → N1, → N3 | —
N11 | intercom.service | scroll subject | observe | → N12 | —
N12 | letter-browser | appendNextPage() | call | → N4, → N7, → N8, → N13, → N14 | —
N13 | intercom.service | sendMessage() | call | → N11 | —
N14 | router | navigate() | call | — | → N9
N15 | letter-browser | if !compact subscribe | conditional | → N11 | —
N16 | letter-browser | if truncated show link | conditional | → U12 | —
N17 | letter-browser | compact (config) | config | — | → N4, → N15, → N16
N18 | letter-browser | fullPageRoute (config) | config | — | → U12
Mermaid Diagram
flowchart TB
subgraph lettersIndex["PLACE: Letters Index Page"]
subgraph letterBrowser["COMPONENT: letter-browser"]
U1["U1: search input"]
U2["U2: loading spinner"]
U3["U3: no results msg"]
U4["U4: result count"]
U5["U5: results list"]
U12["U12: See all X results"]
N1["N1: activeQuery.next"]
N2["N2: activeQuery sub"]
N3["N3: performSearch"]
N6["N6: loading store"]
N7["N7: detailResult store"]
N8["N8: detectChanges"]
N10["N10: initializeState"]
N16["N16: if truncated show link"]
N5["N5: parentId (config)"]
N17["N17: compact (config)"]
N18["N18: fullPageRoute (config)"]
subgraph pagination["PAGINATION"]
U10["U10: scroll"]
N15["N15: if !compact subscribe"]
N12["N12: appendNextPage"]
end
end
end
subgraph letterRow["COMPONENT: letter-row"]
U6["U6: row click"]
U7["U7: date"]
U8["U8: subject"]
U9["U9: teaser"]
end
subgraph browser["BROWSER"]
U11["U11: back button"]
N9["N9: URL ?q="]
N14["N14: Router.navigate"]
end
subgraph services["SERVICES"]
N4["N4: rawSearch"]
N11["N11: intercom subject"]
N13["N13: sendMessage"]
end
subgraph letterDetail["PLACE: Letter Detail Page"]
LD["Letter Detail"]
end
U1 -->|type| N1
N1 --> N2
N2 -->|debounce 90ms, min 3| N3
N3 --> N4
N3 --> N6
N3 --> N7
N3 --> N8
N4 -.-> N3
N4 -.-> N12
N6 -.-> N8
N7 -.-> N8
N8 --> U2
N8 --> U3
N8 --> U4
N8 --> U5
U5 --> U6
U5 --> U7
U5 --> U8
U5 --> U9
U6 -->|navigate| LD
U11 -->|restore| N9
N9 --> N10
N10 --> N1
N10 --> N3
U10 --> N11
N15 -->|if !compact| N11
N11 --> N12
N12 --> N4
N12 --> N7
N12 --> N8
N12 --> N13
N12 --> N14
N13 -->|re-arm| N11
N5 -.->|filter| N4
N17 -.-> N4
N17 -.-> N15
N17 -.-> N16
N18 -.-> U12
N14 -.->|URL| N9
N1 -.-> U12
N7 -.-> N16
N16 -->|if truncated| U12
U12 -->|navigate with ?q| LP["Full Page"]
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,LD,LP ui
class N1,N2,N3,N4,N5,N6,N7,N8,N9,N10,N11,N12,N13,N14,N15,N16,N17,N18 nonui
Slicing the Breadboard
With the full breadboard complete, slice it into vertical increments. Each slice demonstrates a mechanism working:
Slice Summary
---|---|---|---|---
V1 | Widget with real data | F1, F4, F6 | U2-U9, N3-N8, LD | "Widget shows real data"
V2 | Search works | F3 | U1, N1, N2 | "Type 'dharma', results filter"
V3 | Infinite scroll | F5 | U10, N11-N13 | "Scroll down, more load"
V4 | URL state | F2 | U11, N9, N10, N14 | "Refresh preserves search"
V5 | Compact mode | — | U12, N15-N18, LP | "Shows 'See all' link"
Slice Diagram
flowchart TB
subgraph slice1["V1: WIDGET WITH REAL DATA"]
U2["U2: loading spinner"]
U3["U3: no results msg"]
U4["U4: result count"]
U5["U5: results list"]
U6["U6: row click"]
U7["U7: date"]
U8["U8: subject"]
U9["U9: teaser"]
N3["N3: performSearch"]
N4["N4: rawSearch"]
N5["N5: parentId (config)"]
N6["N6: loading store"]
N7["N7: detailResult store"]
N8["N8: detectChanges"]
LD["Letter Detail"]
end
subgraph slice2["V2: SEARCH WORKS"]
U1["U1: search input"]
N1["N1: activeQuery.next"]
N2["N2: activeQuery sub"]
end
subgraph slice3["V3: INFINITE SCROLL"]
U10["U10: scroll"]
N11["N11: intercom subject"]
N12["N12: appendNextPage"]
N13["N13: sendMessage"]
end
subgraph slice4["V4: URL STATE"]
U11["U11: back button"]
N9["N9: URL ?q="]
N10["N10: initializeState"]
N14["N14: Router.navigate"]
end
subgraph slice5["V5: COMPACT MODE"]
U12["U12: See all X results"]
N15["N15: if !compact subscribe"]
N16["N16: if truncated show link"]
N17["N17: compact (config)"]
N18["N18: fullPageRoute (config)"]
LP["Full Page"]
end
U1 -->|type| N1
N1 --> N2
N2 -->|debounce| N3
N3 --> N4
N3 --> N6
N3 --> N7
N3 --> N8
N4 -.-> N3
N5 -.->|filter| N4
N6 -.-> N8
N7 -.-> N8
N8 --> U2
N8 --> U3
N8 --> U4
N8 --> U5
U5 --> U6
U5 --> U7
U5 --> U8
U5 --> U9
U6 -->|navigate| LD
U11 -->|restore| N9
N9 --> N10
N10 --> N1
N10 --> N3
U10 --> N11
N11 --> N12
N12 --> N4
N12 --> N7
N12 --> N8
N12 --> N13
N12 --> N14
N13 -->|re-arm| N11
N4 -.-> N12
N14 -.->|URL| N9
N15 -->|if !compact| N11
N17 -.-> N4
N17 -.-> N15
N17 -.-> N16
N18 -.-> U12
N1 -.-> U12
N7 -.-> N16
N16 -->|if truncated| U12
U12 -->|navigate with ?q| LP
style slice1 fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
style slice2 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px
style slice3 fill:#fff3e0,stroke:#ff9800,stroke-width:2px
style slice4 fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px
style slice5 fill:#fff8e1,stroke:#ffc107,stroke-width:2px
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,LD,LP ui
class N1,N2,N3,N4,N5,N6,N7,N8,N9,N10,N11,N12,N13,N14,N15,N16,N17,N18 nonui
Weekly Installs
2
Repository
First Seen
12 days ago
Security Audits
Installed on
opencode2
antigravity2
qwen-code2
claude-code2
windsurf2
github-copilot2
AI新闻播客制作技能:实时新闻转对话式播客脚本与音频生成
1,200 周安装