Skip to content

Commit df7331e

Browse files
authored
Merge pull request #207 from AdaWorldAPI/claude/ogar-a2ui-transcoding-b7xzrn
feat(ogar-render-askama): W2/W3 fieldview — the addressed desktop surface
2 parents cc701ea + 4b50da3 commit df7331e

4 files changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
//! `field_view` — the **addressed desktop surface** (charter C1.2 of
2+
//! `AdaWorldAPI/OGAR docs/A2UI-SCREEN-ADDRESSING-PROPOSAL.md`).
3+
//!
4+
//! This is the render half of *"don't push pixels — address the screen."*
5+
//! Where [`rust_class`](crate::rust_class) emits a masked class as Rust
6+
//! **source** (build-time codegen) and [`html_detail_view`](crate::artifact_kinds::html_detail_view)
7+
//! emits a record's detail page from `RenderColumn`/`CellSource`, this module
8+
//! renders a **`WideFieldMask`-projected [`ClassView`] instance** as a live,
9+
//! addressed screen — the operator's *"ERB pattern fieldview ClassView
10+
//! rendering from memory without serialization."*
11+
//!
12+
//! # What "addressed" means (the fieldview contract)
13+
//!
14+
//! - **Every field carries its POSITION** (`data-field-pos`) — the field's
15+
//! [`WideFieldMask`](lance_graph_contract::class_view::WideFieldMask) bit
16+
//! index, which IS its layout address (the ordered-set position the nested
17+
//! ClassView projects; the same `X:Y` rail documents and Klickwege surfaces
18+
//! use). The client repaints exactly the positions a `NodeDelta` mask
19+
//! carries — nothing else redraws.
20+
//! - **Every action carries its ORDINAL only** (`data-action-ordinal`,
21+
//! charter trap T2) — the index into the class's `ActionDef` set. A click
22+
//! emits `ActionInvoke { key, action_ordinal, args }`; the surface carries
23+
//! the *address* of behavior, never behavior. `onClick: <lambda>` in a
24+
//! component tree is the `DEFINE EVENT` hijack, and it is rejected by
25+
//! construction here — there is nowhere to put a handler.
26+
//! - **The key is the node address** (`data-key`) — the canonical 16-byte
27+
//! GUID as hex. The client prerenders layout from the key alone (OGAR P0
28+
//! "the key prerenders nodes"); the fields fill in the addressed slots.
29+
//!
30+
//! # Where the values come from (ClassView, not the wire)
31+
//!
32+
//! [`FieldView`]s are built from a [`ClassView`] projection — see
33+
//! [`from_value_rows`], which maps
34+
//! [`ClassView::facet_rows`](lance_graph_contract::class_view::ClassView::facet_rows)
35+
//! output straight onto the surface. The labels are the meta-DTO's *late*
36+
//! resolution (above the SoA); the value bytes are the row's V3 facet. The
37+
//! render borrows both — nothing serializes in order to draw (charter T3).
38+
//!
39+
//! # XSS
40+
//!
41+
//! The template compiles with `escape = "html"`, and **every** interpolated
42+
//! field (title, labels, values, action captions) is data-derived, so all of
43+
//! it is HTML-escaped. There is no `|safe` escape hatch on this surface — a
44+
//! fieldview never carries pre-rendered HTML — which closes the class of
45+
//! injection the list/detail kits had to fix under codex P1 on #83/#84.
46+
47+
use askama::Template;
48+
49+
use lance_graph_contract::class_view::{RenderRow, ValueRow};
50+
51+
/// One projected field on the addressed surface.
52+
///
53+
/// `position` is the field's [`WideFieldMask`](lance_graph_contract::class_view::WideFieldMask)
54+
/// bit index — its **layout address** on the screen. `value` is the
55+
/// already-formatted display text (the consumer's ClassView formats the raw
56+
/// facet byte / value-slab field into text); the template escapes it.
57+
#[derive(Debug, Clone, PartialEq, Eq)]
58+
pub struct FieldView {
59+
/// The field's mask position — its layout address (the ordered-set slot).
60+
pub position: u8,
61+
/// The display label, late-resolved from the ontology cache (above the SoA).
62+
pub label: String,
63+
/// The field's predicate IRI (the stable key behind the label).
64+
pub predicate: String,
65+
/// The formatted value text — escaped by the template.
66+
pub value: String,
67+
}
68+
69+
/// One action the surface exposes — carried by **address only** (charter T2).
70+
///
71+
/// `ordinal` is the index into the class's `ActionDef` set; a click emits
72+
/// `ActionInvoke { key, action_ordinal: ordinal, args }`. There is no handler
73+
/// field — behavior lives on the Core node, never on the surface.
74+
#[derive(Debug, Clone, PartialEq, Eq)]
75+
pub struct ActionRef {
76+
/// Index into the class's `ActionDef` set — the invocation address.
77+
pub ordinal: u32,
78+
/// The human caption for the control (escaped by the template).
79+
pub label: String,
80+
}
81+
82+
// ── askama binding struct ────────────────────────────────────────────
83+
84+
#[derive(Template)]
85+
#[template(path = "dispatch/field_view.askama", escape = "html")]
86+
struct FieldViewCtx<'a> {
87+
class_id_hex: String,
88+
canonical_concept: &'a str,
89+
key_hex: &'a str,
90+
title: &'a str,
91+
fields: &'a [FieldView],
92+
actions: &'a [ActionRef],
93+
}
94+
95+
/// Render a `WideFieldMask`-projected [`ClassView`](lance_graph_contract::class_view::ClassView)
96+
/// instance as an **addressed desktop surface** (the fieldview).
97+
///
98+
/// - `class_id` / `canonical_concept` identify the class (the client resolves
99+
/// the template codebook from `class_id` — the font of the desktop).
100+
/// - `key_hex` is the node's canonical 16-byte GUID as hex — the node address.
101+
/// - `fields` are the projected, present fields (build them with
102+
/// [`from_value_rows`] / [`from_render_rows`] from a ClassView projection).
103+
/// - `actions` are the `ActionDef` addresses the surface exposes (T2).
104+
///
105+
/// # Errors
106+
///
107+
/// Propagates [`askama::Error`] if template rendering fails (it never should
108+
/// for well-formed input — the template has no fallible expressions).
109+
pub fn render_field_view(
110+
class_id: u16,
111+
canonical_concept: &str,
112+
key_hex: &str,
113+
title: &str,
114+
fields: &[FieldView],
115+
actions: &[ActionRef],
116+
) -> Result<String, askama::Error> {
117+
FieldViewCtx {
118+
class_id_hex: format!("0x{class_id:04X}"),
119+
canonical_concept,
120+
key_hex,
121+
title,
122+
fields,
123+
actions,
124+
}
125+
.render()
126+
}
127+
128+
/// Build the surface's [`FieldView`]s from a ClassView's
129+
/// [`facet_rows`](lance_graph_contract::class_view::ClassView::facet_rows)
130+
/// output — the value-projected rows (each present facet-backed field paired
131+
/// with its byte).
132+
///
133+
/// The raw facet byte is formatted with `fmt` (the consumer supplies the
134+
/// class's display formatting — e.g. an enum label, a scaled number); the
135+
/// default caller can pass `|b| b.to_string()`. This is the direct
136+
/// ClassView-facet → addressed-surface bridge: the mask already gated which
137+
/// rows exist (C2 presence, above the SoA), and each row already knows its
138+
/// `position` (layout address) — nothing about presence or address is
139+
/// recomputed here.
140+
pub fn from_value_rows(rows: &[ValueRow<'_>], mut fmt: impl FnMut(u8) -> String) -> Vec<FieldView> {
141+
rows.iter()
142+
.map(|r| FieldView {
143+
position: r.position,
144+
label: r.label.to_string(),
145+
predicate: r.predicate.to_string(),
146+
value: fmt(r.value),
147+
})
148+
.collect()
149+
}
150+
151+
/// Build the surface's [`FieldView`]s from a ClassView's
152+
/// [`render_rows`](lance_graph_contract::class_view::ClassView::render_rows)
153+
/// output — the label-only rows (no facet byte), paired positionally with the
154+
/// consumer's already-formatted value strings.
155+
///
156+
/// `render_rows` yields `(label, predicate)` for each present field but not
157+
/// the field's mask position (it filters the projection down to present rows),
158+
/// so the caller supplies `positions` (the present bit indices, ascending) and
159+
/// `values` (one formatted value per present row) alongside. All three slices
160+
/// are index-aligned; the shortest wins (a length mismatch truncates rather
161+
/// than panics — the caller is expected to pass matched slices).
162+
pub fn from_render_rows(
163+
rows: &[RenderRow<'_>],
164+
positions: &[u8],
165+
values: &[String],
166+
) -> Vec<FieldView> {
167+
rows.iter()
168+
.zip(positions.iter())
169+
.zip(values.iter())
170+
.map(|((r, &position), value)| FieldView {
171+
position,
172+
label: r.label.to_string(),
173+
predicate: r.predicate.to_string(),
174+
value: value.clone(),
175+
})
176+
.collect()
177+
}
178+
179+
#[cfg(test)]
180+
mod tests {
181+
use super::*;
182+
183+
fn sample_fields() -> Vec<FieldView> {
184+
vec![
185+
FieldView {
186+
position: 0,
187+
label: "Total".to_string(),
188+
predicate: "amount_total".to_string(),
189+
value: "1200".to_string(),
190+
},
191+
FieldView {
192+
// A position past 63 — the wide-surface case (charter C1.4):
193+
// the fieldview addresses fields beyond the 64-field ceiling.
194+
position: 133,
195+
label: "Partner".to_string(),
196+
predicate: "partner_id".to_string(),
197+
value: "ACME GmbH".to_string(),
198+
},
199+
]
200+
}
201+
202+
#[test]
203+
fn renders_addressed_fields_and_action_ordinals() {
204+
let actions = vec![ActionRef {
205+
ordinal: 7,
206+
label: "Post".to_string(),
207+
}];
208+
let src = render_field_view(
209+
0x0102,
210+
"commercial_document",
211+
"0801000301020304",
212+
"Invoice #42",
213+
&sample_fields(),
214+
&actions,
215+
)
216+
.unwrap();
217+
218+
// The surface is addressed by class + concept + key.
219+
assert!(src.contains("data-class-id=\"0x0102\""), "{src}");
220+
assert!(src.contains("data-concept=\"commercial_document\""), "{src}");
221+
assert!(src.contains("data-key=\"0801000301020304\""), "{src}");
222+
// Each field carries its POSITION (layout address) — including the
223+
// wide position past 63.
224+
assert!(src.contains("data-field-pos=\"0\""), "{src}");
225+
assert!(
226+
src.contains("data-field-pos=\"133\""),
227+
"wide position must address past the 64 ceiling:\n{src}"
228+
);
229+
assert!(src.contains("Total"), "{src}");
230+
assert!(src.contains("ACME GmbH"), "{src}");
231+
// The action carries its ORDINAL address (T2) and the node key — never
232+
// an inline handler.
233+
assert!(
234+
src.contains("data-action-ordinal=\"7\""),
235+
"action must carry its ordinal address:\n{src}"
236+
);
237+
assert!(!src.contains("onclick"), "no inline handler (T2):\n{src}");
238+
}
239+
240+
#[test]
241+
fn escapes_data_derived_strings_xss_regression() {
242+
// Every fieldview string is data-derived and MUST be escaped — the
243+
// fieldview has no `|safe` hatch (unlike the list/detail kits, which
244+
// needed codex-P1 fixes on #83/#84). A poisoned label/value/title/
245+
// action-caption must not inject raw HTML.
246+
let fields = vec![FieldView {
247+
position: 0,
248+
label: "<script>alert('label')</script>".to_string(),
249+
predicate: "x".to_string(),
250+
value: "<img src=x onerror=alert('value')>".to_string(),
251+
}];
252+
let actions = vec![ActionRef {
253+
ordinal: 0,
254+
label: "<script>alert('action')</script>".to_string(),
255+
}];
256+
let src = render_field_view(
257+
0x0102,
258+
"<concept-xss>",
259+
"<key-xss>",
260+
"<title-xss>x</title-xss>",
261+
&fields,
262+
&actions,
263+
)
264+
.unwrap();
265+
266+
assert!(!src.contains("<script>alert('label')"), "label raw:\n{src}");
267+
assert!(!src.contains("<img src=x onerror"), "value raw:\n{src}");
268+
assert!(!src.contains("<script>alert('action')"), "action raw:\n{src}");
269+
assert!(!src.contains("<title-xss>"), "title raw:\n{src}");
270+
assert!(!src.contains("<concept-xss>"), "concept raw:\n{src}");
271+
// The escaped form is present (quote encoding varies by askama
272+
// version, so assert only the angle-bracket escaping).
273+
assert!(src.contains("&lt;script&gt;"), "{src}");
274+
}
275+
276+
#[test]
277+
fn empty_actions_omit_the_nav() {
278+
let src =
279+
render_field_view(1, "c", "00", "T", &sample_fields(), &[]).unwrap();
280+
assert!(
281+
!src.contains("fieldview-actions"),
282+
"no action nav when there are no actions:\n{src}"
283+
);
284+
}
285+
286+
#[test]
287+
fn from_value_rows_maps_classview_facet_projection() {
288+
// ValueRow is what ClassView::facet_rows yields — position + label +
289+
// predicate + the raw facet byte. from_value_rows maps it straight
290+
// onto the addressed surface, formatting the byte.
291+
let rows = vec![
292+
ValueRow {
293+
label: "Status",
294+
predicate: "state",
295+
position: 3,
296+
value: 2,
297+
},
298+
ValueRow {
299+
label: "Priority",
300+
predicate: "priority",
301+
position: 5,
302+
value: 9,
303+
},
304+
];
305+
let fields = from_value_rows(&rows, |b| format!("code:{b}"));
306+
assert_eq!(fields.len(), 2);
307+
assert_eq!(fields[0].position, 3);
308+
assert_eq!(fields[0].label, "Status");
309+
assert_eq!(fields[0].value, "code:2");
310+
assert_eq!(fields[1].position, 5);
311+
assert_eq!(fields[1].value, "code:9");
312+
313+
// And it renders through the surface with those addresses.
314+
let src = render_field_view(0x0102, "c", "00", "T", &fields, &[]).unwrap();
315+
assert!(src.contains("data-field-pos=\"3\""), "{src}");
316+
assert!(src.contains("data-field-pos=\"5\""), "{src}");
317+
assert!(src.contains("code:2"), "{src}");
318+
}
319+
320+
#[test]
321+
fn from_render_rows_pairs_positions_and_values() {
322+
let rows = vec![
323+
RenderRow {
324+
label: "Total",
325+
predicate: "amount_total",
326+
},
327+
RenderRow {
328+
label: "Partner",
329+
predicate: "partner_id",
330+
},
331+
];
332+
let positions = [0u8, 133u8];
333+
let values = ["1200".to_string(), "ACME".to_string()];
334+
let fields = from_render_rows(&rows, &positions, &values);
335+
assert_eq!(fields.len(), 2);
336+
assert_eq!(fields[1].position, 133);
337+
assert_eq!(fields[1].label, "Partner");
338+
assert_eq!(fields[1].value, "ACME");
339+
}
340+
}

crates/ogar-render-askama/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
#![warn(missing_docs)]
7070

7171
pub mod artifact_kinds;
72+
pub mod field_view;
7273
pub mod form_view;
7374
pub mod list_view;
7475
pub mod rust_class;
@@ -79,6 +80,7 @@ pub use artifact_kinds::{
7980
GroupHeader, InputData, RelationEntryOwned, RowSource, SelectOptionOwned, UserEntryOwned,
8081
for_kind, render_detail, render_form, render_list,
8182
};
83+
pub use field_view::{ActionRef, FieldView, from_render_rows, from_value_rows, render_field_view};
8284
pub use form_view::{InputKind, default_input_kind_for};
8385
pub use list_view::{ColumnKind, RenderColumn, SortOrder, default_kind_for};
8486
pub use rust_class::{RenderError, render_class_with_methods, render_class_with_methods_wide};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{# The addressed desktop surface (charter C1.2 — the fieldview). #}
2+
{# Don't push pixels, address the screen: every field carries its mask #}
3+
{# POSITION (data-field-pos = its layout address), every action carries #}
4+
{# its ActionDef ORDINAL (data-action-ordinal = its invocation address, #}
5+
{# trap T2), and the node key addresses the whole surface (data-key). The #}
6+
{# client holds the ClassView/template codebook and repaints only the #}
7+
{# addressed positions. Every interpolation is data-derived and escaped #}
8+
{# (escape="html", no |safe) — there is no pre-rendered HTML on a #}
9+
{# fieldview, so the XSS class the list/detail kits had to fix cannot #}
10+
{# arise here. #}
11+
<section class="ogar-fieldview" data-class-id="{{ class_id_hex }}" data-concept="{{ canonical_concept }}" data-key="{{ key_hex }}">
12+
<header class="fieldview-header">
13+
<h2 class="fieldview-title">{{ title }}</h2>
14+
</header>
15+
16+
<dl class="fieldview-fields">
17+
{%- for f in fields %}
18+
<div class="fieldview-field" data-field-pos="{{ f.position }}">
19+
<dt class="fieldview-label">{{ f.label }}</dt>
20+
<dd class="fieldview-value">{{ f.value }}</dd>
21+
</div>
22+
{%- endfor %}
23+
</dl>
24+
25+
{%- if !actions.is_empty() %}
26+
<nav class="fieldview-actions">
27+
{%- for a in actions %}
28+
<button type="button" class="fieldview-action" data-action-ordinal="{{ a.ordinal }}" data-key="{{ key_hex }}">{{ a.label }}</button>
29+
{%- endfor %}
30+
</nav>
31+
{%- endif %}
32+
</section>

0 commit comments

Comments
 (0)