Skip to content

Commit 390e1fa

Browse files
authored
feat(inspector): tenancy stream redesign — filmstrip + write-vs-read, animated fold (#187)
* feat(inspector): animate the fold on the replay scrubber Choreograph the fold when the scrubber prefix advances or retreats: the just-folded event pulses, a pulse runs down the pipeline connectors, and the aggregate-state and read-model fields that actually changed flash; numeric headline fields count to their new value. Forward steps also flash the newest ledger row. The server still re-renders the real panes; this is purely presentational motion layered via a colocated hook that diffs field text against a cache. Respects prefers-reduced-motion. anime.js v3.2.2 vendored through app.js. * feat(inspector): redesign the tenancy stream view — filmstrip + write-vs-read Rebuild the deep-stream view in an editorial visual language ('stream-detail'): a warm-paper palette (CSS custom properties, light + dark) scoped under .stream-detail so it does not touch the nav rail, firehose, or other Inspector routes. The event log is now a horizontal filmstrip that doubles as the scrubber — a spine of type-coloured node frames (type / date / signed amount), the frame is the scrub target, future frames dim, the current one lifts. A view toggle flips it to a vertical evidence log carrying the full payloads, both envelope dates, and divergence flags (the tribunal-evidence detail). Both lenses honour the same prefix k and auto-scroll the folded-to event into view. Below, the same prefix folds into the write model and the read model facing each other, with the consistency check as the seam between them, then the ledger. The fold animation carries over unchanged (targets data-fold-field by id), and the guided tour is retained, adapted to the three new stages. Server: a persisted event_view assign (:horizontal | :vertical) + set_event_view. The dead Scrubber component is removed (the filmstrip is now the D4 scrubber). * fix(inspector): dark-mode contrast + de-duplicate envelope dates Address the CodeRabbit review on the stream-detail redesign: - Text on the accent (primary button, active view toggle) was #fff on a light dark-mode accent (~2.37:1, fails WCAG AA). Introduce a semantic --sd-on-accent token (#fff light, warm near-black dark) so contrast is ~8:1 in dark while the light theme is unchanged. - Four highlight/tint rules baked in light-mode rgba literals that stayed fixed across themes (current frame, current vlog row, arrears badge, drift bar). Derive them from the theme var via color-mix(), matching the pattern already used elsewhere in the file. - payload_pairs/1 dumped the whole event struct, so occurred_on/recorded_on rendered twice in the vertical log (once as dedicated fields, once in the payload list). Drop those keys from the payload list.
1 parent 1503126 commit 390e1fa

16 files changed

Lines changed: 1504 additions & 813 deletions

‎assets/css/app.css‎

Lines changed: 542 additions & 0 deletions
Large diffs are not rendered by default.

‎assets/js/app.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import {Socket} from "phoenix"
2424
import {LiveSocket} from "phoenix_live_view"
2525
import {hooks as colocatedHooks} from "phoenix-colocated/latchkey"
2626
import topbar from "../vendor/topbar"
27+
import anime from "../vendor/anime.min.js"
28+
29+
// Expose anime to colocated hooks (e.g. .FoldFlow), which run as separately
30+
// bundled modules and read it off window rather than importing it directly.
31+
window.anime = anime
2732

2833
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
2934
const liveSocket = new LiveSocket("/live", Socket, {

‎assets/vendor/anime.min.js‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/latchkey_web/live/inspector/event_log.ex‎

Lines changed: 118 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,156 +1,142 @@
11
defmodule LatchkeyWeb.Inspector.EventLog do
22
@moduledoc """
3-
The read-only **event-log pane** (`LatchkeyWeb.InspectorLive`, spec
4-
`docs/spec/developer-view.md`, issue #81, decisions D3/D7): for a selected
5-
stream it renders the raw, immutable events in commit order with their **full
6-
stored payloads**.
7-
8-
It doubles as the **Accounts edge** view (D3): the single `accounts` stream
9-
renders through this same pane, **events-only**, captioned as an edge context
10-
that folds no aggregate state (no aggregate / read-model panes, by design).
11-
12-
Each row is **self-describing out of context** (the tribunal-evidence goal): it
13-
names the **property** (leading — the primary PM identifier) and **tenant**, and
14-
shows **both envelope dates** (`occurred_on` / `recorded_on`) with a visible
15-
**divergence flag** when they differ (D7 — for `RentFellDue`, an imported/rebuilt
16-
tenancy (#117), since organic accrual books same-day; or a forward-dated fact).
17-
Identity is resolved upstream in `LatchkeyWeb.InspectorLive`; accounts
18-
rows honestly show an `UNKNOWN` sentinel when the payment holder is unresolvable.
19-
20-
Presentational only. It renders the pre-built rows it is handed — it reads no
21-
store, folds no state, and exposes **no** create/update/delete affordance. The
22-
log is **append-only / immutable** (never "tamper-evident" — issue #16).
3+
The read-only **vertical evidence log** — the full-detail lens on a stream's raw,
4+
immutable events (`LatchkeyWeb.InspectorLive`, spec `docs/spec/developer-view.md`
5+
D3/D7, issue #81). Rendered in the editorial "stream-detail" visual language.
6+
7+
It is the **evidence** lens: unlike the horizontal filmstrip (the narrative lens,
8+
which shows only type + date + amount), each row here is **self-describing out of
9+
context** for the tribunal-evidence goal — the **property** (leading) and
10+
**tenant**, **both envelope dates** (`occurred_on` / `recorded_on`) with a visible
11+
**divergence flag** when they differ (D7), and the **full stored payload**.
12+
13+
It serves two callers:
14+
15+
- the **deep** (tenancy) stream, as the vertical toggle of the filmstrip — rows are
16+
dimmed past the current prefix and the row at `k` is highlighted + `aria-current`
17+
(so the fold-flow animation pulses it), and each row is a scrub target;
18+
- the **Accounts edge** (D3), events-only, captioned as an edge context that folds
19+
no aggregate state (no filmstrip, no scrub — the *absence* is the teaching point).
20+
21+
Presentational only. It renders the pre-built rows it is handed — it reads no store,
22+
folds no state, and exposes **no** create/update/delete affordance. The log is
23+
**append-only / immutable** (never "tamper-evident" — issue #16).
2324
"""
2425
use LatchkeyWeb, :html
2526

26-
import LatchkeyWeb.InspectorComponents, only: [caption: 1, read_more: 1]
27-
2827
@doc """
29-
The event-log pane for one stream. `rows` are the pre-resolved event rows (see
30-
`LatchkeyWeb.InspectorLive`), `kind` is `:deep` (a tenancy stream) or `:edge`
31-
(the `accounts` stream), and `docs` carries the canonical "read more" URLs.
28+
The vertical evidence log for one stream. `rows` are pre-resolved event rows in
29+
commit order. When `scrubbable?` (the deep stream), `k` dims rows past the prefix,
30+
`highlight_version` marks the current row, and rows are scrub targets; the edge
31+
passes `scrubbable?: false` (no dim, no highlight, no click).
3232
"""
3333
attr :stream_id, :string, required: true
3434
attr :context_name, :string, required: true
3535
attr :kind, :atom, required: true, doc: ":deep (tenancy) or :edge (accounts)"
3636
attr :rows, :list, required: true, doc: "pre-resolved event rows in commit order"
3737
attr :docs, :map, required: true, doc: "canonical doc URLs for read-more links"
38+
attr :scrubbable?, :boolean, default: false, doc: "deep streams scrub; the edge does not"
39+
attr :k, :integer, default: nil, doc: "current prefix length — dims rows past it (deep)"
3840

3941
attr :highlight_version, :integer,
4042
default: nil,
41-
doc: "stream_version of the replay scrubber's current event (D4), or nil when unscrubbed"
43+
doc: "stream_version of the row at the current prefix (deep), else nil"
4244

43-
def events_pane(assigns) do
45+
def vertical_log(assigns) do
4446
~H"""
45-
<section id="event-log" class="max-w-3xl">
46-
<div id="stream-view">
47-
<div id={"stream-view-#{@stream_id}"}>
48-
<header class="mb-3">
49-
<p class="text-[11px] font-semibold uppercase tracking-widest text-base-content/50">
50-
{@context_name} · events
51-
</p>
52-
<h2 class="mt-1 text-lg font-semibold font-mono">{@stream_id}</h2>
53-
</header>
54-
55-
<%!-- Thin, interaction-anchored teaching captions (spec D2 altitude split). --%>
56-
<div class="mb-4 space-y-2">
57-
<.caption :if={@kind == :edge} id="accounts-edge-caption">
58-
<b>Edge context.</b>
59-
Accounts emits payment facts and <b>folds no aggregate state</b>
60-
—
61-
so there is no aggregate or read-model pane here, only this raw log. The <i>absence</i>
62-
of those panes is the teaching point.
63-
</.caption>
64-
65-
<.caption id="bitemporal-caption">
66-
Every event carries two dates — <b>occurred_on</b>
67-
(when the fact became true) and <b>recorded_on</b>
68-
(when it was booked). When they diverge it is flagged: an
69-
imported/rebuilt tenancy or a forward-dated fact — organic accrual
70-
books same-day.
71-
<.read_more href={"#{@docs.domain_model}#3-events-producers"}>
72-
domain-model.md §3
73-
</.read_more>
74-
</.caption>
75-
76-
<p id="immutability-note" class="text-xs leading-relaxed text-base-content/60">
77-
This log is <b class="text-base-content/80">append-only / immutable</b> — events are
78-
never edited or deleted; corrections are compensating appends.
79-
</p>
80-
</div>
81-
82-
<ol id="event-log-rows" class="relative border-l border-base-300 ml-1.5">
83-
<li
84-
:if={@rows == []}
85-
id="event-log-empty"
86-
class="pl-5 py-3 text-xs text-base-content/50 italic"
87-
>
88-
No events on this stream yet.
89-
</li>
90-
91-
<li
92-
:for={row <- @rows}
93-
id={"event-row-#{@stream_id}-#{row.version}"}
94-
aria-current={row.version == @highlight_version && "step"}
95-
class={[
96-
"relative pl-5 pb-5",
97-
row.version == @highlight_version &&
98-
"-ml-px rounded-r-md border-l-2 border-accent bg-accent/5"
99-
]}
100-
>
47+
<section id="event-log">
48+
<p :if={@kind == :edge} id="accounts-edge-caption" class="sd-note">
49+
<b>Edge context.</b>
50+
Accounts emits payment facts and <b>folds no aggregate state</b>
51+
— so there is no aggregate or read-model pane here, only this raw log. The <i>absence</i>
52+
of those panes is the teaching point.
53+
</p>
54+
55+
<p id="bitemporal-caption" class="sd-note">
56+
Every event carries two dates — <b>occurred_on</b>
57+
(when the fact became true) and <b>recorded_on</b>
58+
(when it was booked). When they diverge it is flagged: an imported/rebuilt
59+
tenancy or a forward-dated fact — organic accrual books same-day.
60+
<.link
61+
navigate={"#{@docs.domain_model}#3-events-producers"}
62+
class="sd-readmore"
63+
>
64+
domain-model.md §3
65+
</.link>
66+
</p>
67+
68+
<p id="immutability-note" class="sd-note">
69+
This log is <b>append-only / immutable</b> — events are never edited or
70+
deleted; corrections are compensating appends.
71+
</p>
72+
73+
<div id={"stream-view-#{@stream_id}"} class="sd-vlog" phx-hook=".LogScroll">
74+
<script :type={Phoenix.LiveView.ColocatedHook} name=".LogScroll">
75+
// Keep the folded-to row in view as the prefix moves, scrolling this log
76+
// pane (deep vertical lens); on the edge there is no current row, so no-op.
77+
export default {
78+
mounted() { this.follow() },
79+
updated() { this.follow() },
80+
follow() {
81+
const cur = this.el.querySelector('[aria-current="step"]')
82+
if (cur) cur.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
83+
}
84+
}
85+
</script>
86+
<p :if={@rows == []} id="event-log-empty" class="sd-note">
87+
No events on this stream yet.
88+
</p>
89+
90+
<%= for row <- @rows do %>
91+
<% dimmed? = @scrubbable? and @k != nil and row.version > @k %>
92+
<% here? = row.version == @highlight_version %>
93+
<div
94+
id={"event-row-#{@stream_id}-#{row.version}"}
95+
aria-current={here? && "step"}
96+
phx-click={@scrubbable? && "scrub"}
97+
phx-value-k={@scrubbable? && row.version}
98+
role={@scrubbable? && "button"}
99+
tabindex={@scrubbable? && "0"}
100+
class={[
101+
"sd-vrow",
102+
@scrubbable? && "cursor-pointer",
103+
here? && "sd-here",
104+
dimmed? && "sd-dim"
105+
]}
106+
>
107+
<div class="sd-vhead">
108+
<span class="sd-vtype">{row.type}</span>
109+
<span class="sd-vver">#{row.version}</span>
101110
<span
102-
class={[
103-
"absolute -left-[5px] top-1.5 size-2.5 rounded-full",
104-
if(row.version == @highlight_version, do: "bg-accent", else: "bg-primary")
105-
]}
106-
aria-hidden="true"
107-
/>
108-
109-
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
110-
<span class="font-mono text-sm font-semibold">{row.type}</span>
111-
<span class="font-mono text-[11px] text-base-content/40">#{row.version}</span>
112-
113-
<span
114-
:if={row.divergent?}
115-
id={"event-divergence-#{@stream_id}-#{row.version}"}
116-
class="badge badge-sm badge-warning gap-1"
117-
title="occurred_on and recorded_on differ (bitemporal divergence)"
118-
>
119-
occurred ≠ recorded
120-
</span>
121-
</div>
122-
123-
<%!-- Property-leading identity line: self-describing out of context (#81). --%>
124-
<p
125-
id={"event-identity-#{@stream_id}-#{row.version}"}
126-
class="mt-0.5 text-[11px] text-base-content/60"
111+
:if={row.divergent?}
112+
id={"event-divergence-#{@stream_id}-#{row.version}"}
113+
class="sd-flag"
114+
title="occurred_on and recorded_on differ (bitemporal divergence)"
127115
>
128-
<span class="font-medium text-base-content/80">{row.identity.property}</span>
129-
<span aria-hidden="true">·</span>
130-
<span>{row.identity.tenant}</span>
131-
<span class="font-mono text-base-content/40">{row.identity.ref}</span>
132-
</p>
116+
occurred ≠ recorded
117+
</span>
118+
</div>
119+
120+
<p id={"event-identity-#{@stream_id}-#{row.version}"} class="sd-vident">
121+
<b>{row.identity.property}</b>
122+
<span aria-hidden="true">·</span>
123+
<span>{row.identity.tenant}</span>
124+
<span class="sd-mono" style="color:var(--sd-muted)">{row.identity.ref}</span>
125+
</p>
133126
134-
<%!-- Two-date bitemporal display (D7). --%>
135-
<dl class="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 text-[11px]">
136-
<dt class="text-base-content/50">occurred_on</dt>
137-
<dd class="font-mono">{fmt(row.occurred_on)}</dd>
138-
<dt class="text-base-content/50">recorded_on</dt>
139-
<dd class={["font-mono", row.divergent? && "text-warning"]}>
140-
{fmt(row.recorded_on)}
141-
</dd>
142-
</dl>
127+
<dl class="sd-payload">
128+
<dt>occurred_on</dt>
129+
<dd>{fmt(row.occurred_on)}</dd>
130+
<dt>recorded_on</dt>
131+
<dd class={[row.divergent? && "sd-diverge"]}>{fmt(row.recorded_on)}</dd>
143132
144-
<%!-- Full stored payload. --%>
145-
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-lg bg-base-200/60 p-2.5 text-[11px]">
146-
<div :for={{key, value} <- row.payload} class="contents">
147-
<dt class="font-mono text-base-content/50">{key}</dt>
148-
<dd class="font-mono break-all">{fmt(value)}</dd>
149-
</div>
150-
</dl>
151-
</li>
152-
</ol>
153-
</div>
133+
<div :for={{key, value} <- row.payload} class="contents">
134+
<dt>{key}</dt>
135+
<dd>{fmt(value)}</dd>
136+
</div>
137+
</dl>
138+
</div>
139+
<% end %>
154140
</div>
155141
</section>
156142
"""

0 commit comments

Comments
 (0)