Replace console demo with SQLite Research Shelf
This commit is contained in:
parent
f93bdf56d6
commit
dcb0ad9d83
176
DESIGN.md
176
DESIGN.md
@ -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: 900–940 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 900–940 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.
|
||||
|
||||
132
DESIGN.zh-CN.md
132
DESIGN.zh-CN.md
@ -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。
|
||||
- Overview:hero/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 全宽,卡片有明确内边距。
|
||||
- 形状:细深色边框、克制 elevation;ETAF 不依赖浮层。
|
||||
- 动效:不以动画为正确性前提,状态变化即时且支持 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 rows;RET 派发 focused ref;移除目标时清焦点。
|
||||
- 每个状态都有文字反馈,颜色不是唯一信号;不依赖 hover-only 行为。
|
||||
- Ebox Theme 承载继承的 Ebox 默认属性;应用 palette 仍由 companion 查询,
|
||||
Renderer 会在进入 Ebox 几何层前过滤未知 token 名。
|
||||
|
||||
## 响应式
|
||||
|
||||
- 支持 compact 900–940px body 和 fullscreen desktop GUI。
|
||||
- metric 尽量一行展示,workspace 从双列退化为上下堆叠,控件保持 intrinsic 单行宽度。
|
||||
- 需要分配剩余宽度的行使用 `flex`,固定 intrinsic 控件组才使用 `row`;导航
|
||||
控件组使用 max-content,确保每个 hover/active 区域都是独立控件。
|
||||
- mouse-1 和键盘激活使用同一组公开 refs。
|
||||
|
||||
## 交互状态
|
||||
|
||||
- Loading:Resource 显示 `Loading…`,shell 几何不移动。
|
||||
- Empty:DataGrid 明确显示 empty,选择控件仍可用。
|
||||
- Error:reload 可故意失败一次,Boundary 显示 handled error 且仍可恢复。
|
||||
- Success:action/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 <=100ms,counter/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、错误
|
||||
状态和 cleanup;GUI 用干净 fullscreen 单窗口截图验证真实布局。
|
||||
- 目标:一次 Data mutation 对应一次 Runtime generation/publication;warm 交互
|
||||
维持现有 100ms p50 预算。
|
||||
|
||||
## 待 review
|
||||
|
||||
- [ ] 用户可在第一版 HTML/ETAF 对照后调整 palette 或页面命名;行为和公开 ref 覆盖保持不变。
|
||||
- [ ] 用户可调整产品命名或 palette;pair 边界、组合/复用原则和公开交互合同不变。
|
||||
|
||||
17
Makefile
17
Makefile
@ -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
|
||||
|
||||
|
||||
15
README.md
15
README.md
@ -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.
|
||||
|
||||
@ -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 选行/主题延迟)。
|
||||
|
||||
@ -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
138
design/research-shelf.html
Normal 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 1–4 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>
|
||||
30
etaf-playground-catalog.el
Normal file
30
etaf-playground-catalog.el
Normal 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
|
||||
@ -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))))
|
||||
|
||||
@ -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
|
||||
@ -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
542
examples/research-shelf.el
Normal 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
|
||||
16
examples/research-shelf.etaf
Normal file
16
examples/research-shelf.etaf
Normal 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"))
|
||||
87
scripts/benchmark-research-shelf.el
Normal file
87
scripts/benchmark-research-shelf.el
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user