Skip to content
quarry Artifact [ff838552f3]
Login

Artifact [ff838552f3]

Artifact ff838552f3c5ee3cb64202cd5d08c003caca31d7ad2127854de1a54f25561af4:


package web

import (
	"html/template"
	"net/http/httptest"
	"strings"

	"golang.org/x/net/html"
	"testing"
)

// chatRun renders a list the way the page does — grouping first, then
// one row at a time through the single renderer.
func chatRun(msgs []ChatMessage, readSeq uint64, viewer string) string {
	return strings.Join(chatRows(msgs, readSeq, viewer), "")
}

// chatRows is the same, one string per row, for the assertions that
// care which row drew what.
func chatRows(msgs []ChatMessage, readSeq uint64, viewer string) []string {
	chatGroupRuns(msgs, readSeq)
	chatRefQuotes(msgs)
	out := make([]string, len(msgs))
	for i, m := range msgs {
		out[i] = string(chatMsgHTML(m, "alpha", viewer, false))
	}
	return out
}

// One header per run of an author's messages inside one minute, and one
// action row per message however it was drawn.
func TestChatGroupsARunUnderOneHeader(t *testing.T) {
	got := chatRun([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "one", At: "2026-08-29T14:05:01Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "two", At: "2026-08-29T14:05:40Z"},
		{Seq: 3, Kind: "msg", User: "dan", Text: "three", At: "2026-08-29T14:05:59Z"},
	}, 0, "dan")
	if n := strings.Count(got, `class="chat-head"`); n != 1 {
		t.Errorf("a three-message run drew %d headers, want 1", n)
	}
	if n := strings.Count(got, `class="chat-toolbar"`); n != 3 {
		t.Errorf("%d action rows for three messages, want 3", n)
	}
	if n := strings.Count(got, "data-grouped"); n != 2 {
		t.Errorf("%d rows marked as continuing the run, want 2", n)
	}
}

// A different author, a different minute, and a reply each open a new
// group — the reply because its quote has to sit under a header.
func TestChatBreaksGroups(t *testing.T) {
	for _, tc := range []struct {
		name string
		next ChatMessage
	}{
		{"another author", ChatMessage{Seq: 2, Kind: "msg", User: "eve", Text: "b", At: "2026-08-29T14:05:10Z"}},
		{"another minute", ChatMessage{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:06:00Z"}},
		{"a reply", ChatMessage{Seq: 2, Kind: "msg", User: "dan", Ref: 1, RefUser: "eve", RefLine: "x",
			Text: "b", At: "2026-08-29T14:05:10Z"}},
		{"a robot line", ChatMessage{Seq: 2, Kind: "robot", User: "robot", Text: "b", At: "2026-08-29T14:05:10Z"}},
	} {
		got := chatRun([]ChatMessage{
			{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
			tc.next,
		}, 0, "dan")
		if n := strings.Count(got, `class="chat-head"`); n != 2 {
			t.Errorf("%s drew %d headers, want 2", tc.name, n)
		}
	}
}

// The "new" divider is drawn between the read and the unread, so the
// first unread row keeps its own header rather than losing it to a run
// on the other side of the line.
func TestChatFirstUnreadStartsAGroup(t *testing.T) {
	got := chatRun([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:10Z"},
	}, 1, "dan")
	if n := strings.Count(got, `class="chat-head"`); n != 2 {
		t.Errorf("the first unread row drew %d headers in the run, want 2", n)
	}
}

// The row's controls: reply and edit as their own icons, everything
// rare or dangerous behind an overflow — and every one of them a link
// or a <details>, so a tap does the same thing a click does with no
// script anywhere in the path.
func TestChatActionsAreOneQuietRow(t *testing.T) {
	m := ChatMessage{Seq: 7, Kind: "msg", User: "dan", Text: "mine", At: "2026-08-29T14:05:00Z"}
	got := string(chatMsgHTML(m, "alpha", "dan", false))
	for _, want := range []string{
		`class="chat-toolbar"`,
		`class="chat-icon chat-act chatreplyto" href="/r/alpha/chat?reply=7"`,
		`class="chatmenu-item chat-act chatpermalink" href="/r/alpha/chat/7">Permalink #7<`,
		`<details class="chatedit"><summary class="chat-icon chat-act"`,
		`<textarea name="text"`,
		`action="/r/alpha/chat/7/delete"`,
		`data-on:submit=`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("own message missing %q:\n%s", want, got)
		}
	}
	// Somebody else's message offers no edit and no delete.
	other := string(chatMsgHTML(m, "alpha", "eve", false))
	for _, unwanted := range []string{"chatedit", "chatdelete"} {
		if strings.Contains(other, unwanted) {
			t.Errorf("another account's message offered %q", unwanted)
		}
	}
	if !strings.Contains(other, `class="chat-toolbar"`) {
		t.Error("a message the viewer cannot edit still needs its permalink row")
	}
	// A deleted message loses edit, delete and reply — and its
	// permalink, which would resolve to a message that is not there.
	// What it gains is the one thing still worth doing to it.
	del := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Deleted: true, At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
	if strings.Contains(del, "chatedit") || strings.Contains(del, "chatdelete") {
		t.Error("a deleted message still offered edit or delete")
	}
	if strings.Contains(del, "chatpermalink") {
		t.Error("a deleted message still offers a permalink to nothing")
	}
	if !strings.Contains(del, "chatpurge") || !strings.Contains(del, "Remove from timeline") {
		t.Errorf("a deleted message offers no way off the timeline:\n%s", del)
	}
	// Purge is the SECOND step and keeps its own confirmation: the
	// tombstone is the last trace, and erasing it is final.
	if !strings.Contains(del, "/chat/7/purge") || !strings.Contains(del, "Remove for everyone") {
		t.Error("removal is not two deliberate steps")
	}
	// Somebody else's deleted message offers nothing to remove.
	if other := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Deleted: true, At: "2026-08-29T14:05:00Z"}, "alpha", "eve", false)); strings.Contains(other, "chatpurge") {
		t.Error("a reader who is neither the author nor an admin was offered a purge")
	}
	// An admin may.
	if adm := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Deleted: true, At: "2026-08-29T14:05:00Z"}, "alpha", "eve", true)); !strings.Contains(adm, "chatpurge") {
		t.Error("an admin cannot clear a tombstone")
	}
}

