Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
21368ba
feat(ui_l0): lower the five data visualisations, not markers
ymote Aug 2, 2026
4fa3331
feat(ui_l0): lower a tint for every text role that admits one
ymote Aug 2, 2026
25de52d
feat(ui_l0): find the boundary by trying to cross it
ymote Aug 3, 2026
6415be9
fix(ui_l0): a useless format! in the decoration helper
ymote Aug 3, 2026
54d0019
feat(ui_l0): Map and Field, and a §1.0 that stops overreading one card
ymote Aug 3, 2026
381e892
feat(ui_l0): nav, at L0, in 54 lines
ymote Aug 3, 2026
622ff4a
docs(ui_l0): a card is not the only thing an app can be
ymote Aug 3, 2026
1e92c7f
feat(ui_l0): a backend-answered source now survives a loop
ymote Aug 3, 2026
26f7ff6
refactor(ui_l0): name the scope frame type
ymote Aug 3, 2026
37ff11d
feat(ui_l0): a loop over a source with no data realizes its declared …
ymote Aug 3, 2026
d1dcf09
feat(ui_l0): a card lowers what is true now, not what it was handed
ymote Aug 5, 2026
4068630
feat(ui_l0): a read must name a field the card asked for
ymote Aug 5, 2026
db171f2
feat(ui_l0): a durable collection is a source, written through a tran…
ymote Aug 5, 2026
c055552
feat(ui_l0): admit a card that declares L1, and give it arithmetic
ymote Aug 5, 2026
db1cbf0
docs(roadmap): bring the L0 section current with what L0 does
ymote Aug 5, 2026
d5c095d
docs(ui_l0): specify the L1 expression form, and stop claiming L1 is …
ymote Aug 5, 2026
eeb1698
fix(ui_l0): a tap that writes the value already there is not a change
ymote Aug 5, 2026
782a830
fix(ui_l0): every operand a record reads is a dependency, in a guard too
ymote Aug 5, 2026
cc6f70d
docs(roadmap): reconciliation stays deferred, and now there is a number
ymote Aug 5, 2026
cf6c879
fix(ui_l0): a tint is as live as the number it tints
ymote Aug 5, 2026
2083a5f
fix(ui_l0): a scalar argument is live where the backend can answer it
ymote Aug 5, 2026
e37cb29
fix(ui_l0): an L1 operand that is a live call is coerced to a number
ymote Aug 5, 2026
fdf8b88
test(ui_l0): make the catalog executable, and fix the column count
ymote Aug 5, 2026
ebc0c47
feat(ui_l0): a Map draws a live route, and gps/search answer live
ymote Aug 5, 2026
734f1f2
feat(ui_l0): the kit lowers a Map too, so nav's route draws on device
ymote Aug 5, 2026
246d3f0
fix(ui_l0): a Map needs the shipping card's geometry, not a guessed one
ymote Aug 5, 2026
05e3ea6
fix(ui_l0): a map guards on having a position, and stops at 0% CPU
ymote Aug 5, 2026
11dbda5
fix(ui_l0): a comparison binds looser than arithmetic
ymote Aug 5, 2026
56c1e9a
feat(ui_l0): close L1's three remaining holes
ymote Aug 5, 2026
89173ff
fix(ui_l0): sys.search answers an unindexed read, and 1 equals 1.0
ymote Aug 5, 2026
98a4385
fix(ui_l0): a week forecast realizes seven days, each asking for its own
ymote Aug 6, 2026
c48c00c
fix(ui_l0): a forecast row's label and the week's range go live
ymote Aug 6, 2026
89c0026
feat(ui_l0): the satellite pane, and a row that can stop filling
ymote Aug 6, 2026
9f321d9
fix(ui_l0): a visualisation's parameters are numbers, and a grid is rows
ymote Aug 6, 2026
4f59bbf
test(ui_l0): the backend conformance test §4 has been owing since it …
ymote Aug 6, 2026
c070835
docs(ui_l0): nav's drive screen is settled AGAINST L0, not for it
ymote Aug 6, 2026
6a24d9e
docs(ui_l0): nav's drive screen is L2 as written, not L2 by necessity
ymote Aug 6, 2026
5cc2d5e
fix(ui_l0): a source argument goes live whether or not a blob seeded it
ymote Aug 6, 2026
a3c3575
feat(ui_l0): nav gains a drive screen, and the camera has to earn it
ymote Aug 6, 2026
15b38ef
docs(roadmap): L2's strongest candidate card has been written at L0 i…
ymote Aug 6, 2026
ed71824
fix(ui_l0): a role admitted once has to be lowered twice
ymote Aug 6, 2026
f89e84a
fix(ui_l0): a card holding a map is laid out the way the shipping nav…
ymote Aug 6, 2026
64177f1
fix(ui_l0): the kit floats a map card too, and its arm was dead code
ymote Aug 6, 2026
a527f4e
feat(ui_l0): a trip has a travel mode and can route through a stop
ymote Aug 6, 2026
a26eb6d
feat(ui_l0): nav gains travel modes, a stop, and per-leg times
ymote Aug 6, 2026
6355234
perf(ui_l0): a followed map tracks the fix without rebuilding the card
ymote Aug 6, 2026
6111d6d
feat(ui_l0): a driving map can be tilted, which is the shipping app's…
ymote Aug 6, 2026
025ac45
docs(ui_l0): a second stop is blocked on a list-valued attribute, not…
ymote Aug 6, 2026
5008b47
feat(ui_l0): the trip's own places are pickable while the query is empty
ymote Aug 6, 2026
2f70fea
fix(ui_l0): the follow camera's position is not a property, and mine …
ymote Aug 6, 2026
ed1c71f
perf(ui_l0): a live value carries its call, so a driving card stops r…
ymote Aug 6, 2026
eee5be7
test(ui_l0): the card stays L0 while the lowering emits a tick for it
ymote Aug 6, 2026
d203bbe
fix(ui_l0): the add-a-stop control could never add a stop
ymote Aug 6, 2026
a6b8edf
fix(ui_l0): a numeric argument cannot carry an expression
ymote Aug 6, 2026
71daa52
feat(ui_l0): a map card gets a top pane and a swipeable bottom sheet
ymote Aug 7, 2026
f035bc1
feat(ui_l0): an action can be destructive, and the sheet reads at a g…
ymote Aug 7, 2026
36c6daa
l0: the drive sheet says how long is left, above how far
ymote Aug 7, 2026
5694fc9
l0: a transition advances from the value the card is SHOWING
ymote Aug 7, 2026
f18ff0e
l0: a map pins the trip it draws
ymote Aug 7, 2026
9a3d5f2
nav: no fix means show the trip, not a camera aimed at -9999
ymote Aug 7, 2026
3e2efc7
nav: the search box can actually search
ymote Aug 7, 2026
69b97bb
l0: a card may name a map's controls, and still may not call it
ymote Aug 7, 2026
b8dfb25
l0: undo a guard that removed the map it was protecting
ymote Aug 7, 2026
c31d98e
nav: previewing a trip is a screen, not a state of planning one
ymote Aug 7, 2026
db09aa9
l0: a trip from here CAPTURES the fix instead of referencing it
ymote Aug 7, 2026
5d79f1e
l0: the hero ramp comes down to 70% with the rest of the theme
ymote Aug 7, 2026
3fd123c
nav: back out the captured origin — realize is the wrong moment
ymote Aug 7, 2026
2213ee3
l0: a search result says WHICH place it is, and carries the way back …
ymote Aug 7, 2026
a334ec3
l0: back out R11.3 again — there is no moment to capture the fix at
ymote Aug 7, 2026
49ea164
l0: a field can answer a keystroke and a commit differently
ymote Aug 7, 2026
adea55c
l0: a map can route through two stops, and pin both
ymote Aug 7, 2026
b8c99c5
l0: an initial taken from a source is a CAPTURE, and someone must wri…
ymote Aug 7, 2026
e067b04
nav: the chase camera sits where the card it replaces sits
ymote Aug 7, 2026
b44cde8
l0: `width: .label` — the slot a form label sits in
ymote Aug 7, 2026
e282a90
l0: a map's camera can follow card state, so a toggle is not four maps
ymote Aug 7, 2026
a6d4fc9
nav: the two things you can do, centred, as icons
ymote Aug 7, 2026
b06e9f7
nav: the planning sheet, arranged the way iOS Maps arranges one
ymote Aug 7, 2026
7165b54
nav: the cost goes on the route, and GO is centred under it
ymote Aug 7, 2026
54b346e
feat(ui_l0): search you can type — and the four classes it flushed out
ymote Aug 8, 2026
85f8e1f
feat(ui_l0): GO goes straight to the drive, and the map's side chips …
ymote Aug 8, 2026
5f89fac
fix(ui_l0): the two live-refusal syntax classes teach instead of cascade
ymote Aug 8, 2026
3451d0e
fix(ui_l0): weather/news/stock fixtures gain the §5.9 lifecycle guard…
ymote Aug 8, 2026
62c071a
fix(ui_l0): a money format survives the tick
ymote Aug 8, 2026
c3ef99d
fix(ui_l0): .speed and .pressure render their dimension; .index leave…
ymote Aug 8, 2026
d2cc719
feat(ui_l0): a preference can be written, and the declaration names i…
ymote Aug 8, 2026
089f87f
feat(ui_l0): prove the weather card's saved-cities flow end to end
ymote Aug 8, 2026
c63ec62
fix(ui_l0): the remove chip escapes its row's hit target
ymote Aug 8, 2026
59de60b
feat(ui_l0): a reading list and a watchlist outlive the process
ymote Aug 8, 2026
6b73e99
feat(ui_l0): sys.topics — a followed word, joined live
ymote Aug 9, 2026
6ff7e06
feat(ui_l0): sys.link, and a write is a use
ymote Aug 9, 2026
1a10db7
feat(ui_l0): next/prev walk a collection, the way a swipe pages
ymote Aug 9, 2026
bd76744
feat(ui_l0): looking is not keeping — the add flows, and width on a chip
ymote Aug 9, 2026
dff19d6
chore(ui_l0): the mover star goes; a mover row is one tap again
ymote Aug 9, 2026
4bfac2e
chore(ui_l0): the stock fixture reveals per row
ymote Aug 9, 2026
35fcf6d
feat(ui_l0): a mover swipes to reveal Add
ymote Aug 9, 2026
a960810
chore(ui_l0): movers are recommendations — Add moves to the quote page
ymote Aug 9, 2026
7dbc77e
feat(ui_l0): a watchlist source naming a ticker is a membership probe
ymote Aug 9, 2026
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
9 changes: 9 additions & 0 deletions crates/splash-ui-l0/examples/check_card.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
fn main() {
let path = std::env::args().nth(1).expect("card");
let card = std::fs::read_to_string(&path).expect("read");
let r = splash_ui_l0::check_ui_l0_named("activity", &card);
println!(" valid = {} level = {:?}", r.valid, r.level);
for d in &r.diagnostics {
println!(" {}:{} {}", d.line, d.column, d.message);
}
}
3,963 changes: 3,767 additions & 196 deletions crates/splash-ui-l0/src/lib.rs

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions crates/splash-ui-l0/tests/fixtures/activity.card
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# level: L0
# model: activity
#
# The FIRST reference card L0 was not designed against.
#
# weather, news and stock shaped the profile — every role and capability in the
# catalog exists because one of them needed it, so "L0 renders them" is close to
# circular. This one comes from `a2app/apps/activity/app.md`, written for a
# different framework, and what it needs that L0 lacks is the point of it.

