diff --git a/apps/adventure-arena/controllers/LibraryController.lua b/apps/adventure-arena/controllers/LibraryController.lua
index 3aad6b5a..36fecd97 100644
--- a/apps/adventure-arena/controllers/LibraryController.lua
+++ b/apps/adventure-arena/controllers/LibraryController.lua
@@ -17,13 +17,12 @@ function Controller.new(options)
}, Controller)
end
--- Where a saved story stands, in the words a reader uses: the chapter and
--- the room, then the status line and how far the score has come.
+-- Where a saved story stands, in the words a reader uses: the room, then
+-- the status line and how far the score has come.
function Controller:progressEntry(record)
local game = self.model:find(record.gameId)
if not game then return nil end
- local chapter = (record.chapter or 0) > 0 and ("Chapter " .. Session.roman(record.chapter)) or nil
- local place = chapter and record.room and (chapter .. " · " .. record.room) or record.room or game.title
+ local place = record.room or game.title
local maxScore = tonumber(record.maxScore) or 0
return {
game = game,
diff --git a/apps/adventure-arena/controllers/ReadingSettingsController.lua b/apps/adventure-arena/controllers/ReadingSettingsController.lua
index 2c1b1ccb..3ef523d5 100644
--- a/apps/adventure-arena/controllers/ReadingSettingsController.lua
+++ b/apps/adventure-arena/controllers/ReadingSettingsController.lua
@@ -52,6 +52,7 @@ function Controller:actions()
decreaseSize = function() self:apply(model:adjustFontSize(-1)) end,
increaseSize = function() self:apply(model:adjustFontSize(1)) end,
justifyChanged = function(on) self:apply(model:setJustified(on)) end,
+ dropCapChanged = function(on) self:apply(model:setDropCap(on)) end,
}
for index = 0, #model.themes() - 1 do
actions["theme_" .. index] = function() self:apply(model:setThemeIndex(index)) end
diff --git a/apps/adventure-arena/controllers/SessionController.lua b/apps/adventure-arena/controllers/SessionController.lua
index e910af7e..12d3f59d 100644
--- a/apps/adventure-arena/controllers/SessionController.lua
+++ b/apps/adventure-arena/controllers/SessionController.lua
@@ -90,6 +90,8 @@ function Controller:show(id, fresh)
dictate = function() self:toggleDictation() end,
close = function() self:close() end,
readingSettings = function() self:showReadingSettings() end,
+ -- The reader's page no longer shows the compass; Compass.etlua
+ -- still binds to this when a page includes it.
compassDrag = function(gesture)
if type(gesture) ~= "table" or not self.refs then return end
local direction = CompassGesture.direction(gesture.translation)
@@ -193,17 +195,51 @@ function Controller:renderTranscript()
font = settings.font, fontSize = settings.fontSize, lineSpacing = settings.lineSpacing,
alignment = settings.alignment, primary = settings.primaryTextColor,
secondary = settings.secondaryTextColor, rule = settings.ruleColor,
+ dropCap = settings.dropCap,
}
data.reveal = self:revealState(data.earlierEntries)
- data.actions = {}
+ data.linkMenus, data.actions = self:linkMenus(data.entries)
self.transcriptEarlier = data.earlierEntries
return self.transcript:update(data)
end
+-- Links are live in the scene the reader stands in: the words of earlier
+-- rooms name things that are no longer at hand, so they are set as plain
+-- prose. Returns the menus keyed "entry_paragraph", as the Transcript
+-- template's paragraph ids are, and the action each menu item runs.
+function Controller:linkMenus(entries)
+ local menus, actions = {}, {}
+ local first = #entries + 1
+ for index = #entries, 1, -1 do
+ first = index
+ if entries[index].kind == "scene" then break end
+ end
+ for index = first, #entries do
+ for paragraph, links in pairs(entries[index].links or {}) do
+ local menu = {}
+ for linkIndex, link in ipairs(links) do
+ local items = {}
+ for itemIndex, action in ipairs(self.model:linkActions(link.target)) do
+ local name = table.concat({ "link", index, paragraph, linkIndex, itemIndex }, "_")
+ actions[name] = function() self:submitCommand(action.command) end
+ table.insert(items, { title = action.title, action = name })
+ end
+ if #items > 0 then
+ table.insert(menu, {
+ location = link.location, length = link.length, label = link.label, items = items,
+ })
+ end
+ end
+ if #menu > 0 then menus[index .. "_" .. paragraph] = menu end
+ end
+ end
+ return menus, actions
+end
+
-- ── Typing ──────────────────────────────────────────────────────────────
-- New prose is rendered whole and revealed by `revealedCharacters`, so its
-- lines never reflow as it types; paragraphs still waiting are hidden, and
--- so is a whole entry (a chapter heading with it) until typing reaches it. The
+-- so is a whole entry (a scene's title with it) until typing reaches it. The
-- template describes the reveal at each render (a command or a reading
-- settings change); between renders each tick writes the one paragraph
-- being typed.
@@ -355,8 +391,7 @@ function Controller:submitCommand(command)
self:beginTyping(firstNew)
local presentation = self.model:presentation()
self.refs.progress.text = presentation.progress
- self.refs.sessionPlace.text = presentation.chapterLabel ~= ""
- and (presentation.chapterLabel .. " · " .. presentation.roomTitle) or presentation.roomTitle
+ self.refs.sessionPlace.text = presentation.roomTitle
self.refs.input.text = ""
self.refs.dictationStatus.text = ""
self.refs.dictationStatus.hidden = true
diff --git a/apps/adventure-arena/models/ReadingSettings.lua b/apps/adventure-arena/models/ReadingSettings.lua
index 1f862b3a..a9dd1be2 100644
--- a/apps/adventure-arena/models/ReadingSettings.lua
+++ b/apps/adventure-arena/models/ReadingSettings.lua
@@ -48,6 +48,8 @@ function ReadingSettings.new(initial)
theme = indexOf(THEMES, initial.theme) and initial.theme or THEMES[1].id,
spacing = indexOf(SPACING, initial.spacing) and initial.spacing or "normal",
justified = initial.justified == true,
+ -- A scene opens with a dropped initial unless the reader turns it off.
+ dropCap = initial.dropCap ~= false,
}, ReadingSettings)
self:setFontSize(initial.fontSize or SIZE.default)
return self
@@ -85,11 +87,16 @@ function ReadingSettings:setJustified(value)
return true
end
+function ReadingSettings:setDropCap(value)
+ self.dropCap = value == true
+ return true
+end
+
-- Plain values for persistence; `ReadingSettings.new(snapshot)` restores them.
function ReadingSettings:snapshot()
return {
font = self.font, fontSize = self.fontSize, theme = self.theme,
- spacing = self.spacing, justified = self.justified,
+ spacing = self.spacing, justified = self.justified, dropCap = self.dropCap,
}
end
@@ -115,6 +122,7 @@ function ReadingSettings:presentation()
spacingIndex = spacingIndex - 1,
justified = self.justified,
alignment = self.justified and "justified" or "leading",
+ dropCap = self.dropCap,
theme = theme.id,
themeIndex = themeIndex - 1,
themes = themes,
diff --git a/apps/adventure-arena/models/SavedGames.lua b/apps/adventure-arena/models/SavedGames.lua
index 3be1c402..5050d6c5 100644
--- a/apps/adventure-arena/models/SavedGames.lua
+++ b/apps/adventure-arena/models/SavedGames.lua
@@ -2,8 +2,8 @@ local SavedGames = {}
SavedGames.__index = SavedGames
-- Autosaves, one per adventure: the command history and random seed that
--- replay the story, plus what the library shows about it (chapter, room and
--- score) without starting an engine. The store is injected, so the model
+-- replay the story, plus what the library shows about it (room and score)
+-- without starting an engine. The store is injected, so the model
-- never touches files; `store.load()` returns the saved table and
-- `store.save(table)` persists it.
local VERSION = 1
diff --git a/apps/adventure-arena/models/Session.lua b/apps/adventure-arena/models/Session.lua
index 8810d6e0..aafb3c4f 100644
--- a/apps/adventure-arena/models/Session.lua
+++ b/apps/adventure-arena/models/Session.lua
@@ -20,21 +20,8 @@ local DIRECTIONS = {
-- reader keeps the most recent entries; the story itself is unaffected.
local TRANSCRIPT = { limit = 120 }
-local ROMAN = {
- { 1000, "M" }, { 900, "CM" }, { 500, "D" }, { 400, "CD" }, { 100, "C" }, { 90, "XC" },
- { 50, "L" }, { 40, "XL" }, { 10, "X" }, { 9, "IX" }, { 5, "V" }, { 4, "IV" }, { 1, "I" },
-}
-
-function Session.roman(number)
- local parts = {}
- for _, pair in ipairs(ROMAN) do
- while number >= pair[1] do
- table.insert(parts, pair[2])
- number = number - pair[1]
- end
- end
- return table.concat(parts)
-end
+-- A tapped word offers a handful of actions, never a wall of them.
+local LINK = { actions = 6 }
function Session.new(options)
options = options or {}
@@ -49,7 +36,7 @@ end
function Session:reset()
self.entries, self.history = {}, {}
- self.moves, self.score, self.maxScore, self.chapters, self.scoreChange = 0, 0, 0, 0, 0
+ self.moves, self.score, self.maxScore, self.scoreChange = 0, 0, 0, 0
self.availableDirections, self.exitList = {}, {}
self.items, self.knownItems, self.knownByNoun = {}, {}, {}
self.roomTitle, self.scene, self.openEntry = nil, nil, nil
@@ -110,9 +97,10 @@ end
-- ── Transcript structure ────────────────────────────────────────────────
-- The engine prints plain text. Books give the reader structure, so the
--- transcript is parsed into a title-page banner, scenes (a new room opens a
--- chapter whose first letter is set as a drop cap), the player's own commands, and
--- narration.
+-- transcript is parsed into a title-page banner, scenes (a new room opens
+-- one under the room's name), the player's own commands, and narration.
+-- Stories mark the words a reader can act on as "[[label]]" or
+-- "[[label->target]]"; paragraphs keep the label and carry the links apart.
local function trim(text)
return (tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", ""))
@@ -154,12 +142,39 @@ local function splitFirstLine(paragraph)
return trim(first), rest ~= "" and rest or nil
end
+-- Splits link markup from prose. Returns the text as the reader sees it and
+-- its links, each { location, length, label, target }: `location` counts
+-- characters from 0 as `utf8.len` does, and `target` is the parser's word
+-- for the thing (the label itself when the story names no other).
+function Session.parseLinks(text)
+ text = tostring(text or "")
+ local plain, links, position = {}, {}, 1
+ local length = 0
+ local function keep(piece)
+ table.insert(plain, piece)
+ length = length + (utf8.len(piece) or #piece)
+ end
+ while true do
+ local first, last, body = text:find("%[%[(.-)%]%]", position)
+ if not first then break end
+ keep(text:sub(position, first - 1))
+ local label, target = body:match("^(.-)%->(.*)$")
+ label, target = trim(label or body), trim(target or body)
+ if label ~= "" and target ~= "" then
+ table.insert(links, {
+ location = length, length = utf8.len(label) or #label,
+ label = label, target = target:lower(),
+ })
+ end
+ keep(label)
+ position = last + 1
+ end
+ keep(text:sub(position))
+ return table.concat(plain), links
+end
+
function Session:beginScene(title)
- self.chapters = self.chapters + 1
- local scene = {
- kind = "scene", chapter = self.chapters, chapterLabel = "Chapter " .. Session.roman(self.chapters),
- title = title, paragraphs = {},
- }
+ local scene = { kind = "scene", title = title, paragraphs = {}, links = {} }
table.insert(self.entries, scene)
self.scene, self.openEntry = scene, scene
return scene
@@ -167,10 +182,56 @@ end
function Session:appendParagraph(paragraph)
if not self.openEntry then
- self.openEntry = { kind = "narration", paragraphs = {} }
+ self.openEntry = { kind = "narration", paragraphs = {}, links = {} }
table.insert(self.entries, self.openEntry)
end
- table.insert(self.openEntry.paragraphs, paragraph)
+ local text, links = Session.parseLinks(paragraph)
+ table.insert(self.openEntry.paragraphs, text)
+ self.openEntry.links[#self.openEntry.paragraphs] = links
+end
+
+local function capitalized(text)
+ return (text:gsub("^%l", string.upper))
+end
+
+-- What a reader can do with a linked word, as { title, command } in the
+-- order they are likeliest to want. A direction walks; an object offers
+-- "examine" and the verbs the story accepts for it, where those need no
+-- second object. The command uses the story's own word for the thing.
+function Session:linkActions(target)
+ target = trim(target):lower()
+ if target == "" then return {} end
+ local direction = DIRECTIONS[target]
+ if direction then
+ return { { title = "Go " .. direction, command = direction } }
+ end
+ local words = {}
+ for word in target:gmatch("[%w']+") do words[word] = true end
+ local noun = Suggestions.noun(target)
+ local function find(items)
+ for _, item in ipairs(items) do
+ if item.noun == noun then return item end
+ end
+ for _, item in ipairs(items) do
+ for word in item.name:lower():gmatch("[%w']+") do
+ if words[word] then return item end
+ end
+ end
+ end
+ local item = find(self.items) or find(self.knownItems)
+ local verbs, seen = { "examine" }, { examine = true, look = true }
+ for _, verb in ipairs(item and Suggestions.oneTapVerbs(item) or {}) do
+ if not seen[verb] then
+ seen[verb] = true
+ table.insert(verbs, verb)
+ end
+ end
+ local actions = {}
+ for index = 1, math.min(#verbs, LINK.actions) do
+ local command = verbs[index] .. " " .. target
+ table.insert(actions, { title = capitalized(command), command = command })
+ end
+ return actions
end
function Session:appendOutput(text, openingTitle)
@@ -185,7 +246,7 @@ function Session:appendOutput(text, openingTitle)
end
-- A move without a printed heading still enters a new room: the scene
-- opens at the first paragraph that is not the title page.
- -- The opening always starts Chapter I, even when the engine names no room.
+ -- The opening always starts a scene, even when the engine names no room.
local pendingRoom = not headingFound and (room or openingTitle)
and (not self.scene or self.scene.title ~= (room or openingTitle)) and (room or openingTitle) or nil
@@ -217,7 +278,7 @@ end
-- `saved` is a snapshot from `Session:snapshot()`. The story is restored by
-- replaying its commands against an engine seeded as before, which rebuilds
--- the chapters exactly as the reader first saw them.
+-- the transcript exactly as the reader first saw it.
function Session:start(game, saved)
if not game then return false, "Adventure not found." end
if not self.engineFactory then return false, "No session engine configured." end
@@ -262,7 +323,7 @@ function Session:snapshot()
for _, command in ipairs(self.history) do table.insert(commands, command) end
return {
gameId = game.id, seed = self.seed, commands = commands,
- chapter = self.chapters, room = self.roomTitle or (self.scene and self.scene.title) or game.title,
+ room = self.roomTitle or (self.scene and self.scene.title) or game.title,
score = self.score, maxScore = self.maxScore, moves = self.moves,
}
end
@@ -329,7 +390,6 @@ function Session:presentation()
ink = game.ink or game.tint or "accent",
initialFont = game.initialFont,
roomTitle = self.roomTitle or (scene and scene.title) or game.title or "",
- chapterLabel = scene and scene.chapterLabel or "",
entries = entries,
earlierEntries = earlier,
score = self.score,
diff --git a/apps/adventure-arena/models/Suggestions.lua b/apps/adventure-arena/models/Suggestions.lua
index 1d70c1a1..54f4338d 100644
--- a/apps/adventure-arena/models/Suggestions.lua
+++ b/apps/adventure-arena/models/Suggestions.lua
@@ -92,6 +92,22 @@ local function primaryVerb(item)
return best or "examine"
end
+-- The verbs the story accepts for an item that make whole commands on
+-- their own, best known first; verbs outside the common set keep the
+-- story's order after them.
+function Suggestions.oneTapVerbs(item)
+ local verbs = {}
+ for index, verb in ipairs(item.verbs or {}) do
+ if not NEEDS_SECOND[verb] then
+ table.insert(verbs, { verb = verb, rank = VERB_RANK[verb] or (#VERBS + index) })
+ end
+ end
+ table.sort(verbs, function(a, b) return a.rank < b.rank end)
+ local names = {}
+ for _, entry in ipairs(verbs) do table.insert(names, entry.verb) end
+ return names
+end
+
local function tokenize(input)
local words = {}
for word in input:lower():gmatch("%S+") do table.insert(words, word) end
diff --git a/apps/adventure-arena/services/ZILRuntime.lua b/apps/adventure-arena/services/ZILRuntime.lua
index 06261caa..6eb53925 100644
--- a/apps/adventure-arena/services/ZILRuntime.lua
+++ b/apps/adventure-arena/services/ZILRuntime.lua
@@ -66,6 +66,8 @@ function ZILRuntime.new(game, readFile, seed)
local runtime = require("zilscript.runtime")
local env = runtime.create_game_env()
env.rawget, env.rawset, env.rawequal = rawget, rawset, rawequal
+ -- The reader renders the story's link markup; see Session.parseLinks.
+ env.PROSE_LINKS = true
env.math = setmetatable({ random = ZILRuntime.random(seed or os.time()),
randomseed = function() end }, { __index = math })
local bootstrapPath = "apps/adventure-arena/zilscript/zilscript/bootstrap.lua"
diff --git a/apps/adventure-arena/views/ProseLinks.etlua b/apps/adventure-arena/views/ProseLinks.etlua
new file mode 100644
index 00000000..00e9c161
--- /dev/null
+++ b/apps/adventure-arena/views/ProseLinks.etlua
@@ -0,0 +1,10 @@
+<%
+ -- The words of a paragraph the reader can act on, and what each offers.
+%>
+<% for _, link in ipairs(links or {}) do %>
+
+ <% for _, item in ipairs(link.items) do %>
+
+ <% end %>
+
+<% end %>
diff --git a/apps/adventure-arena/views/ReadingOptions.etlua b/apps/adventure-arena/views/ReadingOptions.etlua
index 8ec22a0a..0d33644e 100644
--- a/apps/adventure-arena/views/ReadingOptions.etlua
+++ b/apps/adventure-arena/views/ReadingOptions.etlua
@@ -1,6 +1,6 @@
<%
-- Apple Books’ "Themes & Settings": type size, page themes shown as pages,
- -- the face, the leading and justification. Used by the in-book sheet and the
+ -- the face, the leading, justification and drop caps. Used by the in-book sheet and the
-- Settings tab; `preview` adds a sample page for when no book is behind it.
local SWATCH = { width = 74, height = 92, radius = 16, sample = 26 }
%>
@@ -50,13 +50,13 @@
+
<% if preview then %>
-
-
-
+
+
<% end %>
diff --git a/apps/adventure-arena/views/Session.etlua b/apps/adventure-arena/views/Session.etlua
index 541786a2..53b2f84d 100644
--- a/apps/adventure-arena/views/Session.etlua
+++ b/apps/adventure-arena/views/Session.etlua
@@ -1,15 +1,15 @@
<%
-- The reader. A page of the book fills the screen; the navigation bar is a
- -- running head (the story, then the chapter and room). Everything that is
- -- not the story floats in one Liquid Glass group at the foot of the page:
- -- quick actions, the command field and the compass, with suggestion chips
- -- above them and the status line set small like a book’s folio.
+ -- running head (the story, then the room). Everything that is not the
+ -- story floats in one Liquid Glass group at the foot of the page: quick
+ -- actions and the command field, with suggestion chips above them and the
+ -- status line set small like a book’s folio.
local BAR = { height = 52, radius = 26, merge = 14 }
%>
- <%- partial("SessionTitle.etlua", { gameTitle = gameTitle, chapterLabel = chapterLabel, roomTitle = roomTitle }) %>
+ <%- partial("SessionTitle.etlua", { gameTitle = gameTitle, roomTitle = roomTitle }) %>
@@ -41,7 +41,6 @@
- <%- partial("Compass.etlua", { availableDirections = availableDirections, compassSegments = compassSegments, size = BAR.height }) %>
diff --git a/apps/adventure-arena/views/SessionTitle.etlua b/apps/adventure-arena/views/SessionTitle.etlua
index ab1a7bf4..c529be65 100644
--- a/apps/adventure-arena/views/SessionTitle.etlua
+++ b/apps/adventure-arena/views/SessionTitle.etlua
@@ -1,8 +1,8 @@
<%
- -- A running head: the book’s title over the chapter and room, as printed
- -- books carry the title on one page and the chapter on the facing one.
+ -- A running head: the book’s title over the room, as printed books carry
+ -- the title on one page and the section on the facing one.
%>
-
+
diff --git a/apps/adventure-arena/views/Transcript.etlua b/apps/adventure-arena/views/Transcript.etlua
index d9b9e9e1..10177d9e 100644
--- a/apps/adventure-arena/views/Transcript.etlua
+++ b/apps/adventure-arena/views/Transcript.etlua
@@ -3,11 +3,14 @@
-- · A title page: the author in small capitals, the title in the story’s
-- own face and colour, a fleuron, and the tagline as an epigraph.
-- · The Infocom banner becomes the colophon, small and centred.
- -- · Each new room opens a chapter: "Chapter II" in small capitals, the
- -- room as its title, and a drop cap the first lines wrap around.
+ -- · Each new room opens a scene: the room as its title and, when the
+ -- reader keeps drop caps, an initial the first lines wrap around.
-- · What the reader does is set in small capitals in the story’s colour,
-- like a stage direction; the story answers in ordinary prose. Nothing
-- is a bubble: the page is one continuous column of type.
+ -- · Words the reader can act on carry a dashed rule in the story’s colour
+ -- and open a menu of what can be done with them. `linkMenus` holds the
+ -- live links of each paragraph, keyed like `reveal`.
-- `reading` carries the reader’s face, size, leading, alignment and inks.
-- `reveal` holds the characters shown of paragraphs still typing, keyed
-- "entry_paragraph"; they, and entries in `reveal.waiting`, wait hidden
@@ -15,12 +18,13 @@
local size, lead = reading.fontSize, reading.lineSpacing
local PAGE = {
side = 24, top = 28, bottom = 24,
- paragraph = math.floor(size * 0.75), chapter = math.floor(size * 2.6),
+ paragraph = math.floor(size * 0.75), scene = math.floor(size * 2.6),
command = math.floor(size * 1.3), afterCommand = math.floor(size * 0.45),
dropCapLines = 3,
}
local hyphenate = reading.alignment == "justified" and "true" or "false"
reveal = reveal or { waiting = {} }
+ linkMenus = linkMenus or {}
local function revealed(index, paragraphIndex)
local shown = reveal[index .. "_" .. paragraphIndex]
return string.format('revealedCharacters="%d" hidden="%s"', shown or -1, shown == 0 and "true" or "false")
@@ -31,7 +35,7 @@
%>
<% if earlierEntries == 0 then %>
-
+
<% if gameAuthor ~= "" then %>
<% end %>
@@ -53,14 +57,15 @@
<% end %>
<% elseif entry.kind == "scene" then %>
- >
-
-
+ >
+
<% for paragraphIndex, paragraph in ipairs(entry.paragraphs) do %>
- />
+ >
+ <%- partial("ProseLinks.etlua", { links = linkMenus[index .. "_" .. paragraphIndex] }) %>
+
<% end %>
@@ -71,7 +76,9 @@
<% else %>
>
<% for paragraphIndex, paragraph in ipairs(entry.paragraphs) do %>
- />
+ >
+ <%- partial("ProseLinks.etlua", { links = linkMenus[index .. "_" .. paragraphIndex] }) %>
+
<% end %>
<% end %>
diff --git a/apps/adventure-arena/zilscript b/apps/adventure-arena/zilscript
index a014873e..bf7d98ad 160000
--- a/apps/adventure-arena/zilscript
+++ b/apps/adventure-arena/zilscript
@@ -1 +1 @@
-Subproject commit a014873ea17fafbedf0d6cbf7ce10f092db811a4
+Subproject commit bf7d98ad363b5be40ddb618d9723c7188280d6cf
diff --git a/docs/PROJECT_REFERENCE.md b/docs/PROJECT_REFERENCE.md
index bd71b47d..14896ff3 100644
--- a/docs/PROJECT_REFERENCE.md
+++ b/docs/PROJECT_REFERENCE.md
@@ -778,6 +778,8 @@ around.
| `dropCap`, `dropCapLines` (default 3) | drop the first letter through N lines |
| `dropCapFontName`, `dropCapDesign`, `dropCapWeight`, `dropCapColor` | the initial's face and colour |
| `revealedCharacters` | typewriter reveal: characters shown so far, counted as `utf8.len` counts them; `-1` (default) shows everything |
+| `linkColor` | colour of the dashed rule under links; defaults to the tint |
+| `` children | the words a reader can act on; see **Links** below |
The initial is sized by its **ink**, not font metrics: a plain capital spans
from the first line's cap height to the last line's baseline, as in print; a
@@ -790,6 +792,30 @@ space. Implementation: a non-scrolling `UITextView` / non-editable
`NSTextView` on TextKit 1 with `NSTextContainer.exclusionPaths`
(`src/uikit/paragraph.m`, `src/appkit/paragraph.m`).
+**Links.** `` children mark runs of the text the reader can act
+on, as WPF sets `Hyperlink` inside a `TextBlock`. `location` (from 0) and
+`length` count characters as `utf8.len` does; `