// A reply's quote is compact: who, which message, and a bounded first
// line — a reminder, not a second copy.
func TestChatQuoteIsBounded(t *testing.T) {
	long := strings.Repeat("x", 400)
	got := string(chatMsgHTML(ChatMessage{Seq: 9, Kind: "msg", User: "dan", Ref: 4,
		RefUser: "eve", RefLine: long, Text: "answer", At: "2026-08-29T14:05:00Z"},
		"alpha", "dan", false))
	if !strings.Contains(got, `class="chat-quote"`) || !strings.Contains(got, "· #4 ·") {
		t.Errorf("the quote does not name the message it answers:\n%s", got)
	}
	if strings.Contains(got, strings.Repeat("x", 200)) {
		t.Error("the quote carried the whole quoted message")
	}
	if !strings.Contains(got, "…") {
		t.Error("the truncated quote does not say it was cut")
	}
}

// The chat page carries the chip rule the mention markup renders into,
// and the mention itself is visual only — no preview hook, because the
// person card is capability-gated.
func TestChatPageStylesTheMentionChip(t *testing.T) {
	// Asserted against the SERVED sheet, not the page. These rules used
	// to ride in an inline <style> on the chat page, and this test
	// passed for as long as they did — while the browser applied none
	// of them, because a strict style-src drops a <style> element.
	// Rendering the rule into the HTML proves nothing; being in the
	// stylesheet the page links is the only thing that reaches anyone.
	if !strings.Contains(sheet, ".at{") {
		t.Error("the stylesheet has no rule for the mention chip")
	}
}

// The rail and the identicon are the forum's own vocabulary at chat
// density: a group block says whose column it is before a word is read.
func TestChatGroupBlockCarriesTheAuthorsHue(t *testing.T) {
	got := chatRun([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
	}, 0, "dan")
	if !strings.Contains(got, `style="--uh:`) {
		t.Errorf("the group block carries no author hue:\n%s", got)
	}
	if !strings.Contains(got, `class="idn"`) {
		t.Errorf("the group header carries no identicon:\n%s", got)
	}
	// A robot line is the channel talking, not a person: a rail, but no
	// hue to claim it belongs to somebody.
	bot := chatRun([]ChatMessage{
		{Seq: 1, Kind: "robot", User: "robot", Text: "check-in", At: "2026-08-29T14:05:00Z"},
	}, 0, "dan")
	if strings.Contains(bot, `style="--uh:`) {
		t.Errorf("a robot line took an author hue:\n%s", bot)
	}
}

// quotesIn counts the diminished quote blocks in one rendered row.
func quotesIn(s string) int { return strings.Count(s, `class="chat-quote"`) }

// An inline #seq is a reference like any other, and gets the same
// diminished quote a formal reply does.
func TestInlineRefRendersItsQuote(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "see #1", Refs: []string{"#1"},
			At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[1]); n != 1 {
		t.Errorf("an inline #1 drew %d quotes, want 1:\n%s", n, rows[1])
	}
	if !strings.Contains(rows[1], `href="/r/alpha/chat/1"`) {
		t.Errorf("the inline quote does not link the permalink:\n%s", rows[1])
	}
	if !strings.Contains(rows[1], "the original") {
		t.Errorf("the inline quote carries no first line:\n%s", rows[1])
	}
}

// A reply that also names the message it answers is still one quote:
// the formal reply already drew it.
func TestInlineRefIsNotQuotedTwice(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "yes, #1", Ref: 1, RefUser: "eve",
			RefLine: "the original", Refs: []string{"#1"}, At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[1]); n != 1 {
		t.Errorf("a reply that also names its target drew %d quotes, want 1:\n%s", n, rows[1])
	}
}

// Two is the cap: a quote is a reminder, and a message that names five
// things must not become a wall of other people's words.
func TestInlineRefQuotesAreCappedAtTwo(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "one", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "eve", Text: "two", At: "2026-08-29T14:05:10Z"},
		{Seq: 3, Kind: "msg", User: "eve", Text: "three", At: "2026-08-29T14:05:20Z"},
		{Seq: 4, Kind: "msg", User: "dan", Text: "#1 #2 #3",
			Refs: []string{"#1", "#2", "#3"}, At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[3]); n != 2 {
		t.Errorf("three inline refs drew %d quotes, want 2:\n%s", n, rows[3])
	}
}

// A seq the page cannot source is quoted not at all — an aged-out
// reference keeps its link and loses only the words.
func TestUnknownInlineRefRendersNoQuote(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 2, Kind: "msg", User: "dan", Text: "see #99", Refs: []string{"#99"},
			At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[0]); n != 0 {
		t.Errorf("an unknown seq drew %d quotes, want 0:\n%s", n, rows[0])
	}
}

// fakeLive replays a fixed script down the tail, then closes it, which
// is how liveChatAs is made to return.
type fakeLive struct{ msgs []ChatMessage }

func (fakeLive) Join(string, string) func() { return func() {} }
func (fakeLive) Present(string) []string    { return nil }
func (fakeLive) PresenceChanged(string) (<-chan struct{}, func()) {
	return make(chan struct{}), func() {}
}

func (f fakeLive) Tail(string, uint64) (<-chan ChatMessage, func()) {
	ch := make(chan ChatMessage, len(f.msgs))
	for _, m := range f.msgs {
		ch <- m
	}
	close(ch)
	return ch, func() {}
}

// The live path groups against every row it appended, not only against
// the messages: a robot line between two of an author's messages breaks
// the run there exactly as it does on the reloaded page.
func TestLiveGroupingBreaksOnARobotLine(t *testing.T) {
	live := fakeLive{msgs: []ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "robot", User: "robot", Text: "check-in", At: "2026-08-29T14:05:10Z"},
		{Seq: 3, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:20Z"},
	}}
	w := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/r/alpha/chat.sse", nil)
	liveChatAs(live, "alpha", "dan", false, nil, w, req)
	got := w.Body.String()
	if n := strings.Count(got, `class="chat-head"`); n != 3 {
		t.Errorf("the live run drew %d headers for three rows, want 3:\n%s", n, got)
	}
	if strings.Contains(got, "data-grouped") {
		t.Errorf("a robot line did not break the live run:\n%s", got)
	}
}

