chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Markdown folds a part away with <details> on the pages and in the iOS app
.changeset/details-fold.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Markdown folds a part away with `<details>` | ||
| ||
A pull, issue, comment or README can fold its long part away behind `<details><summary>…</summary>`, as on GitHub: the pages and the iOS app show the summary, and the rest once it is opened. | ||
crates/knotless-serve/src/web/preview.rs+4−4
| @@ -1,10 +1,10 @@ | ||
//! Markdown as the pages will show it once posted: | ||
//! `dev.knotless.renderMarkdown`, which a composer's preview asks. It is | ||
//! the same renderer the pages' comments, bodies and READMEs are drawn | ||
//! with, raw HTML shown as text and links kept to the web's and the | ||
//! repository's own, so what is previewed is what is shown. It reads | ||
//! nothing and fetches nothing: no login is needed, and the text it takes | ||
//! is no longer than a body may be. | ||
//! with, raw HTML shown as text but for `<details>` folds, and links | ||
//! kept to the web's and the repository's own, so what is previewed is | ||
//! what is shown. It reads nothing and fetches nothing: no login is | ||
//! needed, and the text it takes is no longer than a body may be. | ||
| ||
use super::repo::invalid; | ||
use super::*; | ||
crates/knotless-serve/src/web.rs+230−6
| @@ -2353,9 +2353,10 @@ fn is_markdown(path: &str) -> bool { | ||
/// Markdown's links to a directory go there rather than to a file. | ||
type Trees<'a> = Option<(&'a str, &'a dyn Fn(&str) -> bool)>; | ||
| ||
/// Markdown, with raw HTML shown as text and links made relative to the | ||
/// directory `dir` it was found in, among files shown under `blobs`, | ||
/// directories under `trees` and, for images, `raws`. | ||
/// Markdown, with raw HTML shown as text but for the folds [`folds`] | ||
/// keeps, and links made relative to the directory `dir` it was found | ||
/// in, among files shown under `blobs`, directories under `trees` and, | ||
/// for images, `raws`. | ||
fn markdown(text: &str, blobs: &str, raws: &str, dir: &str, trees: Trees) -> String { | ||
drawn(text, blobs, raws, dir, trees, None, None) | ||
} | ||
| @@ -2421,7 +2422,6 @@ fn drawn( | ||
.map(|(path, _)| path.clone()) | ||
}; | ||
let events = autolinks(parsed).map(|event| match event { | ||
Event::Html(html) | Event::InlineHtml(html) => Event::Text(html), | ||
Event::Start(Tag::Link { | ||
link_type, | ||
dest_url, | ||
| @@ -2451,9 +2451,12 @@ fn drawn( | ||
}), | ||
other => other, | ||
}); | ||
let events = folds(events); | ||
let events: Vec<Event> = match said { | ||
Some(said) => mentions(references(events, said.named), said.mentioned).collect(), | ||
None => events.collect(), | ||
Some(said) => { | ||
mentions(references(events.into_iter(), said.named), said.mentioned).collect() | ||
} | ||
None => events, | ||
}; | ||
let mut out = String::new(); | ||
pulldown_cmark::html::push_html(&mut out, fenced(events).into_iter()); | ||
| @@ -2463,6 +2466,169 @@ fn drawn( | ||
} | ||
} | ||
| ||
/// A part of Markdown folded away: `<details>`, its `<summary>` said | ||
/// beside the arrow that opens it. | ||
#[derive(Clone, Copy, PartialEq)] | ||
enum Fold { | ||
Details, | ||
Summary, | ||
} | ||
| ||
impl Fold { | ||
fn closer(self) -> &'static str { | ||
match self { | ||
Fold::Details => "</details>", | ||
Fold::Summary => "</summary>", | ||
} | ||
} | ||
} | ||
| ||
/// Raw HTML shown as text, but for the `<details>` and `<summary>` that | ||
/// fold a part away, as GitHub's Markdown keeps them: each written anew, | ||
/// `open` the only attribute kept, and only on lines of their own (an | ||
/// HTML block), so never inside a paragraph. They nest as the blocks | ||
/// around them do: a closing tag counts only where it closes what was | ||
/// opened beside it, a block closes what it left open as it ends, and so | ||
/// does the end of the text, so nothing written can close the page's own | ||
/// elements. | ||
fn folds<'a>( | ||
events: impl Iterator<Item = pulldown_cmark::Event<'a>>, | ||
) -> Vec<pulldown_cmark::Event<'a>> { | ||
use pulldown_cmark::{Event, Tag, TagEnd}; | ||
let mut out = Vec::new(); | ||
// What is open, each with how many blocks deep it was opened. | ||
let mut open: Vec<(Fold, usize)> = Vec::new(); | ||
let mut depth = 0usize; | ||
for event in events { | ||
match event { | ||
Event::Start(Tag::HtmlBlock) | Event::End(TagEnd::HtmlBlock) => out.push(event), | ||
Event::Start(_) => { | ||
depth += 1; | ||
out.push(event); | ||
} | ||
Event::End(_) => { | ||
depth -= 1; | ||
let kept = open.iter().take_while(|(_, d)| *d <= depth).count(); | ||
shut(&mut out, &mut open, kept); | ||
out.push(event); | ||
} | ||
Event::InlineHtml(html) => out.push(Event::Text(html)), | ||
Event::Html(html) => unfolded(&html, depth, &mut open, &mut out), | ||
other => out.push(other), | ||
} | ||
} | ||
shut(&mut out, &mut open, 0); | ||
out | ||
} | ||
| ||
/// The folds `open` holds past its first `kept`, closed. | ||
fn shut(out: &mut Vec<pulldown_cmark::Event<'_>>, open: &mut Vec<(Fold, usize)>, kept: usize) { | ||
for (fold, _) in open.drain(kept..).rev() { | ||
out.push(pulldown_cmark::Event::Html(fold.closer().into())); | ||
} | ||
} | ||
| ||
/// A line of an HTML block, `depth` blocks deep, as [`folds`] shows it. | ||
fn unfolded<'a>( | ||
html: &str, | ||
depth: usize, | ||
open: &mut Vec<(Fold, usize)>, | ||
out: &mut Vec<pulldown_cmark::Event<'a>>, | ||
) { | ||
use pulldown_cmark::Event; | ||
let mut text = String::new(); | ||
let mut rest = html; | ||
while let Some(at) = rest.find('<') { | ||
text.push_str(&rest[..at]); | ||
rest = &rest[at..]; | ||
let top = open.last().copied(); | ||
let under = open.len().checked_sub(2).map(|i| open[i]); | ||
let kept: Option<(&'static str, usize)> = match fold_tag(rest) { | ||
Some((FoldTag::Opens(Fold::Details, shown), len)) => { | ||
open.push((Fold::Details, depth)); | ||
Some((if shown { "<details open>" } else { "<details>" }, len)) | ||
} | ||
Some((FoldTag::Opens(Fold::Summary, _), len)) | ||
if top == Some((Fold::Details, depth)) => | ||
{ | ||
open.push((Fold::Summary, depth)); | ||
Some(("<summary>", len)) | ||
} | ||
Some((FoldTag::Closes(Fold::Summary), len)) if top == Some((Fold::Summary, depth)) => { | ||
open.pop(); | ||
Some(("</summary>", len)) | ||
} | ||
Some((FoldTag::Closes(Fold::Details), len)) if top == Some((Fold::Details, depth)) => { | ||
open.pop(); | ||
Some(("</details>", len)) | ||
} | ||
// A summary left open closes with its fold. | ||
Some((FoldTag::Closes(Fold::Details), len)) | ||
if top == Some((Fold::Summary, depth)) && under == Some((Fold::Details, depth)) => | ||
{ | ||
open.truncate(open.len() - 2); | ||
Some(("</summary></details>", len)) | ||
} | ||
_ => None, | ||
}; | ||
match kept { | ||
Some((tag, len)) => { | ||
if !text.is_empty() { | ||
out.push(Event::Text(std::mem::take(&mut text).into())); | ||
} | ||
out.push(Event::Html(tag.into())); | ||
rest = &rest[len..]; | ||
} | ||
None => { | ||
text.push('<'); | ||
rest = &rest[1..]; | ||
} | ||
} | ||
} | ||
text.push_str(rest); | ||
if !text.is_empty() { | ||
out.push(Event::Text(text.into())); | ||
} | ||
} | ||
| ||
/// A `<details>` or `<summary>` tag [`folds`] keeps. | ||
enum FoldTag { | ||
/// Opening one, and for `<details>`, whether it is drawn open. | ||
Opens(Fold, bool), | ||
Closes(Fold), | ||
} | ||
| ||
/// The fold tag `html` starts with, and its length: `<details>`, | ||
/// `<details open>`, `<summary>` or the closing tag of either, in any | ||
/// case, with any space before its `>`. | ||
fn fold_tag(html: &str) -> Option<(FoldTag, usize)> { | ||
let end = html.find('>')?; | ||
let inner = &html[1..end]; | ||
let (closes, inner) = match inner.strip_prefix('/') { | ||
Some(inner) => (true, inner), | ||
None => (false, inner), | ||
}; | ||
if !inner.starts_with(|c: char| c.is_ascii_alphabetic()) { | ||
return None; | ||
} | ||
let mut words = inner.split_ascii_whitespace(); | ||
let fold = match words.next()?.to_ascii_lowercase().as_str() { | ||
"details" => Fold::Details, | ||
"summary" => Fold::Summary, | ||
_ => return None, | ||
}; | ||
let attributes: Vec<&str> = words.collect(); | ||
let tag = match (closes, fold, attributes.as_slice()) { | ||
(true, _, []) => FoldTag::Closes(fold), | ||
(false, _, []) => FoldTag::Opens(fold, false), | ||
(false, Fold::Details, [open]) if open.eq_ignore_ascii_case("open") => { | ||
FoldTag::Opens(Fold::Details, true) | ||
} | ||
_ => return None, | ||
}; | ||
Some((tag, end + 1)) | ||
} | ||
| ||
/// Fenced code in a language highlighted here, its block opened with the | ||
/// ranges [`highlight::fence`] gives, which the page paints as it paints a | ||
/// file's. | ||
| @@ -3878,6 +4044,64 @@ mod tests { | ||
} | ||
} | ||
| ||
#[test] | ||
fn details_fold_a_part_away() { | ||
let draw = |text: &str| markdown(text, "/B", "/R", "", None); | ||
// As GitHub's: the summary beside the arrow, Markdown inside. | ||
let html = draw(concat!( | ||
"Short.\n\n<details>\n<summary>Claude Opus 5.5</summary>\n\n", | ||
"## What changes\n\n- [ ] a task\n\n</details>\n", | ||
)); | ||
assert!( | ||
html.contains("<details>\n<summary>Claude Opus 5.5</summary>\n<h2>What changes</h2>"), | ||
"{html}" | ||
); | ||
assert!(html.ends_with("</ul>\n</details>\n"), "{html}"); | ||
assert_eq!( | ||
draw("<DETAILS Open><Summary >a <b></summary ></details>"), | ||
"<details open><summary>a &lt;b&gt;</summary></details>" | ||
); | ||
// Anything else about them is text: attributes, a tag in a | ||
// paragraph, one unfinished. | ||
for text in [ | ||
"<details onclick=\"x\">", | ||
"<details open=x>", | ||
"</details x>", | ||
"a <details> b", | ||
"<details\n", | ||
"< details>", | ||
"<detailsx>", | ||
] { | ||
let html = draw(text); | ||
assert!(!html.contains("<details"), "{text}: {html}"); | ||
} | ||
// A summary only in its fold; closers only of what is open beside | ||
// them; what is left open closed as its block, or the text, ends. | ||
assert_eq!( | ||
draw("</summary>\n<summary>\n</details>"), | ||
"</summary>\n<summary>\n</details>" | ||
); | ||
assert_eq!( | ||
draw("<details><summary>a</details>"), | ||
"<details><summary>a</summary></details>" | ||
); | ||
assert_eq!( | ||
draw("<details><summary>a"), | ||
"<details><summary>a</summary></details>" | ||
); | ||
// A `<` that starts no tag is text beside them. | ||
assert_eq!( | ||
draw("<details>1 < 2 >\n"), | ||
"<details>1 < 2 >\n</details>" | ||
); | ||
let html = draw("> <details>\n\n</details>"); | ||
assert!( | ||
html.contains("<details>\n</details></blockquote>"), | ||
"{html}" | ||
); | ||
assert!(html.ends_with("</details>"), "{html}"); | ||
} | ||
| ||
#[test] | ||
fn failures_by_name() { | ||
let named = [ | ||
docs/design/security.md+6−2
| @@ -390,8 +390,12 @@ site: | ||
| ||
- Pages carry a strict content security policy: scripts only from the | ||
site, and no other site may frame them. | ||
- Markdown keeps raw HTML as text. Links keep only http, https and mailto, | ||
and relative links resolve inside the repository. | ||
- Markdown keeps raw HTML as text, but for the `<details>` and `<summary>` | ||
that fold a part away: each written anew with no attribute but `open`, | ||
only on a line of its own, and closed where the block holding it ends, | ||
so nothing written closes the page's own elements. Links keep only | ||
http, https and mailto, and relative links resolve inside the | ||
repository. | ||
- A composer's preview (`dev.knotless.renderMarkdown`) is that same | ||
renderer, open to anyone without a login: it reads no repository and | ||
fetches nothing, a link's target is only written into the HTML, and the | ||
docs/design/serve.md+8−2
| @@ -1528,8 +1528,14 @@ running script on the site. | ||
| ||
- Pages carry a strict content security policy: scripts only from the site, and | ||
no other site may frame them. | ||
- Markdown keeps raw HTML as text. Links keep only http, https and mailto. | ||
Relative links resolve inside the repository, never above its root. | ||
- Markdown keeps raw HTML as text, but for `<details>` and `<summary>`, which | ||
fold a part away as GitHub's Markdown does. Each is written anew, with no | ||
attribute but `open`, and only on a line of its own, never inside a | ||
paragraph. A closing tag counts only where it closes one opened beside it, | ||
and what a block leaves open closes as the block ends, so a body never | ||
closes the page's own elements. The iOS app draws the same folds. | ||
Links keep only http, https and mailto. Relative links resolve inside the | ||
repository, never above its root. | ||
- Mermaid blocks arrive as code. The browser swaps them for diagrams drawn by | ||
Pele, loaded only on a page that has one. A block that fails shows its source | ||
again. Pele escapes text, drops script callbacks, blocks `javascript:` and | ||
ios/Knotless/Support/Markdown.swift+58−3
| @@ -34,6 +34,9 @@ struct MarkdownView: View { | ||
case rule | ||
case table(MarkdownTable) | ||
case task(String, number: Int, done: Bool, depth: Int) | ||
/// A part folded away, as `<details>` is: what its summary says, the | ||
/// blocks inside, and whether it starts open. | ||
case fold(String, blocks: [Block], open: Bool) | ||
} | ||
| ||
var body: some View { | ||
| @@ -87,6 +90,13 @@ struct MarkdownView: View { | ||
.foregroundStyle(done ? Color.mute : Color.fg) | ||
} | ||
.padding(.leading, CGFloat(depth) * 16) | ||
case .fold(let summary, let blocks, let open): | ||
FoldedMarkdown(summary: summary, open: open) { | ||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in | ||
// Erased, as a fold can hold folds. | ||
AnyView(view(block, colours)) | ||
} | ||
} | ||
} | ||
} | ||
| ||
| @@ -260,6 +270,13 @@ struct MarkdownView: View { | ||
} | ||
| ||
static func parse(_ source: String) -> [Block] { | ||
let lines = source.components(separatedBy: "\n") | ||
let tasks = Dictionary(MarkdownTasks.all(source).map { ($0.line, $0) }) { first, _ in first } | ||
return parse(lines, lines.indices, tasks) | ||
} | ||
| ||
/// The blocks of `lines` in `range`, `tasks` by the line each is on. | ||
private static func parse(_ lines: [String], _ range: Range<Int>, _ tasks: [Int: MarkdownTasks.Task]) -> [Block] { | ||
var blocks: [Block] = [] | ||
var paragraph: [String] = [] | ||
var code: [String]? | ||
| @@ -273,8 +290,10 @@ struct MarkdownView: View { | ||
table = nil | ||
} | ||
| ||
let tasks = Dictionary(MarkdownTasks.all(source).map { ($0.line, $0) }) { first, _ in first } | ||
for (at, raw) in source.components(separatedBy: "\n").enumerated() { | ||
var at = range.lowerBound | ||
while at < range.upperBound { | ||
let raw = lines[at] | ||
defer { at += 1 } | ||
let trimmed = raw.trimmingCharacters(in: .whitespaces) | ||
if trimmed.hasPrefix("```") { | ||
if let lines = code { | ||
| @@ -299,7 +318,12 @@ struct MarkdownView: View { | ||
} | ||
flush() | ||
} | ||
if let task = tasks[at] { | ||
if let fold = MarkdownFolds.fold(lines, at: at, end: range.upperBound) { | ||
flush() | ||
blocks.append(.fold(fold.summary, blocks: parse(lines, fold.inside, tasks), open: fold.open)) | ||
// Past its `</details>`, as the loop steps on by one. | ||
at = fold.next - 1 | ||
} else if let task = tasks[at] { | ||
flush() | ||
let depth = raw.prefix { $0 == " " }.count / 2 | ||
blocks.append(.task(MarkdownTasks.words(raw, task), number: task.number, done: task.done, depth: depth)) | ||
| @@ -338,6 +362,37 @@ struct MarkdownView: View { | ||
} | ||
} | ||
| ||
/// A part of Markdown folded away, as `<details>` is: what its summary says, | ||
/// beside an arrow, and what's inside once that's tapped. | ||
private struct FoldedMarkdown<Content: View>: View { | ||
let summary: String | ||
let content: Content | ||
@State private var open: Bool | ||
| ||
init(summary: String, open: Bool, @ViewBuilder content: () -> Content) { | ||
self.summary = summary | ||
self.content = content() | ||
_open = State(initialValue: open) | ||
} | ||
| ||
var body: some View { | ||
VStack(alignment: .leading, spacing: Space.s2_5) { | ||
Button { open.toggle() } label: { | ||
HStack(spacing: Space.s2) { | ||
Oct(name: "chevron-down", size: Icon.sm) | ||
.rotationEffect(open ? .zero : .degrees(-90)) | ||
.foregroundStyle(.secondary) | ||
Text(summary).foregroundStyle(Color.fg).multilineTextAlignment(.leading) | ||
} | ||
.contentShape(Rectangle()) | ||
} | ||
.buttonStyle(.plain) | ||
.accessibilityValue(open ? "Open" : "Closed") | ||
if open { content } | ||
} | ||
} | ||
} | ||
| ||
/// Markdown its author can tick: each tick shows at once and is saved behind it, | ||
/// one write at a time as each says the whole body, and a late one mustn't undo | ||
/// a newer one. Once all are saved the thread is read anew. One that fails puts | ||
ios/Knotless/Support/MarkdownFolds.swiftnew+73−0
| @@ -0,0 +1,73 @@ | ||
import Foundation | ||
| ||
/// The parts of what someone wrote folded away by `<details>`, as GitHub's | ||
/// Markdown and the web pages (`folds` in crates/knotless-serve/src/web.rs) | ||
/// draw them: a line starting `<details>`, its `<summary>` on that line or the | ||
/// next, then Markdown, to a `</details>` on a line of its own, or the end. | ||
enum MarkdownFolds { | ||
struct Fold: Equatable { | ||
/// What its summary says, or "Details", as a browser says without one. | ||
let summary: String | ||
/// Whether it starts open, as `<details open>` does. | ||
let open: Bool | ||
/// Its lines past the summary. | ||
let inside: Range<Int> | ||
/// The line after its `</details>`. | ||
let next: Int | ||
} | ||
| ||
/// The fold line `at` of `lines` opens, none past `end`, or nil. | ||
static func fold(_ lines: [String], at: Int, end: Int) -> Fold? { | ||
guard let opened = opens(trimmed(lines[at])) else { return nil } | ||
let open = opened.0 | ||
var rest = opened.1 | ||
var from = at + 1 | ||
if rest.isEmpty, from < end, starts(trimmed(lines[from]), "<summary>") != nil { | ||
rest = trimmed(lines[from]) | ||
from += 1 | ||
} | ||
var summary = "Details" | ||
if let after = starts(rest, "<summary>"), let close = rest.range(of: "</summary>", options: .caseInsensitive, range: after..<rest.endIndex) { | ||
summary = rest[after..<close.lowerBound].trimmingCharacters(in: .whitespaces) | ||
rest = rest[close.upperBound...].trimmingCharacters(in: .whitespaces) | ||
} | ||
if closes(rest) { return Fold(summary: summary, open: open, inside: from..<from, next: from) } | ||
// Anything else beside it is no fold of ours. | ||
guard rest.isEmpty else { return nil } | ||
var line = from | ||
var fenced = false | ||
while line < end { | ||
let words = trimmed(lines[line]) | ||
if words.hasPrefix("```") { | ||
fenced.toggle() | ||
} else if !fenced { | ||
if closes(words) { return Fold(summary: summary, open: open, inside: from..<line, next: line + 1) } | ||
// A fold inside, closed by its own `</details>`. | ||
if let inner = fold(lines, at: line, end: end) { | ||
line = inner.next | ||
continue | ||
} | ||
} | ||
line += 1 | ||
} | ||
return Fold(summary: summary, open: open, inside: from..<end, next: end) | ||
} | ||
| ||
private static func trimmed(_ line: String) -> String { line.trimmingCharacters(in: .whitespaces) } | ||
| ||
/// Where `line` goes on past `tag`, in any case, if it starts with it. | ||
private static func starts(_ line: String, _ tag: String) -> String.Index? { | ||
line.range(of: tag, options: [.caseInsensitive, .anchored])?.upperBound | ||
} | ||
| ||
/// Whether `line` starts with `<details>` (false) or `<details open>` | ||
/// (true), and what follows it. | ||
private static func opens(_ line: String) -> (Bool, String)? { | ||
for (tag, open) in [("<details>", false), ("<details open>", true)] { | ||
if let after = starts(line, tag) { return (open, line[after...].trimmingCharacters(in: .whitespaces)) } | ||
} | ||
return nil | ||
} | ||
| ||
private static func closes(_ line: String) -> Bool { line.caseInsensitiveCompare("</details>") == .orderedSame } | ||
} | ||
ios/ModelCheck/main.swift+7−0
| @@ -124,6 +124,13 @@ expect(MarkdownTasks.all(written).map(\.done) == [false, true, true, false, fals | ||
expect(MarkdownTasks.ticked("- [ ] a\n```\n- [ ] b\n```\n- [ ] c", 1, done: true) == "- [ ] a\n```\n- [ ] b\n```\n- [x] c", "a tick rewrote another line") | ||
expect(MarkdownTasks.ticked("é\n- [x] 🦊 a\r\n", 0, done: false) == "é\n- [ ] 🦊 a\r\n", "an untick changed more than its mark") | ||
expect(MarkdownTasks.ticked("- [ ] a", 1, done: true) == nil, "a tick of no task") | ||
// The folds the web pages draw, `<details>` to `</details>`. | ||
let folded = "a\n<details open>\n<SUMMARY> Claude </summary>\n\n```\n</details>\n```\n<details><summary>in</summary></details>\n<details>\n</details>\n</Details>\nb".components(separatedBy: "\n") | ||
expect(MarkdownFolds.fold(folded, at: 0, end: folded.count) == nil, "a line opening no fold") | ||
expect(MarkdownFolds.fold(folded, at: 1, end: folded.count) == .init(summary: "Claude", open: true, inside: 3..<10, next: 11), "a fold with fenced code and folds inside: \(String(describing: MarkdownFolds.fold(folded, at: 1, end: folded.count)))") | ||
expect(MarkdownFolds.fold(folded, at: 7, end: folded.count) == .init(summary: "in", open: false, inside: 8..<8, next: 8), "a fold on one line") | ||
expect(MarkdownFolds.fold(folded, at: 8, end: 9) == .init(summary: "Details", open: false, inside: 9..<9, next: 9), "a fold left open, with no summary") | ||
expect(MarkdownFolds.fold(["<details>words"], at: 0, end: 1) == nil, "words beside a fold's tag") | ||
if wrong > 0 { exit(1) } | ||
| ||
// Every write the app makes, for check-writes.mjs to hold to its lexicon. | ||
ios/check-models.sh+1−1
| @@ -6,6 +6,6 @@ set -eu | ||
cd "$(dirname "$0")/.." | ||
out=$(mktemp -d) | ||
trap 'rm -rf "$out"' EXIT | ||
swiftc -o "$out/check" ios/Knotless/API/Models.swift ios/Knotless/API/Writes.swift ios/Knotless/API/Standing.swift ios/Knotless/Support/Identicon.swift ios/Knotless/Support/MarkdownPlace.swift ios/Knotless/Support/MarkdownFences.swift ios/Knotless/Support/MarkdownTable.swift ios/Knotless/Support/MarkdownTasks.swift ios/ModelCheck/main.swift | ||
swiftc -o "$out/check" ios/Knotless/API/Models.swift ios/Knotless/API/Writes.swift ios/Knotless/API/Standing.swift ios/Knotless/Support/Identicon.swift ios/Knotless/Support/MarkdownPlace.swift ios/Knotless/Support/MarkdownFences.swift ios/Knotless/Support/MarkdownTable.swift ios/Knotless/Support/MarkdownTasks.swift ios/Knotless/Support/MarkdownFolds.swift ios/ModelCheck/main.swift | ||
"$out/check" harness/shots/fixtures.json "$out/writes.json" | ||
node ios/check-writes.mjs "$out/writes.json" | ||
web/src/app.css+12−0
| @@ -2834,6 +2834,18 @@ a.row:hover { | ||
padding-left: var(--space-4); | ||
margin-left: 0; | ||
} | ||
.prose details { | ||
margin: 0 0 var(--space-4); | ||
} | ||
.prose summary { | ||
cursor: pointer; | ||
} | ||
.prose details[open] > summary { | ||
margin-bottom: var(--space-2); | ||
} | ||
.prose details > :last-child { | ||
margin-bottom: 0; | ||
} | ||
.prose .diagram { | ||
margin: 0 0 var(--space-4); | ||
line-height: normal; | ||