source place sys.geocode(name: state.city)
source parks sys.places(lat: place.lat, lon: place.lon, category: "park",
count: 4, fields: [id, name, distance])
source cafes sys.places(lat: place.lat, lon: place.lon, category: "cafe",
count: 4, fields: [id, name, distance])
source env.locale sys.locale()

state city { shape: text, initial: "" } # empty ⇒ device location

copy eyebrow { class: vocabulary, en: "NEARBY · GREEN & COFFEE" }
copy heading { class: vocabulary, en: "Around You" }
copy loading { class: vocabulary, en: "Finding places nearby…" }
copy park_why { class: vocabulary, en: "away · quiet green space" }
copy cafe_why { class: vocabulary, en: "away · somewhere to sit" }

view root Surface {
Col(gap: 2) {
TextCaption(text: copy.eyebrow)
TextTitle(text: copy.heading)
}

# §5.9's lifecycle, where the source framework used a -9999 sentinel. The
# card asks whether the fetch has landed; it does not compare against a magic
# number that also has to mean "no data".
when parks.$state == .pending { TextBody(text: copy.loading) }

when parks.$state == .ready {
Panel {
for p, i in parks key p.id {
Row(align: .center, gap: 10) {
TextRow(text: "🌳")
Col(gap: 2) {
TextRow(text: p.name)
TextCaption(text: p.distance, suffix: copy.park_why)
}
}
Rule()
}
for c, i in cafes key c.id {
Row(align: .center, gap: 10) {
TextRow(text: "☕")
Col(gap: 2) {
TextRow(text: c.name)
TextCaption(text: c.distance, suffix: copy.cafe_why)
}
}
Rule()
}
}
}
}
18 changes: 18 additions & 0 deletions crates/splash-ui-l0/tests/fixtures/nav-excerpt.splash
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// name: nav-app
let q = "{{state.q}}"
let find = "{{state.find}}"
let orig = "{{state.orig}}"
let dest = "{{state.dest}}"
let wp1 = "{{state.wp1}}"
let wp2 = "{{state.wp2}}"
let sel = "{{state.sel}}"
let ss = {{state.sel}}
let go = "{{state.go}}"
let vw = "{{state.view}}"
let md = "{{state.mode}}"
let oq = "{{state.oq}}"