// A live row quotes an inline reference to a message the same
// connection already appended, through the same renderer the page uses.
func TestLiveRowQuotesAnInlineRef(t *testing.T) {
	live := fakeLive{msgs: []ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "see #1", Refs: []string{"#1"},
			At: "2026-08-29T14:06:00Z"},
	}}
	w := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/r/alpha/chat.sse", nil)
	liveChatAs(live, "alpha", "dan", false, nil, w, req)
	if n := quotesIn(w.Body.String()); n != 1 {
		t.Errorf("the live row drew %d quotes, want 1:\n%s", n, w.Body.String())
	}
}

// --- visual-polish hooks -------------------------------------------------

// chatContent renders the chat page's content block for the assertions
// that are about the page rather than one row.
func chatContent(t *testing.T, d pageData) string {
	t.Helper()
	var b strings.Builder
	if err := chatPage.ExecuteTemplate(&b, "content", d); err != nil {
		t.Fatal(err)
	}
	return b.String()
}

// Every row reserves the same gutter: a group header fills it with the
// author's mark, a continuation row with the absolute clock, so the
// column reads as one column whichever kind of row is in it.
func TestChatRowsShareAnAvatarGutter(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:20Z"},
	}, 0, "dan")
	for i, r := range rows {
		if !strings.Contains(r, `class="chat-gut"`) {
			t.Errorf("row %d has no gutter:\n%s", i, r)
		}
	}
	if !strings.Contains(rows[0], `class="idn"`) {
		t.Errorf("the group header's gutter has no mark:\n%s", rows[0])
	}
	if !strings.Contains(rows[1], `class="chat-clock"`) {
		t.Errorf("a continuation row carries no absolute clock:\n%s", rows[1])
	}
	if !strings.Contains(rows[1], `>14:05<`) {
		t.Errorf("the continuation clock is not the message's own time:\n%s", rows[1])
	}
}

// The row's controls are a toolbar, not a line of prose under the text.
func TestChatToolbarIsItsOwnBox(t *testing.T) {
	got := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Text: "mine", At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
	if !strings.Contains(got, `class="chat-toolbar"`) {
		t.Errorf("no toolbar on the row:\n%s", got)
	}
	if !strings.Contains(sheet, ".chat-toolbar{") {
		t.Error("the stylesheet has no toolbar rule")
	}
	if !strings.Contains(sheet, "var(--shadow-1)") {
		t.Error("the floating toolbar has no shadow token")
	}
}

// The quote is a card: a mark, a name, the number, one line — one row.
func TestChatQuoteCarriesAMark(t *testing.T) {
	got := string(chatQuoteBlock(4, "eve", "the original", "alpha"))
	if !strings.Contains(got, `class="idn"`) {
		t.Errorf("the quote card has no identicon:\n%s", got)
	}
	if !strings.Contains(got, "· #4 ·") {
		t.Errorf("the quote card does not name the message:\n%s", got)
	}
}

// A day boundary gets its own separator, so a log read the next morning
// says which morning.
func TestChatLogSeparatesDays(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatMsgs: []ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-28T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-28T14:06:00Z"},
		{Seq: 3, Kind: "msg", User: "dan", Text: "c", At: "2026-08-29T09:00:00Z"},
	}})
	if n := strings.Count(got, `class="chat-date"`); n != 2 {
		t.Errorf("a two-day log drew %d date separators, want 2:\n%s", n, got)
	}
}

