Replace console demo with SQLite Research Shelf

This commit is contained in:
Kinneyzhang 2026-08-22 08:21:26 +08:00
parent f93bdf56d6
commit dcb0ad9d83
15 changed files with 1293 additions and 1558 deletions

176
DESIGN.md
View File

@ -2,104 +2,156 @@
## Source of truth
- Status: Active draft for the sole reviewed pair
- Last refreshed: 2026-08-20
- Primary product surface: `examples/operations-console.etaf` + `examples/operations-console.el`
- Visual reference: `design/operations-console.html`
- Evidence reviewed: current pair loader/manifest, `etaf-ui.el`, Ebox viewport contract, and the current GUI/performance evidence.
- Status: Active
- Last refreshed: 2026-08-22
- Primary product surface: `examples/research-shelf.etaf` + `examples/research-shelf.el`
- Visual reference: `design/research-shelf.html`
- Evidence reviewed: ETAF public View/Component/Data/Resource APIs, `etaf-ui`
Button/Checkbox/Panel/DataGrid/Pagination, and `etaf-sqlite`'s typed source
contract.
## Brand
- Personality: calm operations cockpit; precise, warm, and quietly technical.
- Trust signals: explicit status chips, visible last-updated time, deterministic counts, clear success/error feedback.
- Avoid: generic toy dashboard, rainbow gradients, dense ungrouped controls, decorative widgets with no semantic state.
- Personality: editorial, focused, warm, quietly intelligent.
- Trust signals: durable SQLite data, visible save state, selected-item detail,
explicit progress/status, and recoverable storage errors.
- Avoid: generic admin dashboards, KPI walls, fake charts, rainbow gradients,
and a showcase that exists only to exercise APIs.
## Product goals
- Make one attractive app prove ETAF's Component, composition, reactive, Context/Theme, Behavior, event/Action, Data, Resource, error-boundary, focus, and lifecycle contracts.
- Do not add another Playground scenario or hide framework limitations behind fake animation.
- Keep the HTML prototype and ETAF rendering aligned in hierarchy, state labels, interaction outcomes, and compact/fullscreen geometry.
- Build a useful personal reading/research shelf backed by a real SQLite file.
- Make triage, selection, progress updates, filtering, pagination, and
recovery feel like one coherent product rather than isolated demos.
- Exercise existing ETAF components through composition before adding any new
component type.
- Keep the static `.etaf` file responsible for shell/section composition while
the `.el` companion owns storage, state, callbacks, and lifecycle.
## Personas and jobs
- Primary personas: ETAF framework reviewer and application author learning the public API.
- User jobs: understand the layout model, trigger each capability, inspect visible state changes, and verify recovery/cleanup behavior.
- Key contexts: 900940 px compact GUI, fullscreen GUI, keyboard-only review, and batch semantic tests.
- Primary persona: an Emacs user collecting papers, books, essays, and notes.
- Jobs: find what to read next, filter by state/priority, inspect one item,
update progress, mark an item finished, and recover after a storage error.
- Contexts: compact desktop window, fullscreen review, keyboard-first use, and
a local SQLite database that survives unmount/remount.
## Information architecture
- Shell: brand/header -> navigation tabs -> page surface -> persistent status footer.
- Overview: hero/status strip, KPI cards, activity/control workspace, capability cards, and event timeline.
- Data: query/selection summary, interactive DataGrid, and selection detail panel.
- Resource: resource status, reload/fail-next controls, boundary result, and cleanup counter.
- Content hierarchy: page title and status first; primary action second; diagnostics/details third.
- Shell: compact brand bar -> filter rail -> reading list -> detail inspector.
- Filter rail: All, In progress, Unread, Finished, and Starred; each is a real
action and the list summary exposes the matching count.
- Reading list: title/author/status/progress rows, single selection, paging,
empty/loading/error states.
- Detail inspector: title, author, type, status, progress, priority, note,
last updated, and actions (`+10%`, `Finish`, `Star`, `Archive`).
- Footer: database path/status, visible result count, and current page.
## Design principles
1. One visual hierarchy: every control belongs to a named surface and every surface has a visible status.
2. State before decoration: theme, loading, error, selected, disabled, and success states are readable as text as well as color.
3. Stable geometry: controls remain single-line at compact width; page changes replace only the page surface.
4. Public API truth: the prototype may use ordinary HTML/JS, but the ETAF port uses only public ETAF/ETAF-UI APIs.
1. One useful workflow: every visible control helps decide or update what to
read next.
2. Editorial hierarchy over dashboard chrome: one strong title, restrained
metadata, and generous whitespace around the selected record.
3. State is durable and observable: SQLite is the source of truth; UI status
tells the user when a mutation is loading, saved, or failed.
4. Composition first: reuse public UI Components and keep storage/state in the
companion, not in `.etaf` or a parallel widget layer.
5. Fail closed: a SQLite failure keeps the last usable list and exposes a
retry action instead of replacing the whole surface with a stack trace.
## Visual language
- Color: ink navy `#142235`, paper `#F6F1E8`, slate `#526174`, teal `#2E8B83`, coral `#E26D5A`, amber `#D99A3D`, success `#3E9B72`.
- Typography: readable sans body, compact monospace labels/metrics, bold page titles.
- Spacing/layout rhythm: one-line outer rhythm, two-column workspace, fixed gaps, full-width shell, deliberate card padding.
- Shape/elevation: thin dark borders and restrained card elevation; no floating overlay is required by the ETAF port.
- Motion: no animation is required; state changes are immediate and reduced-motion safe.
- Imagery/iconography: text marks and Unicode status glyphs only; no external asset dependency.
- Color: ink `#172033`, paper `#F7F3EA`, cobalt `#3657D6`, mint `#3E9B8F`,
coral `#D86B5D`, amber `#C58A3A`, muted slate `#6D7482`.
- Typography: readable proportional labels with compact monospace metadata;
title and selected record use bold weight, not oversized headings.
- Spacing/layout rhythm: 1-cell outer rhythm, 2-cell panel padding, 1-cell
grid gaps, and stable intrinsic control widths.
- Shape/elevation: thin borders, small radius-like grouped rows where Ebox
permits, no floating overlays or decorative shadows.
- Motion: immediate state changes; no timer-driven animation; reduced-motion
safe by construction.
- Imagery/iconography: Unicode marks with semantic meaning: `⌕`, `★`, `✓`,
`◷`, `↗`, `⚠`, and `·`.
## Components
- Existing components to reuse: public `button`, `checkbox`, `label`, `panel`, and `data-grid` from `etaf-ui`.
- New/changed components: `operations-console-shell`, `hero-status`, `metric-strip`, `activity-card`, `capability-card`, `timeline`, `data-page`, and `resource-page` in the companion.
- Variants/states: active/inactive nav, light/dark theme, selected/unselected row, Behavior on/off, resource ready/loading/error, boundary handled, compact/fullscreen.
- Ownership: `.etaf` owns static composition and section names; `.el` owns tokens, props, refs, resources, handlers, and state.
- Existing components to reuse: public `button`, `checkbox`, `label`, `panel`,
`data-grid`, and `pagination` from `etaf-ui`.
- Companion composition: `research-shelf-shell`, `filter-rail`,
`reading-list`, and `detail-inspector`; these are ordinary ETAF Components,
not a second UI kit.
- Variants/states: light/dark Theme, selected/unselected row, starred, empty,
loading, error, saved, disabled-at-boundary, and archived.
- Token ownership: palette and spacing tokens live in the companion; `.etaf`
contains only validated structural facts and labels.
## Playground framework boundary
- `etaf-playground.el` is a reusable framework, not an application module.
- The framework owns pair registration semantics, safe inert `.etaf` parsing,
companion loading, mount/reset/close, and test/GUI entry points. Replaceable
deployment metadata lives in `etaf-playground-catalog.el`, outside the
loader implementation.
- A pair companion owns its Components, storage adapters, state, resources,
events, and root factory. The framework must not reference a business
component, database package, table schema, palette, or application-specific
ref.
- Reuse is expressed through the generic manifest/loader contract and the
existing public ETAF/etaf-ui components. New business behavior belongs in a
same-basename pair, never in the framework.
- Any future Playground example must add a catalog entry and its own `.etaf`/
`.el` files without changing framework semantics.
## Accessibility
- Target: keyboard-complete, high-contrast text, semantic roles through public UI components.
- Keyboard/focus: Tab and Shift-Tab traverse nav/actions/data rows; RET dispatches the focused ref; focus clears when a target is removed.
- Every state has text feedback; color is never the only signal. No hover-only behavior.
- Ebox Theme carries inherited Ebox defaults; application palette lookups remain
in the companion, while the Renderer filters unknown token names before they
reach Ebox geometry.
- Target: keyboard-complete and high-contrast desktop UI.
- Keyboard/focus: Tab order follows filters -> rows -> detail actions -> pager;
RET dispatches the focused public ref; selected row is announced in text.
- Color is never the only state signal; status uses text and Unicode marks.
- Disabled actions remain visible with disabled semantics and explanatory help.
## Responsive behavior
- Supported surfaces: compact 900940 px GUI body and fullscreen desktop GUI.
- Metric cards remain in one row when possible; workspace falls back from two columns to stacked sections; controls keep intrinsic single-line widths.
- Use `flex` for rows that distribute remaining width; reserve `row` for fixed
intrinsic control groups. The navigation control group is max-content so
each hover/active range remains an independent control.
- Mouse-1 and keyboard activation use the same public refs.
- Wide/fullscreen: three-column grid (rail / list / detail).
- Compact: rail becomes a top filter row, detail follows the list, and all
controls remain intrinsic single-line widths.
- Use `grid` for the product skeleton, `flex` for toolbars and action groups,
and `row` only for compact intrinsic controls.
## Interaction states
- Loading: resource card reports `Loading…` without moving the shell.
- Empty: DataGrid reports an explicit empty state and keeps selection controls available.
- Error: resource reload can fail once; the boundary card reports the handled error and recovery remains available.
- Success: action/status and timestamp update visibly.
- Disabled: unavailable actions expose disabled semantics rather than silently ignoring input.
- Loading: list keeps its frame and shows `◷ Loading library…`.
- Empty: selected filter explains why there are no matches and offers `All`.
- Error: storage card shows `⚠ Could not save` with `Retry`; last committed
data remains visible.
- Success: mutation shows `✓ Saved locally` and updates `updated` metadata.
- Disabled: `Finish` is disabled for finished records; `+10%` is disabled at
100%; archive is disabled while loading.
## Content voice
- Tone: concise, operational, and explanatory; labels name the framework capability being demonstrated.
- Terminology: use `Component`, `Context`, `Theme`, `Behavior`, `Action`, `Data`, `Resource`, and `Boundary` consistently.
- Microcopy: prefer a visible verb + result (`Reload resource`, `Handled: …`, `Selected Beacon`) over generic `Done`.
- Tone: concise, observant, and useful; never framework-centric in primary UI.
- Terminology: Reading, In progress, Finished, Starred, Saved locally,
Storage issue.
- Microcopy: action + result (`Mark finished`, `Saved locally`) instead of
generic `Done`.
## Implementation constraints
- Framework/styling: ETAF public View DSL in `.etaf`/`.el`; Ebox renders the result; no HTML/CSS runtime dependency.
- Design tokens: keep palette/spacing tokens in one companion source and reuse them; no scattered ad-hoc colors.
- Performance: ordinary warm p50 <=100 ms, counter/resource p50 <=150 ms, every measured max <=250 ms; one publication per logical action.
- Compatibility: preserve one same-basename `operations-console` pair, safe static reader, public API boundary, mount twice/unmount cleanup, and manifest refs.
- Navigation: the stable page ref is read by the retained router Component; its
page Host is the single semantic Range anchor, so page switches replace one
Range payload instead of rebuilding the shell root.
- Review: HTML is the visual reference; ETAF needs compact/fullscreen screenshots and dynamic keyboard/mouse checkpoints.
- Framework/styling: public ETAF View DSL and `etaf-ui`; SQLite through the
sibling `etaf-sqlite` package; no HTML/CSS runtime dependency.
- Data schema: one typed `reading_items` SQLite table with id/title/author/
kind/status/progress/priority/starred/note/updated columns.
- Performance: one Data mutation -> one Runtime generation/publication; normal
warm actions remain under the existing 100ms p50 target.
- Compatibility: one same-basename `research-shelf` pair only; old console
examples are removed from the active manifest and compile surface.
- Verification: SQLite temp-file integration tests, pair mount/remount tests,
repeated selection/mutation tests, and clean fullscreen GUI screenshots.
## Open questions
- [ ] User review may adjust palette or page naming after the first HTML/ETAF comparison; behavior and public-ref coverage remain fixed.
- [ ] User may rename the product after reviewing the first HTML/ETAF render;
architecture and interaction contracts remain unchanged.

View File

@ -2,94 +2,80 @@
## 来源与状态
- 状态:唯一 reviewed pair 的 active draft
- 更新日期2026-08-20
- 主产品面:`examples/operations-console.etaf` + `examples/operations-console.el`
- 视觉参考:`design/operations-console.html`
- 已审查证据pair loader/manifest、`etaf-ui.el`、Ebox viewport 合同及当前 GUI/性能证据。
- 状态active
- 更新日期2026-08-22
- 主产品面:`examples/research-shelf.etaf` + `examples/research-shelf.el`
- HTML 视觉基线:`design/research-shelf.html`
- 已审查ETAF View/Component/Data API、`etaf-ui` 的公开组件,以及
`etaf-sqlite` 的 typed source 合同。
## 品牌
## 产品定位
- 气质:安静的操作控制台;准确、温暖、克制地体现技术感。
- 信任信号:状态 chip、更新时间、明确计数、清晰的成功/错误反馈
- 避免:普通玩具 dashboard、彩虹渐变、无语义状态的装饰控件和杂乱控制区
Research Shelf 是一个真正有用的本地研究/阅读架SQLite 保存书籍、论文、
文章和笔记,用户可以筛选、分页、选择一条记录、更新进度、完成、收藏和归档
它不是把 API 名称堆成控制台,而是用一个连贯 workflow 验证 ETAF 的组合能力
## 产品目标
## 结构与分层
- 用一个好看的 app 同时展示 Component、组合、响应式、Context/Theme、Behavior、事件/Action、Data、Resource、错误边界、焦点和生命周期。
- 不扩展第二个 Playground 场景,不伪装成完整生产分析产品,不用假动画掩盖框架问题。
- HTML 原型和 ETAF 实现保持同一层级、状态文字、交互结果和 compact/fullscreen 几何。
- Shell标题/副标题/主题切换 → 三列 workspace → 持久化状态栏。
- 三列 workspace筛选栏 / SQLite reading list / selected-item detail inspector。
- `.etaf`只保存经过白名单校验的静态结构和文案section、filter、label
- 同名 `.el`:消费 `.etaf`,组合公开 UI Components创建 SQLite schema、Data
Controller、refs、事件处理和生命周期清理。
- 现有可复用组件:`button`、`checkbox`、`label`、`panel`、`data-grid`、
`pagination`;应用只负责把它们组合成产品,不复制一套 UI kit。
## 信息架构
## Playground 框架边界(必须长期遵守)
- Shell品牌/header → 导航 tabs → page surface → 持久 status footer。
- Overviewhero/status、KPI 卡片、活动/控制 workspace、能力卡片和事件时间线。
- Data选择摘要、可交互 DataGrid、selection detail。
- Resource资源状态、reload/fail-next、boundary 结果和 cleanup 计数。
- 层级:先显示页面标题和状态,再显示主操作,最后显示诊断/细节。
`etaf-playground.el` 是通用 pair playground 框架,不是业务应用模块。
`etaf-playground-catalog.el` 是独立、可替换的部署目录;业务 pair 的名字、refs
和验证元数据放在 catalog不写进 loader 实现。
## 设计原则
框架只负责:
1. 一个视觉层级:每个控件属于有名字的 surface每个 surface 都有可见状态。
2. 状态优先于装饰theme、loading、error、selected、disabled、success 都有文字表达。
3. 几何稳定compact 下控件保持单行,页面只替换 page surface。
4. API 真实HTML 可以用普通 HTML/JS但 ETAF 只用公开 ETAF/ETAF-UI API。
- pair 注册语义和 catalog 读取;
- `.etaf` 的惰性、inert、白名单读取
- 按同名 pair 加载 companion
- mount、reset、close以及测试/GUI 入口。
框架绝不负责:业务 Component、数据库包或表结构、palette、业务 state、
handlers、resources 或应用 refs。它不能 `require etaf-sqlite`,也不能把业务
组件实现塞进 loader/helper。未来新增例子时只增加一个同名 `.etaf`/`.el` pair
和 catalog entry不改变框架语义。
组合和复用是默认设计:优先复用 ETAF/`etaf-ui` 的公开契约,优先拆出清晰的
Component 边界,避免新增 helper 层或抽象泄漏。
## 视觉语言
- 色彩:墨蓝 `#142235`、纸张 `#F6F1E8`、灰蓝 `#526174`、青绿 `#2E8B83`、珊瑚 `#E26D5A`、琥珀 `#D99A3D`、成功绿 `#3E9B72`
- 字体:易读 sans body紧凑 monospace 指标/标签,粗体页面标题。
- 节奏:一行外部节奏、双列 workspace、固定 gap、shell 全宽,卡片有明确内边距。
- 形状:细深色边框、克制 elevationETAF 不依赖浮层。
- 动效:不以动画为正确性前提,状态变化即时且支持 reduced motion。
- 图形:只用文字标记和 Unicode 状态符号,不依赖外部资源。
- 气质editorial、专注、温暖、安静而聪明避免通用 admin dashboard、KPI
墙、假图表、彩虹渐变和装饰性噪声。
- 色彩ink `#172033`、paper `#F7F3EA`、cobalt `#3657D6`、mint `#3E9B8F`
coral `#D86B5D`、amber `#C58A3A`、muted `#6D7482`
- 节奏1 格外部节奏、2 格 panel padding、1 格 grid gap控件保持 intrinsic
宽度;`grid` 负责页面骨架,`flex` 负责 toolbar/action group。
- 状态:文字和 Unicode 同时表达状态:`⌕`、`★`、`✓`、`◷`、`↗`、`⚠`、`·`。
- 动效:状态立即可见,不依赖 timer animation默认支持 reduced motion。
## 组件
## 交互合同
- 复用公开 `button`、`checkbox`、`label`、`panel`、`data-grid`。
- companion 组件:`operations-console-shell`、`hero-status`、`metric-strip`、`activity-card`、`capability-card`、`timeline`、`data-page`、`resource-page`。
- 状态active/inactive nav、light/dark theme、selected/unselected row、Behavior on/off、resource ready/loading/error、boundary handled、compact/fullscreen。
- 所有权:`.etaf` 只放静态 composition 和 section 名称;`.el` 持有 token、props、refs、资源、handlers 和 state。
- 筛选All、In progress、Unread、Finished、Starred。
- 列表DataGrid 行单选,重复点击可以重新选择;分页按钮在边界处 disabled。
- 详情:`+ 10%`、`✓ Finish`、`☆/★ Star`、`Archive`;完成或 100% 时正确禁用。
- 存储:成功显示 `✓ Saved locally`,失败保留当前可用内容并提供 Reload。
- 主题Light/Dark 由 checkbox 控制;文字、对比度和布局都应保持可读。
- 键盘Tab 顺序为筛选 → 行 → 详情动作 → 分页RET 使用同一组公开 refs。
## 无障碍
## 响应式与验证
- 目标:键盘完整、高对比文字,使用公开组件提供语义 role。
- Tab/Shift-Tab 遍历 nav/action/data rowsRET 派发 focused ref移除目标时清焦点。
- 每个状态都有文字反馈,颜色不是唯一信号;不依赖 hover-only 行为。
- Ebox Theme 承载继承的 Ebox 默认属性;应用 palette 仍由 companion 查询,
Renderer 会在进入 Ebox 几何层前过滤未知 token 名。
## 响应式
- 支持 compact 900940px body 和 fullscreen desktop GUI。
- metric 尽量一行展示workspace 从双列退化为上下堆叠,控件保持 intrinsic 单行宽度。
- 需要分配剩余宽度的行使用 `flex`,固定 intrinsic 控件组才使用 `row`;导航
控件组使用 max-content确保每个 hover/active 区域都是独立控件。
- mouse-1 和键盘激活使用同一组公开 refs。
## 交互状态
- LoadingResource 显示 `Loading…`shell 几何不移动。
- EmptyDataGrid 明确显示 empty选择控件仍可用。
- Errorreload 可故意失败一次Boundary 显示 handled error 且仍可恢复。
- Successaction/status 和 timestamp 可见更新。
- Disabled不可用操作暴露 disabled 语义,不静默吞输入。
## 内容语气
- 简洁、操作化、解释性;标签明确指出正在展示的框架能力。
- 统一使用 `Component`、`Context`、`Theme`、`Behavior`、`Action`、`Data`、`Resource`、`Boundary`。
- 优先“动词 + 结果”(如 `Reload resource`、`Handled: …`、`Selected Beacon`)。
## 实现约束
- 使用 `.etaf`/`.el` 的公开 ETAF View DSL由 Ebox 渲染,不引入 HTML/CSS runtime dependency。
- palette/spacing token 在 companion 单处定义并复用,禁止散落临时色值。
- 性能:普通 warm p50 <=100mscounter/resource p50 <=150ms每个 warm max <=250ms每个逻辑动作一次 publication。
- 保留唯一同名 `operations-console` pair、静态安全读取、公开 API boundary、双 mount/unmount cleanup 和 manifest 语义。
- 导航:稳定的 page ref 由 retained router Component 读取;页面 Host 是唯一 semantic Range 锚点,页面切换只替换一个 Range payload不重建 shell root。
- HTML 是视觉参考ETAF 需要 compact/fullscreen 截图和动态键鼠 checkpoint 证明布局、宽度、焦点和状态。
- Wide/fullscreen 使用 rail / list / detail 三列 grid。
- Compact 时筛选栏收为顶部工具行detail 位于 list 后面;不使用固定宽度撑坏
viewport所有操作控件仍保持独立 hover/focus 语义。
- 用 SQLite 临时文件测试 mount/remount、筛选、分页、重复选行、mutation、错误
状态和 cleanupGUI 用干净 fullscreen 单窗口截图验证真实布局。
- 目标:一次 Data mutation 对应一次 Runtime generation/publicationwarm 交互
维持现有 100ms p50 预算。
## 待 review
- [ ] 用户可在第一版 HTML/ETAF 对照后调整 palette 或页面命名;行为和公开 ref 覆盖保持不变。
- [ ] 用户可调整产品命名或 palettepair 边界、组合/复用原则和公开交互合同不变。

