Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,122 @@
letter-spacing: 0.8px;
color: var(--x-color-text-muted, rgba(127, 127, 127, 0.7));
}

/* --- right-panel tabs (Inspector / State) ----------------------- */
.right-panel-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--x-color-border, rgba(127, 127, 127, 0.25));
margin-bottom: 4px;
}
.right-panel-tab {
flex: 0 1 auto;
padding: 6px 10px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--x-color-text-muted, rgba(127, 127, 127, 0.7));
font: inherit;
font-size: 12px;
cursor: pointer;
letter-spacing: 0.02em;
}
.right-panel-tab:hover {
color: var(--x-color-text, currentColor);
}
.right-panel-tab[data-active="true"] {
color: var(--x-color-text, currentColor);
border-bottom-color: var(--x-color-primary, #4f46e5);
}

/* --- state panel ------------------------------------------------- */
.state-panel-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.state-panel-body[hidden] { display: none; }
.state-panel-empty {
font-size: 12px;
opacity: 0.65;
padding: 12px 4px;
}
.state-panel-section {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 6px;
border: 1px solid var(--x-color-border, rgba(127, 127, 127, 0.25));
border-radius: 4px;
}
.state-panel-section-head {
display: flex;
align-items: baseline;
gap: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--x-color-border, rgba(127, 127, 127, 0.2));
}
.state-panel-section-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.state-panel-section-kind {
font-size: 10px;
opacity: 0.6;
}
.state-panel-field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0;
font-size: 11px;
}
.state-panel-field-head {
display: grid;
grid-template-columns: minmax(80px, 1fr) auto auto minmax(0, 2fr);
align-items: baseline;
gap: 6px;
}
.state-panel-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.state-panel-type {
font-size: 10px;
opacity: 0.6;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.state-panel-arrow {
opacity: 0.4;
font-size: 11px;
}
.state-panel-value {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
/* No truncation — short scalars render verbatim and wrap if
needed; collections render as a count summary in this slot
and pretty-print into the .state-panel-detail block below. */
overflow-wrap: anywhere;
word-break: break-word;
}
.state-panel-detail {
margin: 0;
padding: 6px 8px;
max-height: 240px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
line-height: 1.4;
white-space: pre;
background: var(--x-color-surface-hover, rgba(127, 127, 127, 0.06));
border-left: 2px solid var(--x-color-border, rgba(127, 127, 127, 0.3));
border-radius: 0 3px 3px 0;
opacity: 0.92;
}

.inspector-body { display: flex; flex-direction: column; gap: 16px; }
.inspector-body[hidden] { display: none; }
.inspector-header {
padding-bottom: 8px;
border-bottom: 1px solid var(--x-color-border, rgba(127, 127, 127, 0.2));
Expand Down
142 changes: 142 additions & 0 deletions src/bareforge/export/state_preview.cljs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
(ns bareforge.export.state-preview
"Pure helpers powering the State panel (`bareforge.ui.state-panel`).

Surfaces what an exported app would carry at startup: each named
group's field shape, the seed/default value of each stored field,
and the evaluated value of each computed field against those
seeds. The State panel renders this as a passive, design-time
preview so the author doesn't have to export to verify their
app's initial state.

Pure: no atom reads, no DOM, no side effects. The companion
effectful module installs the watcher and renders the result.

Computed-field evaluation is intentionally narrow in v1 — it
covers the four ops whose evaluation is local to the group
(`:count-of`, `:sum-of`, `:empty-of`, `:negation`). Multi-signal
ops (`:any-of`, `:filter-by`, `:join-on`) need cross-group
resolution and a real reactive subscription graph; they're
surfaced as `:runtime-only` so the UI can display a hint
instead of a spurious value."
(:require [bareforge.doc.actions :as actions]
[bareforge.doc.model :as m]))

;; --- group enumeration ---------------------------------------------------

(defn named-groups
"Every named group in `doc`, in document order. A group is a node
with a non-empty `:name`. The root never qualifies."
[doc]
(vec
(for [n (m/walk-nodes doc)
:when (and (not= "root" (:id n))
(:name n)
(seq (:name n)))]
n)))

;; --- computed evaluation -------------------------------------------------

(def ^:private supported-computed-ops
"Computed operations whose evaluation is local to the group and
doesn't need a reactive store. The remaining ops (`:any-of`,
`:filter-by`, `:join-on`) need a cross-group sub graph and are
reported as `:runtime-only`."
#{:count-of :sum-of :empty-of :negation})

(defn- field-by-name
"First field-def on `group` whose `:name` matches `fname`, or nil."
[group fname]
(some #(when (= fname (:name %)) %) (:fields group)))

(declare evaluate-field)

(defn- evaluate-source
"Return the resolved value of a field referenced as the source of a
computed op. Recurses through chained computeds on the same group
(e.g. `has-items = (negation is-empty)`); falls back to nil when
the source field can't be located."
[group source-field-name]
(when-let [src (field-by-name group source-field-name)]
(if (actions/computed? src)
(:value (evaluate-field group src))
(:default src))))

(defn- evaluate-computed
"Evaluate one computed field's `:computed` map against `group`'s
seed values. Returns `{:value v}` for supported ops, or
`{:runtime-only true}` for ops that need a cross-group reactive
graph."
[group computed]
(let [{:keys [operation source-field project-field]} computed]
(cond
(not (contains? supported-computed-ops operation))
{:runtime-only true}

(= :count-of operation)
{:value (count (or (evaluate-source group source-field) []))}

(= :empty-of operation)
{:value (empty? (or (evaluate-source group source-field) []))}

(= :negation operation)
{:value (not (evaluate-source group source-field))}

(= :sum-of operation)
(let [src (or (evaluate-source group source-field) [])]
(if project-field
;; Project-field reads a key off each record in the source
;; collection. Records are seeded with unqualified keys
;; (the export pipeline re-qualifies them at codegen) — so
;; we look up by the bare keyword here.
{:value (reduce + 0
(keep #(get % project-field) src))}
{:value (reduce + 0 (filter number? src))})))))

(defn evaluate-field
"Evaluate one field-def on `group`, returning a normalised map:

{:name :clicks
:type :number
:stored? true
:computed? false
:locked? true ; only when present
:value 0} ; or :runtime-only true

Stored fields surface their `:default` verbatim. Computed fields
evaluate their op locally; unsupported ops set `:runtime-only`
instead of `:value` so the renderer can show a hint."
[group field]
(let [base {:name (:name field)
:type (:type field)
:stored? (not (actions/computed? field))
:computed? (actions/computed? field)
:locked? (boolean (:locked? field))}]
(cond-> base
(not (actions/computed? field))
(assoc :value (:default field))

(actions/computed? field)
(merge (evaluate-computed group (:computed field))))))

(defn evaluate-group-state
"Pure: snapshot of a group's design-time state.

Returns:
{:name \"cart\"
:template? true|false
:fields [{:name :type :stored? :computed? :value …} …]}

`template?` indicates the group is a record shape — its fields
describe per-record keys, not stored db state. The renderer uses
it to label the section accordingly."
[doc group]
{:name (:name group)
:template? (actions/template-group? doc group)
:fields (mapv #(evaluate-field group %) (:fields group))})

(defn snapshot
"Pure: full design-time state preview for `doc`. One entry per
named group, in document order. Empty vector when the doc has
no groups."
[doc]
(mapv #(evaluate-group-state doc %) (named-groups doc)))
49 changes: 42 additions & 7 deletions src/bareforge/ui/inspector.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
[bareforge.meta.registry :as registry]
[bareforge.render.canvas :as canvas]
[bareforge.state :as state]
[bareforge.ui.state-panel :as state-panel]
[bareforge.util.dom :as u]
[clojure.string :as str]))

Expand Down Expand Up @@ -2427,15 +2428,49 @@
(doseq [s sections] (.appendChild host-el s))
(set! (.-scrollTop scroll-el) scroll-top)))

(defn- build-tab-bar
"Two-tab control at the top of the right panel: Inspector / State.
Clicking a tab toggles `[hidden]` on each body element so the
layout swap is pure DOM — no app-state churn, no second watcher.
The active tab carries `data-active=\"true\"` for CSS styling."
[inspector-body state-body]
(let [bar (u/el :div {:class "right-panel-tabs" :role "tablist"})
ins-tab (u/set-text!
(u/el :button {:class "right-panel-tab" :type "button"
:role "tab"
:data-active "true"})
"Inspector")
st-tab (u/set-text!
(u/el :button {:class "right-panel-tab" :type "button"
:role "tab"})
"State")
show! (fn [active hidden active-tab inactive-tab]
(.removeAttribute active "hidden")
(.setAttribute hidden "hidden" "")
(.setAttribute active-tab "data-active" "true")
(.removeAttribute inactive-tab "data-active"))]
(u/on! ins-tab :click
(fn [_] (show! inspector-body state-body ins-tab st-tab)))
(u/on! st-tab :click
(fn [_] (show! state-body inspector-body st-tab ins-tab)))
(.appendChild bar ins-tab)
(.appendChild bar st-tab)
bar))

(defn create
"Build the inspector panel. Returns the panel element ready to place
into the chrome. Re-renders on every :document or :selection change."
"Build the right-hand panel: a two-tab control (Inspector / State)
with both bodies mounted, only one visible at a time. Inspector
is the default tab. Each tab body owns its own watcher; the tab
control itself is pure DOM, no app-state.

Returns the outer panel element ready to place into the chrome."
[]
(let [body (u/el :div {:class "inspector-body"})
panel (u/el :div {:id "bareforge-inspector"
:class "panel panel-inspector"}
[(u/set-text! (u/el :div {:class "inspector-label"}) "Inspector")
body])]
(let [body (u/el :div {:class "inspector-body"})
state-body (state-panel/create)
tabs (build-tab-bar body state-body)
panel (u/el :div {:id "bareforge-inspector"
:class "panel panel-inspector"}
[tabs body state-body])]
(render! body (inspector-model @state/app-state))
(add-watch state/app-state ::inspector
(fn [_ _ old-state new-state]
Expand Down
Loading
Loading