// The composer is one rounded field with its own toolbar strip: the
// note, the key hint, mark-read and Send, inside the border.
func TestChatComposerIsARoundedField(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	for _, want := range []string{
		`class="composer"`,
		`class="composer-bar"`,
		`class="cbtn cbtn-send"`,
		`placeholder="Message #alpha"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("the composer is missing %q", want)
		}
	}
	// Its rules come from the served sheet, which is the only place a
	// browser reads them from.
	for _, want := range []string{".composer{", "field-sizing:content"} {
		if !strings.Contains(sheet, want) {
			t.Errorf("the stylesheet is missing %q", want)
		}
	}
	// Signed out there is no composer at all, and nothing dangling.
	out := chatContent(t, pageData{Repo: "alpha"})
	if strings.Contains(out, `class="composer"`) {
		t.Error("a signed-out reader was given a composer")
	}
}

// The chat rules are written in the shared scale, not in loose pixels —
// and they are in the SHEET, which is what a browser applies.
func TestChatStyleUsesTheScale(t *testing.T) {
	for _, want := range []string{"var(--sp-", "var(--r-", "var(--t-fast)", ".chat-date{"} {
		if !strings.Contains(sheet, want) {
			t.Errorf("the stylesheet does not use %q", want)
		}
	}
}

// The scale itself is declared once, in the base stylesheet.
func TestBaseStylesheetDeclaresTheScale(t *testing.T) {
	for _, want := range []string{
		"--sp-1:", "--sp-2:", "--sp-3:", "--sp-4:", "--sp-5:", "--sp-6:",
		"--r-1:", "--r-2:", "--r-3:", "--r-4:", "--shadow-1:", "--t-fast:",
	} {
		if !strings.Contains(styleSource, want) {
			t.Errorf("the base stylesheet declares no %s", want)
		}
	}
}

// The log has an end that can be linked to, and the page offers the
// link. Without script this anchor IS jump-to-latest, which is why it
// is a real <a href> and not a scripted button.
func TestChatOffersJumpToLatestWithoutScript(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
		ChatMsgs: []ChatMessage{{Seq: 1, Kind: "msg", User: "dan", Text: "hi", HTML: "hi"}}})
	if !strings.Contains(got, `id="chat-latest"`) {
		t.Error("the log has no end sentinel to jump to")
	}
	if !strings.Contains(got, `href="#chat-latest"`) {
		t.Error("no jump-to-latest anchor on the live view")
	}
	// The sentinel must be the LAST thing in the log, or it stops
	// naming the latest message. Live rows are inserted before it.
	if strings.Index(got, `id="chat-latest"`) < strings.Index(got, ">hi<") {
		t.Error("the end sentinel is not after the messages")
	}
}

// Following the log is CONDITIONAL. A chat that always scrolls to the
// newest message pulls the page out from under somebody reading back.
func TestChatFollowsOnlyWhenAtTheEnd(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if !strings.Contains(got, "$chatEnd") {
		t.Error("nothing tracks whether the reader is at the end")
	}
	if !strings.Contains(got, "data-effect") || !strings.Contains(got, "$chatTick") {
		t.Error("no effect watching for an arrival")
	}
	// Away from the end the arrival is counted and offered, not forced.
	if !strings.Contains(got, "$chatNew") {
		t.Error("an arrival away from the end is not counted")
	}
	// And the page opens where the reading is.
	if !strings.Contains(got, "chat-divider") || !strings.Contains(got, "scrollIntoView") {
		t.Error("the log does not open at the divider or the end")
	}
}

// Delete takes two deliberate actions WITH OR WITHOUT script. The
// confirm() used to be the only guard, and it is Datastar-only — so the
// scriptless path, the one where a slip cannot be undone, had none.
func TestChatDeleteIsGuardedWithoutScript(t *testing.T) {
	got := string(chatMsgHTML(ChatMessage{Seq: 4, Kind: "msg", User: "dan",
		Text: "mine", HTML: "mine"}, "alpha", "dan", false))
	if !strings.Contains(got, `<details class="chatdelete">`) {
		t.Error("delete is not behind a native disclosure")
	}
	if !strings.Contains(got, "<summary") {
		t.Error("the disclosure has no summary to open it")
	}
	// The form is inside the disclosure, so it cannot be submitted
	// until the reader has opened it.
	d := strings.Index(got, `<details class="chatdelete">`)
	f := strings.Index(got[d:], "<form")
	if f < 0 {
		t.Fatal("no delete form inside the disclosure")
	}
	if !strings.Contains(got, "confirm(") {
		t.Error("the pointer-user confirm was dropped")
	}
}

// TestEveryDatastarAttributeSurvivesTheParser is a guard against a bug
// with no visible symptom: a template comment written {{- ... -}}
// between two attributes eats the whitespace BETWEEN them, so
//
//	data-indicator="chatSending"data-on:submit__prevent="…"
//
// reaches the browser as ONE attribute whose name happens to end in a
// quote. The page renders, the styles apply, nothing throws — the
// behaviour is simply absent. It cost a live regression in the chat
// composer, where sending silently reverted to a full page reload.
//
// Parsing is what catches it: a string search for the attribute finds
// the glued text just as happily as the correct markup.
func TestEveryDatastarAttributeSurvivesTheParser(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", Live: true,
		ChatMsgs: []ChatMessage{{Seq: 1, Kind: "msg", User: "dan", Text: "hi", HTML: "hi"}}})
	doc, err := html.Parse(strings.NewReader(got))
	if err != nil {
		t.Fatal(err)
	}
	var walk func(*html.Node)
	seen := map[string]bool{}
	walk = func(n *html.Node) {
		for _, a := range n.Attr {
			// A well-formed name is letters, digits and the few
			// punctuation marks Datastar uses. A quote in it means two
			// attributes were run together.
			if strings.ContainsAny(a.Key, `"'<>=`) {
				t.Errorf("attribute name %q on <%s> — two attributes ran together, "+
					"almost certainly a {{- comment between them", a.Key, n.Data)
			}
			seen[a.Key] = true
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			walk(c)
		}
	}
	walk(doc)

	// The composer's submit handler is the one this was found by, so it
	// is named outright rather than left to the general scan.
	for _, want := range []string{"data-on:submit__prevent", "data-bind", "data-indicator"} {
		if !seen[want] {
			t.Errorf("the chat page carries no %s attribute the parser can see", want)
		}
	}
}

// The unread-count badge must ship HIDDEN. It reports a live signal, so
// the only honest state for a page whose script has not run — or whose
// script failed — is "nothing arrived"; an empty white pill is a wrong
// answer stated confidently. It showed exactly that on a phone when an
// unrelated expression threw and aborted the attribute walk before
// reaching it.
func TestChatCountBadgeShipsHidden(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", Live: true})
	i := strings.Index(got, `class="chat-jump-n"`)
	if i < 0 {
		t.Fatal("no count badge on the jump control")
	}
	tag := got[i:]
	if end := strings.Index(tag, ">"); end >= 0 {
		tag = tag[:end]
	}
	if !strings.Contains(tag, " hidden") {
		t.Errorf("the badge is not hidden in the markup: <span %s>", tag)
	}
}

// The permalink's job moved into the channel. What these pin is the
// fold-in itself: replying is the composer's band, arriving is ?at=,
// and only "gone" is still a page — because it has nowhere to send you.

// ?reply=N arms the composer with the message the next post answers,
// drawn as who-and-what — identicon, name, seq, one truncated line —
// the same shape the log gives a quote, because it produces one.
func TestReplyBandArmsTheComposer(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
		ChatReplyTo: ChatMessage{Seq: 44, Kind: "msg", User: "eve", Text: "ok, **cool**",
			HTML: template.HTML("<p>ok, <strong>cool</strong></p>")}})
	for _, want := range []string{
		`class="reply-band"`,
		`class="rb-name">eve<`,
		`class="rb-seq">#44<`,
		`class="rb-txt"><p>ok, <strong>cool</strong></p><`,
		`name="ref" value="44"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("the band is missing %q", want)
		}
	}
	// Who is an identicon, not a coloured name: the mark reads at a
	// glance and the band keeps no colour of its own.
	if !strings.Contains(got, `class="rb-idn"`) {
		t.Error("the band carries no identicon")
	}
	// It is a MODE, so it has an exit that works without script: the X
	// is a link back to the channel without ?reply.
	if !strings.Contains(got, `class="rb-x" href="/r/alpha/chat"`) {
		t.Error("the band has no scriptless way out")
	}
	// Without ?reply there is no band and no stray ref.
	plain := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if strings.Contains(plain, "reply-band") || strings.Contains(plain, `name="ref"`) {
		t.Error("an unarmed composer still carries reply machinery")
	}
}

// The reply icon on a message arms the channel's own composer rather
// than leaving for a page: you answer while still looking at the room.
func TestReplyIconArmsTheChannel(t *testing.T) {
	got := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Text: "hi", At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
	if !strings.Contains(got, `chatreplyto" href="/r/alpha/chat?reply=7"`) {
		t.Errorf("the reply icon does not arm the composer:\n%s", got)
	}
}