fn tick() {
if dest != "0" { dlat = sys.coord(dest, "lat") }
vias = "" + sys.coord(wp1, "lat") + "," + sys.coord(wp1, "lon")
}
693 changes: 693 additions & 0 deletions crates/splash-ui-l0/tests/fixtures/nav.card

Large diffs are not rendered by default.

76 changes: 75 additions & 1 deletion crates/splash-ui-l0/tests/fixtures/news.card
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,62 @@ source feed sys.news(count: 7, offset: 1, fields: [id, title, author, poi
source article sys.news_item(id: state.selected,
fields: [title, author, points, comments, url])
source env.locale sys.locale()
# The reading list (§5.12): saved story IDS, each row fetched by id — so a
# bookmark outlives the front page it was found on.
source saved sys.reading(fields: [id, title, points])
# Followed TOPICS (§5.12): the store keeps a word per row; the top story
# beside it is searched fresh at read time, so a topic row is never stale.
source topics sys.topics(fields: [name, top_title, top_id])
# The reader overlay (host-owned): writing a story's url opens the page over
# the card; the host closes it on system back.
source page sys.link(fields: [url])

state selected { shape: text, initial: "" } # "" ⇒ feed, else a story id

event open_story { selected: set($value) }
event back { selected: clear }
# The bookmark toggle, from the story screen. Identity only: the id goes to the
# store; title and points are fetched fresh every time the list renders.
event keep_story { saved: append($value) }
event drop_story { saved: remove($value) }
# Following is idempotent in the store, so a suggestion chip can stay on
# screen after it was picked: tapping it again is nothing, not a duplicate.
event follow { topics: append($value) }
event unfollow { topics: remove($value) }
# The chip carries the article's url as its value: the card never fetches a
# page, it asks the host to show one.
event read_story { page: set($value) }

copy masthead { class: vocabulary, en: "Top Stories", zh: "头条" }
copy source { class: vocabulary, en: "HACKER NEWS", zh: "科技新闻" }
copy saved_hd { class: vocabulary, en: "READING LIST", zh: "收藏" }
copy keep_lbl { class: vocabulary, en: "☆ Save" }
copy drop_lbl { class: vocabulary, en: "Remove" }
copy back_lbl { class: vocabulary, en: "‹ Back", zh: "‹ 返回" }
copy read_lbl { class: vocabulary, en: "Read ↗", zh: "阅读原文 ↗" }
copy latest { class: vocabulary, en: "LATEST", zh: "最新" }
copy topics_hd { class: vocabulary, en: "TOPICS", zh: "关注" }
copy t_ai { class: vocabulary, en: "AI" }
copy t_nba { class: vocabulary, en: "NBA" }
copy t_soccer { class: vocabulary, en: "Soccer" }
copy t_crypto { class: vocabulary, en: "Crypto" }
copy lead { class: vocabulary, en: "LEAD", zh: "焦点" }
copy pts { class: vocabulary, en: "pts", zh: "分" }
copy comments { class: vocabulary, en: "comments", zh: "评论" }
copy by { class: vocabulary, en: "by", zh: "作者" }
copy loading { class: vocabulary, en: "Fetching headlines…", zh: "正在获取头条…" }
copy offline { class: vocabulary, en: "Can't reach the news feed", zh: "无法获取新闻" }

view root Surface(pad: .page) {
when selected == "" { stream }
when selected != "" { story }
}

# §5.9: the feed's lifecycle is a state to branch on — "not yet" and "went
# wrong" each say so, instead of an empty masthead with no explanation.
view stream Col {
when feed.$state == .pending { TextBody(text: copy.loading) }
when feed.$state == .failed { TextBody(text: copy.offline) }
masthead
headline
latest
Expand Down Expand Up @@ -95,10 +131,48 @@ view latest Col {
StoryRow(story: s, position: i, on_open: open_story)
}
}
# Suggested topics to follow; the row of chips is host copy,
# the VALUES are the stored words. Tap → append, idempotent.
TextCaption(text: copy.topics_hd)
Row(gap: 8) {
Chip(text: copy.t_ai, on_tap: follow, value: "ai")
Chip(text: copy.t_nba, on_tap: follow, value: "nba")
Chip(text: copy.t_soccer, on_tap: follow, value: "soccer")
Chip(text: copy.t_crypto, on_tap: follow, value: "crypto")
}
for t, i in topics key t.name {
# Chip beside the tappable part (its own hit target would be
# covered inside the row); the row opens the topic's current
# top story by the id the search answered just now.
Row(align: .center, gap: 8) {
Row(align: .center, gap: 8, width: .fill, on_tap: open_story, value: t.top_id) {
TextCaption(text: t.name, width: .label)
TextRow(text: t.top_title, width: .fill)
}
Chip(text: copy.drop_lbl, on_tap: unfollow, value: t.name)
}
}
for b, i in saved key b.id {
# Chip beside the tappable part, never inside it — a row's hit
# target covers its content and eats the chip's tap, measured.
Row(align: .center, gap: 8) {
Row(align: .center, gap: 8, width: .fill, on_tap: open_story, value: b.id) {
TextCaption(text: copy.saved_hd, width: .label)
TextRow(text: b.title, width: .fill)
}
Chip(text: copy.drop_lbl, on_tap: drop_story, value: b.id)
}
}
}

view story Col {
Row(on_tap: back) { TextCaption(glyph: "‹") }
# A word, not a lone glyph: the glyph alone measured ~20px of
# hit target on device, too small for a thumb.
Row(align: .center, on_tap: back) { TextRow(text: copy.back_lbl) }
Row(gap: 8) {
Chip(text: copy.keep_lbl, on_tap: keep_story, value: selected)
Chip(text: copy.read_lbl, on_tap: read_story, value: article.url)
}
TextTitle(text: article.title, width: .fill)
Row(gap: 6) {
TextCaption(value: article.points, suffix: copy.pts)
Expand Down
122 changes: 114 additions & 8 deletions crates/splash-ui-l0/tests/fixtures/stock.card
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,54 @@ source quote sys.quote(ticker: state.selected,
fields: [name, last, change, pct, open, high, low,
volume, mktcap, pe])
source env.locale sys.locale()
# The user's own layer (§5.12): saved tickers, and the chart range they prefer.
# The store holds IDENTITIES — a ticker is a name the quote endpoint resolves
# fresh every render; a price is never written down.
source watch sys.watchlist(fields: [ticker, last, pct])
source range_pref sys.prefs(fields: [range])
# The membership probe: is the stock this page looks at in the user's list?
# Names a ticker, so `has` answers "1"/"0" from the store — which is what
# flips the page between Add and Remove.
source kept sys.watchlist(ticker: state.selected, fields: [has])
# Free-text ticker lookup for the add flow — weather's editor pattern, the
# stock way: results carry real listings, never a guessed symbol.
source found sys.symbol_search(query: state.query, count: 5, fields: [ticker, name])

state selected { shape: text, initial: "" } # "" ⇒ movers list
state range { shape: enum[d1, w1, m1, m6, y1], initial: .m1 }
state query { shape: text, initial: "" }
state editing { shape: enum[none, add], initial: .none }
# What the LAST keep/forget on this page did, for the moment after a remove
# when the probe already says "not kept" but the user deserves the word.
state last_act { shape: enum[none, added, removed], initial: .none }
# CAPTURED from the stored preference (§5.13); the host guarantees "m1" until
# the user ever picks a range, so the capture is always a member of the enum.
state range { shape: enum[d1, w1, m1, m6, y1], initial: range_pref.range }

event open_quote { selected: set($value) }
event back { selected: clear, range: clear } # two writes, one gesture
event set_range { range: set($value) }
event open_quote { selected: set($value), last_act: set(.none) }
event back { selected: clear, range: clear, last_act: set(.none) }
event set_range { range: set($value), range_pref: set($value) }
# Keep/forget carry the word the page shows after: the probe flips the
# button the moment the store changes, and `last_act` says what just happened.
event keep { watch: append($value), last_act: set(.added) }
event forget { watch: remove($value), last_act: set(.removed) }
# The add flow. Looking is not keeping: tapping a result only OPENS its quote;
# Add is the one event that stores, and the × closes with nothing stored.
event add_sym { editing: set(.add), query: clear }
event typing { query: set($value) }
event preview { selected: set($value), last_act: set(.none) }
event confirm_add { watch: append($value), selected: set($value), editing: set(.none), query: clear }
event close_add { editing: set(.none), query: clear }

copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" }
copy movers { class: vocabulary, en: "Top Movers", zh: "涨跌榜" }
copy back_lbl { class: vocabulary, en: "‹ Back", zh: "‹ 返回" }
copy watch_hd { class: vocabulary, en: "WATCHLIST", zh: "自选" }
copy unstar { class: vocabulary, en: "Remove", zh: "删除" }
copy plus { class: vocabulary, en: "+" }
copy add_lbl { class: vocabulary, en: "Add", zh: "添加" }
copy added_lbl { class: vocabulary, en: "✓ Added", zh: "已添加" }
copy removed_lbl { class: vocabulary, en: "Removed", zh: "已删除" }
copy loading { class: vocabulary, en: "Fetching the quote…", zh: "正在获取行情…" }
copy offline { class: vocabulary, en: "Can't reach the market feed", zh: "无法获取行情" }
copy open { class: vocabulary, en: "Open", zh: "开盘" }
copy high { class: vocabulary, en: "High", zh: "最高" }
copy low { class: vocabulary, en: "Low", zh: "最低" }
Expand All @@ -36,11 +75,60 @@ view root Surface(pad: .page) {
}

view list Col {
# The header bar is always there: it names the saved list and
# hosts the add affordance in the top-right corner.
Row(align: .center, gap: 8) {
TextCaption(text: copy.watch_hd, width: .fill)
Chip(text: copy.plus, on_tap: add_sym, value: "1")
}
when editing == .add {
Panel {
Row(align: .center, gap: 10) {
Field(text: query, placeholder: copy.watch_hd, on_commit: preview,
on_change: typing, width: .fill)
Chip(text: "×", on_tap: close_add)
}
when query != "" {
# Row taps PREVIEW the quote; only the Add chip beside a
# result stores it (chip as sibling — a row's hit target
# covers its children, measured).
for f, i in found key f.ticker {
Row(align: .center, gap: 10) {
Row(align: .center, gap: 10, width: .fill, on_tap: preview, value: f.ticker) {
TextRow(text: f.ticker, width: .label)
TextCaption(text: f.name, width: .fill)
}
Chip(text: copy.add_lbl, on_tap: confirm_add, value: f.ticker)
}
}
}
}
}
for w, i in watch key w.ticker {
# Swipe THIS row left and ITS red Remove appears beside it;
# swipe right puts it away. `Reveal` is widget visibility, not
# card state — no other row moves, nothing re-realizes, and
# the swipe fires instead of the tap so a drag cannot also
# open the quote.
Row(align: .center, gap: 8) {
Row(align: .center, gap: 8, width: .fill, on_tap: open_quote, value: w.ticker) {
TextRow(text: w.ticker, width: .fill)
TextValue(value: w.last, format: .money)
TextValue(value: w.pct, format: .signed_pct)
}
Reveal {
Chip(text: copy.unstar, on_tap: forget, value: w.ticker, tone: .danger, width: .fit)
}
}
}
TextTitle(text: copy.movers)
Panel {
# Movers are the market's recommendations, not the user's list:
# no swipe menu here — a mover row is the tap that opens its
# quote, and the quote page is where Add lives.
for m in movers key m.ticker {
Row(align: .center, on_tap: open_quote, value: m.ticker) {
Col(gap: 2) {
Row(align: .center, gap: 8, on_tap: open_quote, value: m.ticker) {
Col(gap: 2, width: .fill) {
TextRow(text: m.ticker)
TextCaption(text: m.name, width: .fill)
}
Expand All @@ -52,8 +140,26 @@ view list Col {
}
}

# §5.9: the quote's lifecycle is a state to branch on — a tapped ticker says
# "fetching" or "can't reach", never a silent grid of em dashes.
view detail Col {
Row(on_tap: back) { TextCaption(glyph: "‹") }
# The page knows whether this stock is kept and says so: a
# kept one shows ✓ Added beside a red Remove, an unkept one
# shows Add — and for the beat after a remove, the word
# Removed, so the tap is answered in place.
Row(align: .center, gap: 8) {
Row(align: .center, width: .fill, on_tap: back) { TextRow(text: copy.back_lbl) }
when kept.has == "1" {
TextCaption(text: copy.added_lbl)
Chip(text: copy.unstar, on_tap: forget, value: selected, tone: .danger, width: .fit)
}
when kept.has != "1" {
when last_act == .removed { TextCaption(text: copy.removed_lbl) }
Chip(text: copy.add_lbl, on_tap: keep, value: selected)
}
}
when quote.$state == .pending { TextBody(text: copy.loading) }
when quote.$state == .failed { TextBody(text: copy.offline) }
header
chart
ranges
Expand Down
Loading
Loading