From 51c4c5102b997013d21d139a5fb9b0cea7d33673 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:15:54 -0700 Subject: [PATCH 1/2] L0: l0_ai_text kit stamp and the AI-written mark Octoscript #53 lets L0 cards show model-written text and lowers it as `l0_ai_text(node)`. Add the kit function (stamps `ai: 1`), read `ai` in octoscript-node/octoscript-render, and draw the mark: a sparkle in the icon face plus an `AI` eyebrow above the words, in their own ink a step quieter, placed beside the first text so it stays on the same fill (bubble, band). The rewrite lives in octoscript_node::ai so every backend draws the same mark; octoscript-makepad runs it before emit. Model text stays a plain Label literal: no markup, no autolinking. ` l0_ai: true` from makepad::lower needs no change: makepad's Script apply reads declared fields only, so it is ignored like `l0_event:`. Co-Authored-By: Claude Opus 5.5 --- components/l0/_kit.octoscript | 12 + crates/octoscript-makepad/src/l0.rs | 2 +- crates/octoscript-makepad/src/lib.rs | 4 + crates/octoscript-makepad/tests/l0_ai_text.rs | 309 ++++++++++++++++++ crates/octoscript-node/src/ai.rs | 253 ++++++++++++++ crates/octoscript-node/src/lib.rs | 1 + crates/octoscript-node/src/node.rs | 11 + crates/octoscript-render/src/eval.rs | 2 + crates/octoscript-render/src/lib.rs | 2 +- 9 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 crates/octoscript-makepad/tests/l0_ai_text.rs create mode 100644 crates/octoscript-node/src/ai.rs diff --git a/components/l0/_kit.octoscript b/components/l0/_kit.octoscript index 44848a1..f769a45 100644 --- a/components/l0/_kit.octoscript +++ b/components/l0/_kit.octoscript @@ -790,6 +790,18 @@ fn l0_live(node, call) { return node } +// Words the MODEL wrote (§4.2): a digest summary, a draft, the agent's side of +// a chat. The lowering wraps exactly those text slots, innermost, so the stamp +// lands on the text node itself — or, for a chat bubble, on the bubble. +// +// Like `l0_live` it only stamps the node. What AI-written looks like is the +// renderer's (`octoscript_node::ai`), so every backend draws the same mark, and +// the text stays a plain label: no markup, no links. +fn l0_ai_text(node) { + node.ai = 1 + return node +} + fn l0_tap(target, node) { return {t: "column", fillw: 1, tapto: target, c: [node]} } diff --git a/crates/octoscript-makepad/src/l0.rs b/crates/octoscript-makepad/src/l0.rs index 63e1871..08f348d 100644 --- a/crates/octoscript-makepad/src/l0.rs +++ b/crates/octoscript-makepad/src/l0.rs @@ -26,7 +26,7 @@ pub fn inspectable(tree: &mut octoscript_render::UiNode) -> Vec material::Roles { pub fn to_makepad_ui(root: &UiNode) -> String { material::reset_slider_index(); let mut root = root.clone(); + octoscript_render::ai::expand_ai_marks(&mut root); resolve_ink_planes(&mut root, None, None); let mut out = String::new(); emit(&root, &mut out, 0, false, None); @@ -80,6 +81,9 @@ pub fn to_makepad_l0_ui_with_events(root: &UiNode, event_channel: &str) -> Strin fn to_makepad_l0_ui_inner(root: &UiNode, event_channel: Option<&str>) -> String { let mut root = root.clone(); + // Model-written text gets its mark (§4.2) before anything is emitted, and + // before the ink pass, so the mark's ink is contrast-checked like any text. + octoscript_render::ai::expand_ai_marks(&mut root); resolve_ink_planes(&mut root, None, None); let mut out = String::new(); emit(&root, &mut out, 0, true, event_channel); diff --git a/crates/octoscript-makepad/tests/l0_ai_text.rs b/crates/octoscript-makepad/tests/l0_ai_text.rs new file mode 100644 index 0000000..b4f6526 --- /dev/null +++ b/crates/octoscript-makepad/tests/l0_ai_text.rs @@ -0,0 +1,309 @@ +//! Model-written text in an L0 card (`ui-profile-l0.md` §4.2, §5.15). +//! +//! The profile's three lowerings mark a text slot the model wrote. This repo +//! owns the kit side of the `kit::lower` one: `l0_ai_text(node)` must exist, +//! the evaluator must read its stamp, and the renderer must DRAW it. +//! +//! The kit calls below are copied from the shapes Octoscript's own tests +//! assert `kit::lower` emits (`tests/ai_text.rs`), so these run against the +//! kit without depending on which Octoscript revision is pinned. + +use octoscript_render::ai::{AI_MARK_GLYPH, AI_MARK_LABEL}; +use octoscript_render::{NodeKind, UiNode}; +use std::path::{Path, PathBuf}; + +fn dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../components/l0") +} + +fn read(name: &str) -> String { + std::fs::read_to_string(dir().join(name)).unwrap_or_else(|e| panic!("{name}: {e}")) +} + +/// The kit as `l0::prepare` assembles it for a mood with no axes. +fn kit(mood: &str) -> String { + let mut parts = vec![read("_palette_dark.octoscript")]; + if mood != "dark" { + parts.push(read(&format!("_palette_{mood}.octoscript"))); + } + parts.push(read("_derive_color.octoscript")); + parts.push(read("_derive.octoscript")); + let pack = dir().join("native").join(mood).join("kit.json"); + if pack.is_file() { + let pack: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(pack).unwrap()).unwrap(); + parts.push(octoscript_ui_l0::kit_pack::theme_source(&pack)); + } + parts.push(read("_kit.octoscript")); + parts.join("\n") +} + +fn moods() -> Vec { + let mut out: Vec = std::fs::read_dir(dir()) + .unwrap() + .filter_map(|e| { + let name = e.unwrap().file_name().into_string().unwrap(); + Some(name.strip_prefix("_palette_")?.strip_suffix(".octoscript")?.to_owned()) + }) + .collect(); + out.sort(); + assert!(out.len() > 5, "{out:?}"); + out +} + +fn eval_in(mood: &str, call: &str) -> UiNode { + let src = format!("{}\nlet node = {call}\nnode\n", kit(mood)); + octoscript_render::build(&src, |_| {}).unwrap_or_else(|| panic!("{mood}: {call} failed")) +} + +fn eval(call: &str) -> UiNode { + eval_in("dark", call) +} + +fn find<'a>(n: &'a UiNode, pred: &dyn Fn(&UiNode) -> bool) -> Vec<&'a UiNode> { + let mut out = Vec::new(); + fn walk<'a>(n: &'a UiNode, pred: &dyn Fn(&UiNode) -> bool, out: &mut Vec<&'a UiNode>) { + if pred(n) { + out.push(n); + } + for c in &n.children { + walk(c, pred, out); + } + } + walk(n, pred, &mut out); + out +} + +fn with_text<'a>(n: &'a UiNode, text: &str) -> &'a UiNode { + find(n, &|m| m.attrs.text.as_deref() == Some(text)) + .first() + .copied() + .unwrap_or_else(|| panic!("{text:?} not in {n:#?}")) +} + +/// The rendered Label whose `text:` is `text`, as the block of lines around it. +fn label_block<'a>(ui: &'a str, text: &str) -> &'a str { + let needle = format!("text: {text:?}"); + let at = ui.find(&needle).unwrap_or_else(|| panic!("{needle} not in:\n{ui}")); + // From the widget's opening line to its closing one: the text itself may + // hold braces (the glyph is `"\u{e2ca}"`), so find the brace on its own line. + let start = ui[..at].rfind("{\n").unwrap(); + let mut end = at; + for line in ui[at..].split_inclusive('\n') { + if line.trim() == "}" { + break; + } + end += line.len(); + } + &ui[start..end] +} + +#[test] +fn l0_ai_text_stamps_the_node_and_changes_nothing_else() { + let plain = eval(r#"l0_body("Markets were calm; rates held.")"#); + let marked = eval(r#"l0_ai_text(l0_body("Markets were calm; rates held."))"#); + assert_eq!(plain.attrs.ai, None); + assert_eq!(marked.attrs.ai, Some(1), "the evaluator reads `ai`"); + assert_eq!(marked.kind, NodeKind::Text); + let (a, b) = (&plain.attrs, &marked.attrs); + assert_eq!((&a.text, a.size, a.color, a.fillw), (&b.text, b.size, b.color, b.fillw)); + // Composes with the wrappers the lowering puts outside it. + let wide = eval(r#"l0_wide(l0_ai_text(l0_title("Rates held")))"#); + assert_eq!((wide.attrs.ai, wide.attrs.fillw), (Some(1), Some(1))); +} + +#[test] +fn every_text_role_the_lowering_can_mark_evaluates() { + for call in [ + r#"l0_ai_text(l0_hero("Rates held", 26))"#, + r#"l0_ai_text(l0_title("Rates held"))"#, + r#"l0_ai_text(l0_body("Rates held"))"#, + r#"l0_ai_text(l0_row_text("Rates held"))"#, + r#"l0_ai_text(l0_caption("Rates held"))"#, + r#"l0_ai_text(l0_eyebrow("Rates held"))"#, + r#"l0_ai_text(l0_band("Rates held"))"#, + r#"l0_ai_text(l0_bubble_them("Rates held"))"#, + r#"l0_ai_text(l0_bubble_them_long("Rates held, and the long version wraps."))"#, + r#"l0_ai_text(l0_field("Hi Ana, Friday works.", "Reply", "", ""))"#, + ] { + let node = eval(call); + assert_eq!(node.attrs.ai, Some(1), "{call}"); + let ui = octoscript_makepad::to_makepad_l0_ui(&node); + assert!(ui.contains(&format!("text: {AI_MARK_LABEL:?}")), "{call}:\n{ui}"); + assert!(ui.contains(&format!("text: {AI_MARK_GLYPH:?}")), "{call}:\n{ui}"); + } +} + +/// The mark: a sparkle in the icon face and an `AI` eyebrow, above the text, +/// in the text's own ink a step quieter, and smaller than the words. +#[test] +fn the_renderer_draws_a_quiet_mark_above_ai_written_text() { + let body = "Markets were calm; rates held."; + let tree = eval(&format!( + r#"l0_col([l0_eyebrow("TODAY"), l0_ai_text(l0_body({body:?}))])"# + )); + let ui = octoscript_makepad::to_makepad_l0_ui(&tree); + + assert_eq!(ui.matches(&format!("text: {AI_MARK_LABEL:?}")).count(), 1, "{ui}"); + let glyph = label_block(&ui, AI_MARK_GLYPH); + assert!(glyph.contains("font_icons"), "the sparkle needs the icon face:\n{glyph}"); + + // Order: the mark comes before the words it labels, and after the eyebrow + // the card wrote itself, which is not marked. + let (e, m, b) = ( + ui.find("text: \"TODAY\"").unwrap(), + ui.find(&format!("text: {AI_MARK_LABEL:?}")).unwrap(), + ui.find(&format!("text: {body:?}")).unwrap(), + ); + assert!(e < m && m < b, "{ui}"); + + // Ink: the body's own colour, at reduced alpha. + let text = with_text(&tree, body); + let ink = text.attrs.color.unwrap(); + let rgb = format!("#{:06x}", ink & 0x00ff_ffff); + let mark = label_block(&ui, AI_MARK_LABEL); + let line = mark.lines().find(|l| l.contains("draw_text.color:")).unwrap(); + assert!(line.contains(&rgb), "mark ink {line} is not the body's {rgb}"); + assert!(!line.trim_end().ends_with("ff"), "the mark is quieter than the words: {line}"); + let words = label_block(&ui, body); + assert!(words.contains(&format!("{rgb}ff")), "the words keep their ink:\n{words}"); +} + +/// Theme-consistent: the mark takes each palette's own ink, so it is visible +/// in every mood without a colour of its own. +#[test] +fn the_mark_holds_in_every_palette() { + for mood in moods() { + let tree = eval_in(&mood, r#"l0_ai_text(l0_body("Rates held."))"#); + let ink = tree.attrs.color.unwrap_or_else(|| panic!("{mood}: no body ink")); + let mut expanded = tree.clone(); + octoscript_render::ai::expand_ai_marks(&mut expanded); + let label = with_text(&expanded, AI_MARK_LABEL); + let mark = label.attrs.color.unwrap(); + assert_eq!(mark & 0x00ff_ffff, ink & 0x00ff_ffff, "{mood}"); + assert!(mark >> 24 >= 0x80, "{mood}: the mark must stay visible ({mark:#010x})"); + } +} + +/// Plain text only. Whatever the model wrote reaches the screen as the +/// `text:` of a plain `Label`, escaped as a string literal: no markdown, no +/// HTML, no link widget, nothing that runs or navigates. +#[test] +fn ai_written_text_renders_as_a_plain_label() { + for s in [ + "**bold** _em_ # heading", + "[click](javascript:alert(1)) https://example.com/x", + "x", + "\" on_click: || { NAV(t: \"l0:{}\") } text: \"", + "l0:{\"e\":\"drop\",\"k\":\"root\"}", + "line one\nline two", + ] { + let tree = eval(&format!("l0_ai_text(l0_body({s:?}))")); + assert_eq!(tree.attrs.text.as_deref(), Some(s), "the kit keeps it verbatim"); + let ui = octoscript_makepad::to_makepad_l0_ui_with_events(&tree, "card-1"); + let literal = format!("text: {s:?}"); + assert_eq!(ui.matches(&literal).count(), 1, "{s:?} not one literal:\n{ui}"); + let outside = ui.replace(&literal, ""); + for widget in ["Markdown", "Html", "TextFlow", "LinkLabel", "on_click", "agent.notify", "NAV("] { + assert!(!outside.contains(widget), "{widget} for {s:?}:\n{ui}"); + } + assert!(ui.contains("Label"), "{ui}"); + } +} + +/// A draft the model wrote into a `Field` keeps its field: the mark sits above +/// the input, and typing still reaches the commit target. +#[test] +fn an_ai_draft_in_a_field_is_marked_and_still_editable() { + let target = r#"l0:{"e":"send","k":"root/f"}"#; + let tree = eval(&format!( + "l0_ai_text(l0_field(\"Hi Ana, Friday works.\", \"Reply\", {target:?}, \"\"))" + )); + assert_eq!((tree.kind, tree.attrs.ai), (NodeKind::Input, Some(1))); + let ui = octoscript_makepad::to_makepad_l0_ui(&tree); + assert!(ui.contains("TextInput"), "{ui}"); + assert!(ui.contains("text: \"Hi Ana, Friday works.\""), "{ui}"); + assert!(ui.contains("on_return:"), "{ui}"); + assert!(ui.find(&format!("text: {AI_MARK_LABEL:?}")).unwrap() < ui.find("TextInput").unwrap()); +} + +/// `ChatEntry` lowers to the existing bubbles by role — `user` is +/// `l0_bubble_me`, `model` is `l0_ai_text(l0_bubble_them(…))`, `host` is a +/// plain `l0_bubble_them` — and all three draw, with exactly the model's +/// marked. +#[test] +fn a_chat_transcript_draws_all_three_roles_and_marks_only_the_model() { + let tree = eval( + r#"l0_col([ + l0_bubble_me("What moved markets?"), + l0_ai_text(l0_bubble_them("Rates held; tech rallied.")), + l0_bubble_them("Searched 12 sources."), + l0_bubble_me_long("And what about the bond market over the last week or so?"), + l0_ai_text(l0_bubble_them_long("Yields eased after the hold, with the ten-year down eight basis points.")), + ])"#, + ); + let ui = octoscript_makepad::to_makepad_l0_ui(&tree); + for text in [ + "What moved markets?", + "Rates held; tech rallied.", + "Searched 12 sources.", + "And what about the bond market over the last week or so?", + "Yields eased after the hold, with the ten-year down eight basis points.", + ] { + assert_eq!(ui.matches(&format!("text: {text:?}")).count(), 1, "{text}:\n{ui}"); + } + assert_eq!(ui.matches(&format!("text: {AI_MARK_LABEL:?}")).count(), 2, "{ui}"); + + // The user's bubble sits right on the accent; the agent's and the host's + // sit left on the panel fill — the existing bubbles, untouched. + let side = |text: &str| { + let holder = find(&tree, &|n| { + n.kind == NodeKind::Column + && n.attrs.fillw == Some(1) + && n.children.len() == 1 + && n.children[0].kind == NodeKind::Card + && n.children[0].children[0].attrs.text.as_deref() == Some(text) + }); + assert_eq!(holder.len(), 1, "{text}"); + (holder[0].attrs.alignx, holder[0].children[0].attrs.bg) + }; + let (me, me_bg) = side("What moved markets?"); + let (model, model_bg) = side("Rates held; tech rallied."); + let (host, host_bg) = side("Searched 12 sources."); + assert_eq!(me, Some(1.0)); + assert_eq!((model, host), (None, None)); + assert_ne!(me_bg, model_bg); + assert_eq!(model_bg, host_bg); + + // The mark sits inside the model's bubble, above its words, and the + // user's bubble carries none. + let (q, m, a) = ( + ui.find("What moved markets?").unwrap(), + ui.find(&format!("text: {AI_MARK_LABEL:?}")).unwrap(), + ui.find("Rates held; tech rallied.").unwrap(), + ); + assert!(q < m && m < a, "{ui}"); +} + +/// Unmarked cards render exactly as before. +#[test] +fn a_card_without_model_text_is_unchanged() { + let tree = eval(r#"l0_col([l0_title("Top Stories"), l0_bubble_them("hi")])"#); + let ui = octoscript_makepad::to_makepad_l0_ui(&tree); + assert!(!ui.contains(&format!("text: {AI_MARK_LABEL:?}")), "{ui}"); + assert!(!ui.contains(AI_MARK_GLYPH), "{ui}"); +} + +/// A band paints the theme's INVERSE: its words are ground-coloured on an ink +/// fill. The mark goes inside, on the same fill, so it borrows ink that reads +/// there — outside, it would be ground on ground. +#[test] +fn a_marked_band_keeps_the_mark_on_its_own_fill() { + let mut tree = eval(r#"l0_ai_text(l0_band("March"))"#); + let ink = with_text(&tree, "March").attrs.color.unwrap(); + octoscript_render::ai::expand_ai_marks(&mut tree); + assert_eq!(tree.kind, NodeKind::Card, "the band stays the outer node"); + assert!(tree.attrs.bg.is_some()); + let label = with_text(&tree, AI_MARK_LABEL); + assert_eq!(label.attrs.color.unwrap() & 0x00ff_ffff, ink & 0x00ff_ffff); +} diff --git a/crates/octoscript-node/src/ai.rs b/crates/octoscript-node/src/ai.rs new file mode 100644 index 0000000..70cfe8e --- /dev/null +++ b/crates/octoscript-node/src/ai.rs @@ -0,0 +1,253 @@ +//! The AI-written mark (`ui-profile-l0.md` §4.2). +//! +//! An L0 card may now show words the model wrote — a digest summary, a draft, +//! the agent's side of a chat — and every such text slot is marked: the kit's +//! `l0_ai_text(node)` stamps `ai: 1` on the node. This module is what that +//! stamp LOOKS like, as a tree rewrite rather than as backend code, so every +//! backend that runs it draws the same mark: a small sparkle glyph and an `AI` +//! eyebrow above the text, in the text's own ink, a step quieter. It is the +//! treatment the mail card already wrote by hand (`Icon(.zap)` + +//! `TextEyebrow("AI SUMMARY")`), applied by the theme instead of by the card. +//! +//! Subtle on purpose. The mark says who wrote the words; it does not restyle +//! them. The body keeps its role, size, face and colour, and the mark borrows +//! the same ink, so it holds in every palette without a colour of its own. +//! +//! The words themselves stay plain text. Nothing here, or downstream, parses +//! model text as markup or turns a URL in it into a link: the node keeps its +//! `text` verbatim, and backends render a text node as a plain label. + +use crate::{NodeKind, UiNode}; + +/// Font Awesome solid `wand-magic-sparkles` (U+E2CA), drawn in the icon face. +/// +/// A sparkle in the TEXT face would be `✦` (U+2726), and none of the faces the +/// kit ships (Roboto, NotoSans, Inter, the CJK and emoji fallbacks) carries it, +/// so it would draw as tofu. The icon font does, and the kit's icons already +/// render through it. +pub const AI_MARK_GLYPH: &str = "\u{e2ca}"; + +/// The eyebrow beside the glyph. +pub const AI_MARK_LABEL: &str = "AI"; + +/// The mark's alpha relative to the text's own: a step quieter than the +/// words it labels, like the soft eyebrow ink. +const MARK_ALPHA: f32 = 0.72; + +/// The `variant` the mark's own nodes carry, so a host or test can find them. +pub const MARK_VARIANT: &str = "ai_mark"; + +/// Whether the kit stamped this node AI-written. +pub fn is_ai_written(node: &UiNode) -> bool { + node.attrs.ai.is_some_and(|v| v != 0) +} + +/// Put the mark above the words of every AI-written node. +/// +/// The mark goes on the node's first TEXT, not around the stamped node: a chat +/// bubble is stamped on its outer column and a band on its filled card, and the +/// words sit on that fill. A mark outside it would sit on the page in ink meant +/// for the fill — a band's ground-coloured text drawn on the ground. Placed +/// beside the words it is on the same surface and borrows their ink safely. +/// +/// Idempotent: the stamp is cleared on the node it expands, so a second pass +/// finds nothing. Backends run it before emitting. +pub fn expand_ai_marks(node: &mut UiNode) { + for child in &mut node.children { + expand_ai_marks(child); + } + if !is_ai_written(node) { + return; + } + node.attrs.ai = None; + if !mark_first_text(node) { + // Nothing to read the words from: mark the node itself. + let body = std::mem::replace(node, empty(NodeKind::Column)); + *node = marked(body); + } +} + +fn is_mark(node: &UiNode) -> bool { + node.attrs.variant.as_deref() == Some(MARK_VARIANT) +} + +fn is_words(node: &UiNode) -> bool { + node.attrs.text.is_some() && node.attrs.icon != Some(1) +} + +fn mark_first_text(node: &mut UiNode) -> bool { + if is_mark(node) { + return false; + } + if is_words(node) { + let body = std::mem::replace(node, empty(NodeKind::Column)); + *node = marked(body); + return true; + } + node.children.iter_mut().any(mark_first_text) +} + +fn empty(kind: NodeKind) -> UiNode { + UiNode { + kind, + attrs: Default::default(), + children: Vec::new(), + } +} + +fn quieter(argb: u32) -> u32 { + let a = ((argb >> 24) & 0xff) as f32 * MARK_ALPHA; + ((a.round() as u32) << 24) | (argb & 0x00ff_ffff) +} + +fn marked(body: UiNode) -> UiNode { + let source = is_words(&body).then_some(&body); + let color = source.and_then(|t| t.attrs.color).map(quieter); + // Caption-sized, whatever the body is: a mark over a hero must not shout. + let size = source + .and_then(|t| t.attrs.size) + .map_or(10.0, |s| (s * 0.7).clamp(9.0, 12.0)); + + let mut glyph = empty(NodeKind::Text); + glyph.attrs.text = Some(AI_MARK_GLYPH.into()); + glyph.attrs.icon = Some(1); + glyph.attrs.size = Some(size); + glyph.attrs.color = color; + glyph.attrs.fitw = Some(1); + glyph.attrs.fith = Some(1); + glyph.attrs.variant = Some(MARK_VARIANT.into()); + + let mut label = empty(NodeKind::Text); + label.attrs.text = Some(AI_MARK_LABEL.into()); + label.attrs.size = Some(size); + label.attrs.weight = Some(600); + label.attrs.color = color; + label.attrs.tracking = Some(0.08); + label.attrs.fitw = Some(1); + label.attrs.fith = Some(1); + label.attrs.variant = Some(MARK_VARIANT.into()); + if let Some(t) = source { + label.attrs.family = t.attrs.family.clone(); + label.attrs.font_src = t.attrs.font_src.clone(); + } + + let mut mark = empty(NodeKind::Row); + mark.attrs.spacing = Some(4.0); + mark.attrs.aligny = Some(0.5); + mark.attrs.fitw = Some(1); + mark.attrs.fith = Some(1); + mark.attrs.variant = Some(MARK_VARIANT.into()); + mark.children = vec![glyph, label]; + + // The wrapper takes the body's width rule, so a filling paragraph still + // fills and a fitted label still hugs. + let mut wrap = empty(NodeKind::Column); + if body.attrs.fillw == Some(1) { + wrap.attrs.fillw = Some(1); + } else { + wrap.attrs.fitw = Some(1); + } + wrap.attrs.fith = Some(1); + wrap.attrs.spacing = Some(3.0); + wrap.children = vec![mark, body]; + wrap +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(s: &str, color: u32, size: f32) -> UiNode { + let mut n = empty(NodeKind::Text); + n.attrs.text = Some(s.into()); + n.attrs.color = Some(color); + n.attrs.size = Some(size); + n + } + + fn texts(n: &UiNode, out: &mut Vec) { + if let Some(t) = &n.attrs.text { + out.push(t.clone()); + } + for c in &n.children { + texts(c, out); + } + } + + #[test] + fn an_unstamped_tree_is_untouched() { + let mut tree = empty(NodeKind::Column); + tree.children.push(text("Rates held.", 0xffeeeeee, 15.0)); + let before = format!("{tree:?}"); + expand_ai_marks(&mut tree); + assert_eq!(format!("{tree:?}"), before); + } + + #[test] + fn a_stamped_text_gets_the_mark_above_it_in_its_own_ink() { + let mut body = text("Markets were calm; rates held.", 0xffeeeeee, 15.0); + body.attrs.ai = Some(1); + body.attrs.fillw = Some(1); + let mut tree = empty(NodeKind::Column); + tree.children.push(body); + expand_ai_marks(&mut tree); + + let wrap = &tree.children[0]; + assert_eq!(wrap.kind, NodeKind::Column); + assert_eq!(wrap.attrs.fillw, Some(1), "a filling body still fills"); + let (mark, body) = (&wrap.children[0], &wrap.children[1]); + assert_eq!(body.attrs.text.as_deref(), Some("Markets were calm; rates held.")); + assert_eq!(body.attrs.ai, None, "the stamp is consumed"); + assert_eq!(body.attrs.color, Some(0xffeeeeee), "the words are not restyled"); + assert_eq!(mark.children[0].attrs.text.as_deref(), Some(AI_MARK_GLYPH)); + assert_eq!(mark.children[0].attrs.icon, Some(1)); + assert_eq!(mark.children[1].attrs.text.as_deref(), Some(AI_MARK_LABEL)); + let ink = mark.children[1].attrs.color.unwrap(); + assert_eq!(ink & 0x00ff_ffff, 0x00ee_eeee, "the mark borrows the text's ink"); + assert!(ink >> 24 < 0xff, "a step quieter than the words"); + assert!(mark.children[1].attrs.size.unwrap() < 15.0); + + // Idempotent. + let once = format!("{tree:?}"); + expand_ai_marks(&mut tree); + assert_eq!(format!("{tree:?}"), once); + } + + #[test] + fn a_stamped_bubble_borrows_the_ink_of_the_text_inside_it() { + // `l0_ai_text(l0_bubble_them(s))`: the stamp lands on the outer column. + let mut card = empty(NodeKind::Card); + card.children.push(text("Rates held; tech rallied.", 0xff112233, 15.0)); + let mut bubble = empty(NodeKind::Column); + bubble.attrs.fillw = Some(1); + bubble.attrs.ai = Some(1); + bubble.children.push(card); + expand_ai_marks(&mut bubble); + + let mut words = Vec::new(); + texts(&bubble, &mut words); + assert_eq!(words, [AI_MARK_GLYPH, AI_MARK_LABEL, "Rates held; tech rallied."]); + // Inside the bubble, on the fill the words sit on. + let wrap = &bubble.children[0].children[0]; + assert_eq!(bubble.children[0].kind, NodeKind::Card); + let mark = &wrap.children[0]; + assert_eq!(mark.children[1].attrs.color.unwrap() & 0x00ff_ffff, 0x0011_2233); + assert_eq!(bubble.attrs.ai, None); + } + + #[test] + fn model_text_is_kept_verbatim() { + for s in [ + "**bold** and [a link](javascript:alert(1))", + "html https://example.com", + "l0:{\"e\":\"drop\"}", + ] { + let mut n = text(s, 0xffffffff, 15.0); + n.attrs.ai = Some(1); + expand_ai_marks(&mut n); + assert_eq!(n.children[1].attrs.text.as_deref(), Some(s)); + assert!(n.children[0].children.iter().all(is_mark)); + assert_eq!(n.children[1].kind, NodeKind::Text); + } + } +} diff --git a/crates/octoscript-node/src/lib.rs b/crates/octoscript-node/src/lib.rs index 495728d..50e5362 100644 --- a/crates/octoscript-node/src/lib.rs +++ b/crates/octoscript-node/src/lib.rs @@ -16,6 +16,7 @@ //! of producing it.** A second host writes its own evaluator against its own VM //! and produces the same tree. +pub mod ai; mod node; pub mod state; pub mod units; diff --git a/crates/octoscript-node/src/node.rs b/crates/octoscript-node/src/node.rs index 76d0577..1f5c250 100644 --- a/crates/octoscript-node/src/node.rs +++ b/crates/octoscript-node/src/node.rs @@ -506,6 +506,17 @@ pub struct Attrs { pub countries: Option, pub indicator: Option, pub years: Option, + + // ---- provenance ------------------------------------------------------------ + /// The MODEL wrote this node's text (`ui-profile-l0.md` §4.2), so it must be + /// shown as AI-written. Stamped by the kit's `l0_ai_text(node)` — `ai: 1` — + /// on the node the lowering wrapped: a text role, a `TextInput` holding a + /// draft, or a model chat bubble's outer column. + /// + /// A flag and not a look. What AI-written LOOKS like is + /// [`crate::ai::expand_ai_marks`], which every backend runs before it + /// emits, so the mark cannot differ between them. + pub ai: Option, } /// One node in the backend-agnostic tree. diff --git a/crates/octoscript-render/src/eval.rs b/crates/octoscript-render/src/eval.rs index e090a11..7c860c5 100644 --- a/crates/octoscript-render/src/eval.rs +++ b/crates/octoscript-render/src/eval.rs @@ -206,6 +206,8 @@ fn walk_inner( countries: string_prop(vm, value, id!(countries)), indicator: string_prop(vm, value, id!(indicator)), years: f32_prop(vm, value, id!(years)), + // The AI-written stamp `l0_ai_text` puts on a node (§4.2). + ai: int_prop(vm, value, id!(ai)), }; let mut children = Vec::new(); diff --git a/crates/octoscript-render/src/lib.rs b/crates/octoscript-render/src/lib.rs index 54fd744..1db2401 100644 --- a/crates/octoscript-render/src/lib.rs +++ b/crates/octoscript-render/src/lib.rs @@ -24,7 +24,7 @@ pub use eval::{add_global_fn, build, num_prop, prop, string_prop}; /// already has one, and octos-one could not take this crate for exactly that /// reason. Re-exported so every existing consumer of `octoscript_render::UiNode` /// keeps working — the split is a packaging change, not an API one. -pub use octoscript_node::{state, Attrs, NodeKind, UiNode}; +pub use octoscript_node::{ai, state, Attrs, NodeKind, UiNode}; /// Re-exported so backends and hosts can name VM types (for capability /// registration) without taking their own makepad-script dependency/version. From bb74a63e6353f4c75a1d55a1178c79ffe6e77d6f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:03:15 -0700 Subject: [PATCH 2/2] Pin Octoscript to main 5991dfae (OctoScript#53 merged) OctoScript#53, the L0 side of model-written text and the in-card chat, is on main as 5991dfae: the runtime pin (runtime.json) and the octoscript-ui-l0 git rev move to it, so this kit stamp and the Octoscript lowerings that call it ship together. Checked: cargo test -p octoscript-makepad -p octoscript-node, with the l0_ai_text tests, against Octoscript at 5991dfae. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- runtime.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7cee7d5..006799a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ makepad-widgets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "e29a0eaa68781231eb9ec582e5f02648c6dad7e9" } makepad-script = { git = "https://github.com/OctoSense-org/makepad.git", rev = "e29a0eaa68781231eb9ec582e5f02648c6dad7e9" } # The VM-independent Octoscript node model, from the Octoscript repo itself. -octoscript-ui-l0 = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "30a21b780ed518acaa3f8fb16dd5cb0b1ca9c9f8" } +octoscript-ui-l0 = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "5991dfae9344589e732b2605b530f788e8bbcd11" } [profile.release] opt-level = 3 diff --git a/runtime.json b/runtime.json index 201e6cf..8fb3619 100644 --- a/runtime.json +++ b/runtime.json @@ -7,7 +7,7 @@ }, "octoscript": { "url": "https://github.com/OctoSense-org/Octoscript.git", - "revision": "30a21b780ed518acaa3f8fb16dd5cb0b1ca9c9f8" + "revision": "5991dfae9344589e732b2605b530f788e8bbcd11" } } }