// An older page continues in both directions. Later is a plain link —
// no script — and the newest page draws nothing, because the stream
// appends there on its own.
func TestOlderPagesOfferLaterMessages(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
		ChatBefore: 40, ChatLaterCur: 39,
		ChatMsgs: []ChatMessage{{Seq: 39, Kind: "msg", User: "dan", Text: "x", HTML: template.HTML("x")}}})
	if !strings.Contains(got, `href="/r/alpha/chat?after=39"`) {
		t.Error("an older page offers no way forward")
	}
	latest := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if strings.Contains(latest, "?after=") {
		t.Error("the newest page draws a later control that leads nowhere")
	}
}

// Gone renders AT the permalink's URL — swept or purged, the same fact
// from outside — with the room's door one tap away. It is the only
// state that stays a page, because it has nothing to redirect to.
func TestGoneStateRendersInTheChannelShell(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatSeq: 19, ChatExpired: true})
	if !strings.Contains(got, `data-hook="chat-expired"`) {
		t.Fatal("no gone state")
	}
	if !strings.Contains(got, "Linked message <b>#19</b>") {
		t.Error("the gone state does not say which message was linked")
	}
	if !strings.Contains(got, `href="/r/alpha/chat"`) {
		t.Error("the gone state is a dead end")
	}
	if strings.Contains(got, `id="chatlog"`) {
		t.Error("the gone state draws a log it does not have")
	}
}

// "Open in the channel at this message" cannot be a fragment: the live
// view loads the newest page, so an older message's id is usually not
// on the page to scroll to. The server positions and marks it.
func TestChatOpensAtAMessage(t *testing.T) {
	d := pageData{Repo: "alpha", ChatViewer: "dan", ChatAt: 8,
		ChatMsgs: []ChatMessage{
			{Seq: 7, Kind: "msg", User: "dan", Text: "seven", HTML: template.HTML("seven")},
			{Seq: 8, Kind: "msg", User: "dan", Text: "eight", HTML: template.HTML("eight")}}}
	got := chatContent(t, d)
	if !strings.Contains(got, `data-seq=&quot;8&quot;`) {
		t.Errorf("the log does not open at the named message:\n%s", got[:min(len(got), 900)])
	}
	if !strings.Contains(got, `<li class="chat-anchor">`) {
		t.Error("the message opened at is not marked")
	}
	// Without ?at the log still opens at the unread divider.
	d.ChatAt = 0
	if got := chatContent(t, d); !strings.Contains(got, ".chat-divider") ||
		strings.Contains(got, `<li class="chat-anchor">`) {
		t.Error("an ordinary visit no longer opens at the divider")
	}
}

// One control, one shape. The older-page jump is a plain link and the
// live one is signal-driven, but both are the same caret in the same
// 2rem circle — drawing the older one as a sentence left the words
// spilling out of the circle and across the composer.
func TestJumpToLatestIsTheSameShapeOnBothPaths(t *testing.T) {
	older := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", ChatBefore: 40})
	if !strings.Contains(older, `class="chat-jump"`) {
		t.Fatal("an older page offers no way back to the latest")
	}
	if strings.Contains(older, "jump to latest &darr;") || strings.Contains(older, ">jump to latest<") {
		t.Error("the older-page jump is still drawn as words inside the circle")
	}
	if !strings.Contains(older, "#i-caret-down") {
		t.Error("the older-page jump carries no caret")
	}
	// The words survive as the accessible name, which is where a glyph
	// control's label belongs.
	if !strings.Contains(older, `aria-label="jump to the latest message"`) {
		t.Error("the older-page jump has no accessible name")
	}
}

// Two shapes, both true at once. On a desk — a pointer AND room — the
// actions float at the row's top-right on hover, the shape every desk
// client uses. Everywhere else they are a permanent row, because a
// phone has no hover and a control whose only signifier is a pointer
// event has no signifier there. What must never happen again is the
// hover shape leaking onto a device that cannot hover.
func TestMessageActionsFloatOnlyWhereThereIsAPointerAndRoom(t *testing.T) {
	desk := "@media (hover: hover) and (pointer: fine) and (min-width: 40.01rem) and (min-height: 32.01rem){"
	if !strings.Contains(sheet, desk) {
		t.Fatal("no desk query in the sheet")
	}
	i := strings.Index(sheet, ".chatlog .chat-toolbar{position:absolute")
	if i < 0 {
		t.Fatal("the desk does not float the actions")
	}
	// The floating rule must sit INSIDE the desk query: the nearest
	// media header above it is the desk one.
	if head := strings.LastIndex(sheet[:i], "@media"); head < 0 || !strings.HasPrefix(sheet[head:], desk) {
		t.Error("the floating toolbar is not scoped to the desk query")
	}
	block := sheet[i : i+strings.Index(sheet[i:], "\n}")]
	if !strings.Contains(block, "opacity:0;pointer-events:none") {
		t.Error("the desk toolbar is not hidden at rest")
	}
	// An open editor is full width IN FLOW, never trapped in the box.
	if !strings.Contains(block, ".chatmsg:has(.chatedit[open]) .chat-toolbar{position:static") {
		t.Error("an open editor stays inside the floating toolbar")
	}
	// The pointer-only query (no width guard) may dim but never hide:
	// it also matches a phone with a mouse, a stylus, a small window.
	j := strings.Index(sheet, "@media (hover: hover) and (pointer: fine){")
	pblock := sheet[j : j+400]
	for _, forbidden := range []string{"opacity:0;", "opacity:0}", "pointer-events:none", "display:none", "visibility:hidden"} {
		if strings.Contains(pblock, forbidden) {
			t.Errorf("the width-agnostic pointer query hides actions with %q", forbidden)
		}
	}
}

// The shell sheds its document habits at EVERY width: no page heading
// (the underlined tab already says it), no footer, no card border. The
// chrome-hiding and the chat bar stay phone-only — on a desk the top
// bar and repo tabs are navigation.
func TestDeskShellKeepsNavigationDropsDocumentHabits(t *testing.T) {
	for _, want := range []string{
		"body.app-shell>main>h1{display:none}",
		"body.app-shell>footer{display:none}",
		"body.app-shell .chatlog{flex:1;min-height:0;margin:0;border:0;border-radius:0;",
		"body.app-shell .chatwrap,body.app-shell .composer{width:100%;max-width:1080px;",
	} {
		if !strings.Contains(sheet, want) {
			t.Errorf("shell rule missing at all widths: %s", want)
		}
	}
	i := strings.Index(sheet, "@media (max-width: 40rem), (max-height: 32rem){")
	phone := sheet[i : i+strings.Index(sheet[i:], "\n}")]
	if !strings.Contains(phone, "body.app-shell>header,body.app-shell>.repobar{display:none}") {
		t.Error("the phone no longer hides the chrome")
	}
	if strings.Contains(sheet[:i], "body.app-shell>header,body.app-shell>.repobar{display:none}") {
		t.Error("the chrome is hidden outside the phone query — the desk needs its tabs")
	}
}

