Stabilize responsive Playground app layout

This commit is contained in:
Kinneyzhang 2026-08-22 10:19:21 +08:00
parent dcb0ad9d83
commit 4bdf5ddaca
5 changed files with 214 additions and 123 deletions

View File

@ -115,10 +115,13 @@
## Responsive behavior ## Responsive behavior
- Wide/fullscreen: three-column grid (rail / list / detail). - Wide/fullscreen: three-column grid (rail / list / detail).
- Compact: rail becomes a top filter row, detail follows the list, and all - Compact: the same grid uses zero-minimum fractional tracks; rail, list, and
controls remain intrinsic single-line widths. detail shrink within the viewport instead of imposing min-content overflow,
- Use `grid` for the product skeleton, `flex` for toolbars and action groups, while the action group wraps through Flex and long table cells wrap inside
and `row` only for compact intrinsic controls. 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.
## Interaction states ## Interaction states

View File

@ -69,8 +69,11 @@ Component 边界,避免新增 helper 层或抽象泄漏。
## 响应式与验证 ## 响应式与验证
- Wide/fullscreen 使用 rail / list / detail 三列 grid。 - Wide/fullscreen 使用 rail / list / detail 三列 grid。
- Compact 时筛选栏收为顶部工具行detail 位于 list 后面;不使用固定宽度撑坏 - Compact 时仍使用三列骨架,但每列采用零最小值的 fractional track在 viewport
viewport所有操作控件仍保持独立 hover/focus 语义。 内收缩而不是被 min-content 撑出边界;长表格单元格在自己的 track 内换行,动作组
由 Flex 自动换行。不要在 shell 边界重新引入固定宽度。
- `minmax(0, fr)` 是响应式 track 合同;`grid` 负责骨架,`flex` 负责 toolbar/action
group`row` 只负责紧凑 intrinsic 控件,所有操作控件仍保持独立 hover/focus 语义。
- 用 SQLite 临时文件测试 mount/remount、筛选、分页、重复选行、mutation、错误 - 用 SQLite 临时文件测试 mount/remount、筛选、分页、重复选行、mutation、错误
状态和 cleanupGUI 用干净 fullscreen 单窗口截图验证真实布局。 状态和 cleanupGUI 用干净 fullscreen 单窗口截图验证真实布局。
- 目标:一次 Data mutation 对应一次 Runtime generation/publicationwarm 交互 - 目标:一次 Data mutation 对应一次 Runtime generation/publicationwarm 交互

View File