View File

@ -1,8 +1,8 @@
EMACS ?= emacs
LOAD_PATH = -L . -L ../etaf -L ../etaf-ui -L ../ebox -L ../ecss -L ../tp
EXAMPLE_EL := examples/operations-console.el
LOAD_PATH = -L . -L ../etaf -L ../etaf-ui -L ../ebox -L ../ecss -L ../tp -L ../etaf-sqlite
EXAMPLE_EL := examples/research-shelf.el
.PHONY: all compile examples-read test check checkdoc load clean
.PHONY: all compile examples-read test perf check checkdoc load clean
all: check
@ -15,29 +15,36 @@ compile:
$(MAKE) -C ../ebox compile EMACS="$(EMACS)" ECSS_DIR=../ecss TP_DIR=../tp
$(MAKE) -C ../etaf compile EMACS="$(EMACS)"
$(MAKE) -C ../etaf-ui compile EMACS="$(EMACS)"
$(MAKE) -C ../etaf-sqlite compile EMACS="$(EMACS)"
rm -f *.elc tests/*.elc examples/*.elc
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \
--eval '(load-file "etaf-playground.el")' \
--eval '(dolist (file (quote ($(foreach file,$(EXAMPLE_EL),"$(file)")))) (byte-compile-file file))' \
--eval '(byte-compile-file "etaf-playground-catalog.el")' \
--eval '(byte-compile-file "etaf-playground.el")' \
--eval '(byte-compile-file "tests/etaf-playground-tests.el")'
examples-read: compile
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \
--eval '(require (quote etaf-playground))' \
--eval '(etaf-playground-read-pair "operations-console")' \
--eval '(load-file "examples/research-shelf.el")' \
--eval '(let ((database (make-temp-file "etaf-playground-read-" nil ".sqlite")) (buffer " *etaf-playground-read*")) (unwind-protect (let ((etaf-research-shelf-database-file database)) (etaf-playground-mount-example buffer "research-shelf")) (etaf-playground-close buffer) (when (file-exists-p database) (delete-file database))))' \
--eval '(princ "ETAF example modules load OK\\n")'
test: examples-read
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \
-l tests/etaf-playground-tests.el -f ert-run-tests-batch-and-exit
perf: compile
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \
-l scripts/benchmark-research-shelf.el
load: compile
$(EMACS) -Q --batch $(LOAD_PATH) --eval '(require (quote etaf-playground))' \
--eval '(princ "etaf-playground load OK\\n")'
checkdoc:
$(EMACS) -Q --batch --eval '(progn (require (quote checkdoc)) (dolist (file (list "etaf-playground.el" "tests/etaf-playground-tests.el" "examples/operations-console.el")) (checkdoc-file file)))'
$(EMACS) -Q --batch --eval '(progn (require (quote checkdoc)) (dolist (file (list "etaf-playground.el" "etaf-playground-catalog.el" "tests/etaf-playground-tests.el" "examples/research-shelf.el" "scripts/benchmark-research-shelf.el")) (checkdoc-file file)))'
check: checkdoc test

View File

@ -1,11 +1,16 @@
# ETAF Playground
The Playground exposes one canonical product example: the
`examples/operations-console.etaf` and `examples/operations-console.el` pair.
`examples/research-shelf.etaf` and `examples/research-shelf.el` pair.
The `.etaf` file is inert, validated static composition data. The companion
owns Components, state, Context, Theme, Behaviors, Data, Resources, Actions,
and lifecycle. `etaf-playground-open`, `etaf-playground-reset`, and
`etaf-playground-close` are the only mounting boundary.
The `.etaf` file is inert, validated structural composition data. The same-
basename companion owns Components, state, theme, storage, events, and
lifecycle. `etaf-playground.el` is a reusable pair framework: it owns only
registration, safe loading, mounting, reset, close, and verification entry
points. `etaf-playground-catalog.el` is the replaceable deployment catalog;
business Components, database schemas, palettes, refs, and handlers
must stay in the companion. Compose and reuse public ETAF/`etaf-ui` contracts
before adding abstractions.
Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`.
Run `make perf` for the 1413×62 warm row-selection/theme latency gate.

View File

@ -1,10 +1,14 @@
# ETAF Playground
Playground 只公开一个标准产品示例:
`examples/operations-console.etaf` 与 `examples/operations-console.el` 配对。
`examples/research-shelf.etaf` 与 `examples/research-shelf.el` 配对。
`.etaf` 是只读、经过白名单验证的静态组合数据;同名 companion 负责
Component、状态、Context、Theme、Behavior、Data、Resource、Action 与生命周期。
`etaf-playground-open/reset/close` 是唯一挂载边界。
`.etaf` 是惰性、inert、经过白名单验证的静态结构数据同名 companion 负责
Component、状态、主题、SQLite、事件与生命周期。`etaf-playground.el` 是通用
pair playground 框架只负责注册、白名单读取、加载、mount/reset/close 和验证
入口;`etaf-playground-catalog.el` 是可替换的部署目录。业务 Component、数据库
schema、palette、refs 与 handlers 不能泄漏进框架。
默认原则是组合和复用公开 ETAF/`etaf-ui` 契约,而不是再造一套组件或 helper 抽象。
验证命令:`make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`。
性能门禁:`make perf`1413×62 viewport 的 warm 选行/主题延迟)。

View File

@ -1,211 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ETAF Operations Console — visual prototype</title>
<style>
:root {
--ink: #142235;
--ink-soft: #526174;
--paper: #f6f1e8;
--paper-strong: #fffdf8;
--line: #c8c1b6;
--teal: #2e8b83;
--teal-soft: #d9eeea;
--coral: #e26d5a;
--coral-soft: #f9ded7;
--amber: #d99a3d;
--amber-soft: #f8e9c9;
--success: #3e9b72;
--shadow: 0 12px 28px rgba(20, 34, 53, .09);
--radius: 14px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--paper);
}
:root[data-theme="dark"] {
--ink: #edf2f4;
--ink-soft: #aab8c2;
--paper: #111923;
--paper-strong: #182433;
--line: #344455;
--teal: #61c6ba;
--teal-soft: #173b3b;
--coral: #ff957f;
--coral-soft: #4b2c31;
--amber: #efc06c;
--amber-soft: #4a3922;
--success: #73d29d;
--shadow: 0 14px 32px rgba(0, 0, 0, .25);
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--paper); }
button { font: inherit; }
.app-shell {
width: min(1240px, calc(100% - 40px));
min-height: 100vh;
margin: 0 auto;
padding: 22px 0 30px;
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
gap: 18px 24px;
}
.topbar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; color: #fff; background: var(--ink); font-weight: 800; letter-spacing: -.08em; }
.brand small, .eyebrow, .mono { font-family: "SFMono-Regular", Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; }
.brand small { color: var(--ink-soft); font-size: 10px; }
.brand strong { display: block; font-size: 16px; letter-spacing: -.02em; }
.top-actions { display: flex; align-items: center; gap: 10px; }
.status-pill { display: inline-flex; align-items: center; gap: 7px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper-strong); font-size: 12px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 18%, transparent); }
.icon-button, .nav-button, .primary-button, .quiet-button { border: 1px solid var(--line); border-radius: 10px; cursor: pointer; color: var(--ink); background: var(--paper-strong); }
.icon-button { width: 36px; height: 36px; }
.primary-button { padding: 10px 14px; color: #fff; border-color: var(--ink); background: var(--ink); }
.quiet-button { padding: 9px 12px; }
button:focus-visible { outline: 3px solid color-mix(in srgb, var(--teal) 60%, transparent); outline-offset: 2px; }
.sidebar { grid-row: 2; display: flex; flex-direction: column; gap: 18px; }
.sidebar-heading { margin: 0 0 4px; color: var(--ink-soft); font: 10px "SFMono-Regular", Consolas, monospace; text-transform: uppercase; letter-spacing: .14em; }
.nav { display: grid; gap: 6px; }
.nav-button { padding: 11px 12px; text-align: left; display: flex; justify-content: space-between; align-items: center; }
.nav-button[aria-current="page"] { color: #fff; border-color: var(--ink); background: var(--ink); }
.nav-kicker { font: 10px "SFMono-Regular", Consolas, monospace; opacity: .7; }
.side-card { margin-top: auto; padding: 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--paper-strong); box-shadow: var(--shadow); }
.side-card strong { display: block; margin-bottom: 6px; }
.side-card p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.45; }
.main { grid-row: 2; min-width: 0; }
.page { display: none; gap: 18px; }
.page.active { display: grid; }
.hero { min-height: 190px; padding: 24px; display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; color: #fff; border-radius: var(--radius); background: linear-gradient(135deg, #17283d 0%, #1b4f5c 60%, #2e8b83 100%); box-shadow: var(--shadow); overflow: hidden; position: relative; }
.hero::after { content: ""; position: absolute; width: 300px; height: 300px; right: -80px; top: -140px; border: 1px solid rgba(255,255,255,.22); border-radius: 50%; box-shadow: 0 0 0 28px rgba(255,255,255,.05), 0 0 0 58px rgba(255,255,255,.04); }
.hero-copy, .hero-actions { position: relative; z-index: 1; }
.eyebrow { margin: 0 0 10px; font-size: 10px; opacity: .72; }
h1, h2, h3, p { margin-top: 0; }
h1 { max-width: 620px; margin-bottom: 10px; font-size: clamp(24px, 4vw, 40px); line-height: 1.04; letter-spacing: -.05em; }
.hero p { max-width: 570px; margin-bottom: 0; color: rgba(255,255,255,.78); line-height: 1.5; }
.hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.hero .primary-button { color: var(--ink); border-color: #fff; background: #fff; }
.hero .quiet-button { color: #fff; border-color: rgba(255,255,255,.35); background: rgba(255,255,255,.08); }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.metric { padding: 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--paper-strong); box-shadow: var(--shadow); }
.metric-label { color: var(--ink-soft); font-size: 12px; }
.metric-value { margin: 8px 0 4px; font: 700 27px "SFMono-Regular", Consolas, monospace; letter-spacing: -.08em; }
.metric-delta { color: var(--success); font-size: 12px; }
.workspace { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(260px, .75fr); gap: 14px; }
.card { min-width: 0; padding: 18px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--paper-strong); box-shadow: var(--shadow); }
.card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 15px; }
.card-header h2, .card-header h3 { margin: 0; font-size: 15px; letter-spacing: -.02em; }
.card-header p { margin: 4px 0 0; color: var(--ink-soft); font-size: 12px; }
.activity-list, .timeline { display: grid; gap: 10px; }
.activity-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); }
.activity-row:last-child { border-bottom: 0; }
.activity-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; color: var(--teal); background: var(--teal-soft); }
.activity-row strong { display: block; font-size: 12px; }
.activity-row small { color: var(--ink-soft); font-size: 11px; }
.activity-time { color: var(--ink-soft); font: 10px "SFMono-Regular", Consolas, monospace; }
.capabilities { display: grid; gap: 8px; }
.capability { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 9px; align-items: start; padding: 10px; border-radius: 10px; background: color-mix(in srgb, var(--paper) 70%, var(--paper-strong)); }
.capability-mark { color: var(--success); font-weight: 800; }
.capability strong { display: block; font-size: 12px; }
.capability span { display: block; margin-top: 3px; color: var(--ink-soft); font-size: 11px; line-height: 1.35; }
.timeline-card { padding-bottom: 12px; }
.timeline-item { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: start; }
.timeline-line { position: relative; display: grid; justify-items: center; }
.timeline-line::before { content: ""; width: 1px; height: 100%; background: var(--line); position: absolute; top: 10px; }
.timeline-dot { position: relative; width: 9px; height: 9px; margin-top: 4px; border-radius: 50%; background: var(--coral); }
.timeline-item strong { font-size: 12px; }
.timeline-item p { margin: 3px 0 0; color: var(--ink-soft); font-size: 11px; }
.timeline-item time { color: var(--ink-soft); font: 10px "SFMono-Regular", Consolas, monospace; }
.data-layout, .resource-layout { display: grid; gap: 14px; }
.data-toolbar { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: center; }
.data-toolbar strong { font-size: 14px; }
.data-toolbar span { color: var(--ink-soft); font-size: 12px; }
.table { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; }
.table-row { display: grid; grid-template-columns: 70px minmax(0, 1fr) 130px 100px; gap: 10px; align-items: center; padding: 11px 13px; border-bottom: 1px solid var(--line); font-size: 12px; }
.table-row:last-child { border-bottom: 0; }
.table-head { color: var(--ink-soft); background: color-mix(in srgb, var(--paper) 65%, var(--paper-strong)); font: 10px "SFMono-Regular", Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; }
.table-row[data-selected="true"] { color: var(--teal); background: var(--teal-soft); }
.row-button { all: unset; cursor: pointer; display: contents; }
.tag { display: inline-flex; width: max-content; padding: 4px 7px; border-radius: 999px; color: var(--teal); background: var(--teal-soft); font: 10px "SFMono-Regular", Consolas, monospace; }
.resource-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 14px; }
.resource-status { display: flex; align-items: center; gap: 10px; padding: 13px; border-radius: 10px; background: var(--teal-soft); }
.resource-status strong { font-size: 13px; }
.resource-status small { display: block; margin-top: 3px; color: var(--ink-soft); font-size: 11px; }
.resource-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.boundary { padding: 14px; border: 1px dashed var(--amber); border-radius: 10px; background: var(--amber-soft); }
.boundary strong { display: block; margin-bottom: 4px; font-size: 12px; }
.boundary p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.45; }
.footer { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid var(--line); color: var(--ink-soft); font-size: 11px; }
.footer strong { color: var(--ink); }
.toast { position: fixed; right: 22px; bottom: 22px; max-width: 340px; padding: 12px 14px; color: #fff; border-radius: 10px; background: var(--ink); box-shadow: var(--shadow); opacity: 0; transform: translateY(8px); pointer-events: none; transition: opacity .15s ease, transform .15s ease; font-size: 12px; }
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 920px) {
.app-shell { width: min(100% - 24px, 760px); grid-template-columns: 1fr; gap: 14px; }
.sidebar { grid-row: auto; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 10px; }
.sidebar-heading { display: none; }
.nav { grid-template-columns: repeat(3, 1fr); }
.nav-button { padding: 9px; }
.side-card { display: none; }
.main { grid-row: auto; }
.metrics { grid-template-columns: repeat(2, 1fr); }
.workspace, .resource-grid { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.app-shell { width: calc(100% - 18px); padding-top: 12px; }
.topbar { align-items: flex-start; }
.status-pill { display: none; }
.hero { padding: 18px; display: block; }
.hero-actions { margin-top: 18px; }
.table-row { grid-template-columns: 48px minmax(0, 1fr) 80px; }
.table-row > :last-child { display: none; }
.footer { display: block; line-height: 1.6; }
}
</style>
</head>
<body>
<div class="app-shell" id="app">
<header class="topbar">
<div class="brand"><div class="brand-mark">OC</div><div><small>ETAF / reference workspace</small><strong>Operations Console</strong></div></div>
<div class="top-actions"><span class="status-pill"><i class="status-dot"></i><span id="system-status">All systems nominal</span></span><button class="icon-button" id="theme-toggle" aria-label="Toggle theme"></button></div>
</header>
<aside class="sidebar">
<div><p class="sidebar-heading">Workspace</p><nav class="nav" aria-label="Primary navigation">
<button class="nav-button" data-page="overview" aria-current="page"><span>Overview</span><span class="nav-kicker">01</span></button>
<button class="nav-button" data-page="data"><span>Data desk</span><span class="nav-kicker">02</span></button>
<button class="nav-button" data-page="resource"><span>Resource lab</span><span class="nav-kicker">03</span></button>
</nav></div>
<div class="side-card"><strong>Owned surface</strong><p>One stable shell, several independently reactive panels, and a status line that never loses the source of truth.</p></div>
</aside>
<main class="main">
<section class="page active" id="page-overview" data-page-view="overview">
<div class="hero"><div class="hero-copy"><p class="eyebrow">Tuesday / 09:42 / live review</p><h1>Make complex state feel calm.</h1><p>A single surface for components, context, behavior, data, and recovery — designed to make every ETAF update visible and explainable.</p></div><div class="hero-actions"><button class="primary-button" id="hero-action">Run health check</button><button class="quiet-button" id="hero-note">View event trail</button></div></div>
<div class="metrics" aria-label="Key metrics"><article class="metric"><span class="metric-label">Throughput</span><div class="metric-value" id="metric-throughput">2.4k</div><span class="metric-delta">↑ 18% this week</span></article><article class="metric"><span class="metric-label">Active flows</span><div class="metric-value" id="metric-flows">18</div><span class="metric-delta">+3 since 08:00</span></article><article class="metric"><span class="metric-label">Response</span><div class="metric-value">42ms</div><span class="metric-delta">p95 within target</span></article><article class="metric"><span class="metric-label">Reliability</span><div class="metric-value">99.8%</div><span class="metric-delta">steady</span></article></div>
<div class="workspace"><article class="card"><div class="card-header"><div><h2>Activity stream</h2><p>Reactive events, rendered as a readable story.</p></div><span class="tag" id="activity-count">4 events</span></div><div class="activity-list" id="activity-list"><div class="activity-row"><span class="activity-icon"></span><div><strong>Theme provider promoted</strong><small>Context defaults were refreshed without losing focus.</small></div><span class="activity-time">09:41</span></div><div class="activity-row"><span class="activity-icon"></span><div><strong>Counter alpha crossed 2k</strong><small>One Component-owned update, one visible publication.</small></div><span class="activity-time">09:36</span></div><div class="activity-row"><span class="activity-icon"></span><div><strong>Resource boundary recovered</strong><small>Handled failure left the shell and routes intact.</small></div><span class="activity-time">09:28</span></div></div></article><article class="card"><div class="card-header"><div><h2>Capability map</h2><p>What this surface is proving.</p></div><span class="tag">ETAF</span></div><div class="capabilities"><div class="capability"><b class="capability-mark"></b><div><strong>Component ownership</strong><span>Props, slots, setup state, and retained identity.</span></div></div><div class="capability"><b class="capability-mark"></b><div><strong>Context + Theme</strong><span>Provider defaults, dark mode, and explicit feedback.</span></div></div><div class="capability"><b class="capability-mark"></b><div><strong>Behavior + Actions</strong><span>Keyboard-ready interactions with stable refs.</span></div></div><div class="capability"><b class="capability-mark"></b><div><strong>Data + Resource</strong><span>Selection, loading, failure, boundary, and cleanup.</span></div></div></div></article></div>
<article class="card timeline-card"><div class="card-header"><div><h2>Event trail</h2><p>Every meaningful action leaves a compact, inspectable trace.</p></div><button class="quiet-button" id="clear-trail">Clear trail</button></div><div class="timeline" id="timeline"><div class="timeline-item"><div class="timeline-line"><i class="timeline-dot"></i></div><div><strong>Surface mounted</strong><p>Operations Console is ready for review.</p></div><time>09:22</time></div><div class="timeline-item"><div class="timeline-line"><i class="timeline-dot"></i></div><div><strong>Data controller loaded</strong><p>3 records are available in the Data desk.</p></div><time>09:23</time></div></div></article>
</section>
<section class="page" id="page-data" data-page-view="data"><article class="card data-layout"><div class="data-toolbar"><div><strong>Data desk</strong><span> / a small table with real selection state</span></div><span class="tag" id="selection-status">No row selected</span></div><div class="table" role="grid"><div class="table-row table-head"><span>ID</span><span>NAME</span><span>STATUS</span><span>OWNER</span></div><button class="row-button" data-row="1"><div class="table-row" data-selected="false"><span>01</span><strong>Atlas</strong><span><i class="tag">active</i></span><span>Core</span></div></button><button class="row-button" data-row="2"><div class="table-row" data-selected="false"><span>02</span><strong>Beacon</strong><span><i class="tag">review</i></span><span>Ops</span></div></button><button class="row-button" data-row="3"><div class="table-row" data-selected="false"><span>03</span><strong>Cirrus</strong><span><i class="tag">done</i></span><span>Data</span></div></button></div><div class="resource-status"><i class="status-dot"></i><div><strong id="data-detail-title">Select a row to inspect it.</strong><small id="data-detail-copy">Selection updates the status without rebuilding the shell.</small></div></div></article></section>
<section class="page" id="page-resource" data-page-view="resource"><div class="resource-grid"><article class="card"><div class="card-header"><div><h2>Resource lab</h2><p>Loading, failure, recovery, and cleanup are first-class states.</p></div><span class="tag" id="resource-tag">ready</span></div><div class="resource-status"><i class="status-dot" id="resource-dot"></i><div><strong id="resource-title">Loaded value 7</strong><small id="resource-copy">Owned by the current application mount.</small></div></div><div class="resource-actions"><button class="primary-button" id="resource-reload">Reload resource</button><button class="quiet-button" id="resource-fail">Fail next load</button><label class="status-pill"><input type="checkbox" id="resource-check"> fail next</label></div></article><article class="card"><div class="card-header"><div><h2>Error boundary</h2><p>Failure is handled in place.</p></div><span class="tag">safe</span></div><div class="boundary"><strong id="boundary-title">No boundary run yet</strong><p id="boundary-copy">Run the boundary to see a handled error while the rest of the application stays mounted.</p></div><div class="resource-actions"><button class="quiet-button" id="run-boundary">Run handled boundary</button></div></article></div><article class="card"><div class="card-header"><div><h2>Lifecycle ledger</h2><p>Resource cleanup is visible, not inferred.</p></div><span class="metric-value" id="cleanup-count">0</span></div><p class="mono">cleanup calls after mount / reload / unmount</p></article></section>
</main>
<footer class="footer"><span><strong id="footer-status">Ready for review</strong> · state is owned by the current generation</span><span class="mono">operations-console / v0.1</span></footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const root = document.documentElement;
const toast = document.getElementById('toast');
const showToast = message => { toast.textContent = message; toast.classList.add('show'); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2200); };
document.getElementById('theme-toggle').addEventListener('click', () => { root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark'; showToast(root.dataset.theme === 'dark' ? 'Dark theme enabled' : 'Light theme enabled'); });
document.querySelectorAll('[data-page]').forEach(button => button.addEventListener('click', () => { const page = button.dataset.page; document.querySelectorAll('[data-page]').forEach(item => item.setAttribute('aria-current', item.dataset.page === page ? 'page' : 'false')); document.querySelectorAll('[data-page-view]').forEach(view => view.classList.toggle('active', view.dataset.pageView === page)); document.getElementById('footer-status').textContent = `Viewing ${page === 'data' ? 'Data desk' : page === 'resource' ? 'Resource lab' : 'Overview'}`; }));
document.getElementById('hero-action').addEventListener('click', () => { document.getElementById('system-status').textContent = 'Health check complete'; document.getElementById('footer-status').textContent = 'Health check completed'; showToast('All owned surfaces responded'); });
document.getElementById('hero-note').addEventListener('click', () => document.getElementById('timeline').scrollIntoView({ behavior: 'smooth', block: 'center' }));
document.querySelectorAll('[data-row]').forEach(button => button.addEventListener('click', () => { const row = button.querySelector('.table-row'); document.querySelectorAll('.table-row[data-selected]').forEach(item => item.dataset.selected = 'false'); row.dataset.selected = 'true'; const name = row.querySelector('strong').textContent; document.getElementById('selection-status').textContent = `Selected ${name}`; document.getElementById('data-detail-title').textContent = `${name} is selected`; document.getElementById('data-detail-copy').textContent = 'The Data Controller and DataGrid share one stable row ref.'; showToast(`Selected ${name}`); }));
document.getElementById('clear-trail').addEventListener('click', () => { document.getElementById('timeline').innerHTML = '<div class="timeline-item"><div class="timeline-line"><i class="timeline-dot"></i></div><div><strong>Trail cleared</strong><p>New actions will append here.</p></div><time>now</time></div>'; showToast('Event trail cleared'); });
let resourceValue = 7; let shouldFail = false; let cleanups = 0;
document.getElementById('resource-check').addEventListener('change', event => { shouldFail = event.target.checked; showToast(shouldFail ? 'Next resource load will fail' : 'Failure flag cleared'); });
document.getElementById('resource-fail').addEventListener('click', () => { shouldFail = true; document.getElementById('resource-check').checked = true; showToast('Next resource load will fail'); });
document.getElementById('resource-reload').addEventListener('click', () => { if (shouldFail) { shouldFail = false; document.getElementById('resource-check').checked = false; document.getElementById('resource-tag').textContent = 'error'; document.getElementById('resource-title').textContent = 'Handled load failure'; document.getElementById('resource-copy').textContent = 'Retry remains available; the shell stayed mounted.'; showToast('Resource error handled'); return; } resourceValue += 1; document.getElementById('resource-tag').textContent = 'ready'; document.getElementById('resource-title').textContent = `Loaded value ${resourceValue}`; document.getElementById('resource-copy').textContent = 'Fresh value promoted through the owned resource.'; showToast('Resource reloaded'); });
document.getElementById('run-boundary').addEventListener('click', () => { document.getElementById('boundary-title').textContent = 'Handled boundary result'; document.getElementById('boundary-copy').textContent = 'The failure was caught and rendered here; navigation and state remain available.'; showToast('Boundary handled safely'); });
</script>
</body>
</html>

138
design/research-shelf.html Normal file
View File

@ -0,0 +1,138 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Research Shelf</title>
<style>
:root {
--ink: #172033;
--muted: #6d7482;
--paper: #f7f3ea;
--panel: #fffdf8;
--line: #d9d4c9;
--cobalt: #3657d6;
--mint: #3e9b8f;
--coral: #d86b5d;
--amber: #c58a3a;
--shadow: 0 18px 48px rgba(23, 32, 51, .09);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: var(--ink);
background: var(--paper);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--paper); }
.shell { max-width: 1440px; margin: 0 auto; padding: 28px 34px 34px; }
.topbar { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
.eyebrow { color: var(--cobalt); font: 700 11px/1.2 ui-monospace, monospace; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 7px 0 5px; font-size: clamp(28px, 4vw, 48px); letter-spacing: -.05em; line-height: .98; }
.subtitle { margin: 0; color: var(--muted); max-width: 590px; font-size: 14px; }
.sync { display: flex; align-items: center; gap: 9px; color: var(--mint); font: 700 12px ui-monospace, monospace; white-space: nowrap; }
.sync i { width: 9px; height: 9px; border-radius: 50%; background: var(--mint); }
.workspace { display: grid; grid-template-columns: 190px minmax(420px, 1.45fr) minmax(280px, .85fr); gap: 18px; align-items: start; }
.rail, .list-card, .detail-card { border: 1px solid var(--line); background: var(--panel); box-shadow: var(--shadow); }
.rail { padding: 18px 12px; }
.rail h2, .card-head h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
.rail-label { margin: 20px 10px 8px; color: var(--muted); font: 700 10px ui-monospace, monospace; text-transform: uppercase; letter-spacing: .12em; }
.filter { width: 100%; display: flex; justify-content: space-between; border: 0; background: transparent; color: var(--ink); padding: 10px; border-radius: 8px; text-align: left; font: inherit; cursor: pointer; }
.filter:hover, .filter.active { color: var(--cobalt); background: #eef1ff; }
.filter span { color: var(--muted); font: 12px ui-monospace, monospace; }
.list-card, .detail-card { overflow: hidden; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 18px 20px; border-bottom: 1px solid var(--line); }
.result-count { color: var(--muted); font: 12px ui-monospace, monospace; }
.table-head, .book-row { display: grid; grid-template-columns: minmax(0, 1.6fr) 100px 96px 72px; gap: 12px; align-items: center; }
.table-head { padding: 11px 20px; color: var(--muted); background: #f1eee7; font: 700 10px ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
.book-row { width: 100%; border: 0; border-bottom: 1px solid var(--line); background: transparent; color: var(--ink); padding: 14px 20px; text-align: left; cursor: pointer; }
.book-row:hover { background: #f7f8ff; }
.book-row.active { box-shadow: inset 4px 0 0 var(--cobalt); background: #eef1ff; }
.book-title { font-weight: 750; }
.book-author, .book-kind { margin-top: 4px; color: var(--muted); font-size: 12px; }
.status { display: inline-flex; width: fit-content; padding: 4px 7px; border-radius: 99px; background: #e7f4ef; color: #267567; font: 700 10px ui-monospace, monospace; }
.status.progress { background: #fff2dc; color: #9a6819; }
.progress { height: 6px; border-radius: 99px; background: #e9e6df; overflow: hidden; }
.progress i { display: block; height: 100%; background: var(--cobalt); border-radius: inherit; }
.detail-card { padding: 22px; }
.detail-kicker { color: var(--cobalt); font: 700 10px ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
.detail-card h2 { margin: 8px 0 4px; font-size: 25px; letter-spacing: -.04em; }
.detail-author { color: var(--muted); }
.detail-rule { height: 1px; margin: 20px 0; background: var(--line); }
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.meta small { display: block; color: var(--muted); font: 10px ui-monospace, monospace; text-transform: uppercase; letter-spacing: .1em; }
.meta strong { display: block; margin-top: 4px; }
.note { padding: 12px; background: #f8f5ed; border-left: 3px solid var(--amber); color: #4b4e57; font-size: 13px; line-height: 1.5; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
.actions button { border: 1px solid var(--cobalt); border-radius: 7px; padding: 9px 12px; background: var(--cobalt); color: white; font-weight: 700; cursor: pointer; }
.actions button.secondary { background: transparent; color: var(--cobalt); }
.footer { display: flex; justify-content: space-between; gap: 14px; margin-top: 16px; color: var(--muted); font: 11px ui-monospace, monospace; }
@media (max-width: 980px) {
.shell { padding: 20px; }
.workspace { grid-template-columns: 160px minmax(0, 1fr); }
.detail-card { grid-column: 1 / -1; }
}
@media (max-width: 680px) {
.topbar { display: block; }
.sync { margin-top: 18px; }
.workspace { display: block; }
.rail { display: flex; gap: 5px; overflow: auto; margin-bottom: 14px; }
.rail h2, .rail-label { display: none; }
.filter { width: auto; white-space: nowrap; }
.list-card { margin-bottom: 14px; }
.table-head { display: none; }
.book-row { grid-template-columns: 1fr auto; gap: 8px; }
.book-row > :nth-child(n+2) { grid-column: 2; }
.book-row > :nth-child(3), .book-row > :nth-child(4) { display: none; }
.footer { display: block; line-height: 1.8; }
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<div class="eyebrow">A quiet place for unfinished ideas</div>
<h1>Research Shelf</h1>
<p class="subtitle">Keep the next paper, book, and note close enough to become part of the work.</p>
</div>
<div class="sync"><i></i> SQLite · saved locally</div>
</header>
<section class="workspace">
<aside class="rail">
<h2>Library</h2>
<div class="rail-label">Views</div>
<button class="filter active">All <span>12</span></button>
<button class="filter">In progress <span>4</span></button>
<button class="filter">Unread <span>5</span></button>
<button class="filter">Finished <span>3</span></button>
<div class="rail-label">Collections</div>
<button class="filter">★ Starred <span>6</span></button>
<button class="filter">⌕ Essays <span>7</span></button>
<button class="filter">↗ Papers <span>5</span></button>
</aside>
<section class="list-card">
<div class="card-head"><h2>Reading queue</h2><span class="result-count">12 records · page 1</span></div>
<div class="table-head"><span>Title</span><span>Status</span><span>Progress</span><span>Kind</span></div>
<button class="book-row active"><span><b class="book-title">The Shape of Tools</b><small class="book-author">M. Abramson</small></span><span class="status progress">In progress</span><span><div class="progress"><i style="width: 64%"></i></div></span><small class="book-kind">Essay</small></button>
<button class="book-row"><span><b class="book-title">Ways of Seeing</b><small class="book-author">John Berger</small></span><span class="status">Unread</span><span><div class="progress"><i style="width: 0%"></i></div></span><small class="book-kind">Book</small></button>
<button class="book-row"><span><b class="book-title">The Cathedral and the Bazaar</b><small class="book-author">Eric S. Raymond</small></span><span class="status">Finished</span><span><div class="progress"><i style="width: 100%"></i></div></span><small class="book-kind">Paper</small></button>
<button class="book-row"><span><b class="book-title">Designing for Calm</b><small class="book-author">A. Ito</small></span><span class="status progress">In progress</span><span><div class="progress"><i style="width: 32%"></i></div></span><small class="book-kind">Notes</small></button>
<div class="card-head"><span class="result-count">Showing 14 of 12</span><div class="actions"><button class="secondary"></button><button class="secondary"></button></div></div>
</section>
<aside class="detail-card">
<div class="detail-kicker">Selected item · 01</div>
<h2>The Shape of Tools</h2>
<div class="detail-author">M. Abramson · Essay · 2025</div>
<div class="detail-rule"></div>
<div class="meta"><div><small>Status</small><strong>◷ In progress</strong></div><div><small>Priority</small><strong>High</strong></div><div><small>Progress</small><strong>64%</strong></div><div><small>Updated</small><strong>Today</strong></div></div>
<div class="progress" style="margin-top: 18px"><i style="width: 64%"></i></div>
<div class="detail-rule"></div>
<div class="note">“The best tools make attention feel larger, not more fragmented.”</div>
<div class="actions"><button>+ 10%</button><button>✓ Finish</button><button class="secondary">★ Star</button><button class="secondary">Archive</button></div>
</aside>
</section>
<footer class="footer"><span>⌂ ~/.emacs.d/research-shelf.sqlite</span><span>✓ Saved locally · 12 items · last sync just now</span></footer>
</main>
</body>
</html>

View File

@ -0,0 +1,30 @@
;;; etaf-playground-catalog.el --- Reviewed Playground pair catalog -*- lexical-binding: t; -*-
;;; Commentary:
;; Deployment configuration lives outside the generic loader. Adding or
;; replacing an application changes this catalog and its pair, not framework
;; loading, validation, mounting, or lifecycle semantics.
;;; Code:
(defconst etaf-playground-catalog
'((:pair "research-shelf"
:root-component etaf-research-shelf-root
:companion-feature etaf-research-shelf
:static-tags (research-shelf-shell header filters filter main library
detail footer)
:category "SQLite-backed research library"
:capabilities (view component reactivity context theme events data sqlite
pagination mutation lifecycle etaf-ui)
:refs (research-shelf-filter-all research-shelf-filter-reading
research-shelf-filter-unread research-shelf-filter-finished
research-shelf-filter-starred research-shelf-theme-toggle
research-shelf-reload research-shelf-add research-shelf-progress
research-shelf-finish research-shelf-star research-shelf-archive
research-shelf-page-previous research-shelf-page-next)
:gui-checkpoints (compact fullscreen all reading unread detail theme)
:performance (:warm-runs 8 :publication-per-event 1)))
"Reviewed same-basename pair specifications available to the Playground.")
(provide 'etaf-playground-catalog)
;;; etaf-playground-catalog.el ends here

View File

@ -1,97 +1,125 @@
;;; etaf-playground.el --- ETAF operations-console application pair -*- lexical-binding: t; -*-
;;; etaf-playground.el --- Generic ETAF pair playground framework -*- lexical-binding: t; -*-
;;; Commentary:
;; One reviewed pair: inert `.etaf' data plus an executable companion.
;; The Playground owns only pair registration, inert `.etaf' validation,
;; companion loading, mounting, and cleanup. Applications belong in their
;; same-basename companion modules and never leak into this framework.
;;; Code:
(require 'cl-lib)
(require 'etaf)
(require 'etaf-ui)
(require 'etaf-playground-catalog)
(defconst etaf-playground-buffer-name "*ETAF Playground*")
;; `read' consults this dynamically scoped safety switch. Declare it here
;; so the lexical-binding compiler does not mistake the protected binding for
;; an unused lexical local.
(defvar read-eval)
(defconst etaf-playground-buffer-name "*ETAF Playground*"
"Default buffer used by the generic Playground launcher.")
(defconst etaf-playground-example-directory
(expand-file-name "examples"
(file-name-directory (or load-file-name buffer-file-name))))
(defconst etaf-playground-scenario-manifest
'((:pair "operations-console" :root-component etaf-operations-console-root
:category "Complete application"
:capabilities (view component reactivity context theme behavior events
actions data resource lifecycle etaf-ui)
:refs (operations-console-alpha-increment operations-console-alpha-reset
operations-console-beta-increment operations-console-beta-reset
operations-console-nav-overview operations-console-nav-data
operations-console-nav-resource operations-console-theme-toggle
operations-console-local-callback operations-console-named-action
operations-console-behavior-toggle operations-console-clear-selection
operations-console-row-1 operations-console-row-2 operations-console-row-3
operations-console-fail-next
operations-console-resource-reload operations-console-error-boundary)
:gui-checkpoints (compact fullscreen overview data resource theme focus)
:performance (:warm-runs 10 :publication-per-event 1)))
"The sole active Playground pair and its public review metadata.")
(defconst etaf-playground-example-names '("operations-console"))
(file-name-directory (or load-file-name buffer-file-name)))
"Directory containing same-basename `.etaf' and `.el' pairs.")
(defvar etaf-playground-default-pair
(plist-get (car etaf-playground-catalog) :pair)
"Pair opened by `etaf-playground-open'.")
(defvar etaf-playground-scenario-manifest
(copy-tree etaf-playground-catalog)
"Registered Playground pair specifications.
Each entry declares the pair name, companion feature, root factory, inert
static tags, public refs, and review metadata. The framework does not inspect
or special-case the application's business model.")
(defvar etaf-playground-example-names
(mapcar (lambda (entry) (plist-get entry :pair))
etaf-playground-scenario-manifest)
"Names of registered Playground pairs.")
(defvar-local etaf-playground-current-example nil)
(defconst etaf-playground--static-tags
'(operations-console-shell header brand summary theme-control navigation tab
main overview hero metric-strip metric workspace activity-panel
capability-panel timeline data resource status
column row text spacer panel label viewport-height stretch))
(defun etaf-playground-register-pair (entry)
"Register or replace a generic Playground pair ENTRY.
ENTRY must contain a string `:pair', symbol `:root-component', symbol
`:companion-feature', and a proper-list `:static-tags'."
(let ((name (plist-get entry :pair)))
(unless (and (stringp name) (symbolp (plist-get entry :root-component))
(symbolp (plist-get entry :companion-feature))
(proper-list-p (plist-get entry :static-tags)))
(error "Invalid Playground pair manifest entry: %S" entry))
(setq etaf-playground-scenario-manifest
(cons entry
(cl-remove name etaf-playground-scenario-manifest
:key (lambda (item) (plist-get item :pair))
:test #'equal)))
(setq etaf-playground-example-names
(mapcar (lambda (item) (plist-get item :pair))
etaf-playground-scenario-manifest))
entry))
(defun etaf-playground-scenario (name)
"Return the sole manifest entry named NAME."
(or (and (equal name "operations-console")
(car etaf-playground-scenario-manifest))
"Return the registered pair specification named NAME."
(or (cl-find name etaf-playground-scenario-manifest
:key (lambda (entry) (plist-get entry :pair))
:test #'equal)
(user-error "Unknown ETAF Playground pair: %s" name)))
(defun etaf-playground--pair-file (name suffix)
"Return NAME pair path ending in SUFFIX."
(expand-file-name (concat name suffix) etaf-playground-example-directory))
(defun etaf-playground--validate-static-node (node)
"Validate inert static View NODE and return it."
(defun etaf-playground--validate-static-node (node tags)
"Validate inert static View NODE against allowlisted TAGS."
(cond
((or (null node) (stringp node) (numberp node) (keywordp node)
(memq node '(t stretch viewport-height))) node)
;; Symbols in a static pair are inert enum/data values. They are not
;; evaluated because only proper-list nodes become View forms; reject
;; private runtime names while keeping the manifest format composable.
((symbolp node)
(unless (or (string-prefix-p "etaf--" (symbol-name node))
(string-prefix-p "ebox--" (symbol-name node)))
node))
((consp node)
(unless (and (proper-list-p node) (symbolp (car node))
(memq (car node) etaf-playground--static-tags)
(memq (car node) tags)
(not (string-prefix-p "etaf--" (symbol-name (car node))))
(not (string-prefix-p "ebox--" (symbol-name (car node)))))
(error "Unsafe ETAF static View form: %S" node))
(mapc #'etaf-playground--validate-static-node (cdr node))
(mapc (lambda (child) (etaf-playground--validate-static-node child tags))
(cdr node))
node)
(t (error "Unsafe ETAF static View value: %S" node))))
(defun etaf-playground-read-static (name)
"Read and validate NAME's single inert `.etaf' form."
(with-temp-buffer
(insert-file-contents (etaf-playground--pair-file name ".etaf"))
(let ((read-eval nil)
(form (read (current-buffer))))
(ignore read-eval)
(condition-case nil
(progn (read (current-buffer))
(error "ETAF static file contains multiple forms"))
(end-of-file nil))
(etaf-playground--validate-static-node form))))
(let ((scenario (etaf-playground-scenario name)))
(with-temp-buffer
(insert-file-contents (etaf-playground--pair-file name ".etaf"))
(let ((read-eval nil))
(ignore read-eval)
(let ((form (read (current-buffer))))
(condition-case nil
(progn (read (current-buffer))
(error "ETAF static file contains multiple forms"))
(end-of-file nil))
(etaf-playground--validate-static-node
form (plist-get scenario :static-tags)))))))
(defun etaf-playground-read-pair (name)
"Read NAME data, load its companion, and return its root View."
(let* ((scenario (etaf-playground-scenario name))
(form (etaf-playground-read-static name))
(feature (plist-get scenario :companion-feature))
(root (plist-get scenario :root-component)))
(unless (featurep 'etaf-operations-console)
;; Use Emacs's normal source/bytecode resolution. `load-file' forced
;; the 39KB companion through the Lisp interpreter on every fresh
;; process, making the first retained render look like a layout
;; regression. A compiled companion is the production path; source is
;; still the deterministic fallback when no `.elc' exists.
(unless (featurep feature)
(load (file-name-sans-extension
(etaf-playground--pair-file name ".el"))
nil nil nil))
(etaf-playground--pair-file name ".el")) nil nil nil))
(unless (functionp root) (error "Missing root factory: %S" root))
(funcall root form)))
(defun etaf-playground-mount-example (buffer-name name)
"Mount pair NAME in BUFFER-NAME."
"Mount registered pair NAME in BUFFER-NAME."
(when-let ((runtime (etaf-runtime-for-buffer buffer-name)))
(etaf-unmount runtime))
(let ((buffer (etaf-mount buffer-name (etaf-playground-read-pair name))))
@ -105,32 +133,37 @@
;;;###autoload
(defun etaf-playground-open (&optional buffer-name)
"Open the operations-console pair in BUFFER-NAME."
"Open the registered default pair in BUFFER-NAME."
(interactive)
(etaf-playground--display-buffer
(etaf-playground-mount-example
(or buffer-name etaf-playground-buffer-name) "operations-console")))
(or buffer-name etaf-playground-buffer-name)
etaf-playground-default-pair)))
;;;###autoload
(defun etaf-playground-open-example (name &optional buffer-name)
"Open pair NAME in BUFFER-NAME."
"Open registered pair NAME in BUFFER-NAME."
(interactive (list (completing-read "Pair: " etaf-playground-example-names)))
(etaf-playground--display-buffer
(etaf-playground-mount-example
(or buffer-name etaf-playground-buffer-name) name)))
;;;###autoload
(defun etaf-playground-reset (&optional buffer-name)
"Remount the active pair in BUFFER-NAME."
(interactive)
(let ((name (or buffer-name etaf-playground-buffer-name)))
(unless (get-buffer name) (user-error "No Playground pair is mounted"))
"Remount the current/default registered pair in BUFFER-NAME."
(let* ((name (or buffer-name etaf-playground-buffer-name))
(buffer (get-buffer name))
(pair (or (and buffer
(buffer-local-value 'etaf-playground-current-example
buffer))
etaf-playground-default-pair)))
(unless buffer (user-error "No ETAF Playground pair is mounted"))
(etaf-playground--display-buffer
(etaf-playground-mount-example name "operations-console"))))
(etaf-playground-mount-example name pair))))
;;;###autoload
(defun etaf-playground-close (&optional buffer-name)
"Unmount and kill the Playground BUFFER-NAME."
(interactive)
(let* ((name (or buffer-name etaf-playground-buffer-name))
(buffer (get-buffer name)))
(when-let ((runtime (and buffer (etaf-runtime-for-buffer buffer))))

View File

@ -1,756 +0,0 @@
;;; operations-console.el --- one complete ETAF application example -*- lexical-binding: t; -*-
;;; Commentary:
;; This single-file sample shows the public development path: `:view'
;; props/slots/styles, `:setup' state, a normal composable, `expr' for
;; dynamic Views, Context/Theme, callbacks/Actions/Behaviors, Data, Resource
;; cleanup, and public open/close commands.
;;; Code:
(require 'cl-lib)
(require 'etaf)
(require 'etaf-ui)
(defconst etaf-operations-console-buffer "*ETAF Operations Console*"
"Default buffer used by `etaf-operations-console-open'.")
(defvar etaf-operations-console-cleanup-count 0
"Observable count of composable cleanup calls for review and tests.")
;;; Pure `:view' Component: props, named/default slots, and `:styles'.
(etaf-define-component etaf-operations-console-card (&key title)
"Render a styled card with explicit named and default slot outlets."
:styles
(styles
("&" :width stretch :border ((1) solid "#687386") :padding (1 2))
(".operations-console-card-title" :face bold))
:view
(column :class "operations-console-card"
(text :class "operations-console-card-title"
(expr :value title))
(slot :name 'header)
(slot (label :text "This card has no default content."))))
;;; A normal Elisp composable called synchronously from Component `:setup'.
(defun etaf-operations-console-use-counter (name initial-value)
"Create isolated counter state and effects for NAME and INITIAL-VALUE."
(let* ((count (etaf-ref initial-value :name name))
(double (etaf-computed (lambda () (* 2 (etaf-value count)))
:name (intern (format "%s-double" name))))
(updates (etaf-ref 0 :name (intern (format "%s-updates" name))))
(audit-text (format "effect saw %d" (* 2 (or initial-value 0))))
(audit (etaf-ref audit-text
:name (intern (format "%s-audit" name)))))
(etaf-watch count
(lambda (_new _old) (cl-incf (etaf-value updates)))
:immediate nil :name (intern (format "%s-watch" name)))
(etaf-watch-effect
(lambda ()
(let ((value (etaf-value double)))
(let ((next (format "effect saw %d" value)))
(unless (equal next audit-text)
(setq audit-text next)
(setf (etaf-value audit) next)))))
:name (intern (format "%s-effect" name)))
(etaf-on-scope-dispose
(lambda () (cl-incf etaf-operations-console-cleanup-count)))
(list :name name :count count :double double :updates updates
:audit audit)))
(defun etaf-operations-console-counter-ref (name operation)
"Return a stable interactive ref for counter NAME and OPERATION."
(intern (format "operations-console-%s-%s" name operation)))
;;; Stateful Component: its render closure retains the composable instance.
(etaf-define-component etaf-operations-console-counter (&key name initial-value)
"Render one isolated counter Component instance."
:setup
(let* ((instance-name name)
(initial initial-value)
(state (etaf-operations-console-use-counter
(intern (format "operations-console-%s" instance-name)) initial))
(count (plist-get state :count))
(double (plist-get state :double))
(updates (plist-get state :updates))
(audit (plist-get state :audit))
(increment (lambda () (cl-incf (etaf-value count))))
(reset (lambda () (setf (etaf-value count) initial))))
(lambda ()
(etaf-view
(operations-console-card
:title (format "Counter %s" instance-name)
(slot :name 'header
(label :text "State belongs to this Component instance."))
(label :text (format "Value: %d · Double: %d"
(etaf-value count) (etaf-value double)))
(label :text (format "Watch updates: %d · Audit: %s"
(etaf-value updates) (etaf-value audit)))
(flex :gap '(0 (8))
(button :label "Increment"
:ref (etaf-operations-console-counter-ref instance-name "increment")
:on-press increment)
(button :label "Reset"
:ref (etaf-operations-console-counter-ref instance-name "reset")
:on-press reset))
(expr
:value
(when (> (etaf-value count) 0)
(etaf-view
(text "Dynamic child is visible after Increment.")))))))))
;;; Context and Theme: the child consumes a value provided by the shell.
;;; Named Action: state is passed explicitly, so no hidden global app state.
(etaf-action-define etaf-operations-console-named-note (runtime target)
"Publish a message through a named Action."
(ignore runtime)
(setf (etaf-value target) "Named Action: pressed"))
;;; Behavior reuse: ordinary Elisp returns semantic interaction bundles.
(defun etaf-operations-console-use-toggle-behaviors (toggle message)
"Return reusable focus and toggle Behaviors for TOGGLE and MESSAGE."
(list
(etaf-focusable)
(etaf-toggleable
:value toggle
:on-change
(lambda (value)
(setf (etaf-value toggle) value
(etaf-value message)
(if value "Behavior toggle: on" "Behavior toggle: off"))))))
(etaf-define-component etaf-operations-console-action-panel ()
"Show a local callback, a named Action, and reusable Behaviors."
:setup
(let* ((message (etaf-ref "Callback action pending" :name 'operations-console-message))
(toggle (etaf-ref nil :name 'operations-console-toggle))
;; Behavior installers use function identity. Construct this
;; reusable bundle once in setup so state renders retain it.
(behaviors (etaf-operations-console-use-toggle-behaviors toggle message))
(local-callback
(lambda () (setf (etaf-value message) "Local callback: pressed")))
(named-callback
(lambda () (etaf-dispatch 'etaf-operations-console-named-note message))))
(lambda ()
(etaf-view
(operations-console-card
:title "Events, Actions, and Behaviors"
(column :width 'stretch
(label :text (etaf-value message)))
(flex :gap '(0 (8))
(button :label "Local callback"
:ref 'operations-console-local-callback
:on-press local-callback)
(button :label "Named Action"
:ref 'operations-console-named-action
:on-press named-callback)
(button
:label (if (etaf-value toggle) "Behavior: on" "Behavior: off")
:ref 'operations-console-behavior-toggle
:use behaviors)))))))
;;; Application model: detached resources are ready before the first render.
(defun etaf-operations-console-create-model ()
"Create and load the external model before mounting the application."
(let* ((source (etaf-data-memory-source
'((:id 1 :name "Ada" :status "open")
(:id 2 :name "Grace" :status "review")
(:id 3 :name "Lin" :status "done"))
:id-key :id :name 'operations-console-records))
(controller (etaf-data-controller
source :page-size 3 :name 'operations-console-controller))
(fail-next (etaf-ref nil :name 'operations-console-fail-next))
(cleanups (etaf-ref 0 :name 'operations-console-resource-cleanups))
(health (etaf-ref "All systems nominal"
:name 'operations-console-health-status))
(activity (etaf-ref "Theme provider promoted"
:name 'operations-console-activity))
(trail (etaf-ref "Surface mounted · Data controller loaded"
:name 'operations-console-event-trail))
(throughput (etaf-ref 2400 :name 'operations-console-throughput))
(generation 0))
(etaf-data-load controller)
(let ((resource
(etaf-resource
(lambda ()
(cl-incf generation)
(if (etaf-value fail-next)
(progn
(setf (etaf-value fail-next) nil)
(error "Example load failed"))
(etaf-resource-result
(format "Loaded value %d" generation)
:cleanup (lambda () (cl-incf (etaf-value cleanups))))))
:immediate t :name 'operations-console-resource)))
(list :controller controller :resource resource
:fail-next fail-next :cleanups cleanups
:health health :activity activity :trail trail
:throughput throughput
:health-check
(lambda ()
(setf (etaf-value health) "Health check complete"
(etaf-value activity) "Health check completed"
(etaf-value trail) "Health check · all owned surfaces responded"))
:show-trail
(lambda ()
(setf (etaf-value activity) "Event trail focused"
(etaf-value trail) "Event trail opened for inspection"))))))
(defun etaf-operations-console-dispose-model (model)
"Stop MODEL's Data Controller and dispose its Resource."
(etaf-data-stop (plist-get model :controller))
(etaf-resource-dispose (plist-get model :resource)))
(defun etaf-operations-console-data-status (selection items)
"Return visible status derived from SELECTION and ITEMS."
(let ((selected (cl-find (car selection) items :key
(lambda (row) (plist-get row :id))
:test #'equal)))
(if selected
(format "Selected %s" (plist-get selected :name))
"Rows loaded from the public controller.")))
;;; Data Controller + official `data-grid'.
(etaf-define-component etaf-operations-console-data-panel (&key model)
"Render MODEL's loaded Data Controller and interactive DataGrid."
:setup
(let* ((controller (plist-get model :controller))
(view-state
(etaf-computed
(lambda ()
(let ((selection (etaf-value (etaf-data-selection controller)))
(items (etaf-value (etaf-data-items controller))))
(list :selected (car selection)
:status (etaf-operations-console-data-status selection items))))
:name 'operations-console-data-view-state))
(row-key (lambda (row) (plist-get row :id)))
(row-ref (lambda (row)
(intern (format "operations-console-row-%s"
(plist-get row :id)))))
(row-press (lambda (row)
(etaf-data-select-one controller (plist-get row :id))))
(clear-selection
(lambda () (etaf-data-clear-selection controller))))
(lambda ()
(let ((state (etaf-value view-state)))
(etaf-view
(operations-console-card
:title "Data Controller and DataGrid"
(label :text (plist-get state :status))
(data-grid
:controller controller
:columns '((:key :id :label "ID" :width 5)
(:key :name :label "NAME" :width 20)
(:key :status :label "STATUS" :width 10))
:row-key row-key
:row-ref row-ref
:selected-key (plist-get state :selected)
:on-row-press row-press)
(button :label "Clear selection"
:ref 'operations-console-clear-selection
:on-press clear-selection)))))))
;;; Resource + explicit error boundary: load, reload, cleanup, and recovery.
(etaf-define-component etaf-operations-console-resource-panel (&key model)
"Render MODEL's Resource with reload, failure, and cleanup feedback."
:setup
(let* ((instance-model model)
(fail-next (plist-get instance-model :fail-next))
(cleanups (plist-get instance-model :cleanups))
(boundary (etaf-ref "No boundary run yet."
:name 'operations-console-boundary))
(resource (plist-get instance-model :resource))
(fail-change (lambda (value) (setf (etaf-value fail-next) value)))
(reload (lambda () (etaf-resource-load resource)))
(run-boundary
(lambda ()
(setf (etaf-value boundary)
(etaf-error-boundary-run
(lambda () (error "Handled sample"))
(lambda (condition)
(format "Handled: %s"
(error-message-string condition))))))))
(lambda ()
(etaf-view
(operations-console-card
:title "Resource and Error Boundary"
(label :text (format "Status: %s"
(etaf-resource-status resource)))
(label :text (or (etaf-resource-value resource)
(when-let ((condition (etaf-resource-error resource)))
(format "Error: %s"
(error-message-string condition)))))
(label :text (format "Cleanup calls: %d"
(etaf-value cleanups)))
(checkbox :label "Fail next load"
:ref 'operations-console-fail-next
:checked (etaf-value fail-next)
:on-change fail-change)
(flex :gap '(0 (8))
(button :label "Reload resource"
:ref 'operations-console-resource-reload
:on-press reload)
(button :label "Run handled boundary"
:ref 'operations-console-error-boundary
:on-press run-boundary))
(label :text (etaf-value boundary)))))))
;;; Page composition and navigation views.
(defun etaf-operations-console-static-child (form tag)
"Return the first static child named TAG from FORM.
The static `.etaf' form is the composition authority; the companion only
fills these safe named slots with reactive Views."
(cl-find-if (lambda (entry)
(and (consp entry) (eq (car entry) tag)))
(cdr form)))
(defun etaf-operations-console-static-value (form key default)
"Return keyword KEY from static FORM, or DEFAULT.
Keyword values are inert data; executable behavior never comes from `.etaf'."
(let ((tail (member key (cdr form))))
(if tail (cadr tail) default)))
(defun etaf-operations-console-static-child-value (form tag key default)
"Return KEY from child TAG in static FORM, or DEFAULT.
The helper keeps `.etaf' consumption at one validated data boundary instead
of scattering plist reads through the application renderer."
(etaf-operations-console-static-value
(or (etaf-operations-console-static-child form tag) nil)
key default))
(defun etaf-operations-console-static-child-by-value (form tag key value)
"Return the child TAG whose KEY equals VALUE in static FORM."
(cl-find-if (lambda (entry)
(and (consp entry)
(eq (car entry) tag)
(equal (plist-get (cdr entry) key) value)))
(cdr form)))
(defun etaf-operations-console-hero-view (model hero-form)
"Return the visual hero for MODEL using static HERO-FORM copy."
(let ((eyebrow (etaf-operations-console-static-value
hero-form :eyebrow "TUESDAY / 09:42 / LIVE REVIEW"))
(title (etaf-operations-console-static-value
hero-form :title "Make complex state feel calm.")))
(etaf-view
(column :class "operations-console-hero" :width 'stretch
:padding '(2 3) :border "#17374A"
:bgcolor "#17374A" :color "#F6F1E8"
(column :width 'stretch
(label :text eyebrow :face 'bold)
(label :text title :face 'bold)
(label :text "A single surface for components, context, behavior, data, and recovery — designed to make every ETAF update visible and explainable.")
(flex :width 'stretch :gap '(1 (8))
(button :label "Run health check"
:ref 'operations-console-health-check
:on-press (plist-get model :health-check))
(button :label "View event trail"
:ref 'operations-console-show-trail
:on-press (plist-get model :show-trail))))))))
(etaf-define-component etaf-operations-console-metric
(&key label value delta color)
"Render one KPI metric with a retained Component owner."
:setup
(lambda ()
(etaf-view
(column :class "operations-console-metric" :width 'stretch
:padding '(1 2) :border (etaf-current-prop :color)
:bgcolor (etaf-theme-value
:surface "#FFFDF8")
:color (etaf-theme-value :color "#142235")
(label :text (etaf-current-prop :label))
(label :text (etaf-current-prop :value) :face 'bold)
(label :text (etaf-current-prop :delta)
:color (etaf-theme-value
:success "#3E9B72"))))))
(defun etaf-operations-console-metric-strip-view (model)
"Return the four-card KPI strip for MODEL."
(etaf-view
(grid :width 'stretch :grid-template-columns '(1fr 1fr 1fr 1fr)
:gap '(1 (8))
(operations-console-metric
:label "Throughput"
:value (format "%dk" (/ (etaf-value (plist-get model :throughput)) 1000))
:delta "↑ 18% this week"
:color (etaf-theme-value :teal "#2E8B83"))
(operations-console-metric
:label "Active flows" :value "18" :delta "+3 since 08:00"
:color (etaf-theme-value :coral "#E26D5A"))
(operations-console-metric
:label "Response" :value "42ms" :delta "p95 within target"
:color (etaf-theme-value :amber "#D99A3D"))
(operations-console-metric
:label "Reliability" :value "99.8%" :delta "steady"
:color (etaf-theme-value :success "#3E9B72")))))
(defun etaf-operations-console-activity-view (model)
"Return the reactive Activity stream card for MODEL."
(etaf-view
(column :class "operations-console-card" :width 'stretch
:padding '(1 2) :border (etaf-theme-value
:line "#C8C1B6")
:bgcolor (etaf-theme-value :surface "#FFFDF8")
(flex :width 'stretch :align-items 'center
(column :width 'stretch :flex-grow 1 :flex-shrink 1 :min-width 0
(label :text "Activity stream" :face 'bold)
(label :text "Reactive events, rendered as a readable story."))
(label :text "4 EVENTS" :color
(etaf-theme-value :teal "#2E8B83")))
(label :text (format "↗ %s" (etaf-value (plist-get model :activity))))
(label :text "◆ Counter alpha crossed 2k · one Component-owned update")
(label :text "✓ Resource boundary recovered · shell stayed mounted"))))
(defun etaf-operations-console-capability-view ()
"Return the static capability map card."
(etaf-view
(column :class "operations-console-card" :width 'stretch
:padding '(1 2) :border (etaf-theme-value
:line "#C8C1B6")
:bgcolor (etaf-theme-value :surface "#FFFDF8")
(flex :width 'stretch :align-items 'center
(column :width 'stretch :flex-grow 1 :flex-shrink 1 :min-width 0
(label :text "Capability map" :face 'bold)
(label :text "What this surface is proving."))
(label :text "ETAF" :color
(etaf-theme-value :teal "#2E8B83")))
(label :text "✓ Component ownership · props, slots, setup, retained identity")
(label :text "✓ Context + Theme · provider defaults and dark mode")
(label :text "✓ Behavior + Actions · keyboard-ready stable refs")
(label :text "✓ Data + Resource · selection, failure, boundary, cleanup"))))
(defun etaf-operations-console-timeline-view (model)
"Return the event trail card for MODEL."
(etaf-view
(column :class "operations-console-card" :width 'stretch
:padding '(1 2) :border (etaf-theme-value
:line "#C8C1B6")
:bgcolor (etaf-theme-value :surface "#FFFDF8")
(flex :width 'stretch :align-items 'center
(column :width 'stretch :flex-grow 1 :flex-shrink 1 :min-width 0
(label :text "Event trail" :face 'bold)
(label :text "Every meaningful action leaves a compact, inspectable trace."))
(button :label "Clear trail" :ref 'operations-console-clear-trail
:on-press (lambda ()
(setf (etaf-value (plist-get model :trail))
"Trail cleared · new actions append here"))))
(label :text (format "● %s" (etaf-value (plist-get model :trail)))))))
;; These wrappers make the HTML sections real ETAF Components. The plain
;; `*-view' helpers stay small and readable, while the Component boundary
;; gives every dynamic `expr' a reviewed semantic owner.
(etaf-define-component etaf-operations-console-hero (&key model hero-form)
"Render the operations hero as a retained Component."
:setup
(lambda () (etaf-operations-console-hero-view
(etaf-current-prop :model)
(etaf-current-prop :hero-form))))
(etaf-define-component etaf-operations-console-metric-strip (&key model)
"Render the operations KPI strip as a retained Component."
:setup
(lambda () (etaf-operations-console-metric-strip-view
(etaf-current-prop :model))))
(etaf-define-component etaf-operations-console-activity (&key model)
"Render the reactive activity card as a retained Component."
:setup
(lambda () (etaf-operations-console-activity-view
(etaf-current-prop :model))))
(etaf-define-component etaf-operations-console-capabilities ()
"Render the static capability map as a reusable Component."
:setup
(lambda () (etaf-operations-console-capability-view)))
(etaf-define-component etaf-operations-console-timeline (&key model)
"Render the event trail as a retained Component."
:setup
(lambda () (etaf-operations-console-timeline-view
(etaf-current-prop :model))))
(defun etaf-operations-console-overview-page (model main-form)
"Return the Overview page View for MODEL using MAIN-FORM sections."
(let* ((overview-form (etaf-operations-console-static-child
main-form 'overview))
(hero-form (etaf-operations-console-static-child
overview-form 'hero)))
(etaf-view
(column :class "operations-console-page" :width 'stretch
(operations-console-hero :model model :hero-form hero-form)
(operations-console-metric-strip :model model)
(grid :width 'stretch :grid-template-columns '(5fr 3fr)
:gap '(1 (12))
(operations-console-activity :model model)
(operations-console-capabilities))
(operations-console-timeline :model model)
(grid :width 'stretch :grid-template-columns '(1fr 1fr)
:gap '(1 (12))
(operations-console-counter :name "alpha" :initial-value 0)
(operations-console-counter :name "beta" :initial-value 10)
(operations-console-action-panel))))))
(defun etaf-operations-console-data-page (model)
"Return the Data page View for MODEL."
(etaf-view
(column :class "operations-console-page" :width 'stretch
(operations-console-data-panel :model model))))
(defun etaf-operations-console-resource-page (model)
"Return the Resource page View for MODEL."
(etaf-view
(column :class "operations-console-page" :width 'stretch
(operations-console-resource-panel :model model))))
(etaf-define-component etaf-operations-console-overview-surface
(&key model main-form)
"Own the material Overview page surface."
:setup
(lambda ()
(etaf-operations-console-overview-page
(etaf-current-prop :model)
(etaf-current-prop :main-form))))
(etaf-define-component etaf-operations-console-data-surface (&key model)
"Own the material Data page surface."
:setup
(lambda ()
(etaf-operations-console-data-page (etaf-current-prop :model))))
(etaf-define-component etaf-operations-console-resource-surface (&key model)
"Own the material Resource page surface."
:setup
(lambda ()
(etaf-operations-console-resource-page (etaf-current-prop :model))))
(defun etaf-operations-console-page-view (page model main-form)
"Return the selected page Component call for PAGE, MODEL, and MAIN-FORM.
The function is called from an `expr' Range, so the page signal belongs to
the retained route owner while the structural forms remain ordinary compiled
View calls."
(pcase page
('data
(etaf-view (operations-console-data-surface :model model)))
('resource
(etaf-view (operations-console-resource-surface :model model)))
(_
(etaf-view
(operations-console-overview-surface
:model model :main-form main-form)))))
(etaf-define-component etaf-operations-console-page-router
(&key page-ref model main-form)
"Render the current material page from PAGE-REF and MODEL.
The page signal is read by this retained router Component, not by the root
shell, so navigation does not invalidate the whole application generation."
:setup
(lambda ()
;; Keep one material Host as the route owner's layout anchor. Page
;; surfaces are Components, so the route never injects a raw View through
;; an unowned expr and the containing-block width remains explicit.
(let ((model (etaf-current-prop :model))
(main-form (etaf-current-prop :main-form))
(page-ref (etaf-current-prop :page-ref)))
(etaf-view
(fragment
(column :class "operations-console-page-router" :width 'stretch
:ref 'operations-console-page-slot
(expr
:value
(etaf-operations-console-page-view
(etaf-value page-ref) model main-form))))))))
;;; Root shell: Context provider, two composable instances, and viewport.
(etaf-define-component etaf-operations-console-shell
(&key model static-form page-ref)
"Compose the complete public ETAF application."
:styles
(styles
(".operations-console-shell" :width stretch :padding (1 2))
(".operations-console-shell-header" :width stretch :padding (1 2))
(".operations-console-nav" :padding (0 2)
:border ((1) solid "#687386"))
(".operations-console-main" :width stretch :padding (1 2))
(".operations-console-page" :width stretch)
(".operations-console-status" :width stretch :padding (0 2)
:border ((1) solid "#687386")))
:setup
(let* ((instance-model model)
(page-cell page-ref)
(title (etaf-operations-console-static-value
static-form :title "Operations Console"))
(kicker (etaf-operations-console-static-value
static-form :kicker "ETAF / reference workspace"))
(header-form (etaf-operations-console-static-child static-form 'header))
(navigation-form
(etaf-operations-console-static-child static-form 'navigation))
(main-form (etaf-operations-console-static-child static-form 'main))
(status-form (etaf-operations-console-static-child static-form 'status))
(brand-form (etaf-operations-console-static-child header-form 'brand))
(summary-form (etaf-operations-console-static-child header-form 'summary))
(theme-control-form
(etaf-operations-console-static-child header-form 'theme-control))
(overview-tab
(etaf-operations-console-static-child-by-value
navigation-form 'tab :key "overview"))
(data-tab
(etaf-operations-console-static-child-by-value
navigation-form 'tab :key "data"))
(resource-tab
(etaf-operations-console-static-child-by-value
navigation-form 'tab :key "resource"))
(brand-mark (etaf-operations-console-static-value
brand-form :mark "OC"))
(brand-name (etaf-operations-console-static-value
brand-form :name title))
(summary-status (etaf-operations-console-static-value
summary-form :status "All systems nominal"))
(theme-label (etaf-operations-console-static-value
theme-control-form :label "Theme"))
(overview-label (etaf-operations-console-static-value
overview-tab :label "Overview"))
(data-label (etaf-operations-console-static-value
data-tab :label "Data"))
(resource-label (etaf-operations-console-static-value
resource-tab :label "Resource"))
(dark (etaf-ref nil :name 'operations-console-dark-theme))
(theme (etaf-computed
(lambda ()
(if (etaf-value dark)
'(:color "#EDF2F4" :bgcolor "#111923"
:surface "#182433" :line "#344455"
:teal "#61C6BA" :coral "#FF957F"
:amber "#EFC06C" :success "#73D29D")
'(:color "#142235" :bgcolor "#F6F1E8"
:surface "#FFFDF8" :line "#C8C1B6"
:teal "#2E8B83" :coral "#E26D5A"
:amber "#D99A3D" :success "#3E9B72"))))))
(unless (and header-form navigation-form main-form status-form
brand-form summary-form theme-control-form
overview-tab data-tab resource-tab)
(error "Invalid Operations Console shell composition: %S" static-form))
(etaf-provide 'operations-console-theme theme)
(etaf-theme-provide theme)
(etaf-on-unmounted
(lambda () (etaf-operations-console-dispose-model instance-model)))
(lambda ()
(etaf-view
(column :class "operations-console-shell" :width 'stretch
:height '(viewport-height)
(column :class "operations-console-shell-header" :width 'stretch
(flex :width 'stretch :align-items 'center
(column :width 'stretch :flex-grow 1 :flex-shrink 1 :min-width 0
(label :face 'bold :text (format "%s %s" brand-mark brand-name))
(label :text kicker))
(label :text (format "● %s" summary-status)
:color (etaf-theme-value
:success "#3E9B72")))
(label :text "A calm reference surface for complex state, ownership, and recovery."))
(flex :class "operations-console-nav" :width 'max-content
:ref 'operations-console-nav
:align-items 'center :gap '(0 (8))
(button :label overview-label :ref 'operations-console-nav-overview
:on-press (lambda () (setf (etaf-value page-cell) 'overview)))
(button :label data-label :ref 'operations-console-nav-data
:on-press (lambda () (setf (etaf-value page-cell) 'data)))
(button :label resource-label :ref 'operations-console-nav-resource
:on-press (lambda () (setf (etaf-value page-cell) 'resource)))
(checkbox :label theme-label :ref 'operations-console-theme-toggle
:checked (etaf-value dark)
:color (etaf-theme-value :color "#252A2E")
:bgcolor (etaf-theme-value :bgcolor "#F8F5EE")
:on-change (lambda (value)
(setf (etaf-value dark) value)))
;; Reserve the small status slot explicitly so a theme flip changes
;; paint/content inside the control bar without reflowing its parent.
(label :width 12
:text (if (etaf-value dark) "Theme: Dark" "Theme: Light")))
(column :class "operations-console-main" :width 'stretch
:flex-grow 1 :flex-shrink 1 :ref 'operations-console-main
(operations-console-page-router :page-ref page-cell
:model instance-model
:main-form main-form))
(column :class "operations-console-status" :width 'stretch
:flex-shrink 0 :ref 'operations-console-status
(label :text (or (etaf-operations-console-static-value
status-form :label nil)
"Ready for review"))))))))
;;; Public View/open/close entry points.
(defun etaf-operations-console-view (model static-form page-ref)
"Return the root View for MODEL, STATIC-FORM, and stable PAGE-REF."
(etaf-view (operations-console-shell :model model :static-form static-form
:page-ref page-ref)))
(defun etaf-operations-console-root (static-form)
"Consume validated STATIC-FORM and return one owned application root View."
(unless (equal static-form
'(operations-console-shell
:title "Operations Console"
:kicker "ETAF / reference workspace"
(header
(brand :mark "OC" :name "Operations Console")
(summary :status "All systems nominal")
(theme-control :label "Theme"))
(navigation
(tab :key "overview" :label "Overview" :index "01")
(tab :key "data" :label "Data desk" :index "02")
(tab :key "resource" :label "Resource lab" :index "03"))
(main
(overview
(hero :eyebrow "Tuesday / 09:42 / live review"
:title "Make complex state feel calm.")
(metric-strip
(metric :key "throughput" :label "Throughput")
(metric :key "flows" :label "Active flows")
(metric :key "response" :label "Response")
(metric :key "reliability" :label "Reliability"))
(workspace
(activity-panel :title "Activity stream")
(capability-panel :title "Capability map"))
(timeline :title "Event trail"))
(data :title "Data desk")
(resource :title "Resource lab"))
(status :label "Ready for review")))
(error "Unsupported Operations Console static root: %S" static-form))
(let ((model (etaf-operations-console-create-model))
(page (etaf-ref 'overview :name 'operations-console-page)))
(lambda ()
(etaf-operations-console-view
model static-form page))))
;;;###autoload
(defun etaf-operations-console-open (&optional buffer-name)
"Open the canonical Playground pair in BUFFER-NAME."
(interactive)
(require 'etaf-playground)
(etaf-playground-open (or buffer-name etaf-operations-console-buffer)))
;;;###autoload
(defun etaf-operations-console-close (&optional buffer-name)
"Unmount and kill the application buffer named BUFFER-NAME."
(interactive)
(let* ((name (or buffer-name etaf-operations-console-buffer))
(runtime (etaf-runtime-for-buffer name))
(buffer (get-buffer name)))
(when runtime (etaf-unmount runtime))
(when (buffer-live-p buffer) (kill-buffer buffer))
buffer))
(provide 'etaf-operations-console)
;;; operations-console.el ends here

View File

@ -1,29 +0,0 @@
;; Static composition only. The companion owns all state, components, and
;; event handlers; this file is the safe layout contract it consumes.
(operations-console-shell
:title "Operations Console"
:kicker "ETAF / reference workspace"
(header
(brand :mark "OC" :name "Operations Console")
(summary :status "All systems nominal")
(theme-control :label "Theme"))
(navigation
(tab :key "overview" :label "Overview" :index "01")
(tab :key "data" :label "Data desk" :index "02")
(tab :key "resource" :label "Resource lab" :index "03"))
(main
(overview
(hero :eyebrow "Tuesday / 09:42 / live review"
:title "Make complex state feel calm.")
(metric-strip
(metric :key "throughput" :label "Throughput")
(metric :key "flows" :label "Active flows")
(metric :key "response" :label "Response")
(metric :key "reliability" :label "Reliability"))
(workspace
(activity-panel :title "Activity stream")
(capability-panel :title "Capability map"))
(timeline :title "Event trail"))
(data :title "Data desk")
(resource :title "Resource lab"))
(status :label "Ready for review"))

542
examples/research-shelf.el Normal file
View File

@ -0,0 +1,542 @@
;;; research-shelf.el --- SQLite-backed research library -*- lexical-binding: t; -*-
;;; Commentary:
;; A useful ETAF application: a small reading/research shelf whose records
;; survive unmounts because the Data Controller is backed by etaf-sqlite.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'etaf)
(require 'etaf-ui)
(require 'etaf-sqlite)
(require 'etaf-playground)
(defcustom etaf-research-shelf-database-file
(expand-file-name "research-shelf.sqlite" user-emacs-directory)
"SQLite file used by the Research Shelf application."
:type 'file
:group 'etaf)
(defconst etaf-research-shelf-seed-records
'((:id 1 :title "The Shape of Tools" :author "M. Abramson"
:kind "Essay" :status "reading" :progress 64 :priority "High"
:starred 1 :note "The best tools make attention feel larger, not more fragmented." :updated "Today")
(:id 2 :title "Ways of Seeing" :author "John Berger"
:kind "Book" :status "unread" :progress 0 :priority "Medium"
:starred 0 :note "Look again at the relationship between image and power." :updated "Yesterday")
(:id 3 :title "The Cathedral and the Bazaar" :author "Eric S. Raymond"
:kind "Paper" :status "finished" :progress 100 :priority "Low"
:starred 1 :note "A useful contrast between closed and open production." :updated "Mon")
(:id 4 :title "Designing for Calm" :author "A. Ito"
:kind "Notes" :status "reading" :progress 32 :priority "High"
:starred 0 :note "Reduce decisions before asking for attention." :updated "Sun")
(:id 5 :title "The Craftsman" :author "Richard Sennett"
:kind "Book" :status "unread" :progress 0 :priority "Medium"
:starred 1 :note "A long-form companion for making and revising." :updated "Sat")
(:id 6 :title "How Buildings Learn" :author "Stewart Brand"
:kind "Book" :status "reading" :progress 78 :priority "Medium"
:starred 0 :note "Change is a property of useful systems." :updated "Fri")
(:id 7 :title "The Interface Is the Message" :author "N. Hara"
:kind "Essay" :status "finished" :progress 100 :priority "Low"
:starred 0 :note "A short note on visible constraints." :updated "Thu")
(:id 8 :title "Notes on Attention" :author "Simone Weil"
:kind "Notes" :status "unread" :progress 0 :priority "High"
:starred 1 :note "Attention is not force; it is patient availability." :updated "Wed"))
"Seed records installed into a new Research Shelf database once.")
(defun etaf-research-shelf--static-child (form tag)
"Return the first child named TAG from static FORM."
(cl-find-if (lambda (entry) (and (consp entry) (eq (car entry) tag)))
(cdr form)))
(defun etaf-research-shelf--static-value (form key default)
"Return KEY from static FORM, or DEFAULT."
(or (plist-get (cdr form) key) default))
(defun etaf-research-shelf--database ()
"Return the configured typed Research Shelf database description."
(etaf-sqlite-database
etaf-research-shelf-database-file
(etaf-sqlite-table
'reading_items
(list (etaf-sqlite-column :id "id" :type 'integer :primary t)
(etaf-sqlite-column :title "title" :type 'text)
(etaf-sqlite-column :author "author" :type 'text)
(etaf-sqlite-column :kind "kind" :type 'text)
(etaf-sqlite-column :status "status" :type 'text)
(etaf-sqlite-column :progress "progress" :type 'integer)
(etaf-sqlite-column :priority "priority" :type 'text)
(etaf-sqlite-column :starred "starred" :type 'integer)
(etaf-sqlite-column :note "note" :type 'text)
(etaf-sqlite-column :updated "updated" :type 'text))
:id)))
(defun etaf-research-shelf--ensure-database ()
"Initialize the SQLite database and install seed data when empty."
(let* ((database (etaf-research-shelf--database))
(source (etaf-sqlite-source database))
(load (plist-get source :load))
(mutate (plist-get source :mutate)))
(etaf-sqlite-initialize database)
(when (= 0 (plist-get (funcall load nil 1 1) :total))
(dolist (record etaf-research-shelf-seed-records)
(funcall mutate 'insert record)))
database))
(defun etaf-research-shelf--create-model ()
"Create the application model and its SQLite-backed Data Controller."
(let* ((database (etaf-research-shelf--ensure-database))
(controller
(etaf-data-controller
(etaf-sqlite-source database)
:page-size 4 :auto-load nil :name 'etaf-research-shelf))
(filter (etaf-ref 'all :name 'research-shelf-filter))
(dark (etaf-ref nil :name 'research-shelf-dark-theme))
(toast (etaf-ref "Ready to read." :name 'research-shelf-toast))
(next-id (etaf-ref 100 :name 'research-shelf-next-id)))
(list :database database :database-file etaf-research-shelf-database-file
:controller controller :filter filter :dark dark :toast toast
:next-id next-id)))
(defun etaf-research-shelf--controller (model)
"Return MODEL's Data Controller."
(plist-get model :controller))
(defun etaf-research-shelf--query (filter)
"Translate FILTER into an allowlisted SQLite equality query."
(pcase filter
('reading '(:status "reading"))
('unread '(:status "unread"))
('finished '(:status "finished"))
('starred '(:starred 1))
(_ nil)))
(defun etaf-research-shelf--load (model &optional message)
"Load MODEL and expose a visible success/error MESSAGE."
(let ((controller (etaf-research-shelf--controller model)))
(condition-case err
(progn
(etaf-data-load controller)
(setf (etaf-value (plist-get model :toast))
(or message "✓ Saved locally")))
(error
(setf (etaf-value (plist-get model :toast))
(format "⚠ Storage issue: %s" (error-message-string err)))))))
(defun etaf-research-shelf--set-filter (model filter)
"Set MODEL FILTER and reload the SQLite-backed list."
(let ((controller (etaf-research-shelf--controller model)))
(setf (etaf-value (plist-get model :filter)) filter)
(etaf-data-set-query controller (etaf-research-shelf--query filter))
(etaf-data-set-page controller 1)
(etaf-research-shelf--load model
(format "Showing %s"
(capitalize (symbol-name filter))))))
(defun etaf-research-shelf--items (model)
"Return loaded items for MODEL."
(etaf-value (etaf-data-items (etaf-research-shelf--controller model))))
(defun etaf-research-shelf--selected (model)
"Return the selected item in MODEL, or nil."
(let* ((controller (etaf-research-shelf--controller model))
(id (car (etaf-value (etaf-data-selection controller)))))
(cl-find id (etaf-research-shelf--items model)
:key (lambda (row) (plist-get row :id)) :test #'equal)))
(defun etaf-research-shelf--mutate (model operation payload message)
"Apply SQLite OPERATION PAYLOAD for MODEL.
Show MESSAGE on success, or preserve the resulting error state."
(let ((controller (etaf-research-shelf--controller model)))
(condition-case err
(progn
(etaf-data-mutate controller operation payload)
(setf (etaf-value (plist-get model :toast)) message))
(error
(setf (etaf-value (plist-get model :toast))
(format "⚠ Could not save: %s" (error-message-string err)))))))
(defun etaf-research-shelf--update-selected (model fields message)
"Update selected MODEL record with FIELDS and show MESSAGE."
(when-let ((row (etaf-research-shelf--selected model)))
(etaf-research-shelf--mutate
model 'update
(append (list :id (plist-get row :id)) fields)
message)))
(defun etaf-research-shelf--theme (model)
"Return MODEL's current palette."
(if (etaf-value (plist-get model :dark))
'(:ink "#F4F7FF" :paper "#111827" :panel "#182235" :line "#34435A"
:muted "#AAB5C8" :accent "#8EA7FF" :mint "#76D6C3" :coral "#FF9A8E"
:amber "#F0C36A" :soft "#202C42")
'(:ink "#172033" :paper "#F7F3EA" :panel "#FFFDF8" :line "#D9D4C9"
:muted "#6D7482" :accent "#3657D6" :mint "#3E9B8F" :coral "#D86B5D"
:amber "#C58A3A" :soft "#F1EEE7")))
(defun etaf-research-shelf--color (model key)
"Return palette KEY for MODEL."
(plist-get (etaf-research-shelf--theme model) key))
(defun etaf-research-shelf--status-label (status)
"Return a readable status label for STATUS."
(pcase status
("reading" "◷ In progress")
("finished" "✓ Finished")
("archived" "↗ Archived")
(_ "○ Unread")))
(defun etaf-research-shelf--filter-label (model form key fallback)
"Return MODEL's active-aware label for KEY from static FILTER FORM."
(let* ((entry (cl-find-if
(lambda (item) (and (consp item) (eq (car item) 'filter)
(eq (plist-get (cdr item) :key) key)))
(cdr form)))
(label (or (and entry (plist-get (cdr entry) :label)) fallback)))
(if (eq key (etaf-value (plist-get model :filter)))
(concat "" label)
label)))
(defun etaf-research-shelf--filter-view (model form)
"Return the filter rail from MODEL and static FILTER FORM."
(etaf-view
(column :class "research-shelf-filter-rail" :width 'stretch
:padding '(1 1)
:border (etaf-research-shelf--color model :line)
:color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel)
(label :text "Library" :face 'bold
:color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel))
(label :text "VIEWS" :color (etaf-research-shelf--color model :muted)
:bgcolor (etaf-research-shelf--color model :panel))
(button :label (etaf-research-shelf--filter-label model form 'all "All")
:ref 'research-shelf-filter-all
:variant (if (eq (etaf-value (plist-get model :filter)) 'all)
'secondary 'ghost)
:on-press (lambda ()
(etaf-research-shelf--set-filter model 'all)))
(button :label (etaf-research-shelf--filter-label
model form 'reading "In progress")
:ref 'research-shelf-filter-reading
:variant (if (eq (etaf-value (plist-get model :filter)) 'reading)
'secondary 'ghost)
:on-press (lambda ()
(etaf-research-shelf--set-filter model 'reading)))
(button :label (etaf-research-shelf--filter-label
model form 'unread "Unread")
:ref 'research-shelf-filter-unread
:variant (if (eq (etaf-value (plist-get model :filter)) 'unread)
'secondary 'ghost)
:on-press (lambda ()
(etaf-research-shelf--set-filter model 'unread)))
(button :label (etaf-research-shelf--filter-label
model form 'finished "Finished")
:ref 'research-shelf-filter-finished
:variant (if (eq (etaf-value (plist-get model :filter)) 'finished)
'secondary 'ghost)
:on-press (lambda ()
(etaf-research-shelf--set-filter model 'finished)))
(button :label (etaf-research-shelf--filter-label
model form 'starred "★ Starred")
:ref 'research-shelf-filter-starred
:variant (if (eq (etaf-value (plist-get model :filter)) 'starred)
'secondary 'ghost)
:on-press (lambda ()
(etaf-research-shelf--set-filter model 'starred)))
(spacer :height 1)
(label :text "STORAGE" :color (etaf-research-shelf--color model :muted)
:bgcolor (etaf-research-shelf--color model :panel))
(label :text (file-name-nondirectory
(plist-get model :database-file))
:color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel))
(button :label "↻ Reload library" :ref 'research-shelf-reload
:variant 'ghost
:on-press (lambda () (etaf-research-shelf--load model "✓ Library reloaded"))))))
(defun etaf-research-shelf--row-press (model row)
"Select ROW as the single current record in MODEL."
(etaf-data-select-one (etaf-research-shelf--controller model)
(plist-get row :id)))
(defun etaf-research-shelf--reading-list-view (model form)
"Return the main reading list for MODEL and static MAIN FORM."
(let ((controller (etaf-research-shelf--controller model)))
(etaf-view
(column :class "research-shelf-list" :width 'stretch
:padding '(1 2)
:border (etaf-research-shelf--color model :line)
:color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel)
(flex :width 'stretch :align-items 'center :gap '(1 (8))
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
(label :text (etaf-research-shelf--static-value form :title
"Reading queue")
:face 'bold
:color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel))
(label :text (format "%d items · SQLite-backed"
(or (etaf-value (etaf-data-total controller)) 0))
:color (etaf-research-shelf--color model :muted)
:bgcolor (etaf-research-shelf--color model :panel)))
(button :label " Add reading" :ref 'research-shelf-add
:variant 'secondary
:on-press
(lambda ()
(let ((id (etaf-value (plist-get model :next-id))))
(etaf-research-shelf--mutate
model 'insert
(list :id id :title (format "New reading %d" id)
:author "You" :kind "Notes" :status "unread"
:progress 0 :priority "Medium" :starred 0
:note "A new note waiting for your attention."
:updated "Just now")
"✓ Added to your shelf")
(setf (etaf-value (plist-get model :next-id)) (1+ id))))))
(data-grid
:controller controller
:columns '((:key :title :label "Title" :width 30)
(:key :author :label "Author" :width 16)
(:key :status :label "Status" :width 14)
(:key :progress :label "Progress" :width 10)
(:key :kind :label "Kind" :width 10))
:row-key (lambda (row) (plist-get row :id))
:row-ref (lambda (row)
(intern (format "research-shelf-row-%s"
(plist-get row :id))))
:row-selected-p
(lambda (row)
(etaf-data-selected-p controller (plist-get row :id)))
:on-row-press (lambda (row) (etaf-research-shelf--row-press model row))
:loading-label "◷ Loading library…"
:error-label "⚠ Could not read the shelf. Use Reload.")
(etaf-pagination :controller controller
:previous-ref 'research-shelf-page-previous
:next-ref 'research-shelf-page-next
:color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel))))))
(defun etaf-research-shelf--detail-view (model form)
"Return the selected record inspector for MODEL and static FORM."
(etaf-view
(column :class "research-shelf-detail" :width 'stretch
:padding '(2 2)
:border (etaf-research-shelf--color model :line)
:color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel)
(label :text (etaf-research-shelf--static-value form :title
"Selected item")
:color (etaf-research-shelf--color model :accent)
:bgcolor (etaf-research-shelf--color model :panel))
(text :face 'bold
(expr :value
(if-let ((row (etaf-research-shelf--selected model)))
(plist-get row :title)
"Choose a record")))
(text :color (etaf-research-shelf--color model :muted)
(expr :value
(if-let ((row (etaf-research-shelf--selected model)))
(format "%s · %s" (plist-get row :author)
(plist-get row :kind))
"Select one row to inspect it.")))
(spacer :height 1)
(text :face 'bold
(expr :value
(if-let ((row (etaf-research-shelf--selected model)))
(etaf-research-shelf--status-label (plist-get row :status))
"○ No selection")))
(text
(expr :value
(if-let ((row (etaf-research-shelf--selected model)))
(format "Progress %d%% · Priority %s"
(or (plist-get row :progress) 0)
(plist-get row :priority))
"Progress — · Priority —")))
(text :color (etaf-research-shelf--color model :accent)
(expr :value
(let* ((row (etaf-research-shelf--selected model))
(progress (or (and row (plist-get row :progress)) 0))
(filled (max 0 (min 10 (/ progress 10)))))
(format "%s %s" (make-string filled ?█)
(make-string (- 10 filled) ?░)))))
(spacer :height 1)
(text :color (etaf-research-shelf--color model :muted)
(expr :value
(if-let ((row (etaf-research-shelf--selected model)))
(concat "" (or (plist-get row :note) "No note yet.") "")
"Your notes and actions will appear here.")))
(spacer :height 1)
(etaf-research-shelf-detail-actions :model model))))
(etaf-define-component etaf-research-shelf-detail-actions (&key model)
"Render stable selected-record actions for MODEL."
:setup
(let ((model (etaf-current-prop :model)))
(lambda ()
(let* ((row (etaf-research-shelf--selected model))
(progress (or (and row (plist-get row :progress)) 0))
(finished (and row (equal (plist-get row :status) "finished")))
(archived (and row (equal (plist-get row :status) "archived"))))
(etaf-view
(flex :width 'stretch :flex-wrap 'wrap :gap '(1 (6))
(button :label "+ 10%" :ref 'research-shelf-progress
:variant 'secondary :disabled (or (null row) (>= progress 100))
:on-press
(lambda ()
(when-let* ((current (etaf-research-shelf--selected model))
(value (or (plist-get current :progress) 0)))
(etaf-research-shelf--update-selected
model (list :progress (min 100 (+ value 10))
:status (if (>= (+ value 10) 100)
"finished" "reading")
:updated "Just now")
"✓ Progress saved"))))
(button :label "✓ Finish" :ref 'research-shelf-finish
:variant 'secondary :disabled (or (null row) finished)
:on-press
(lambda ()
(etaf-research-shelf--update-selected
model '(:progress 100 :status "finished" :updated "Just now")
"✓ Marked finished")))
(button :label "★ Star" :ref 'research-shelf-star :variant 'ghost
:disabled (null row)
:on-press
(lambda ()
(when-let ((current (etaf-research-shelf--selected model)))
(let ((current-starred
(= 1 (or (plist-get current :starred) 0))))
(etaf-research-shelf--update-selected
model (list :starred (if current-starred 0 1)
:updated "Just now")
(if current-starred
"Removed star" "★ Starred locally"))))))
(button :label "Archive" :ref 'research-shelf-archive
:variant 'ghost :disabled (or (null row) archived)
:on-press
(lambda ()
(etaf-research-shelf--update-selected
model '(:status "archived" :updated "Just now")
"↗ Archived")))))))))
(etaf-define-component etaf-research-shelf-filter-rail
(&key model static-form)
"Render the reusable Research Shelf filter rail.
The rail is a Component boundary so its button Components remain ordinary
structural children instead of leaking through a direct material `expr'."
:setup
(let ((model (etaf-current-prop :model))
(static-form (etaf-current-prop :static-form)))
(lambda () (etaf-research-shelf--filter-view model static-form))))
(etaf-define-component etaf-research-shelf-reading-list
(&key model static-form)
"Render the reusable Research Shelf reading list."
:setup
(let ((model (etaf-current-prop :model))
(static-form (etaf-current-prop :static-form)))
(lambda () (etaf-research-shelf--reading-list-view model static-form))))
(etaf-define-component etaf-research-shelf-detail-inspector
(&key model static-form)
"Render the reusable Research Shelf detail inspector."
:setup
(let ((model (etaf-current-prop :model))
(static-form (etaf-current-prop :static-form)))
(lambda () (etaf-research-shelf--detail-view model static-form))))
(etaf-define-component etaf-research-shelf-shell (&key model static-form)
"Compose the SQLite-backed Research Shelf application."
:styles
(styles
(".research-shelf-shell" :width stretch)
(".research-shelf-header" :width stretch :padding (1 0))
(".research-shelf-filter-rail" :width stretch)
(".research-shelf-list" :width stretch)
(".research-shelf-detail" :width stretch)
(".research-shelf-footer" :width stretch :padding (0 1)))
:setup
(let ((model (etaf-current-prop :model))
(static-form (etaf-current-prop :static-form)))
(etaf-on-unmounted
(lambda () (etaf-data-stop (etaf-research-shelf--controller model))))
(lambda ()
(let* ((header (etaf-research-shelf--static-child static-form 'header))
(filters (etaf-research-shelf--static-child static-form 'filters))
(main (etaf-research-shelf--static-child static-form 'main))
(library (etaf-research-shelf--static-child main 'library))
(detail (etaf-research-shelf--static-child main 'detail))
(dark (etaf-value (plist-get model :dark)))
(palette (etaf-research-shelf--theme model)))
(etaf-view
(column :class "research-shelf-shell" :width '(viewport)
:height '(viewport-height)
:color (plist-get palette :ink)
:bgcolor (plist-get palette :paper)
(flex :class "research-shelf-header" :width 'stretch
:align-items 'center :gap '(1 (10))
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
(label :text (etaf-research-shelf--static-value
header :eyebrow "Research Shelf")
:color (plist-get palette :accent)
:bgcolor (plist-get palette :paper))
(label :text (etaf-research-shelf--static-value
static-form :title "Research Shelf") :face 'bold
:color (plist-get palette :ink)
:bgcolor (plist-get palette :paper))
(label :text (etaf-research-shelf--static-value
static-form :subtitle "A quiet place for unfinished ideas")
:color (plist-get palette :muted)
:bgcolor (plist-get palette :paper)))
(checkbox :label "Dark" :ref 'research-shelf-theme-toggle
:checked dark
:on-change (lambda (value)
(setf (etaf-value (plist-get model :dark)) value)
(setf (etaf-value (plist-get model :toast))
(if value "Dark theme" "Light theme")))))
(grid :class "research-shelf-workspace" :width 'stretch
:grid-template-columns '(2fr 5fr 3fr) :gap '(1 (10))
:color (plist-get palette :ink)
:bgcolor (plist-get palette :paper)
(etaf-research-shelf-filter-rail :model model :static-form filters)
(etaf-research-shelf-reading-list :model model :static-form library)
(etaf-research-shelf-detail-inspector :model model :static-form detail))
(flex :class "research-shelf-footer" :width 'stretch :gap '(1 (8))
(column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0
(label :text (or (etaf-value (plist-get model :toast)) "Ready")
:color (plist-get palette :ink)
:bgcolor (plist-get palette :paper)))
(label :text (file-name-nondirectory
(plist-get model :database-file))
:color (plist-get palette :muted)
:bgcolor (plist-get palette :paper)))))))))
(defun etaf-research-shelf-view (model static-form)
"Return the root View for MODEL and validated STATIC-FORM."
(etaf-view (research-shelf-shell :model model :static-form static-form)))
(defun etaf-research-shelf-root (static-form)
"Consume validated STATIC-FORM and return one owned application root View."
(unless (eq (car static-form) 'research-shelf-shell)
(error "Unsupported Research Shelf static root: %S" static-form))
(let ((model (etaf-research-shelf--create-model)))
;; Load before the first retained generation. A DataGrid that is created
;; empty and populated from an on-mounted callback has no committed
;; material artifact to use as its first incremental anchor.
(etaf-research-shelf--load model "✓ SQLite shelf ready")
(lambda () (etaf-research-shelf-view model static-form))))
;;;###autoload
(defun etaf-research-shelf-open (&optional buffer-name)
"Mount and optionally display the Research Shelf application in BUFFER-NAME."
(interactive)
(let ((buffer (etaf-mount (or buffer-name "*ETAF Research Shelf*")
(etaf-research-shelf-root
(etaf-playground-read-static "research-shelf")))))
(when (called-interactively-p 'interactive) (pop-to-buffer buffer))
buffer))
(provide 'etaf-research-shelf)
;;; research-shelf.el ends here

View File

@ -0,0 +1,16 @@
;; Static composition only. Storage, state, Components, and events live in
;; the same-basename `.el' companion.
(research-shelf-shell
:title "Research Shelf"
:subtitle "A quiet place for unfinished ideas"
(header :eyebrow "A quiet place for unfinished ideas")
(filters
(filter :key all :label "All")
(filter :key reading :label "In progress")
(filter :key unread :label "Unread")
(filter :key finished :label "Finished")
(filter :key starred :label "★ Starred"))
(main
(library :title "Reading queue")
(detail :title "Selected item"))
(footer :label "SQLite · saved locally"))

View File

@ -0,0 +1,87 @@
;;; benchmark-research-shelf.el --- Research Shelf latency gate -*- lexical-binding: t; -*-
;;; Commentary:
;; Reproducible fullscreen-equivalent warm interaction benchmark. The process
;; exits nonzero when the declared p50/max budgets are exceeded.
;;; Code:
(require 'cl-lib)
(require 'etaf-playground)
(defconst etaf-research-shelf-benchmark-row-p50-budget-ms 100.0)
(defconst etaf-research-shelf-benchmark-row-max-budget-ms 250.0)
(defconst etaf-research-shelf-benchmark-theme-p50-budget-ms 250.0)
(defconst etaf-research-shelf-benchmark-theme-max-budget-ms 500.0)
(defun etaf-research-shelf-benchmark--percentile (samples percentile)
"Return PERCENTILE from numeric SAMPLES using nearest rank."
(let* ((ordered (sort (copy-sequence samples) #'<))
(index (min (1- (length ordered))
(floor (* percentile (length ordered))))))
(nth index ordered)))
(defun etaf-research-shelf-benchmark--measure (runtime refs runs)
"Measure warm press events through RUNTIME while cycling REFS.
The number of measured events is `RUNS'."
(cl-loop for index below runs
for ref = (nth (% index (length refs)) refs)
collect
(let ((started (float-time)))
(etaf-dispatch-event runtime ref 'press)
(* 1000.0 (- (float-time) started)))))
(defun etaf-research-shelf-benchmark--summary (label samples)
"Print and return LABEL summary for SAMPLES."
(let ((p50 (etaf-research-shelf-benchmark--percentile samples 0.5))
(maximum (apply #'max samples)))
(princ (format "%s p50=%.3fms max=%.3fms samples=%S\n"
label p50 maximum samples))
(list :p50 p50 :max maximum)))
(defun etaf-research-shelf-benchmark-run ()
"Run the Research Shelf evaluator and return non-nil on success."
(load-file (expand-file-name "examples/research-shelf.el"
default-directory))
(let ((database (make-temp-file "etaf-research-shelf-perf-" nil ".sqlite"))
(buffer " *etaf-research-shelf-perf*"))
(unwind-protect
(let ((etaf-research-shelf-database-file database))
(ignore etaf-research-shelf-database-file)
(etaf-playground-open buffer)
(ebox-surface-update-buffer-viewport (get-buffer buffer) 1413 62)
(let ((runtime (etaf-runtime-for-buffer buffer)))
;; Prewarm both structural states before collecting evidence.
(etaf-dispatch-event runtime 'research-shelf-row-1 'press)
(etaf-dispatch-event runtime 'research-shelf-row-2 'press)
(etaf-dispatch-event runtime 'research-shelf-theme-toggle 'press)
(etaf-dispatch-event runtime 'research-shelf-theme-toggle 'press)
(let* ((row (etaf-research-shelf-benchmark--summary
"row-selection"
(etaf-research-shelf-benchmark--measure
runtime '(research-shelf-row-1 research-shelf-row-2) 8)))
(theme (etaf-research-shelf-benchmark--summary
"theme-toggle"
(etaf-research-shelf-benchmark--measure
runtime '(research-shelf-theme-toggle) 8)))
(pass
(and (<= (plist-get row :p50)
etaf-research-shelf-benchmark-row-p50-budget-ms)
(<= (plist-get row :max)
etaf-research-shelf-benchmark-row-max-budget-ms)
(<= (plist-get theme :p50)
etaf-research-shelf-benchmark-theme-p50-budget-ms)
(<= (plist-get theme :max)
etaf-research-shelf-benchmark-theme-max-budget-ms))))
(princ (format "research-shelf-perf %s\n"
(if pass "PASS" "FAIL")))
(unless pass
(error "Research Shelf interaction latency budget exceeded"))
t)))
(when (get-buffer buffer)
(etaf-playground-close buffer))
(when (file-exists-p database)
(delete-file database)))))
(etaf-research-shelf-benchmark-run)
;;; benchmark-research-shelf.el ends here

View File

@ -1,33 +1,79 @@
;;; etaf-playground-tests.el --- operations-console pair contract -*- lexical-binding: t; -*-
;;; etaf-playground-tests.el --- Research Shelf pair contract -*- lexical-binding: t; -*-
;;; Commentary:
;; These tests exercise the generic pair boundary and one real application.
;; The framework tests never reach into the application's model; application
;; tests drive only public refs/events and observe the rendered surface.
;;; Code:
(require 'cl-lib)
(require 'ert)
(require 'etaf-playground)
(defvar etaf-operations-console-cleanup-count)
(defun etaf-playground-test--ensure-app-loaded ()
"Load the same-basename Research Shelf companion for test setup."
(unless (featurep 'etaf-research-shelf)
(load-file (expand-file-name "examples/research-shelf.el"
default-directory))))
(defun etaf-playground-test--text (buffer)
"Return BUFFER plain text."
(with-current-buffer buffer (substring-no-properties (buffer-string))))
"Return BUFFER's plain rendered text."
(with-current-buffer buffer
(substring-no-properties (buffer-string))))
(defun etaf-playground-test--close (buffer)
"Close BUFFER when it exists."
(when (get-buffer buffer) (etaf-playground-close buffer)))
(when (get-buffer buffer)
(etaf-playground-close buffer)))
(ert-deftest etaf-playground-manifest-has-one-reviewed-pair ()
"Expose only the operations-console pair and keep old examples inactive."
(should (equal '("operations-console") etaf-playground-example-names))
(defmacro etaf-playground-test--with-app (variables &rest body)
"Run BODY with VARIABLES.
VARIABLES is a `(BUFFER DATABASE)' list; the macro creates a temporary SQLite
database and mounts a test buffer before running BODY."
(declare (indent 1))
(let ((buffer (car variables))
(database (cadr variables)))
(unless (and (symbolp buffer) (symbolp database))
(error "Expected (BUFFER DATABASE) variables, got %S" variables))
`(let* ((,database (make-temp-file "etaf-research-shelf-" nil ".sqlite"))
(,buffer " *etaf-research-shelf-test*"))
(ignore ,database)
(etaf-playground-test--ensure-app-loaded)
(unwind-protect
(let ((etaf-research-shelf-database-file ,database))
(ignore etaf-research-shelf-database-file)
,@body)
(etaf-playground-test--close ,buffer)
(when (file-exists-p ,database)
(delete-file ,database))))))
(ert-deftest etaf-playground-manifest-describes-one-pair ()
"Expose one reviewed pair through a generic manifest."
(should (equal '("research-shelf") etaf-playground-example-names))
(should (= 1 (length etaf-playground-scenario-manifest)))
(let ((entry (car etaf-playground-scenario-manifest))
(framework (with-temp-buffer
(insert-file-contents "etaf-playground.el")
(buffer-string)))
(catalog (with-temp-buffer
(insert-file-contents "etaf-playground-catalog.el")
(buffer-string)))
(makefile (with-temp-buffer
(insert-file-contents "Makefile") (buffer-string))))
(dolist (key '(:pair :root-component :capabilities :refs
:gui-checkpoints :performance))
(insert-file-contents "Makefile")
(buffer-string))))
(dolist (key '(:pair :root-component :companion-feature :static-tags
:capabilities :refs :gui-checkpoints :performance))
(should (plist-member entry key)))
(should (string-match-p
"EXAMPLE_EL := examples/operations-console.el" makefile))
(should-not (string-match-p "wildcard examples" makefile))))
"EXAMPLE_EL := examples/research-shelf.el" makefile))
(should-not (string-match-p "wildcard examples" makefile))
(should-not (string-match-p "require.*etaf-sqlite" framework))
(should-not (string-match-p "research-shelf" framework))
(should-not (string-match-p "operations-console" framework))
(should (string-match-p "research-shelf" catalog))))
(ert-deftest etaf-playground-compile-builds-bytecode-dependencies ()
"Integration builds must not silently fall back to interpreted dependencies."
(ert-deftest etaf-playground-compile-builds-the-dependency-graph ()
"Integration builds must compile the framework's dependency graph first."
(let ((makefile (with-temp-buffer
(insert-file-contents "Makefile")
(buffer-string))))
@ -35,351 +81,136 @@
"$(MAKE) -C ../tp compile"
"$(MAKE) -C ../ebox compile"
"$(MAKE) -C ../etaf compile"
"$(MAKE) -C ../etaf-ui compile"))
"$(MAKE) -C ../etaf-ui compile"
"$(MAKE) -C ../etaf-sqlite compile"))
(should (string-match-p (regexp-quote dependency) makefile)))))
(ert-deftest etaf-playground-static-reader-is-inert-and-strict ()
"Read static data without companion execution and reject unsafe AST nodes."
(when (featurep 'etaf-operations-console)
(unload-feature 'etaf-operations-console t))
(let ((form (etaf-playground-read-static "operations-console")))
(should (equal (car form) 'operations-console-shell))
(should (equal (plist-get (cdr form) :title) "Operations Console"))
"Read pair structure as inert data and reject executable AST nodes."
(let* ((form (etaf-playground-read-static "research-shelf"))
(tags (plist-get (etaf-playground-scenario "research-shelf")
:static-tags)))
(should (equal (car form) 'research-shelf-shell))
(should (equal (plist-get (cdr form) :title) "Research Shelf"))
(should (equal (mapcar #'car
(cl-remove-if-not #'consp (cdr form)))
'(header navigation main status))))
(should-not (featurep 'etaf-operations-console))
(dolist (form '((lambda () 1) (eval '(message "x"))
(etaf--private) (ebox--private) (shell-command "x")))
(should-error (etaf-playground--validate-static-node form) :type 'error)))
'(header filters main footer)))
(let* ((filters (cl-find-if
(lambda (entry) (and (consp entry)
(eq (car entry) 'filters)))
(cdr form)))
(all-filter (cl-find-if
(lambda (entry) (and (consp entry)
(eq (car entry) 'filter)
(eq (plist-get (cdr entry) :key)
'all)))
(cdr filters))))
(should all-filter))
(dolist (unsafe '((lambda () 1) (eval '(message "x"))
(etaf--private) (ebox--private) (shell-command "x")))
(should-error (etaf-playground--validate-static-node unsafe tags)
:type 'error))))
(ert-deftest etaf-playground-pair-load-and-mount-is-repeatable ()
"Load the same pair twice and mount/unmount it twice without stale state."
(let ((buffer " *etaf-operations-console-pair-test*"))
(unwind-protect
(dotimes (_iteration 2)
(let ((form (etaf-playground-read-static "operations-console")))
(should (equal (car form) 'operations-console-shell))
(should (equal (mapcar #'car
(cl-remove-if-not #'consp (cdr form)))
'(header navigation main status))))
(etaf-playground-mount-example buffer "operations-console")
(should (etaf-runtime-p (etaf-runtime-for-buffer buffer)))
(should (string-match-p "Operations Console"
(etaf-playground-test--text buffer)))
(etaf-unmount (etaf-runtime-for-buffer buffer))
(when (get-buffer buffer) (kill-buffer buffer))
(should-not (etaf-runtime-for-buffer buffer)))
(etaf-playground-test--close buffer))
(should-not (etaf-runtime-for-buffer buffer))))
(ert-deftest etaf-playground-pair-mounts-sqlite-backed-surface ()
"Mounting the pair initializes SQLite and renders the real app shell."
(etaf-playground-test--with-app (buffer database)
(etaf-playground-open buffer)
(should (etaf-runtime-p (etaf-runtime-for-buffer buffer)))
(should (file-exists-p database))
(let ((text (etaf-playground-test--text buffer))
(runtime (etaf-runtime-for-buffer buffer)))
(should (string-match-p "Research Shelf" text))
(should (string-match-p "8 items · SQLite-backed" text))
(should (string-match-p "The Shape of Tools" text))
(dolist (ref '(research-shelf-filter-all research-shelf-filter-reading
research-shelf-filter-unread research-shelf-filter-finished
research-shelf-filter-starred research-shelf-reload
research-shelf-add research-shelf-page-next
research-shelf-theme-toggle))
(should (etaf-runtime-handler-for runtime ref))))))
(ert-deftest etaf-playground-operations-console-drives-public-refs ()
"Drive representative app capabilities through public event/focus ports."
(let ((buffer " *etaf-operations-console-actions-test*"))
(unwind-protect
(progn
(dolist (entry
'((operations-console-alpha-increment)
(operations-console-alpha-reset)
(operations-console-beta-increment)
(operations-console-beta-reset)
(operations-console-local-callback)
(operations-console-named-action)
(operations-console-behavior-toggle)
(operations-console-theme-toggle)
(operations-console-nav-data)
(operations-console-clear-selection operations-console-nav-data)
(operations-console-row-1 operations-console-nav-data)
(operations-console-row-2 operations-console-nav-data)
(operations-console-row-3 operations-console-nav-data)
(operations-console-nav-resource)
(operations-console-fail-next operations-console-nav-resource)
(operations-console-resource-reload operations-console-nav-resource)
(operations-console-error-boundary operations-console-nav-resource)
(operations-console-nav-overview)))
(etaf-playground-open buffer)
(let* ((runtime (etaf-runtime-for-buffer buffer))
(ref (car entry))
(page-ref (cadr entry)))
(when page-ref
(etaf-dispatch-event runtime page-ref 'press))
(etaf-focus runtime ref)
(should (eq ref (etaf-focused-host-ref runtime)))
(etaf-dispatch-event runtime ref 'press)
(when (eq ref 'operations-console-theme-toggle)
(should (string-match-p
"☑[[:space:]]+Theme"
(etaf-playground-test--text buffer))))
(etaf-playground-close buffer))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-row-selection-is-repeatable ()
"Repeated DataGrid row presses replace one selected identity."
(etaf-playground-test--with-app (buffer database)
(etaf-playground-open buffer)
(let ((runtime (etaf-runtime-for-buffer buffer)))
(dolist (entry '((research-shelf-row-1 research-shelf-row-2
"The best tools make attention feel larger")
(research-shelf-row-2 research-shelf-row-1
"Look again at the relationship between image and power")
(research-shelf-row-1 research-shelf-row-2
"The best tools make attention feel larger")
(research-shelf-row-2 research-shelf-row-1
"Look again at the relationship between image and power")))
(let ((selected (nth 0 entry))
(other (nth 1 entry))
(detail (nth 2 entry)))
(should (etaf-runtime-handler-for runtime selected))
(etaf-focus runtime selected)
(etaf-dispatch-event runtime selected 'press)
(let ((text (etaf-playground-test--text buffer)))
(should (string-match-p (regexp-quote detail) text)))
(should (string-match-p
"selected"
(plist-get (etaf-runtime-host-props-for runtime selected)
:class)))
(should-not (string-match-p
"selected"
(plist-get (etaf-runtime-host-props-for runtime other)
:class))))))))
(ert-deftest etaf-playground-data-grid-row-selection-is-repeatable ()
"Repeated row presses replace the single selected row without a bar."
(let ((buffer " *etaf-operations-console-row-selection-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(let ((runtime (etaf-runtime-for-buffer buffer)))
(etaf-dispatch-event runtime 'operations-console-nav-data 'press)
(dolist (entry '((operations-console-row-1 . "Selected Ada")
(operations-console-row-2 . "Selected Grace")
(operations-console-row-3 . "Selected Lin")
(operations-console-row-1 . "Selected Ada")
(operations-console-row-2 . "Selected Grace")))
(etaf-focus runtime (car entry))
(etaf-dispatch-event runtime (car entry) 'press)
(let ((text (etaf-playground-test--text buffer)))
(should (string-match-p (regexp-quote (cdr entry)) text))
(should-not (string-match-p "#DCEBDD" text))))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-filters-and-pagination-reload-data ()
"Filter and pager refs drive the SQLite-backed Data Controller."
(etaf-playground-test--with-app (buffer database)
(etaf-playground-open buffer)
(let ((runtime (etaf-runtime-for-buffer buffer)))
(etaf-dispatch-event runtime 'research-shelf-filter-reading 'press)
(let ((text (etaf-playground-test--text buffer)))
(should (string-match-p "Showing Reading" text))
(should (string-match-p "Designing for Calm" text))
(should-not (string-match-p "Ways of Seeing" text)))
(etaf-dispatch-event runtime 'research-shelf-filter-all 'press)
(etaf-dispatch-event runtime 'research-shelf-page-next 'press)
(should (string-match-p "Page 2 / 2"
(etaf-playground-test--text buffer)))
(etaf-dispatch-event runtime 'research-shelf-page-previous 'press)
(should (string-match-p "Page 1 / 2"
(etaf-playground-test--text buffer))))))
(ert-deftest etaf-playground-behavior-toggle-uses-one-retained-publication ()
"Behavior content and paint changes stay in two fixed owners per turn."
(let ((buffer " *etaf-operations-console-behavior-retained-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(let* ((runtime (etaf-runtime-for-buffer buffer))
(surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(old-commit (symbol-function 'ebox-commit))
(root-id (plist-get
(plist-get (ebox--buffer-render-state buffer)
:root-node)
:node-id)))
(dotimes (index 4)
(let ((commits 0)
(before (etaf-runtime-generation runtime)))
(cl-letf (((symbol-function 'ebox-commit)
(lambda (&rest args)
(cl-incf commits)
(apply old-commit args))))
(etaf-dispatch-event runtime
'operations-console-behavior-toggle
'press))
(let ((report (ebox-buffer-update-report buffer)))
(should (= commits 1))
(should (= 1 (- (etaf-runtime-generation runtime)
before)))
(should (eq (plist-get report :projection-kind)
'owner-scoped))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (= 2 (plist-get report :tp-scope-count)))
(should (= 2 (plist-get report :tp-scope-range-count)))
(should (= 2 (plist-get report :tp-text-operations)))
(should-not (member root-id (plist-get report :owner-ids)))
(should (< (plist-get report :reconciled-objects)
(plist-get (tp-surface-inspect surface)
:object-count))))
(should (string-match-p
(if (cl-evenp index)
"Behavior toggle: on"
"Behavior toggle: off")
(etaf-playground-test--text buffer)))))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-mutations-persist-and-refresh ()
"Add and mutate actions persist through the SQLite source and refresh UI."
(etaf-playground-test--with-app (buffer database)
(etaf-playground-open buffer)
(let ((runtime (etaf-runtime-for-buffer buffer)))
(etaf-dispatch-event runtime 'research-shelf-row-1 'press)
(etaf-dispatch-event runtime 'research-shelf-progress 'press)
(should (string-match-p "Progress saved"
(etaf-playground-test--text buffer)))
(etaf-dispatch-event runtime 'research-shelf-star 'press)
(should (string-match-p "Removed star"
(etaf-playground-test--text buffer)))
(etaf-dispatch-event runtime 'research-shelf-add 'press)
(should (string-match-p "Added to your shelf"
(etaf-playground-test--text buffer)))
(should (file-exists-p database)))))
(ert-deftest etaf-playground-a-theme-toggle-uses-mixed-owner-publication ()
"Theme paint plus fixed-slot label changes publish through one mixed owner."
(let ((buffer " *etaf-operations-console-theme-mixed-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(let* ((runtime (etaf-runtime-for-buffer buffer))
(before (etaf-runtime-generation runtime))
(commits 0)
(old-commit (symbol-function 'ebox-commit)))
(cl-letf (((symbol-function 'ebox-commit)
(lambda (&rest args)
(cl-incf commits)
(apply old-commit args))))
(etaf-dispatch-event runtime
'operations-console-theme-toggle
'press))
(let ((report (ebox-buffer-update-report buffer)))
(should (= commits 1))
(should (= 1 (- (etaf-runtime-generation runtime) before)))
(should (eq (plist-get report :projection-kind)
'mixed-owner-reflow))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should-not
(member (ebox--buffer-root-node-id buffer)
(plist-get report :owner-ids)))
(should (string-match-p "☑[[:space:]]+Theme"
(etaf-playground-test--text buffer)))
(should (string-match-p "Theme: Dark"
(etaf-playground-test--text buffer))))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-a-theme-toggle-after-navigation-retires-nested-range-effects ()
"Theme changes after page navigation must not reuse removed Range anchors."
(let ((buffer " *etaf-operations-console-theme-after-navigation-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(let* ((runtime (etaf-runtime-for-buffer buffer))
(before (etaf-runtime-generation runtime)))
(etaf-dispatch-event runtime 'operations-console-nav-data 'press)
(dotimes (_ 4)
(etaf-dispatch-event runtime
'operations-console-theme-toggle 'press))
(should (= 5 (- (etaf-runtime-generation runtime) before)))
(should (string-match-p "Data desk"
(etaf-playground-test--text buffer)))
(should (string-match-p "Theme: Light"
(etaf-playground-test--text buffer)))
(should (plist-get (ebox-buffer-update-report buffer)
:strategy))
(let* ((generation (etaf-runtime-current-generation runtime))
(nodes (etaf-generation-semantic-nodes generation))
(stale 0))
(dotimes (effect-id (etaf-runtime-next-effect-id runtime))
(when-let ((effect (etaf--generation-effect
generation effect-id)))
(unless (etaf--pvec-get
nodes
(etaf--generation-effect-semantic-id effect))
(cl-incf stale))))
(should (zerop stale)))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-repeated-counter-press-retains-handler ()
"Recreated keyed counter Hosts keep their public handler across turns."
(let ((buffer " *etaf-operations-console-repeat-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(let ((runtime (etaf-runtime-for-buffer buffer))
(old-commit (symbol-function 'ebox-commit)))
(dotimes (expected 3)
(let ((commits 0)
(before (etaf-runtime-generation runtime)))
(cl-letf (((symbol-function 'ebox-commit)
(lambda (&rest args)
(cl-incf commits)
(apply old-commit args))))
(etaf-dispatch-event runtime
'operations-console-alpha-increment 'press))
(should (= commits 1))
(should (= 1 (- (etaf-runtime-generation runtime) before))))
(should (etaf-runtime-handler-for
runtime 'operations-console-alpha-increment))
(should (string-match-p
(format "Value: %d" (1+ expected))
(etaf-playground-test--text buffer))))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-reset-and-close-use-pair-boundary ()
"Reset the sole pair and leave no mounted Runtime after close."
(let ((buffer " *etaf-operations-console-reset-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(etaf-playground-reset buffer)
(should (etaf-runtime-for-buffer buffer))
(etaf-playground-close buffer)
(should-not (get-buffer buffer)))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-navigation-swaps-one-page-subtree ()
"Show exactly the selected Overview, Data, or Resource page."
(let ((buffer " *etaf-operations-console-navigation-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(let* ((runtime (etaf-runtime-for-buffer buffer))
(root-id
(plist-get
(plist-get (ebox--buffer-render-state buffer)
:root-node)
:node-id)))
(should (string-match-p "Counter alpha"
(etaf-playground-test--text buffer)))
(etaf-dispatch-event runtime 'operations-console-nav-data 'press)
(should (string-match-p "Data Controller"
(etaf-playground-test--text buffer)))
(let ((report (ebox-buffer-update-report buffer)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (plist-get report :range-metrics))
(should-not (member root-id (plist-get report :owner-ids)))
(should (= 1 (plist-get report :tp-scope-range-count))))
(should-not (string-match-p "Counter alpha"
(etaf-playground-test--text buffer)))
(etaf-dispatch-event runtime 'operations-console-nav-resource 'press)
(should (string-match-p "Resource and Error Boundary"
(etaf-playground-test--text buffer)))
(should-not (string-match-p "Data Controller"
(etaf-playground-test--text buffer)))
(etaf-dispatch-event runtime 'operations-console-nav-overview 'press)
(should (string-match-p "Counter alpha"
(etaf-playground-test--text buffer)))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-controls-use-intrinsic-single-line-widths ()
"Keep navigation and action controls on their owning row without clipping."
(let ((buffer " *etaf-operations-console-control-width-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(let ((runtime (etaf-runtime-for-buffer buffer)))
(let ((nav (etaf-host-ref-bounds runtime 'operations-console-nav))
(main (etaf-host-ref-bounds runtime 'operations-console-main))
(status (etaf-host-ref-bounds runtime
'operations-console-status)))
;; These are vertical shell siblings. If the navigation flex
;; group is accidentally left open, MAIN and STATUS become row
;; children and the whole application is laid out horizontally.
(with-current-buffer buffer
(should (< (line-number-at-pos (car nav))
(line-number-at-pos (car main))))
(should (< (line-number-at-pos (car main))
(line-number-at-pos (car status))))))
(dolist (refs '((operations-console-nav-overview
operations-console-nav-data
operations-console-nav-resource
operations-console-theme-toggle)
(operations-console-local-callback
operations-console-named-action
operations-console-behavior-toggle)))
(let (lines)
(dolist (ref refs)
(let ((bounds (etaf-host-ref-bounds runtime ref)))
(should bounds)
(with-current-buffer buffer
(push (line-number-at-pos (car bounds)) lines)
(should (<= (cdr bounds) (point-max))))))
(should (= 1 (length (delete-dups lines))))
;; The navigation owner declares a pixel gap so adjacent
;; mouse-face ranges remain distinct controls.
(should (equal
(plist-get
(gethash 'operations-console-nav
(etaf-runtime-host-props runtime))
:gap)
'(0 (8))))))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-fixed-viewport-layout-does-not-double-width ()
"Keep every rendered line inside the containing viewport after reflow."
(let ((buffer " *etaf-operations-console-width-test*"))
(unwind-protect
(progn
(etaf-playground-open buffer)
(ebox-surface-update-buffer-viewport (get-buffer buffer) 1413 60)
(with-current-buffer buffer
(let (widths)
(goto-char (point-min))
(while (< (point) (point-max))
(push (ebox--string-pixel-width
(buffer-substring (point) (line-end-position)))
widths)
(forward-line 1))
(setq widths (nreverse widths))
(should (= 60 (length widths)))
(should (<= (apply #'max widths) 1413)))))
(etaf-playground-test--close buffer))))
(ert-deftest etaf-playground-theme-and-lifecycle-are-repeatable ()
"Theme changes and reset/close do not leave a stale mounted runtime."
(etaf-playground-test--with-app (buffer database)
(etaf-playground-open buffer)
(let ((runtime (etaf-runtime-for-buffer buffer)))
(etaf-dispatch-event runtime 'research-shelf-row-1 'press)
(dotimes (_ 4)
(etaf-dispatch-event runtime 'research-shelf-theme-toggle 'press))
(should (string-match-p "The Shape of Tools"
(etaf-playground-test--text buffer)))
(should (or (string-match-p "☑ Dark"
(etaf-playground-test--text buffer))
(string-match-p "☐ Dark"
(etaf-playground-test--text buffer)))))
(etaf-playground-reset buffer)
(should (etaf-runtime-p (etaf-runtime-for-buffer buffer))))
(should-not (get-buffer " *etaf-research-shelf-test*")))
(provide 'etaf-playground-tests)
;;; etaf-playground-tests.el ends here