// Every action has to work on a touch screen, where there is no hover
// and no right-click. That rules out both of the mechanisms every other
// chat client uses, so each control is a link or a <details>: a tap
// does exactly what a click does, with no script in the path.
func TestMessageActionsWorkWithoutHoverOrScript(t *testing.T) {
	got := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Text: "mine", At: "2026-08-29T14:05:00Z"}, "alpha", "dan", true))

	// The overflow opens natively.
	if !strings.Contains(got, `<details class="chatmenu"`) {
		t.Error("the overflow is not a native disclosure")
	}
	// name= makes the menus one exclusive group, so a second tap
	// elsewhere closes the first — the "click outside to dismiss" that
	// otherwise costs a document listener.
	if !strings.Contains(got, `name="chatmenu"`) {
		t.Error("the menus are not an exclusive group, so taps leave a trail of open ones")
	}
	// Nothing in the row depends on script to be reachable: no button
	// whose only handler is a Datastar expression, no href="#".
	for _, dead := range []string{`href="#"`, `href="javascript:`} {
		if strings.Contains(got, dead) {
			t.Errorf("a control is inert without script: %q", dead)
		}
	}
	// In flow the panel cannot be clipped sideways — but on the LAST
	// message it grew past the log's visible bottom and nothing
	// scrolled to it, so it opened half off the edge. block:"nearest"
	// moves the log the least amount that shows the whole disclosure,
	// and by zero when it is already visible. With no script the
	// reader scrolls those few pixels themselves; the panel is still
	// in the log and still reachable, which is the difference between
	// a degradation and a break.
	if !strings.Contains(got, "data-on:toggle=") {
		t.Error("an opened menu does not scroll itself into view")
	}
	if !strings.Contains(got, "block: 'nearest'") {
		t.Error("the menu scrolls by more than it needs to")
	}

	// Delete stays two deliberate steps INSIDE the menu — three taps
	// for the one action nobody can undo.
	menu := got[strings.Index(got, `class="chatmenu-panel"`):]
	if !strings.Contains(menu, `<details class="chatdelete">`) {
		t.Error("delete is not behind the overflow")
	}
	if !strings.Contains(menu, "Delete for everyone") {
		t.Error("delete lost its confirmation step")
	}

	// Reply and edit are top-level icons: the common actions cost one
	// tap, not two.
	head := got[strings.Index(got, "chat-toolbar"):strings.Index(got, `class="chatmenu-panel"`)]
	for _, want := range []string{"#i-reply", "#i-pencil", "#i-more"} {
		if !strings.Contains(head, want) {
			t.Errorf("the toolbar is missing the %s control", want)
		}
	}
	// A glyph with no accessible name is a control a screen reader
	// cannot announce.
	if strings.Count(head, "aria-label=") < 3 {
		t.Errorf("an icon control has no accessible name:\n%s", head)
	}
}

// A tap target has to be a target. The glyph is 16px because that reads
// right beside the text; the box around it has to be a thumb's worth.
func TestActionIconsAreThumbSized(t *testing.T) {
	i := strings.Index(sheet, ".chat-icon-box{")
	if i < 0 {
		t.Fatal("no icon box rule in the sheet")
	}
	rule := sheet[i : i+strings.Index(sheet[i:], "}")]
	// 32, not 36: the box used to be 36 pulled back 9px at each end,
	// and that overhang painted outside the message's own padding and
	// over the rounded edge of the card below it. A box that sits in
	// its row cannot overhang anything.
	for _, want := range []string{"width:32px", "height:32px"} {
		if !strings.Contains(rule, want) {
			t.Errorf("the icon hit box is not thumb-sized, missing %q: %s", want, rule)
		}
	}
}

// Changing a <summary>'s display is a documented way to lose the
// disclosure's own behaviour in some engines. What sits behind these
// particular ones is an edit form and a delete confirmation — the
// two-step guard on the only irreversible action in the app — and the
// failure would be silent: the page would look right and the guard
// would be gone. This is a hazard guard, not a regression test; the
// rule costs nothing, because the box it would style can live on an
// inner span instead.
func TestNoRuleSetsDisplayOnASummary(t *testing.T) {
	// Every class the markup puts on a <summary>.
	onSummaries := []string{".chat-icon", ".chatmenu-item", ".chat-act"}
	for _, sel := range onSummaries {
		for i := 0; ; {
			j := strings.Index(sheet[i:], sel+"{")
			if j < 0 {
				break
			}
			i += j
			end := strings.Index(sheet[i:], "}")
			if end < 0 {
				break
			}
			rule := sheet[i : i+end]
			// Only the BARE class matters. "button.chatmenu-item" is a
			// selector no <summary> can match, so a display there is
			// both intentional and harmless.
			bare := i == 0 || !isSelectorChar(sheet[i-1])
			// A marker reset is the one display nobody minds.
			if bare && strings.Contains(rule, "display:") && !strings.Contains(rule, "::-webkit-details-marker") {
				t.Errorf("%s sets display, which disables every disclosure wearing it: %s", sel, rule)
			}
			i += end
		}
	}
}

// Two modes, and the DEFAULT must be the safe one. In flow the panel
// can never be clipped by the log's scroll box, which is what a page
// with no script gets. Floating is the enhancement — no layout shift —
// and it is gated on the log having said script is running.
func TestMenuPanelFloatsOnlyAsAnEnhancement(t *testing.T) {
	i := strings.Index(sheet, ".chatmenu-panel{")
	if i < 0 {
		t.Fatal("no menu panel rule in the sheet")
	}
	rule := sheet[i : i+strings.Index(sheet[i:], "}")]
	if strings.Contains(rule, "position:absolute") || strings.Contains(rule, "position:fixed") {
		t.Errorf("the panel floats by default, so a scriptless page can clip it: %s", rule)
	}
	// The enhanced rule exists, and is gated.
	j := strings.Index(sheet, ".chatlog.js .chatmenu-panel{")
	if j < 0 {
		t.Fatal("the floating panel is not offered where script is running")
	}
	if !strings.Contains(sheet[j:j+160], "position:absolute") {
		t.Error("the enhanced panel does not actually float")
	}
}