@ -126,27 +126,33 @@ ENTRY must contain a string `:pair', symbol `:root-component', symbol
(with-current-buffer buffer (setq etaf-playground-current-example name)) (with-current-buffer buffer (setq etaf-playground-current-example name))
buffer)) buffer))
(defun etaf-playground--display-buffer (buffer) (defun etaf-playground--mount-for-display (buffer-name name)
"Display BUFFER interactively and return it." "Display BUFFER-NAME before mounting pair NAME when running in a GUI.
(unless noninteractive (pop-to-buffer buffer))
buffer) Ebox resolves viewport-relative widths from the window that owns the target
buffer. Mounting first would make an off-window daemon/terminal window the
initial containing block, leaving a responsive pair permanently laid out at
that stale width until a later explicit resize."
(unless noninteractive
;; Keep the current window as the containing block. `pop-to-buffer' may
;; split a clean frame, cutting a responsive three-column app in half.
(switch-to-buffer (get-buffer-create buffer-name)))
(etaf-playground-mount-example buffer-name name))
;;;###autoload ;;;###autoload
(defun etaf-playground-open (&optional buffer-name) (defun etaf-playground-open (&optional buffer-name)
"Open the registered default pair in BUFFER-NAME." "Open the registered default pair in BUFFER-NAME."
(interactive) (interactive)
(etaf-playground--display-buffer (etaf-playground--mount-for-display
(etaf-playground-mount-example (or buffer-name etaf-playground-buffer-name)
(or buffer-name etaf-playground-buffer-name) etaf-playground-default-pair))
etaf-playground-default-pair)))
;;;###autoload ;;;###autoload
(defun etaf-playground-open-example (name &optional buffer-name) (defun etaf-playground-open-example (name &optional buffer-name)
"Open registered pair NAME in BUFFER-NAME." "Open registered pair NAME in BUFFER-NAME."
(interactive (list (completing-read "Pair: " etaf-playground-example-names))) (interactive (list (completing-read "Pair: " etaf-playground-example-names)))
(etaf-playground--display-buffer (etaf-playground--mount-for-display
(etaf-playground-mount-example (or buffer-name etaf-playground-buffer-name) name))
(or buffer-name etaf-playground-buffer-name) name)))
;;;###autoload ;;;###autoload
(defun etaf-playground-reset (&optional buffer-name) (defun etaf-playground-reset (&optional buffer-name)
@ -158,8 +164,7 @@ ENTRY must contain a string `:pair', symbol `:root-component', symbol
buffer)) buffer))
etaf-playground-default-pair))) etaf-playground-default-pair)))
(unless buffer (user-error "No ETAF Playground pair is mounted")) (unless buffer (user-error "No ETAF Playground pair is mounted"))
(etaf-playground--display-buffer (etaf-playground--mount-for-display name pair)))
(etaf-playground-mount-example name pair))))
;;;###autoload ;;;###autoload
(defun etaf-playground-close (&optional buffer-name) (defun etaf-playground-close (&optional buffer-name)

View File

@ -198,10 +198,45 @@ Show MESSAGE on success, or preserve the resulting error state."
(concat "" label) (concat "" label)
label))) label)))
(defun etaf-research-shelf--button-style (palette variant disabled)
"Return theme-aware style props for a Research Shelf action button.
The public `button' Component owns interaction and pressed 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)))
(list :color (plist-get palette (if disabled :muted
(if secondary :paper :ink)))
:bgcolor (plist-get palette (if disabled :soft
(if secondary :mint :panel)))
:border (plist-get palette (if secondary :mint :line))
:face (if (and secondary (not disabled)) 'bold 'normal))))
(etaf-define-component etaf-research-shelf-button
(&key theme label ref variant disabled on-press)
"Compose the public Button with the Research Shelf theme palette."
:setup
(lambda ()
(let* ((theme (etaf-current-prop :theme))
(label (etaf-current-prop :label))
(ref (etaf-current-prop :ref))
(variant (etaf-current-prop :variant))
(disabled (etaf-current-prop :disabled))
(on-press (etaf-current-prop :on-press))
(style (etaf-research-shelf--button-style
theme variant disabled)))
(etaf-view
(button :label label :ref ref :variant variant :disabled disabled
:on-press on-press
:color (plist-get style :color)
:bgcolor (plist-get style :bgcolor)
:border (plist-get style :border)
:face (plist-get style :face))))))
(defun etaf-research-shelf--filter-view (model form) (defun etaf-research-shelf--filter-view (model form)
"Return the filter rail from MODEL and static FILTER FORM." "Return the filter rail from MODEL and static FILTER FORM."
(etaf-view (etaf-view
(column :class "research-shelf-filter-rail" :width 'stretch (column :class "research-shelf-filter-rail" :width 'stretch :min-width 0
:padding '(1 1) :padding '(1 1)
:border (etaf-research-shelf--color model :line) :border (etaf-research-shelf--color model :line)
:color (etaf-research-shelf--color model :ink) :color (etaf-research-shelf--color model :ink)
@ -211,40 +246,45 @@ Show MESSAGE on success, or preserve the resulting error state."
:bgcolor (etaf-research-shelf--color model :panel)) :bgcolor (etaf-research-shelf--color model :panel))
(label :text "VIEWS" :color (etaf-research-shelf--color model :muted) (label :text "VIEWS" :color (etaf-research-shelf--color model :muted)
:bgcolor (etaf-research-shelf--color model :panel)) :bgcolor (etaf-research-shelf--color model :panel))
(button :label (etaf-research-shelf--filter-label model form 'all "All") (etaf-research-shelf-button
:ref 'research-shelf-filter-all :theme (etaf-research-shelf--theme model)
:variant (if (eq (etaf-value (plist-get model :filter)) 'all) :label (etaf-research-shelf--filter-label model form 'all "All")
'secondary 'ghost) :ref 'research-shelf-filter-all
:on-press (lambda () :variant (if (eq (etaf-value (plist-get model :filter)) 'all)
(etaf-research-shelf--set-filter model 'all))) 'secondary 'ghost)
(button :label (etaf-research-shelf--filter-label :on-press (lambda () (etaf-research-shelf--set-filter model 'all)))
model form 'reading "In progress") (etaf-research-shelf-button
:ref 'research-shelf-filter-reading :theme (etaf-research-shelf--theme model)
:variant (if (eq (etaf-value (plist-get model :filter)) 'reading) :label (etaf-research-shelf--filter-label
'secondary 'ghost) model form 'reading "In progress")
:on-press (lambda () :ref 'research-shelf-filter-reading
(etaf-research-shelf--set-filter model 'reading))) :variant (if (eq (etaf-value (plist-get model :filter)) 'reading)
(button :label (etaf-research-shelf--filter-label 'secondary 'ghost)
model form 'unread "Unread") :on-press (lambda () (etaf-research-shelf--set-filter model 'reading)))
:ref 'research-shelf-filter-unread (etaf-research-shelf-button
:variant (if (eq (etaf-value (plist-get model :filter)) 'unread) :theme (etaf-research-shelf--theme model)
'secondary 'ghost) :label (etaf-research-shelf--filter-label
:on-press (lambda () model form 'unread "Unread")
(etaf-research-shelf--set-filter model 'unread))) :ref 'research-shelf-filter-unread
(button :label (etaf-research-shelf--filter-label :variant (if (eq (etaf-value (plist-get model :filter)) 'unread)
model form 'finished "Finished") 'secondary 'ghost)
:ref 'research-shelf-filter-finished :on-press (lambda () (etaf-research-shelf--set-filter model 'unread)))
:variant (if (eq (etaf-value (plist-get model :filter)) 'finished) (etaf-research-shelf-button
'secondary 'ghost) :theme (etaf-research-shelf--theme model)
:on-press (lambda () :label (etaf-research-shelf--filter-label
(etaf-research-shelf--set-filter model 'finished))) model form 'finished "Finished")
(button :label (etaf-research-shelf--filter-label :ref 'research-shelf-filter-finished
model form 'starred "★ Starred") :variant (if (eq (etaf-value (plist-get model :filter)) 'finished)
:ref 'research-shelf-filter-starred 'secondary 'ghost)
:variant (if (eq (etaf-value (plist-get model :filter)) 'starred) :on-press (lambda () (etaf-research-shelf--set-filter model 'finished)))
'secondary 'ghost) (etaf-research-shelf-button
:on-press (lambda () :theme (etaf-research-shelf--theme model)
(etaf-research-shelf--set-filter model 'starred))) :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) (spacer :height 1)
(label :text "STORAGE" :color (etaf-research-shelf--color model :muted) (label :text "STORAGE" :color (etaf-research-shelf--color model :muted)
:bgcolor (etaf-research-shelf--color model :panel)) :bgcolor (etaf-research-shelf--color model :panel))
@ -252,9 +292,11 @@ Show MESSAGE on success, or preserve the resulting error state."
(plist-get model :database-file)) (plist-get model :database-file))
:color (etaf-research-shelf--color model :ink) :color (etaf-research-shelf--color model :ink)
:bgcolor (etaf-research-shelf--color model :panel)) :bgcolor (etaf-research-shelf--color model :panel))
(button :label "↻ Reload library" :ref 'research-shelf-reload (etaf-research-shelf-button
:variant 'ghost :theme (etaf-research-shelf--theme model)
:on-press (lambda () (etaf-research-shelf--load model "✓ Library reloaded")))))) :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) (defun etaf-research-shelf--row-press (model row)
"Select ROW as the single current record in MODEL." "Select ROW as the single current record in MODEL."
@ -265,7 +307,7 @@ Show MESSAGE on success, or preserve the resulting error state."
"Return the main reading list for MODEL and static MAIN FORM." "Return the main reading list for MODEL and static MAIN FORM."
(let ((controller (etaf-research-shelf--controller model))) (let ((controller (etaf-research-shelf--controller model)))
(etaf-view (etaf-view
(column :class "research-shelf-list" :width 'stretch (column :class "research-shelf-list" :width 'stretch :min-width 0
:padding '(1 2) :padding '(1 2)
:border (etaf-research-shelf--color model :line) :border (etaf-research-shelf--color model :line)
:color (etaf-research-shelf--color model :ink) :color (etaf-research-shelf--color model :ink)
@ -281,27 +323,29 @@ Show MESSAGE on success, or preserve the resulting error state."
(or (etaf-value (etaf-data-total controller)) 0)) (or (etaf-value (etaf-data-total controller)) 0))
:color (etaf-research-shelf--color model :muted) :color (etaf-research-shelf--color model :muted)
:bgcolor (etaf-research-shelf--color model :panel))) :bgcolor (etaf-research-shelf--color model :panel)))
(button :label " Add reading" :ref 'research-shelf-add (etaf-research-shelf-button
:variant 'secondary :theme (etaf-research-shelf--theme model)
:on-press :label " Add reading" :ref 'research-shelf-add
(lambda () :variant 'secondary
(let ((id (etaf-value (plist-get model :next-id)))) :on-press
(etaf-research-shelf--mutate (lambda ()
model 'insert (let ((id (etaf-value (plist-get model :next-id))))
(list :id id :title (format "New reading %d" id) (etaf-research-shelf--mutate
:author "You" :kind "Notes" :status "unread" model 'insert
:progress 0 :priority "Medium" :starred 0 (list :id id :title (format "New reading %d" id)
:note "A new note waiting for your attention." :author "You" :kind "Notes" :status "unread"
:updated "Just now") :progress 0 :priority "Medium" :starred 0
"✓ Added to your shelf") :note "A new note waiting for your attention."
(setf (etaf-value (plist-get model :next-id)) (1+ id)))))) :updated "Just now")
"✓ Added to your shelf")
(setf (etaf-value (plist-get model :next-id)) (1+ id))))))
(data-grid (data-grid
:controller controller :controller controller
:columns '((:key :title :label "Title" :width 30) :columns '((:key :title :label "Title" :width 22)
(:key :author :label "Author" :width 16) (:key :author :label "Author" :width 12)
(:key :status :label "Status" :width 14) (:key :status :label "Status" :width 11)
(:key :progress :label "Progress" :width 10) (:key :progress :label "Progress" :width 9)
(:key :kind :label "Kind" :width 10)) (:key :kind :label "Kind" :width 8))
:row-key (lambda (row) (plist-get row :id)) :row-key (lambda (row) (plist-get row :id))
:row-ref (lambda (row) :row-ref (lambda (row)
(intern (format "research-shelf-row-%s" (intern (format "research-shelf-row-%s"
@ -321,7 +365,7 @@ Show MESSAGE on success, or preserve the resulting error state."
(defun etaf-research-shelf--detail-view (model form) (defun etaf-research-shelf--detail-view (model form)
"Return the selected record inspector for MODEL and static FORM." "Return the selected record inspector for MODEL and static FORM."
(etaf-view (etaf-view
(column :class "research-shelf-detail" :width 'stretch (column :class "research-shelf-detail" :width 'stretch :min-width 0
:padding '(2 2) :padding '(2 2)
:border (etaf-research-shelf--color model :line) :border (etaf-research-shelf--color model :line)
:color (etaf-research-shelf--color model :ink) :color (etaf-research-shelf--color model :ink)
@ -375,50 +419,56 @@ Show MESSAGE on success, or preserve the resulting error state."
:setup :setup
(let ((model (etaf-current-prop :model))) (let ((model (etaf-current-prop :model)))
(lambda () (lambda ()
(let* ((row (etaf-research-shelf--selected model)) (let* ((theme (etaf-research-shelf--theme model))
(row (etaf-research-shelf--selected model))
(progress (or (and row (plist-get row :progress)) 0)) (progress (or (and row (plist-get row :progress)) 0))
(finished (and row (equal (plist-get row :status) "finished"))) (finished (and row (equal (plist-get row :status) "finished")))
(archived (and row (equal (plist-get row :status) "archived")))) (archived (and row (equal (plist-get row :status) "archived"))))
(etaf-view (etaf-view
(flex :width 'stretch :flex-wrap 'wrap :gap '(1 (6)) (flex :width 'stretch :flex-wrap 'wrap :gap '(1 (6))
(button :label "+ 10%" :ref 'research-shelf-progress (etaf-research-shelf-button
:variant 'secondary :disabled (or (null row) (>= progress 100)) :theme theme :label "+ 10%" :ref 'research-shelf-progress
:on-press :variant 'secondary
(lambda () :disabled (or (null row) archived (>= progress 100))
(when-let* ((current (etaf-research-shelf--selected model)) :on-press
(value (or (plist-get current :progress) 0))) (lambda ()
(etaf-research-shelf--update-selected (when-let* ((current (etaf-research-shelf--selected model))
model (list :progress (min 100 (+ value 10)) (value (or (plist-get current :progress) 0)))
:status (if (>= (+ value 10) 100) (etaf-research-shelf--update-selected
"finished" "reading") model (list :progress (min 100 (+ value 10))
:updated "Just now") :status (if (>= (+ value 10) 100)
"✓ Progress saved")))) "finished" "reading")
(button :label "✓ Finish" :ref 'research-shelf-finish :updated "Just now")
:variant 'secondary :disabled (or (null row) finished) "✓ Progress saved"))))
:on-press (etaf-research-shelf-button
(lambda () :theme theme :label "✓ Finish" :ref 'research-shelf-finish
(etaf-research-shelf--update-selected :variant 'secondary :disabled (or (null row) finished archived)
model '(:progress 100 :status "finished" :updated "Just now") :on-press
"✓ Marked finished"))) (lambda ()
(button :label "★ Star" :ref 'research-shelf-star :variant 'ghost (etaf-research-shelf--update-selected
:disabled (null row) model '(:progress 100 :status "finished" :updated "Just now")
:on-press "✓ Marked finished")))
(lambda () (etaf-research-shelf-button
(when-let ((current (etaf-research-shelf--selected model))) :theme theme :label "★ Star" :ref 'research-shelf-star
(let ((current-starred :variant 'ghost :disabled (null row)
(= 1 (or (plist-get current :starred) 0)))) :on-press
(etaf-research-shelf--update-selected (lambda ()
model (list :starred (if current-starred 0 1) (when-let ((current (etaf-research-shelf--selected model)))
:updated "Just now") (let ((current-starred
(if current-starred (= 1 (or (plist-get current :starred) 0))))
"Removed star" "★ Starred locally")))))) (etaf-research-shelf--update-selected
(button :label "Archive" :ref 'research-shelf-archive model (list :starred (if current-starred 0 1)
:variant 'ghost :disabled (or (null row) archived) :updated "Just now")
:on-press (if current-starred
(lambda () "Removed star" "★ Starred locally"))))))
(etaf-research-shelf--update-selected (etaf-research-shelf-button
model '(:status "archived" :updated "Just now") :theme theme :label "Archive" :ref 'research-shelf-archive
"↗ Archived"))))))))) :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 (etaf-define-component etaf-research-shelf-filter-rail
(&key model static-form) (&key model static-form)
@ -497,7 +547,11 @@ structural children instead of leaking through a direct material `expr'."
(setf (etaf-value (plist-get model :toast)) (setf (etaf-value (plist-get model :toast))
(if value "Dark theme" "Light theme"))))) (if value "Dark theme" "Light theme")))))
(grid :class "research-shelf-workspace" :width 'stretch (grid :class "research-shelf-workspace" :width 'stretch
:grid-template-columns '(2fr 5fr 3fr) :gap '(1 (10)) :grid-template-columns
'((minmax (0) (fr 2))
(minmax (0) (fr 5))
(minmax (0) (fr 3)))
:gap '(1 (6))
:color (plist-get palette :ink) :color (plist-get palette :ink)
:bgcolor (plist-get palette :paper) :bgcolor (plist-get palette :paper)
(etaf-research-shelf-filter-rail :model model :static-form filters) (etaf-research-shelf-filter-rail :model model :static-form filters)
@ -532,11 +586,15 @@ structural children instead of leaking through a direct material `expr'."
(defun etaf-research-shelf-open (&optional buffer-name) (defun etaf-research-shelf-open (&optional buffer-name)
"Mount and optionally display the Research Shelf application in BUFFER-NAME." "Mount and optionally display the Research Shelf application in BUFFER-NAME."
(interactive) (interactive)
(let ((buffer (etaf-mount (or buffer-name "*ETAF Research Shelf*") (let* ((name (or buffer-name "*ETAF Research Shelf*"))
(etaf-research-shelf-root ;; Put the target in its real GUI window before the first Ebox
(etaf-playground-read-static "research-shelf"))))) ;; projection so `(viewport)' resolves against the user's frame.
(when (called-interactively-p 'interactive) (pop-to-buffer buffer)) (buffer (get-buffer-create name)))
buffer)) (when (called-interactively-p 'interactive)
(switch-to-buffer buffer))
(etaf-mount buffer
(etaf-research-shelf-root
(etaf-playground-read-static "research-shelf")))))
(provide 'etaf-research-shelf) (provide 'etaf-research-shelf)
;;; research-shelf.el ends here ;;; research-shelf.el ends here

