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
:theme (etaf-research-shelf--theme model)
:label (etaf-research-shelf--filter-label model form 'all "All")
:ref 'research-shelf-filter-all :ref 'research-shelf-filter-all
:variant (if (eq (etaf-value (plist-get model :filter)) 'all) :variant (if (eq (etaf-value (plist-get model :filter)) 'all)
'secondary 'ghost) 'secondary 'ghost)
:on-press (lambda () :on-press (lambda () (etaf-research-shelf--set-filter model 'all)))
(etaf-research-shelf--set-filter model 'all))) (etaf-research-shelf-button
(button :label (etaf-research-shelf--filter-label :theme (etaf-research-shelf--theme model)
:label (etaf-research-shelf--filter-label
model form 'reading "In progress") model form 'reading "In progress")
:ref 'research-shelf-filter-reading :ref 'research-shelf-filter-reading
:variant (if (eq (etaf-value (plist-get model :filter)) 'reading) :variant (if (eq (etaf-value (plist-get model :filter)) 'reading)
'secondary 'ghost) 'secondary 'ghost)
:on-press (lambda () :on-press (lambda () (etaf-research-shelf--set-filter model 'reading)))
(etaf-research-shelf--set-filter model 'reading))) (etaf-research-shelf-button
(button :label (etaf-research-shelf--filter-label :theme (etaf-research-shelf--theme model)
:label (etaf-research-shelf--filter-label
model form 'unread "Unread") model form 'unread "Unread")
:ref 'research-shelf-filter-unread :ref 'research-shelf-filter-unread
:variant (if (eq (etaf-value (plist-get model :filter)) 'unread) :variant (if (eq (etaf-value (plist-get model :filter)) 'unread)
'secondary 'ghost) 'secondary 'ghost)
:on-press (lambda () :on-press (lambda () (etaf-research-shelf--set-filter model 'unread)))
(etaf-research-shelf--set-filter model 'unread))) (etaf-research-shelf-button
(button :label (etaf-research-shelf--filter-label :theme (etaf-research-shelf--theme model)
:label (etaf-research-shelf--filter-label
model form 'finished "Finished") model form 'finished "Finished")
:ref 'research-shelf-filter-finished :ref 'research-shelf-filter-finished
:variant (if (eq (etaf-value (plist-get model :filter)) 'finished) :variant (if (eq (etaf-value (plist-get model :filter)) 'finished)
'secondary 'ghost) 'secondary 'ghost)
:on-press (lambda () :on-press (lambda () (etaf-research-shelf--set-filter model 'finished)))
(etaf-research-shelf--set-filter model 'finished))) (etaf-research-shelf-button
(button :label (etaf-research-shelf--filter-label :theme (etaf-research-shelf--theme model)
:label (etaf-research-shelf--filter-label
model form 'starred "★ Starred") model form 'starred "★ Starred")
:ref 'research-shelf-filter-starred :ref 'research-shelf-filter-starred
:variant (if (eq (etaf-value (plist-get model :filter)) 'starred) :variant (if (eq (etaf-value (plist-get model :filter)) 'starred)
'secondary 'ghost) 'secondary 'ghost)
:on-press (lambda () :on-press (lambda () (etaf-research-shelf--set-filter model 'starred)))
(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,7 +292,9 @@ 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
:theme (etaf-research-shelf--theme model)
:label "↻ Reload library" :ref 'research-shelf-reload
:variant 'ghost :variant 'ghost
:on-press (lambda () (etaf-research-shelf--load model "✓ Library reloaded")))))) :on-press (lambda () (etaf-research-shelf--load model "✓ Library reloaded"))))))
@ -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,7 +323,9 @@ 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
:theme (etaf-research-shelf--theme model)
:label " Add reading" :ref 'research-shelf-add
:variant 'secondary :variant 'secondary
:on-press :on-press
(lambda () (lambda ()
@ -297,11 +341,11 @@ Show MESSAGE on success, or preserve the resulting error state."
(setf (etaf-value (plist-get model :next-id)) (1+ id)))))) (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,14 +419,17 @@ 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
:variant 'secondary
:disabled (or (null row) archived (>= progress 100))
:on-press :on-press
(lambda () (lambda ()
(when-let* ((current (etaf-research-shelf--selected model)) (when-let* ((current (etaf-research-shelf--selected model))
@ -393,15 +440,17 @@ Show MESSAGE on success, or preserve the resulting error state."
"finished" "reading") "finished" "reading")
:updated "Just now") :updated "Just now")
"✓ Progress saved")))) "✓ Progress saved"))))
(button :label "✓ Finish" :ref 'research-shelf-finish (etaf-research-shelf-button
:variant 'secondary :disabled (or (null row) finished) :theme theme :label "✓ Finish" :ref 'research-shelf-finish
:variant 'secondary :disabled (or (null row) finished archived)
:on-press :on-press
(lambda () (lambda ()
(etaf-research-shelf--update-selected (etaf-research-shelf--update-selected
model '(:progress 100 :status "finished" :updated "Just now") model '(:progress 100 :status "finished" :updated "Just now")
"✓ Marked finished"))) "✓ Marked finished")))
(button :label "★ Star" :ref 'research-shelf-star :variant 'ghost (etaf-research-shelf-button
:disabled (null row) :theme theme :label "★ Star" :ref 'research-shelf-star
:variant 'ghost :disabled (null row)
:on-press :on-press
(lambda () (lambda ()
(when-let ((current (etaf-research-shelf--selected model))) (when-let ((current (etaf-research-shelf--selected model)))
@ -412,7 +461,8 @@ Show MESSAGE on success, or preserve the resulting error state."
:updated "Just now") :updated "Just now")
(if current-starred (if current-starred
"Removed star" "★ Starred locally")))))) "Removed star" "★ Starred locally"))))))
(button :label "Archive" :ref 'research-shelf-archive (etaf-research-shelf-button
:theme theme :label "Archive" :ref 'research-shelf-archive
:variant 'ghost :disabled (or (null row) archived) :variant 'ghost :disabled (or (null row) archived)
:on-press :on-press
(lambda () (lambda ()
@ -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*"))
;; Put the target in its real GUI window before the first Ebox
;; projection so `(viewport)' resolves against the user's frame.
(buffer (get-buffer-create name)))
(when (called-interactively-p 'interactive)
(switch-to-buffer buffer))
(etaf-mount buffer
(etaf-research-shelf-root (etaf-research-shelf-root
(etaf-playground-read-static "research-shelf"))))) (etaf-playground-read-static "research-shelf")))))
(when (called-interactively-p 'interactive) (pop-to-buffer buffer))
buffer))
(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)))