// The flag that gates it is a CLASS, never a data-* attribute: Datastar
// watches data-* and reads one back as a plugin of its own. A
// data-enhanced would become a plugin called "enhanced", which does not
// exist, and throw on every toggle — which is exactly how an earlier
// data-attr:data-show broke this page.
func TestScriptFlagIsAClassNotADataAttribute(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if !strings.Contains(got, "el.classList.add('js')") {
		t.Error("the log does not flag that script is running")
	}
	for _, trap := range []string{"data-attr:data-", "dataset.enhanced", `setAttribute('data-`} {
		if strings.Contains(got, trap) {
			t.Errorf("the page writes a data-* attribute Datastar will try to interpret: %q", trap)
		}
	}
}

func isSelectorChar(c byte) bool {
	return c == '.' || c == '#' || c == '-' || c == '_' ||
		(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}

// Pressing a control must not move the control. The icons used to be
// centred in the toolbar, so an opening panel — which makes the row
// taller — slid them downward; switching the alignment on open was a
// second shift stacked on the first. Top alignment is unconditional
// now: closed, every item is the same height and it looks identical;
// open, the icons cannot move, because nothing about their position
// depends on the row's height.
func TestOpeningAMenuDoesNotMoveTheIcons(t *testing.T) {
	i := strings.Index(sheet, ".chat-toolbar{")
	if i < 0 {
		t.Fatal("no toolbar rule in the sheet")
	}
	rule := sheet[i : i+strings.Index(sheet[i:], "}")]
	if !strings.Contains(rule, "align-items:flex-start") {
		t.Errorf("the toolbar does not pin its icons to the top: %s", rule)
	}
	// No rule may re-align the toolbar based on what is open — that is
	// the layout shift, not the cure for it.
	if strings.Contains(sheet, ".chat-toolbar:has(details[open]){align-items") {
		t.Error("the toolbar still changes its alignment when something opens")
	}
}

// Focusing the composer used to draw two rings at once: the sheet's
// global textarea:focus-visible outline inside — which a plain
// outline:none on the class loses to on specificity — and the panel's
// border turning accent outside. A box inside a glowing box, every
// time you tapped to type.
//
// A text field states its focus with a caret. What this pins is that
// the suppression is SCOPED: every other input in the forge keeps the
// ring it needs to say which of several it is.
func TestComposerHasNoFocusRing(t *testing.T) {
	if !strings.Contains(sheet, ".composer-in:focus,.composer-in:focus-visible{outline:none}") {
		t.Error("the composer still takes the global focus outline")
	}
	if strings.Contains(sheet, ".composer:focus-within{border-color") {
		t.Error("the composer panel still lights up on focus")
	}
	// The global ring survives for everything else.
	if !strings.Contains(sheet, "textarea:focus-visible { outline: 2px solid var(--muted)") {
		t.Error("the forge-wide focus ring was removed along with the composer's")
	}
}

// The linked message is ONE card. It used to draw an accent stripe down
// its left edge — beside the author rail every message already has —
// inside a tinted band, around a row that kept its own rounded corners
// and hover wash. Four edges and three surfaces for one message, which
// is what read as striated.
func TestLinkedMessageIsOneCardNotAStackOfEdges(t *testing.T) {
	i := strings.Index(sheet, ".chat-anchor{")
	if i < 0 {
		t.Fatal("no anchor rule in the sheet")
	}
	rule := sheet[i : i+strings.Index(sheet[i:], "}")]
	if strings.Contains(rule, "box-shadow:inset") {
		t.Errorf("the card still draws a stripe beside the author rail: %s", rule)
	}
	if !strings.Contains(rule, "border:1px solid") {
		t.Errorf("the card has no single border of its own: %s", rule)
	}
	// The message inside gives up the edges the card now carries.
	if !strings.Contains(sheet, ".chat-anchor>.chatmsg{border-left-color:transparent") {
		t.Error("the message inside the card keeps its own rail")
	}
	if !strings.Contains(sheet, ".chat-anchor>.chatmsg:hover,.chat-anchor>.chatmsg:focus-within{background:none}") {
		t.Error("the hover wash still fights the card's surface")
	}
}

// The channel and a permalink name the same room the same way. "Chat"
// was the tab's word, not the room's.
func TestChannelAndPermalinkAgreeOnTheName(t *testing.T) {
	if got := repoChatTitle("alpha"); got != "#alpha" {
		t.Errorf("repoChatTitle = %q, want #alpha", got)
	}
}

// ONE line, four things: attach at the left, a spring, preview, Send.
// The script-only preview SHIPS HIDDEN — drawn on a page with no
// script it is a button that does nothing, which teaches the reader
// not to press buttons. The attach button is disabled and says why:
// the row's shape is part of what a composer teaches, and attachments
// land with the zit forum verbs.
func TestComposerStripIsOneLine(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	for _, want := range []string{
		"#i-plus", "#i-eye", "#i-up",
		`class="composer-spring"`,
		"chat/preview.frag",
		`id="chat-preview" class="chat-preview" hidden`,
		`disabled title="Attachments land with durable archival"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("the strip is missing %q", want)
		}
	}
	// Everything the strip shed stays shed: the hint, the note, the
	// info circle, the mark-read control and its form.
	for _, gone := range []string{"composer-hint", "composer-note", "c-info",
		"chat-mark", `id="chat-read"`, "newline"} {
		if strings.Contains(got, gone) {
			t.Errorf("the strip still carries %q", gone)
		}
	}
	// One line means one line: nothing may wrap the bar.
	i := strings.Index(sheet, ".composer-bar{")
	rule := sheet[i : i+strings.Index(sheet[i:], "}")]
	if strings.Contains(rule, "flex-wrap") {
		t.Errorf("the bar can wrap onto a second line: %s", rule)
	}
}

// Where script runs, reaching the end of the log marks the channel
// read; the strip's button is the same verb for a page with no script.
// One data-effect carries both behaviours, because the effect plugin
// denies keyed attributes — a second data-effect:x would throw and
// abort the whole attribute walk.
func TestReadingToTheEndMarksRead(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if strings.Count(got, "data-effect=") != 1 {
		t.Fatalf("want exactly one data-effect, got %d", strings.Count(got, "data-effect="))
	}
	if strings.Contains(got, "data-effect:") {
		t.Error("a keyed data-effect will throw: the plugin denies keys")
	}
	if !strings.Contains(got, "$chatMarked != $chatTick") ||
		!strings.Contains(got, "/r/alpha/chat/read") {
		t.Error("reaching the end does not mark the channel read")
	}
	// Signed out, no auto-read: the POST would only bounce off the
	// capability gate, once per scroll.
	anon := chatContent(t, pageData{Repo: "alpha"})
	if strings.Contains(anon, "chat/read") {
		t.Error("an anonymous reader fires read posts it cannot make")
	}
}

// With no script the NEW divider would be a flag nobody could ever
// lower: reading-to-the-end needs script to observe. The check in the
// strip is the same verb by hand — shipped visible, hidden by CSS
// exactly where the automatic path exists, and drawn only while
// something is actually unread.
func TestNoScriptReadersCanLowerTheNewFlag(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", ChatHasNew: true})
	if !strings.Contains(got, `class="cbtn chat-read-btn" form="chat-read"`) ||
		!strings.Contains(got, "#i-check") {
		t.Error("no mark-read check in the strip")
	}
	if !strings.Contains(got, `id="chat-read" method="post" action="/r/alpha/chat/read"`) {
		t.Error("the check has no form to submit")
	}
	if !strings.Contains(sheet, "body:has(.chatlog.js) .chat-read-btn{display:none}") {
		t.Error("the check is not hidden where the automatic path exists")
	}
	// Nothing unread, nothing to lower.
	read := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if strings.Contains(read, "chat-read-btn") || strings.Contains(read, `id="chat-read"`) {
		t.Error("a fully-read channel still offers mark-read")
	}
}

// iOS zooms the whole page when a focused field computes under 16px,
// and the zoom sticks — the shell comes back letterboxed inside dark
// margins. The message editor sits inside .chatbody at 0.95rem, which
// is 15.2px: under the threshold. Every text field a thumb can focus
// must clear it.
func TestNoTextFieldTriggersIOSZoom(t *testing.T) {
	i := strings.Index(sheet, ".chatedit textarea{")
	if i < 0 {
		t.Fatal("no editor rule")
	}
	rule := sheet[i : i+strings.Index(sheet[i:], "}")]
	if !strings.Contains(rule, "font-size:max(16px, 1em)") {
		t.Errorf("the editor can compute under 16px and zoom the page: %s", rule)
	}
	// The composer inherits body's 16px; pin the floor so a future
	// body-size change cannot quietly re-arm the zoom.
	if !strings.Contains(sheet, "font: 16px/1.5 system-ui") {
		t.Error("body no longer anchors 16px — recheck every field against the iOS zoom threshold")
	}
}

// A reference to a message already on the page must not reload the
// page: the script scrolls to it and flashes it, and the conversation
// stays exactly where it was. Without the script the link still works,
// opening the channel around the message.
func TestReferenceOnPageJumpsWithoutReload(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	_ = got
	if !strings.Contains(string(chatJS), `log.querySelector('.chatmsg[data-seq="' + seq + '"]')`) {
		t.Error("the jump does not look the message up on the page")
	}
	if !strings.Contains(string(chatJS), "ev.preventDefault()") ||
		!strings.Contains(string(chatJS), "if (!row) return;") {
		t.Error("the link is not left to navigate when the message is off the page")
	}
	// Only this channel's own permalinks: a cross-repository reference
	// carries another repo in its path and must navigate.
	if !strings.Contains(string(chatJS), `href.indexOf(here) !== 0`) {
		t.Error("a reference into another channel could be mistaken for one here")
	}
	if !strings.Contains(sheet, ".chatmsg.chat-found{animation:chat-found") {
		t.Error("the found message does not flash")
	}
}

// Preview REPLACES the field. The field's own display:block outranks
// the UA's [hidden] rule, so without an explicit rule the attribute was
// set and the raw text stayed on screen above the rendered preview —
// the one state that shows the draft twice.
func TestPreviewHidesTheField(t *testing.T) {
	if !strings.Contains(sheet, ".composer-in[hidden]{display:none}") {
		t.Error("a hidden composer field still renders")
	}
}

// The desk rail: every channel the reader may open with its unread
// count, who is reading this one now, and the retention note — server
// rendered, so it is right with no script. Hidden on a phone by CSS;
// the phone's room is the whole screen.
func TestRailRendersChannelsAndPresence(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
		ChatRail: chatRail{
			Channels: []chatChannel{{Name: "alpha"}, {Name: "beta", Unread: 3}},
			Here:     []string{"dan", "eve"}}})
	for _, want := range []string{
		`class="chat-rail"`,
		`href="/r/alpha/chat"`, `href="/r/beta/chat"`,
		`class="rail-n">3<`,
		`id="chat-here"`, `<span>dan</span>`, `<span>eve</span>`,
		"Messages persist for 7 days",
	} {
		if !strings.Contains(got, want) {
			t.Errorf("the rail is missing %q", want)
		}
	}
	// The current channel is marked; the read one carries no badge.
	if !strings.Contains(got, `<li class="on"><a href="/r/alpha/chat"`) {
		t.Error("the current channel is not marked")
	}
	if strings.Contains(got, `class="rail-n">0<`) {
		t.Error("a read channel shows a zero badge")
	}
	// An empty room says so rather than drawing nothing.
	empty := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if !strings.Contains(empty, "chat-here-none") {
		t.Error("an empty room draws no list at all")
	}
	// The rail is desk-only, and the desk grid puts it in column 1.
	if !strings.Contains(sheet, ".chat-rail{display:none}") ||
		!strings.Contains(sheet, "body.app-shell .chat-rail{display:block;grid-column:1;grid-row:1 / -1") {
		t.Error("the rail is not desk-only, or not in the desk grid")
	}
}

// Presence is an open stream and nothing else: opening the stream is
// arriving, and the connection closing is leaving. The patch that
// follows a change re-renders the WHOLE list with the same function
// the page used, so the two cannot drift.
func TestStreamIsPresence(t *testing.T) {
	one := chatHereHTML([]string{"dan"})
	if !strings.Contains(string(one), `id="chat-here"`) || !strings.Contains(string(one), "<span>dan</span>") {
		t.Errorf("the presence list is not what the page renders: %s", one)
	}
}