View File

@ -72,6 +72,22 @@ database and mounts a test buffer before running BODY."
(should-not (string-match-p "operations-console" framework)) (should-not (string-match-p "operations-console" framework))
(should (string-match-p "research-shelf" catalog)))) (should (string-match-p "research-shelf" catalog))))
(ert-deftest etaf-playground-displays-before-responsive-mount ()
"GUI entry points establish the containing window before Ebox mount."
(let ((calls nil)
(noninteractive nil))
(cl-letf (((symbol-function 'switch-to-buffer)
(lambda (buffer)
(push (list 'display (buffer-name buffer)) calls)
buffer))
((symbol-function 'etaf-playground-mount-example)
(lambda (buffer name)
(push (list 'mount buffer name) calls)
buffer)))
(etaf-playground--mount-for-display
" *etaf-playground-display-order*" "research-shelf"))
(should (equal '(display mount) (mapcar #'car (nreverse calls))))))
(ert-deftest etaf-playground-compile-builds-the-dependency-graph () (ert-deftest etaf-playground-compile-builds-the-dependency-graph ()
"Integration builds must compile the framework's dependency graph first." "Integration builds must compile the framework's dependency graph first."
(let ((makefile (with-temp-buffer (let ((makefile (with-temp-buffer
@ -189,6 +205,12 @@ database and mounts a test buffer before running BODY."
(etaf-dispatch-event runtime 'research-shelf-star 'press) (etaf-dispatch-event runtime 'research-shelf-star 'press)
(should (string-match-p "Removed star" (should (string-match-p "Removed star"
(etaf-playground-test--text buffer))) (etaf-playground-test--text buffer)))
(etaf-dispatch-event runtime 'research-shelf-archive 'press)
(should (equal t
(plist-get
(etaf-runtime-host-props-for runtime
'research-shelf-finish)
:disabled)))
(etaf-dispatch-event runtime 'research-shelf-add 'press) (etaf-dispatch-event runtime 'research-shelf-add 'press)
(should (string-match-p "Added to your shelf" (should (string-match-p "Added to your shelf"
(etaf-playground-test--text buffer))) (etaf-playground-test--text buffer)))