Build responsive SQLite Research Shelf example
This commit is contained in:
parent
0c0d1a3c5a
commit
658a086cbc
26
DESIGN.md
26
DESIGN.md
@ -21,6 +21,9 @@
|
||||
## Product goals
|
||||
|
||||
- Build a useful personal reading/research shelf backed by a real SQLite file.
|
||||
- Ship a deterministic 256-record fixture by default so pagination and warm
|
||||
incremental updates are exercised on a realistic surface; fixture size and
|
||||
page size remain configurable for focused tests.
|
||||
- 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
|
||||
@ -67,7 +70,7 @@
|
||||
- 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.
|
||||
workspace 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
|
||||
@ -114,14 +117,12 @@
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
- Wide/fullscreen: three-column grid (rail / list / detail).
|
||||
- Compact: the same grid uses zero-minimum fractional tracks; rail, list, and
|
||||
detail shrink within the viewport instead of imposing min-content overflow,
|
||||
while the action group wraps through Flex and long table cells wrap inside
|
||||
their tracks.
|
||||
- Use `grid` for the product skeleton, `flex` for toolbars/action groups, and
|
||||
`row` only for compact intrinsic controls. `minmax(0, fr)` is the responsive
|
||||
track contract; do not reintroduce fixed child widths at the shell boundary.
|
||||
- Wide/fullscreen: one Flex line presents rail / list / detail as three columns.
|
||||
- Medium: rail and list remain together while detail wraps to the next line.
|
||||
- Narrow: the same three Components wrap in document order into a vertical flow.
|
||||
- The shell uses one wrapping Flex composition with semantic basis/grow weights;
|
||||
it does not read window size or maintain breakpoint state. Use `row` only for
|
||||
compact intrinsic controls and Flex whenever free space must be distributed.
|
||||
|
||||
## Interaction states
|
||||
|
||||
@ -132,6 +133,8 @@
|
||||
- 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.
|
||||
- Page density: activating `Rows N ✎` uses Emacs's native minibuffer to accept
|
||||
any integer from 1 through 100, then reloads page one.
|
||||
|
||||
## Content voice
|
||||
|
||||
@ -147,8 +150,11 @@
|
||||
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.
|
||||
- Pressure fixture: `etaf-research-shelf-fixture-size` defaults to 256 and
|
||||
`etaf-research-shelf-page-size` defaults to 12; existing local rows are
|
||||
preserved and missing fixture rows are topped up with fresh IDs.
|
||||
- Performance: one Data mutation -> one Runtime generation/publication; normal
|
||||
warm actions remain under the existing 100ms p50 target.
|
||||
warm actions remain under the accepted 105ms 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,
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
Research Shelf 是一个真正有用的本地研究/阅读架:SQLite 保存书籍、论文、
|
||||
文章和笔记,用户可以筛选、分页、选择一条记录、更新进度、完成、收藏和归档。
|
||||
它不是把 API 名称堆成控制台,而是用一个连贯 workflow 验证 ETAF 的组合能力。
|
||||
Playground 默认安装确定性的 256 条 fixture、每页 12 条,用于真实验证分页、DataGrid
|
||||
增量更新和 SQLite 查询压力;fixture 数量和 page size 都可以配置。
|
||||
|
||||
## 结构与分层
|
||||
|
||||
@ -52,8 +54,8 @@ Component 边界,避免新增 helper 层或抽象泄漏。
|
||||
墙、假图表、彩虹渐变和装饰性噪声。
|
||||
- 色彩: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。
|
||||
- 节奏:1 格外部节奏、2 格 panel padding、1 格 workspace gap,控件保持
|
||||
intrinsic 宽度;需要分配剩余空间或换行时统一使用 `flex`。
|
||||
- 状态:文字和 Unicode 同时表达状态:`⌕`、`★`、`✓`、`◷`、`↗`、`⚠`、`·`。
|
||||
- 动效:状态立即可见,不依赖 timer animation,默认支持 reduced motion。
|
||||
|
||||
@ -61,6 +63,8 @@ Component 边界,避免新增 helper 层或抽象泄漏。
|
||||
|
||||
- 筛选:All、In progress、Unread、Finished、Starred。
|
||||
- 列表:DataGrid 行单选,重复点击可以重新选择;分页按钮在边界处 disabled。
|
||||
- 每页行数:激活 `Rows N ✎` 后使用 Emacs 原生 minibuffer 输入 1–100 的整数,
|
||||
应用后回到第一页。
|
||||
- 详情:`+ 10%`、`✓ Finish`、`☆/★ Star`、`Archive`;完成或 100% 时正确禁用。
|
||||
- 存储:成功显示 `✓ Saved locally`,失败保留当前可用内容并提供 Reload。
|
||||
- 主题:Light/Dark 由 checkbox 控制;文字、对比度和布局都应保持可读。
|
||||
@ -68,16 +72,19 @@ Component 边界,避免新增 helper 层或抽象泄漏。
|
||||
|
||||
## 响应式与验证
|
||||
|
||||
- Wide/fullscreen 使用 rail / list / detail 三列 grid。
|
||||
- Compact 时仍使用三列骨架,但每列采用零最小值的 fractional track,在 viewport
|
||||
内收缩而不是被 min-content 撑出边界;长表格单元格在自己的 track 内换行,动作组
|
||||
由 Flex 自动换行。不要在 shell 边界重新引入固定宽度。
|
||||
- `minmax(0, fr)` 是响应式 track 合同;`grid` 负责骨架,`flex` 负责 toolbar/action
|
||||
group,`row` 只负责紧凑 intrinsic 控件,所有操作控件仍保持独立 hover/focus 语义。
|
||||
- Wide/fullscreen:rail / list / detail 位于同一条 Flex line,呈现三栏。
|
||||
- Medium:rail 与 list 保持同排,detail 自动换到下一行。
|
||||
- Narrow:同一组三个 Component 按文档顺序自动变为纵向流。
|
||||
- Shell 只使用一套带 basis/grow 权重的 wrapping Flex,不读取窗口宽度、不维护
|
||||
breakpoint 状态;`row` 只负责紧凑 intrinsic 控件,所有操作控件仍保持独立
|
||||
hover/focus 语义。
|
||||
- 用 SQLite 临时文件测试 mount/remount、筛选、分页、重复选行、mutation、错误
|
||||
状态和 cleanup;GUI 用干净 fullscreen 单窗口截图验证真实布局。
|
||||
- 目标:一次 Data mutation 对应一次 Runtime generation/publication;warm 交互
|
||||
维持现有 100ms p50 预算。
|
||||
维持已接受的 105ms p50 预算。
|
||||
- 压测入口:`etaf-research-shelf-fixture-size` 默认 256,
|
||||
`etaf-research-shelf-page-size` 默认 12;已有本地记录保留,不足部分使用新 ID
|
||||
补齐 fixture。
|
||||
|
||||
## 待 review
|
||||
|
||||
|
||||
@ -14,3 +14,10 @@ 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.
|
||||
|
||||
The example installs a deterministic 256-record SQLite fixture with 12 records
|
||||
per page. Bind `etaf-research-shelf-fixture-size` and
|
||||
`etaf-research-shelf-page-size` for smaller tests or larger pressure runs.
|
||||
In the UI, activate `Rows N ✎` to enter any value from 1 through 100. The
|
||||
workspace is one responsive wrapping Flex composition: three columns when
|
||||
wide, rail/list plus wrapped detail at medium widths, and vertical when narrow.
|
||||
|
||||
@ -12,3 +12,8 @@ schema、palette、refs 与 handlers 不能泄漏进框架。
|
||||
|
||||
验证命令:`make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`。
|
||||
性能门禁:`make perf`(1413×62 viewport 的 warm 选行/主题延迟)。
|
||||
|
||||
示例默认安装确定性的 256 条 SQLite fixture,每页显示 12 条。测试或压测时可以
|
||||
绑定 `etaf-research-shelf-fixture-size` 和 `etaf-research-shelf-page-size` 调整规模;
|
||||
界面中激活 `Rows N ✎` 可以输入 1–100。Workspace 使用同一套 wrapping Flex:
|
||||
宽屏三栏,中屏 detail 换行,窄屏按文档顺序变为纵向布局。
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
: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-reload research-shelf-page-size 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)
|
||||
|
||||
@ -19,6 +19,29 @@
|
||||
:type 'file
|
||||
:group 'etaf)
|
||||
|
||||
(defcustom etaf-research-shelf-fixture-size 256
|
||||
"Minimum deterministic records installed in the Playground database.
|
||||
|
||||
The example deliberately uses a realistic fixture size so pagination, SQLite
|
||||
counting, DataGrid Range updates, and repeated selection exercise more than a
|
||||
toy four-row surface. Tests and small teaching fixtures may bind this to a
|
||||
smaller value."
|
||||
:type 'natnum
|
||||
:group 'etaf)
|
||||
|
||||
(defcustom etaf-research-shelf-page-size 12
|
||||
"Number of records shown on one Research Shelf page."
|
||||
:type 'positive-integer
|
||||
:group 'etaf)
|
||||
|
||||
(defconst etaf-research-shelf--grid-columns
|
||||
'((:key :title :label "Title" :width 21)
|
||||
(:key :author :label "Author" :width 12)
|
||||
(:key :status :label "Status" :width 10)
|
||||
(:key :progress :label "Progress" :width 8)
|
||||
(:key :kind :label "Kind" :width 7))
|
||||
"Compact DataGrid columns that remain readable in a wrapped workspace.")
|
||||
|
||||
(defconst etaf-research-shelf-seed-records
|
||||
'((:id 1 :title "The Shape of Tools" :author "M. Abramson"
|
||||
:kind "Essay" :status "reading" :progress 64 :priority "High"
|
||||
@ -46,6 +69,54 @@
|
||||
:starred 1 :note "Attention is not force; it is patient availability." :updated "Wed"))
|
||||
"Seed records installed into a new Research Shelf database once.")
|
||||
|
||||
(defconst etaf-research-shelf--generated-titles
|
||||
'("A Pattern Language" "The Craftsman" "How Buildings Learn"
|
||||
"The Interface Is the Message" "Notes on Attention"
|
||||
"Ways of Making" "The Timeless Way of Building"
|
||||
"Designing for the Real World" "The Practice of Everyday Life"
|
||||
"Small Tools, Large Consequences")
|
||||
"Stable title vocabulary for generated fixture records.")
|
||||
|
||||
(defconst etaf-research-shelf--generated-authors
|
||||
'("C. Alexander" "Richard Sennett" "Stewart Brand" "N. Hara"
|
||||
"Simone Weil" "M. Ito" "A. Ito" "V. Papanek" "M. de Certeau"
|
||||
"You")
|
||||
"Stable author vocabulary for generated fixture records.")
|
||||
|
||||
(defun etaf-research-shelf--generated-record (id index)
|
||||
"Return deterministic generated fixture record ID at INDEX."
|
||||
(let* ((status (nth (mod index 4) '("reading" "unread" "finished" "reading")))
|
||||
(progress (pcase status
|
||||
("finished" 100)
|
||||
("reading" (+ 10 (* 8 (mod index 12))))
|
||||
(_ 0)))
|
||||
(kind (nth (mod index 4) '("Essay" "Book" "Paper" "Notes"))))
|
||||
(list :id id
|
||||
:title (format "%s · %03d"
|
||||
(nth (mod index (length etaf-research-shelf--generated-titles))
|
||||
etaf-research-shelf--generated-titles)
|
||||
(1+ index))
|
||||
:author (nth (mod index (length etaf-research-shelf--generated-authors))
|
||||
etaf-research-shelf--generated-authors)
|
||||
:kind kind :status status :progress progress
|
||||
:priority (nth (mod index 3) '("High" "Medium" "Low"))
|
||||
:starred (if (zerop (% index 5)) 1 0)
|
||||
:note (format "Generated fixture note %03d for paging and update checks."
|
||||
(1+ index))
|
||||
:updated (format "D-%03d" (1+ index)))))
|
||||
|
||||
(defun etaf-research-shelf--fixture-records ()
|
||||
"Return the deterministic fixture set sized by `...-fixture-size'."
|
||||
(let* ((target (max 0 etaf-research-shelf-fixture-size))
|
||||
(base (copy-tree etaf-research-shelf-seed-records))
|
||||
(base-count (length base)))
|
||||
(if (<= target base-count)
|
||||
(cl-subseq base 0 target)
|
||||
(append base
|
||||
(cl-loop for id from (1+ base-count) to target
|
||||
for index from 0
|
||||
collect (etaf-research-shelf--generated-record id index))))))
|
||||
|
||||
(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)))
|
||||
@ -74,15 +145,32 @@
|
||||
:id)))
|
||||
|
||||
(defun etaf-research-shelf--ensure-database ()
|
||||
"Initialize the SQLite database and install seed data when empty."
|
||||
"Initialize SQLite and top up the deterministic Playground fixture.
|
||||
|
||||
Existing records are preserved. Missing fixture rows receive fresh IDs so a
|
||||
user's prior local additions cannot collide with the generated dataset."
|
||||
(let* ((database (etaf-research-shelf--database))
|
||||
(source (etaf-sqlite-source database))
|
||||
(load (plist-get source :load))
|
||||
(mutate (plist-get source :mutate)))
|
||||
(mutate (plist-get source :mutate))
|
||||
(target-records (etaf-research-shelf--fixture-records))
|
||||
(target (length target-records)))
|
||||
(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)))
|
||||
(let* ((current (funcall load nil 1 (max 1 target)))
|
||||
(total (plist-get current :total))
|
||||
(existing-items (plist-get current :items))
|
||||
(next-id (1+ (if existing-items
|
||||
(apply #'max
|
||||
(mapcar (lambda (row)
|
||||
(or (plist-get row :id) 0))
|
||||
existing-items))
|
||||
0))))
|
||||
(when (< total target)
|
||||
(dolist (record (nthcdr total target-records))
|
||||
(let ((copy (copy-sequence record)))
|
||||
(plist-put copy :id next-id)
|
||||
(funcall mutate 'insert copy)
|
||||
(setq next-id (1+ next-id))))))
|
||||
database))
|
||||
|
||||
(defun etaf-research-shelf--create-model ()
|
||||
@ -91,11 +179,24 @@
|
||||
(controller
|
||||
(etaf-data-controller
|
||||
(etaf-sqlite-source database)
|
||||
:page-size 4 :auto-load nil :name 'etaf-research-shelf))
|
||||
:page-size etaf-research-shelf-page-size
|
||||
: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)))
|
||||
(next-id (etaf-ref
|
||||
(let* ((load (plist-get (etaf-sqlite-source database) :load))
|
||||
(items (plist-get
|
||||
(funcall load nil 1
|
||||
(max 1 etaf-research-shelf-fixture-size))
|
||||
:items)))
|
||||
(1+ (if items
|
||||
(apply #'max
|
||||
(mapcar (lambda (row)
|
||||
(or (plist-get row :id) 0))
|
||||
items))
|
||||
0)))
|
||||
: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)))
|
||||
@ -135,6 +236,18 @@
|
||||
(format "Showing %s"
|
||||
(capitalize (symbol-name filter))))))
|
||||
|
||||
(defun etaf-research-shelf--change-page-size (model)
|
||||
"Prompt for MODEL's page size, validate it, and reload from page one."
|
||||
(let* ((controller (etaf-research-shelf--controller model))
|
||||
(current (etaf-value (etaf-data-page-size controller)))
|
||||
(next (read-number "Rows per page (1–100): " current)))
|
||||
(unless (and (integerp next) (<= 1 next 100))
|
||||
(user-error "Rows per page must be an integer from 1 to 100"))
|
||||
(etaf-data-set-page-size controller next)
|
||||
(etaf-data-set-page controller 1)
|
||||
(etaf-research-shelf--load
|
||||
model (format "Showing %d rows per page" next))))
|
||||
|
||||
(defun etaf-research-shelf--items (model)
|
||||
"Return loaded items for MODEL."
|
||||
(etaf-value (etaf-data-items (etaf-research-shelf--controller model))))
|
||||
@ -213,9 +326,9 @@ color or the frame edge exposes Emacs's unrelated default background."
|
||||
label)))
|
||||
|
||||
(defun etaf-research-shelf--button-style (palette variant disabled)
|
||||
"Return theme-aware style props for a Research Shelf action button.
|
||||
"Return style props for PALETTE, VARIANT, and DISABLED button state.
|
||||
|
||||
The public `button' Component owns interaction and pressed state; this small
|
||||
The public `button' Component owns native interaction state; this small
|
||||
adapter only maps the app palette to its ordinary presentation props so ghost
|
||||
buttons do not fall back to a light-theme paper surface in dark mode."
|
||||
(let ((secondary (eq variant 'secondary)))
|
||||
@ -251,6 +364,7 @@ buttons do not fall back to a light-theme paper surface in dark mode."
|
||||
"Return the filter rail from MODEL and static FILTER FORM."
|
||||
(etaf-view
|
||||
(column :class "research-shelf-filter-rail" :width 'stretch :min-width 0
|
||||
:flex-grow 1 :flex-shrink 1 :flex-basis '(220)
|
||||
:padding '(1 1)
|
||||
:border (etaf-research-shelf--color model :line)
|
||||
:color (etaf-research-shelf--color model :ink)
|
||||
@ -312,41 +426,17 @@ buttons do not fall back to a light-theme paper surface in dark mode."
|
||||
: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--grid-columns ()
|
||||
"Return readable wide or compact columns for the active GUI window."
|
||||
(let* ((window (get-buffer-window (current-buffer) t))
|
||||
(pixel-width
|
||||
(and (window-live-p window)
|
||||
(condition-case nil
|
||||
(window-body-width window t)
|
||||
(error nil)))))
|
||||
(if (and pixel-width (> pixel-width 1200))
|
||||
'((: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))
|
||||
'((:key :title :label "Title" :width 21)
|
||||
(:key :author :label "Author" :width 12)
|
||||
(:key :status :label "Status" :width 10)
|
||||
(:key :progress :label "Progress" :width 8)
|
||||
(:key :kind :label "Kind" :width 7)))))
|
||||
|
||||
(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 :min-width 0
|
||||
:flex-grow 4 :flex-shrink 1 :flex-basis '(620)
|
||||
: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))
|
||||
(flex :width 'stretch :flex-wrap 'wrap :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")
|
||||
@ -357,6 +447,14 @@ buttons do not fall back to a light-theme paper surface in dark mode."
|
||||
(or (etaf-value (etaf-data-total controller)) 0))
|
||||
:color (etaf-research-shelf--color model :muted)
|
||||
:bgcolor (etaf-research-shelf--color model :panel)))
|
||||
(etaf-research-shelf-button
|
||||
:theme (etaf-research-shelf--theme model)
|
||||
:label (format "Rows %d ✎"
|
||||
(etaf-value (etaf-data-page-size controller)))
|
||||
:ref 'research-shelf-page-size
|
||||
:variant 'ghost
|
||||
:on-press (lambda ()
|
||||
(etaf-research-shelf--change-page-size model)))
|
||||
(etaf-research-shelf-button
|
||||
:theme (etaf-research-shelf--theme model)
|
||||
:label "+ Add reading" :ref 'research-shelf-add
|
||||
@ -375,7 +473,7 @@ buttons do not fall back to a light-theme paper surface in dark mode."
|
||||
(setf (etaf-value (plist-get model :next-id)) (1+ id))))))
|
||||
(data-grid
|
||||
:controller controller
|
||||
:columns (etaf-research-shelf--grid-columns)
|
||||
:columns etaf-research-shelf--grid-columns
|
||||
:row-key (lambda (row) (plist-get row :id))
|
||||
:row-ref (lambda (row)
|
||||
(intern (format "research-shelf-row-%s"
|
||||
@ -383,7 +481,9 @@ buttons do not fall back to a light-theme paper surface in dark mode."
|
||||
: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))
|
||||
:on-row-press
|
||||
(lambda (row)
|
||||
(etaf-data-select-one controller (plist-get row :id)))
|
||||
:loading-label "◷ Loading library…"
|
||||
:error-label "⚠ Could not read the shelf. Use Reload.")
|
||||
(etaf-pagination :controller controller
|
||||
@ -396,6 +496,7 @@ buttons do not fall back to a light-theme paper surface in dark mode."
|
||||
"Return the selected record inspector for MODEL and static FORM."
|
||||
(etaf-view
|
||||
(column :class "research-shelf-detail" :width 'stretch :min-width 0
|
||||
:flex-grow 2 :flex-shrink 1 :flex-basis '(340)
|
||||
:padding '(2 2)
|
||||
:border (etaf-research-shelf--color model :line)
|
||||
:color (etaf-research-shelf--color model :ink)
|
||||
@ -561,6 +662,7 @@ structural children instead of leaking through a direct material `expr'."
|
||||
:color (plist-get palette :ink)
|
||||
:bgcolor (plist-get palette :paper)
|
||||
(flex :class "research-shelf-header" :width 'stretch
|
||||
:flex-wrap 'wrap
|
||||
: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
|
||||
@ -583,18 +685,15 @@ structural children instead of leaking through a direct material `expr'."
|
||||
(etaf-research-shelf--theme model))
|
||||
(setf (etaf-value (plist-get model :toast))
|
||||
(if value "Dark theme" "Light theme")))))
|
||||
(grid :class "research-shelf-workspace" :width 'stretch
|
||||
:grid-template-columns
|
||||
'((minmax (0) (fr 2))
|
||||
(minmax (0) (fr 5))
|
||||
(minmax (0) (fr 3)))
|
||||
:gap '(1 (6))
|
||||
:color (plist-get palette :ink)
|
||||
(flex :class "research-shelf-workspace" :width 'stretch
|
||||
:flex-wrap 'wrap :align-items 'stretch :align-content 'start
|
||||
:gap '(1 (6)) :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))
|
||||
(flex :class "research-shelf-footer" :width 'stretch
|
||||
:flex-wrap 'wrap :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)
|
||||
@ -604,10 +703,6 @@ structural children instead of leaking through a direct material `expr'."
|
||||
: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)
|
||||
@ -617,7 +712,9 @@ structural children instead of leaking through a direct material `expr'."
|
||||
;; 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))))
|
||||
(lambda ()
|
||||
(etaf-view
|
||||
(research-shelf-shell :model model :static-form static-form)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun etaf-research-shelf-open (&optional buffer-name)
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
(require 'cl-lib)
|
||||
(require 'etaf-playground)
|
||||
|
||||
(defconst etaf-research-shelf-benchmark-row-p50-budget-ms 100.0)
|
||||
(defconst etaf-research-shelf-benchmark-row-p50-budget-ms 105.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)
|
||||
|
||||
@ -40,7 +40,11 @@ database and mounts a test buffer before running BODY."
|
||||
(ignore ,database)
|
||||
(etaf-playground-test--ensure-app-loaded)
|
||||
(unwind-protect
|
||||
(let ((etaf-research-shelf-database-file ,database))
|
||||
(let ((etaf-research-shelf-database-file ,database)
|
||||
(etaf-research-shelf-fixture-size 8)
|
||||
(etaf-research-shelf-page-size 4))
|
||||
(ignore etaf-research-shelf-fixture-size
|
||||
etaf-research-shelf-page-size)
|
||||
(ignore etaf-research-shelf-database-file)
|
||||
,@body)
|
||||
(etaf-playground-test--close ,buffer)
|
||||
@ -141,10 +145,32 @@ database and mounts a test buffer before running BODY."
|
||||
(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-page-size research-shelf-add
|
||||
research-shelf-page-next
|
||||
research-shelf-theme-toggle))
|
||||
(should (etaf-runtime-handler-for runtime ref))))))
|
||||
|
||||
(ert-deftest etaf-playground-fixture-supports-realistic-page-counts ()
|
||||
"The Playground can mount a larger deterministic fixture for pressure runs."
|
||||
(etaf-playground-test--ensure-app-loaded)
|
||||
(let ((database (make-temp-file "etaf-research-shelf-large-" nil ".sqlite"))
|
||||
(buffer " *etaf-research-shelf-large-test*"))
|
||||
(unwind-protect
|
||||
(let ((etaf-research-shelf-database-file database)
|
||||
(etaf-research-shelf-fixture-size 32)
|
||||
(etaf-research-shelf-page-size 8))
|
||||
(ignore etaf-research-shelf-database-file
|
||||
etaf-research-shelf-fixture-size
|
||||
etaf-research-shelf-page-size)
|
||||
(etaf-playground-mount-example buffer "research-shelf")
|
||||
(let ((text (etaf-playground-test--text buffer)))
|
||||
(should (string-match-p "32 items · SQLite-backed" text))
|
||||
(should (string-match-p "Page 1 / 4" text)))
|
||||
(should (file-exists-p database)))
|
||||
(etaf-playground-test--close buffer)
|
||||
(when (file-exists-p database)
|
||||
(delete-file database)))))
|
||||
|
||||
(ert-deftest etaf-playground-row-selection-is-repeatable ()
|
||||
"Repeated DataGrid row presses replace one selected identity."
|
||||
(etaf-playground-test--with-app (buffer database)
|
||||
@ -168,12 +194,16 @@ database and mounts a test buffer before running BODY."
|
||||
(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))))))))
|
||||
(or (plist-get
|
||||
(etaf-runtime-host-props-for runtime selected)
|
||||
:class)
|
||||
"")))
|
||||
(should-not
|
||||
(string-match-p
|
||||
"selected"
|
||||
(or (plist-get (etaf-runtime-host-props-for runtime other)
|
||||
:class)
|
||||
""))))))))
|
||||
|
||||
(ert-deftest etaf-playground-filters-and-pagination-reload-data ()
|
||||
"Filter and pager refs drive the SQLite-backed Data Controller."
|
||||
@ -191,7 +221,48 @@ database and mounts a test buffer before running BODY."
|
||||
(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))))))
|
||||
(etaf-playground-test--text buffer)))
|
||||
(cl-letf (((symbol-function 'read-number)
|
||||
(lambda (&rest _) 6)))
|
||||
(etaf-dispatch-event runtime 'research-shelf-page-size 'press))
|
||||
(should (string-match-p "Rows 6 ✎"
|
||||
(etaf-playground-test--text buffer)))
|
||||
(should (string-match-p "Page 1 / 2"
|
||||
(etaf-playground-test--text buffer)))
|
||||
(cl-letf (((symbol-function 'read-number)
|
||||
(lambda (&rest _) 0)))
|
||||
(should-error
|
||||
(etaf-dispatch-event runtime 'research-shelf-page-size 'press)
|
||||
:type 'user-error)))))
|
||||
|
||||
(ert-deftest etaf-playground-workspace-reflows-at-responsive-widths ()
|
||||
"Workspace cards share a row when wide and wrap in document order."
|
||||
(etaf-playground-test--with-app (buffer database)
|
||||
(etaf-playground-open buffer)
|
||||
(cl-labels
|
||||
((line-of (label)
|
||||
(with-current-buffer buffer
|
||||
(save-excursion
|
||||
(goto-char (point-min))
|
||||
(search-forward label)
|
||||
(line-number-at-pos (match-beginning 0)))))
|
||||
(layout-at (width)
|
||||
(ebox-surface-update-buffer-viewport (get-buffer buffer) width 80)
|
||||
(list (line-of "Library")
|
||||
(line-of "Reading queue")
|
||||
(line-of "Selected item"))))
|
||||
(pcase-let ((`(,filter-line ,list-line ,detail-line)
|
||||
(layout-at 1600)))
|
||||
(should (= filter-line list-line))
|
||||
(should (<= (- detail-line list-line) 1)))
|
||||
(pcase-let ((`(,filter-line ,list-line ,detail-line)
|
||||
(layout-at 900)))
|
||||
(should (= filter-line list-line))
|
||||
(should (> detail-line list-line)))
|
||||
(pcase-let ((`(,filter-line ,list-line ,detail-line)
|
||||
(layout-at 600)))
|
||||
(should (< filter-line list-line))
|
||||
(should (< list-line detail-line))))))
|
||||
|
||||
(ert-deftest etaf-playground-mutations-persist-and-refresh ()
|
||||
"Add and mutate actions persist through the SQLite source and refresh UI."
|
||||
|
||||
Loading…
Reference in New Issue
Block a user