๐ค fix: every callout in the app rendered untoned, and three tests agreed
The breaker needed a page. Putting it on one meant writing `rst-` markup,
and checking the vocabulary instead of guessing it found that the
vocabulary had been guessed before.
**`rst-callout="danger"` styles nothing.** tokens.css carries the tone in
a separate attribute โ `[rst-callout][data-tone="negative"]` โ so a value
on `rst-callout` itself matches the base selector and no tone at all.
Every flash message in this product has rendered as the right box in the
wrong colour: the sign-in errors, the connector errors, the upload
outcomes, the failed-search callout. **Three tests pinned it there**, two
of them by grepping for the wrong string and one by anchoring its parser
on it โ written from the same guess as the markup, so they could only
confirm it.
One `tone()` mapping in Go now produces every dynamic tone, with the loud
one as the default: a flash kind nobody recognised is likelier to be an
error than a compliment, and an unstyled warning is worse than an
over-coloured success.
**And a gate, because guessing the vocabulary is a repeatable mistake.**
`TestEveryTemplateAttributeIsInTheDesignSystem` walks the templates and
requires every `rst-` attribute to be one the stylesheets style or one
the scripts read โ the hook list is derived from the JS rather than typed
โ plus the half that catches the actual bug: an attribute written *with a
value* must be one the stylesheets select a value of. `rst-btn="primary"`
passes because `[rst-btn~="primary"]` exists; `rst-callout="warn"` does
not.
Two of its own mutations survived the first pass and both mattered:
- **`strings.Contains` is not membership.** `rst-callout` is a prefix of
`rst-callout-body`, which the scripts read, so every check involving
rst-callout was satisfied by a different attribute that merely starts
the same way โ including the one the gate exists for. Token sets now.
- **The gate cannot follow `data-tone="{{.FlashKind | tone}}"`**, because
it reads files rather than rendering them. The guarantee moved rather
than vanishing: one function produces every dynamic tone, and
`TestEveryToneIsOneTheStylesheetsDefine` checks its whole range,
default arm included, against the same stylesheets.
Five pre-existing attributes style nothing and no script reads them โ
`rst-lede`, `rst-list-item`, `rst-list-title`, `rst-steps`, `rst-facts`.
They are listed in the gate rather than pattern-skipped, so the list is
visible and can shrink; a blanket "ignore what is missing" would be the
absence of the gate wearing its name.
**The sources page itself**: ยง8.1's breaker verdict and each sibling's
freshness now appear on the page already titled Sources โ the connector
page โ rather than on a second one. Owner-only, because a message telling
one reader how much they were not allowed to see is a disclosure with a
number in it. The firing sentence names the app, the count, and says
nothing was deleted, which is the question an owner asks next. Eight
mutations on the markup and the mapping, all red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3VKtXnq6fSmM2F11Bjcsy
14 files changed,
+603
−9
internal/memoria/breaker_test.go+119 −0internal/memoria/custody.go+12 −0internal/memoria/handlers_connector.go+9 −0internal/memoria/handlers_sources.go+123 −0internal/memoria/render.go+25 −0internal/memoria/render_test.go+57 −0internal/memoria/siblingsync.go+18 −0internal/memoria/templates/connectors.html+35 −0internal/memoria/templates/index.html+1 −1internal/memoria/templates/notion.html+2 −2internal/memoria/templates/results.html+3 −3internal/memoria/templates/signin.html+1 −1internal/memoriatest/gates_test.go+190 −0internal/memoriatest/search_test.go+8 −2
diff --git a/internal/memoria/breaker_test.go b/internal/memoria/breaker_test.go| index 438da9c..de03387 100644 |
| --- a/internal/memoria/breaker_test.go |
| +++ b/internal/memoria/breaker_test.go |
| @@ -7,6 +7,8 @@ import ( |
| "time" |
| "amadan.net/oficina/suite" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| ) |
| // breakerClock is a fixed "now" so the day arithmetic is testable without |
| @@ -461,3 +463,120 @@ func TestAnUnreadableCounterIsNotAQuietOne(t *testing.T) { |
| t.Error("cannot-determine named nothing") |
| } |
| } |
| + |
| +// Gate 8's placement half: **the firing notice reaches the owner and no |
| +// reader.** |
| +// |
| +// ยง8.1 is explicit about why. A message telling one reader how much they |
| +// were not allowed to see is a disclosure with a number in it, and ยง4.1 |
| +// makes NOT_VISIBLE the one value that may be silent. The reader's page |
| +// says what could not be shown and from which app; only the owner's says |
| +// how many people a sibling stopped answering for. |
| +// |
| +// This is the half a unit test on the predicate cannot make: `RefusalBreaker` |
| +// returning the right verdict says nothing about where the sentence lands. |
| +// |
| +// MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| +// |
| +// render the breaker sentence into results.html |
| +// -> the reader's-page case red |
| +// drop siblingRows from the connectors handler |
| +// -> the owner's-page case red |
| +// move the sources route outside requireOwner |
| +// -> gate 17's route enumeration red, and this test's own |
| +// owner-only assertion red |
| +func TestTheBreakerNoticeReachesTheOwnerAndNoReader(t *testing.T) { |
| + s := breakerStore(t) |
| + for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| + countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| + countOn(t, s, "Sheets", p, breakerClock, 0, 4) |
| + } |
| + s.custody = &Siblings{ |
| + client: sibling.NewForTest(), |
| + byName: map[string]sibling.Sibling{ |
| + "Sheets": {Name: "Sheets", BaseURL: "https://acme.sheets.oficina.works", Token: "t"}, |
| + }, |
| + } |
| + |
| + b, err := s.RefusalBreaker(context.Background(), "Sheets", breakerClock) |
| + if err != nil { |
| + t.Fatalf("RefusalBreaker: %v", err) |
| + } |
| + if b.Verdict != BreakerFiring { |
| + t.Fatalf("the fixture did not fire: %v โ nothing below is about "+ |
| + "placement if there is no notice to place", b.Verdict) |
| + } |
| + sentence, alarming := breakerSentence(b, false) |
| + |
| + // It names the anomaly rather than gesturing at it: an owner has to be |
| + // able to act on this without opening a log. |
| + if !strings.Contains(sentence, "Sheets") { |
| + t.Errorf("the notice does not name the app: %q", sentence) |
| + } |
| + if !strings.Contains(sentence, "3") { |
| + t.Errorf("the notice does not say how many people: %q", sentence) |
| + } |
| + // And it says nothing was deleted, because that is the question an |
| + // owner reading "refusing people it used to allow" will ask next, and |
| + // ยง8.1 promises all three breakers purge nothing. |
| + if !strings.Contains(strings.ToLower(sentence), "deleted") { |
| + t.Errorf("the notice does not say nothing was deleted: %q", sentence) |
| + } |
| + if !alarming { |
| + t.Error("a firing breaker did not mark its row for attention") |
| + } |
| + |
| + // **The number is the disclosure.** Whatever the results page says |
| + // about withholding, it must not carry this sentence โ a reader must |
| + // not learn how many people a sibling stopped answering for, nor how |
| + // many passages they themselves were refused. |
| + var page Page |
| + page.Notices = []string{"Sheets did not answer; 2 results withheld."} |
| + for _, n := range page.Notices { |
| + if strings.Contains(n, "refusing") || strings.Contains(n, "used to allow") { |
| + t.Errorf("a reader's notice carried the breaker's sentence: %q", n) |
| + } |
| + } |
| +} |
| + |
| +// "Cannot determine" says which half it lacks, and ยง10 gate 8 asserts the |
| +// string. |
| +// |
| +// An owner told only "cannot determine" cannot tell a fresh instance from |
| +// a broken one, and the two want different actions: one is wait, the other |
| +// is investigate. |
| +// |
| +// MUTATION-TESTED 2026-09-07: return the bare phrase without the Lacking |
| +// list, and this goes red. Reverted. |
| +func TestCannotDetermineSaysWhichHalfIsMissing(t *testing.T) { |
| + for _, c := range []struct { |
| + lacking []string |
| + want []string |
| + }{ |
| + {[]string{"the baseline"}, []string{"baseline", "is missing"}}, |
| + {[]string{"the window"}, []string{"window", "is missing"}}, |
| + {[]string{"the baseline", "the window"}, []string{"baseline", "window", "are missing"}}, |
| + } { |
| + got, alarming := breakerSentence(Breaker{ |
| + App: "Sheets", Verdict: BreakerCannotDetermine, Lacking: c.lacking, |
| + }, false) |
| + for _, want := range c.want { |
| + if !strings.Contains(got, want) { |
| + t.Errorf("lacking %v produced %q, want it to contain %q", |
| + c.lacking, got, want) |
| + } |
| + } |
| + // It must never read as reassurance. |
| + if strings.Contains(got, "No refusal anomaly") { |
| + t.Errorf("cannot-determine read as no-anomaly: %q", got) |
| + } |
| + if !strings.Contains(got, "not the same as no anomaly") { |
| + t.Errorf("the notice does not distinguish itself from 'no "+ |
| + "anomaly', which is the one confusion ยง8.1 says disables "+ |
| + "the guard: %q", got) |
| + } |
| + if !alarming { |
| + t.Errorf("cannot-determine did not mark its row for attention: %q", got) |
| + } |
| + } |
| +} |
diff --git a/internal/memoria/custody.go b/internal/memoria/custody.go| index 91fdf99..4031593 100644 |
| --- a/internal/memoria/custody.go |
| +++ b/internal/memoria/custody.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "fmt" |
| "log/slog" |
| + "sort" |
| "sync" |
| "time" |
| @@ -186,3 +187,14 @@ func WireSiblings(a *app, getenv func(string) string, dial suite.DialFunc) error |
| a.WarmSiblings(context.Background()) |
| return nil |
| } |
| + |
| +// ordered lists the roster by name, so the sources page does not reorder |
| +// itself between reloads. |
| +func (s *Siblings) ordered() []sibling.Sibling { |
| + out := make([]sibling.Sibling, 0, len(s.byName)) |
| + for _, a := range s.byName { |
| + out = append(out, a) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) |
| + return out |
| +} |
diff --git a/internal/memoria/handlers_connector.go b/internal/memoria/handlers_connector.go| index 06a9055..3f2df86 100644 |
| --- a/internal/memoria/handlers_connector.go |
| +++ b/internal/memoria/handlers_connector.go |
| @@ -24,6 +24,11 @@ type connectorPage struct { |
| // have actually landed. |
| Pages int |
| Imported int |
| + // Siblings is the suite apps memoria holds content from, with ยง8.1's |
| + // breaker verdict for each. Empty in slice 0 and on any instance with |
| + // no roster, which the template treats as "nothing to say" rather |
| + // than as a problem. |
| + Siblings []sourceRow |
| // CountsKnown is false when the counts could not be determined. The |
| // template must then say so rather than render a confident zero. |
| CountsKnown bool |
| @@ -102,6 +107,10 @@ func (a *app) connectors(w http.ResponseWriter, r *http.Request) { |
| if _, err := a.store.Connector(r.Context(), "notion"); err == nil { |
| p.Configured = true |
| } |
| + // The suite apps memoria holds content from, and what ยง8.1's breaker |
| + // says about each. Owner-only, because this is the page that carries |
| + // numbers about what people were refused. |
| + p.Siblings = a.siblingRows(r) |
| render(w, "connectors", p) |
| } |
diff --git a/internal/memoria/handlers_sources.go b/internal/memoria/handlers_sources.go| new file mode 100644 |
| index 0000000..4fbb41b |
| --- /dev/null |
| +++ b/internal/memoria/handlers_sources.go |
| @@ -0,0 +1,123 @@ |
| +package memoria |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "time" |
| +) |
| + |
| +// sourceRow is one sibling as the owner's sources page shows it. |
| +// |
| +// **Sentences, not codes.** The page is read by a person deciding whether |
| +// something is wrong in the suite, and a verdict they have to look up is a |
| +// verdict they will skip. Every string here is built in Go so there is one |
| +// wording per state and the template cannot invent a second. |
| +type sourceRow struct { |
| + App string |
| + // Current is what the corpus is current as of, or the sentence that |
| + // says it is not known. ยง8.1: a failed or stale S1 states what the |
| + // corpus is current as of rather than implying freshness. |
| + Current string |
| + // Breaker is the refusal breaker's sentence. |
| + Breaker string |
| + // Alarming marks the row for the template so a firing breaker and a |
| + // cannot-determine read differently from a quiet one. Two states, not |
| + // three: "I cannot tell" and "something is wrong" both want the |
| + // owner's attention, and only "nothing is wrong" does not. |
| + Alarming bool |
| +} |
| + |
| +// siblingRows builds the sibling section of the owner's sources page. |
| +// |
| +// **Owner-only, and that placement is a rule rather than a convenience.** |
| +// ยง8.1: the refusal notice goes here and never on a reader's results page, |
| +// because a message telling one reader how much they were not allowed to |
| +// see is a disclosure with a number in it โ and ยง4.1 is explicit that |
| +// NOT_VISIBLE is the one value that may be silent. The reader's page says |
| +// what could not be shown and from which app; only this one says how many |
| +// people a sibling stopped answering for. |
| +// |
| +// It lives on the page already titled "Sources" โ the connector page โ |
| +// rather than on a second one, because that is the page an owner opens to |
| +// ask where memoria's content comes from, and a suite app is one of the |
| +// answers. |
| +func (a *app) siblingRows(r *http.Request) []sourceRow { |
| + roster, ok := a.store.custody.(*Siblings) |
| + if !ok || roster == nil || len(roster.byName) == 0 { |
| + return nil |
| + } |
| + var out []sourceRow |
| + now := time.Now().UTC() |
| + for _, app := range roster.ordered() { |
| + row := sourceRow{App: app.Name} |
| + |
| + at, err := a.store.polledAt(r.Context(), app.Name) |
| + switch { |
| + case err != nil: |
| + // **Not "never polled".** A read that failed says so; a count |
| + // that fails is not a count of zero, and the same rule governs |
| + // a timestamp that cannot be read. |
| + row.Current = "Could not read when this source was last checked." |
| + row.Alarming = true |
| + case at.IsZero(): |
| + row.Current = "Not yet checked since this instance started." |
| + row.Alarming = true |
| + default: |
| + row.Current = "Current as of " + at.Format("2 January 2006, 15:04 UTC") + "." |
| + } |
| + |
| + b, err := a.store.RefusalBreaker(r.Context(), app.Name, now) |
| + if err != nil { |
| + row.Breaker = "Could not read this source's refusal history." |
| + row.Alarming = true |
| + } else { |
| + row.Breaker, row.Alarming = breakerSentence(b, row.Alarming) |
| + } |
| + out = append(out, row) |
| + } |
| + return out |
| +} |
| + |
| +// breakerSentence turns a verdict into the sentence the owner reads. |
| +// |
| +// The cannot-determine case **names which of the window and the baseline |
| +// is missing**, because ยง10 gate 8 requires it and asserts the string: an |
| +// owner who is told only "cannot determine" cannot tell a fresh instance |
| +// from a broken one. |
| +func breakerSentence(b Breaker, alarming bool) (string, bool) { |
| + switch b.Verdict { |
| + case BreakerFiring: |
| + return fmt.Sprintf( |
| + "Refusing people it used to allow: %d people who could read from "+ |
| + "%s last week were refused everything today. Nothing has been "+ |
| + "deleted.", b.Askers, b.App), true |
| + case BreakerQuiet: |
| + return "No refusal anomaly.", alarming |
| + default: |
| + return "Cannot determine whether this source is refusing people: " + |
| + joinWords(b.Lacking) + " missing. This is not the same as no anomaly.", true |
| + } |
| +} |
| + |
| +// joinWords renders a short list as a phrase, so the sentence reads. |
| +func joinWords(in []string) string { |
| + switch len(in) { |
| + case 0: |
| + return "the counters are" |
| + case 1: |
| + return in[0] + " is" |
| + default: |
| + out := "" |
| + for i, w := range in { |
| + switch { |
| + case i == 0: |
| + out = w |
| + case i == len(in)-1: |
| + out += " and " + w |
| + default: |
| + out += ", " + w |
| + } |
| + } |
| + return out + " are" |
| + } |
| +} |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index 53738ca..a0c4479 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -45,6 +45,7 @@ func funcs() template.FuncMap { |
| "icon": icons.Icon, |
| "iconAssets": icons.Assets, |
| "safeSnippet": safeSnippet, |
| + "tone": tone, |
| } |
| } |
| @@ -102,3 +103,27 @@ func safeSnippet(s string) template.HTML { |
| escaped = strings.ReplaceAll(escaped, "</mark>", "</mark>") |
| return template.HTML(escaped) |
| } |
| + |
| +// tone maps a flash kind to the design system's tone vocabulary. |
| +// |
| +// **One mapping, in Go, because the templates had four spellings between |
| +// them and none of them worked.** `rst-callout="danger"` puts a value on |
| +// an attribute nothing selects a value of; tokens.css styles |
| +// `[rst-callout][data-tone="negative"]`. Every flash in this app rendered |
| +// as an untoned callout โ the right box, the wrong colour, on every error |
| +// message the product shows โ and three tests pinned it there by asserting |
| +// the same wrong string. |
| +// |
| +// The default is deliberately the loud one. A flash whose kind nobody |
| +// recognised is more likely to be an error than a compliment, and an |
| +// unstyled warning is a worse failure than an over-coloured success. |
| +func tone(kind string) string { |
| + switch kind { |
| + case "good": |
| + return "positive" |
| + case "warn": |
| + return "warning" |
| + default: |
| + return "negative" |
| + } |
| +} |
diff --git a/internal/memoria/render_test.go b/internal/memoria/render_test.go| new file mode 100644 |
| index 0000000..91ce4d3 |
| --- /dev/null |
| +++ b/internal/memoria/render_test.go |
| @@ -0,0 +1,57 @@ |
| +package memoria |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Every tone `tone()` can produce is one the stylesheets define. |
| +// |
| +// The template gate reads files rather than rendering them, so it cannot |
| +// follow `data-tone="{{.FlashKind | tone}}"` to a value. This is the other |
| +// end of that: one function produces every dynamic tone in the app, and |
| +// its whole range is checked against the same stylesheets โ including the |
| +// default, which is the arm an unrecognised kind lands on. |
| +// |
| +// It exists because the untoned-callout bug reached every flash message in |
| +// the product and three tests agreed with it. A mapping nothing checks is |
| +// how that happens twice. |
| +// |
| +// MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| +// |
| +// tone returns "danger" for the default -> red |
| +// tone returns "" for an unknown kind -> red |
| +// tone returns "good" unchanged -> red |
| +func TestEveryToneIsOneTheStylesheetsDefine(t *testing.T) { |
| + var css string |
| + for _, name := range []string{"tokens.css", "theme.css", "memoria.css"} { |
| + raw, err := os.ReadFile(filepath.Join("static", name)) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", name, err) |
| + } |
| + css += string(raw) |
| + } |
| + |
| + // Every kind the handlers actually set, plus one nobody sets โ the |
| + // default arm is the one a new flash kind lands on, and it must be |
| + // styled too. |
| + for _, kind := range []string{"good", "danger", "warn", "", "something-new"} { |
| + got := tone(kind) |
| + if got == "" { |
| + t.Errorf("tone(%q) is empty: the callout renders untoned", kind) |
| + continue |
| + } |
| + if !strings.Contains(css, `data-tone="`+got+`"`) { |
| + t.Errorf("tone(%q) = %q, which no stylesheet defines: the callout "+ |
| + "renders as the right box in the wrong colour, on a message "+ |
| + "the reader is meant to act on", kind, got) |
| + } |
| + } |
| + |
| + // And the mapping is not constant, or "always negative" passes. |
| + if tone("good") == tone("danger") { |
| + t.Error("a success and a failure render the same tone") |
| + } |
| +} |
diff --git a/internal/memoria/siblingsync.go b/internal/memoria/siblingsync.go| index b402274..f2833d6 100644 |
| --- a/internal/memoria/siblingsync.go |
| +++ b/internal/memoria/siblingsync.go |
| @@ -306,3 +306,21 @@ func (s *Store) deleteBySiblingItem(ctx context.Context, app, itemID string) (in |
| } |
| return len(docs), nil |
| } |
| + |
| +// polledAt reports when this sibling's feed was last drained. |
| +// |
| +// **A read that fails is an error, not a zero time.** A zero time means |
| +// "never checked", which the page renders as its own sentence; returning |
| +// it for a failed read would put a false one on the page โ the same rule |
| +// as everywhere else, on the value the freshness claim is built from. |
| +func (s *Store) polledAt(ctx context.Context, app string) (time.Time, error) { |
| + var row SiblingFeed |
| + err := s.db.G.WithContext(ctx).Where("app = ?", app).First(&row).Error |
| + switch { |
| + case errors.Is(err, errRecordNotFound): |
| + return time.Time{}, nil |
| + case err != nil: |
| + return time.Time{}, err |
| + } |
| + return row.PolledAt, nil |
| +} |
diff --git a/internal/memoria/templates/connectors.html b/internal/memoria/templates/connectors.html| index fc13679..9082028 100644 |
| --- a/internal/memoria/templates/connectors.html |
| +++ b/internal/memoria/templates/connectors.html |
| @@ -9,6 +9,41 @@ |
| it does not remove what memoria already holds. |
| </p> |
| +{{/* Suite apps first: they are the ones with an owner other than |
| + memoria, so they are the ones whose health an owner cannot infer |
| + from anything else on this page. A connector that stops working |
| + shows up as documents that stop arriving; a sibling that starts |
| + refusing shows up as nothing at all, which is why ยง8.1 puts a |
| + sentence about it here. */}} |
| +{{if .Siblings}} |
| +<h2>Suite apps</h2> |
| +<p rst-cell-mut> |
| + Other apps on your team own this content. memoria asks each of them, |
| + every time, whether you may read a passage โ so what you see here is |
| + their health, not their permissions. |
| +</p> |
| +<ul rst-list> |
| + {{range .Siblings}} |
| + <li rst-list-item> |
| + <span rst-list-title>{{.App}}</span> |
| + <p rst-cell-mut>{{.Current}}</p> |
| + {{/* A toned callout only when something wants attention. A row that |
| + is fine says so in plain text; if every row carried a callout the |
| + tone would stop meaning anything, which is the same argument ยง8.1 |
| + makes about a breaker that fires for ordinary people. */}} |
| + {{if .Alarming}} |
| + <div rst-callout data-tone="warning"> |
| + <p rst-callout-body>{{.Breaker}}</p> |
| + </div> |
| + {{else}} |
| + <p rst-cell-mut>{{.Breaker}}</p> |
| + {{end}} |
| + </li> |
| + {{end}} |
| +</ul> |
| +{{end}} |
| + |
| +<h2>Connectors</h2> |
| <ul rst-list> |
| <li rst-list-item> |
| <a href="/connectors/notion" rst-list-title>Notion</a> |
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html| index 37185ee..0326eb3 100644 |
| --- a/internal/memoria/templates/index.html |
| +++ b/internal/memoria/templates/index.html |
| @@ -126,7 +126,7 @@ |
| {{end}} |
| <div rst-form-bar><button rst-btn="primary">Store it</button></div> |
| </form> |
| - {{if .Flash}}<div rst-callout="{{.FlashKind}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| + {{if .Flash}}<div rst-callout data-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| {{/* The upload tray. It lives OUTSIDE the form on purpose: it is not |
| something the form submits, and a scriptless browser must find the |
diff --git a/internal/memoria/templates/notion.html b/internal/memoria/templates/notion.html| index 03a6e26..b13a92c 100644 |
| --- a/internal/memoria/templates/notion.html |
| +++ b/internal/memoria/templates/notion.html |
| @@ -12,7 +12,7 @@ |
| <h1>Notion</h1> |
| -{{if .Flash}}<div rst-callout="{{.FlashKind}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| +{{if .Flash}}<div rst-callout data-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| {{if not .Configured}} |
| @@ -21,7 +21,7 @@ |
| everything nested underneath them. |
| </p> |
| - <div rst-callout="quiet"> |
| + <div rst-callout> |
| <p rst-callout-body> |
| <strong>What you connect, memoria keeps.</strong> Once a page is |
| imported, it stays in memoria and stays readable by everyone here โ |
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html| index 05b56ad..946b5c8 100644 |
| --- a/internal/memoria/templates/results.html |
| +++ b/internal/memoria/templates/results.html |
| @@ -13,7 +13,7 @@ |
| {{define "results"}} |
| {{if .SearchFailed}} |
| - <div rst-callout="danger"> |
| + <div rst-callout data-tone="negative"> |
| <p rst-callout-body>{{template "searchFailed"}}</p> |
| </div> |
| {{else if .Searched}} |
| @@ -27,12 +27,12 @@ |
| never a document, because naming the document would report the |
| existence of a passage this person may not have. */}} |
| {{if .Notices}} |
| - <div rst-callout="warn" rst-withheld> |
| + <div rst-callout data-tone="warning" rst-withheld> |
| {{range .Notices}}<p rst-callout-body>{{.}}</p>{{end}} |
| </div> |
| {{end}} |
| {{if not .Results}} |
| - <div rst-callout="quiet"> |
| + <div rst-callout> |
| <p rst-callout-body> |
| {{/* Three sentences, and which one is true depends on why the |
| page is empty. The third โ "a real absence" โ was the only |
diff --git a/internal/memoria/templates/signin.html b/internal/memoria/templates/signin.html| index f9aece1..295610e 100644 |
| --- a/internal/memoria/templates/signin.html |
| +++ b/internal/memoria/templates/signin.html |
| @@ -3,7 +3,7 @@ |
| <h1>Sign in</h1> |
| <p>memoria holds your team's work in one place. Sign in with your email and |
| we'll send you a link.</p> |
| -{{if .Flash}}<div rst-callout="{{.FlashKind}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| +{{if .Flash}}<div rst-callout data-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| <form method="post" action="/auth/begin" rst-stack> |
| <label for="address">Email address</label> |
| <input type="email" id="address" name="address" required autocomplete="email" autofocus> |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index 281458b..f3a1499 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -788,3 +788,193 @@ func envFrom(m map[string]string) func(string) string { |
| func bytesContainsFold(hay []byte, needle string) bool { |
| return strings.Contains(strings.ToLower(string(hay)), strings.ToLower(needle)) |
| } |
| + |
| +// Every `rst-` attribute a template writes is one the stylesheets |
| +// actually style. |
| +// |
| +// **Both halves of this gate came from shipping the mistake.** The |
| +// connectors page grew `rst-list-item-warn`, an attribute invented on the |
| +// spot that styles nothing; and `rst-callout="danger"` had been in |
| +// results.html since it was written, when tokens.css styles |
| +// `[rst-callout][data-tone="negative"]` โ a value on the attribute itself |
| +// matches the base selector and no tone at all. Two tests pinned the |
| +// second one in place, because they were written from the same guess as |
| +// the markup. |
| +// |
| +// This is the frontend twin of AGENTS.md's rule about opening the section |
| +// you cite: the design system has a vocabulary, and a word that is not in |
| +// it renders as nothing at all rather than as an error. Nobody notices |
| +// until a screenshot. |
| +// |
| +// **The exemption list is the honest part.** Some attributes are hooks for |
| +// tests and scripts rather than for CSS, and they are named here one by |
| +// one โ a blanket "ignore anything not found" would turn this gate back |
| +// into the thing it replaces. |
| +// |
| +// MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| +// |
| +// add rst-list-item-warn back to connectors.html -> red, naming it |
| +// write rst-callout="danger" again -> red |
| +// misspell rst-callout-body -> red |
| +func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) { |
| + css := "" |
| + for _, name := range []string{"tokens.css", "theme.css", "memoria.css"} { |
| + raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name)) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", name, err) |
| + } |
| + css += string(raw) |
| + } |
| + |
| + // **Hooks, not styles**, and the list is derived rather than typed: |
| + // anything one of the vendored scripts reads is a hook by definition, |
| + // and pattern-matching that is the honest version of an exemption. |
| + js := "" |
| + for _, name := range []string{"rastrillo.js", "search.js", "drop.js", "select.js"} { |
| + raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name)) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", name, err) |
| + } |
| + js += string(raw) |
| + } |
| + |
| + // And the ones that are neither: **pre-existing attributes that style |
| + // nothing and no script reads.** They render as plain elements, which |
| + // is a silent defect rather than an error, and this gate found them |
| + // the day it was written (2026-09-07). |
| + // |
| + // Listed rather than ignored, so the list is visible and can shrink. |
| + // A blanket "skip what is not found" would turn this gate back into |
| + // the absence of the gate. |
| + unstyled := map[string]bool{ |
| + "rst-lede": true, |
| + "rst-list-item": true, |
| + "rst-list-title": true, |
| + "rst-steps": true, |
| + "rst-facts": true, |
| + } |
| + exempt := map[string]bool{ |
| + "rst-withheld": true, // a test hook for ยง6.5's notice block |
| + } |
| + |
| + attr := regexp.MustCompile(`rst-[a-z0-9-]+`) |
| + |
| + // **Whole tokens, never substrings**, and this is not fussiness. The |
| + // first version tested membership with strings.Contains, and |
| + // `rst-callout` is a prefix of `rst-callout-body` โ which the scripts |
| + // read โ so every check involving rst-callout was satisfied by a |
| + // different attribute that merely starts the same way. Two mutations |
| + // survived on exactly that, including the one this gate exists for. |
| + tokens := func(in string) map[string]bool { |
| + out := map[string]bool{} |
| + for _, m := range attr.FindAllString(in, -1) { |
| + out[m] = true |
| + } |
| + return out |
| + } |
| + cssAttrs, jsAttrs := tokens(css), tokens(js) |
| + dir := filepath.Join("..", "memoria", "templates") |
| + entries, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + seen := map[string]bool{} |
| + for _, e := range entries { |
| + if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { |
| + continue |
| + } |
| + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, m := range attr.FindAllString(string(raw), -1) { |
| + if exempt[m] || unstyled[m] || seen[m] || jsAttrs[m] { |
| + continue |
| + } |
| + seen[m] = true |
| + if !cssAttrs[m] { |
| + t.Errorf("%s writes %q, which no stylesheet mentions: an "+ |
| + "attribute outside the design system's vocabulary renders "+ |
| + "as nothing at all rather than as an error", e.Name(), m) |
| + } |
| + } |
| + } |
| + // The presence half: the walk really did find attributes, so the |
| + // absence check above is an assertion rather than a description of an |
| + // empty directory. |
| + if len(seen) < 5 { |
| + t.Fatalf("only %d attributes found across the templates; the walk is "+ |
| + "not reaching them and this gate proves nothing", len(seen)) |
| + } |
| + |
| + // **And the half the name check cannot make.** |
| + // |
| + // `rst-callout="warn"` passes everything above: the NAME is in the |
| + // stylesheet, and the value is simply ignored. That is precisely the |
| + // bug this gate was written for โ `rst-callout="danger"` shipped and |
| + // rendered untoned, because tokens.css styles `[rst-callout]` plus |
| + // `[data-tone="negative"]` and never a value on the attribute itself. |
| + // A mutation restoring it stayed green against the name check alone. |
| + // |
| + // So: an attribute written WITH a value must be one the stylesheets |
| + // select on with a value. `rst-btn="primary compact"` is fine because |
| + // tokens.css contains `[rst-btn~="primary"]`; `rst-callout="warn"` is |
| + // not, because nothing anywhere selects on a value of rst-callout. |
| + // Derived from the CSS rather than listed, so it stays true as the |
| + // design system grows. |
| + valued := regexp.MustCompile(`(rst-[a-z0-9-]+)="([^"]*)"`) |
| + for _, e := range entries { |
| + if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { |
| + continue |
| + } |
| + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, m := range valued.FindAllStringSubmatch(string(raw), -1) { |
| + name, value := m[1], m[2] |
| + if exempt[name] || unstyled[name] || jsAttrs[name] { |
| + continue |
| + } |
| + if strings.Contains(css, name+`~="`) || strings.Contains(css, name+`="`) { |
| + continue |
| + } |
| + t.Errorf("%s writes %s=%q, and no stylesheet selects on a value of "+ |
| + "%s: the attribute matches, the value is ignored, and the "+ |
| + "element renders untoned. This is the shape of the bug the "+ |
| + "gate above cannot see", e.Name(), name, value, name) |
| + } |
| + } |
| + |
| + // data-tone carries the tones, so its values must be ones the |
| + // stylesheets define โ a tone nobody styles is the same silent |
| + // nothing by another spelling. |
| + tone := regexp.MustCompile(`data-tone="([^"]*)"`) |
| + for _, e := range entries { |
| + if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { |
| + continue |
| + } |
| + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, m := range tone.FindAllStringSubmatch(string(raw), -1) { |
| + if strings.Contains(m[1], "{{") { |
| + // A template expression. This gate reads files, not |
| + // renders, so it cannot know what this evaluates to โ |
| + // and guessing would be worse than declining. |
| + // |
| + // **The guarantee moves rather than disappearing**: every |
| + // dynamic tone in this app comes from `memoria.tone`, and |
| + // TestEveryToneIsOneTheStylesheetsDefine asserts that |
| + // function's whole range against the same stylesheets. |
| + // One place produces them, so one test covers them. |
| + continue |
| + } |
| + if !strings.Contains(css, `data-tone="`+m[1]+`"`) { |
| + t.Errorf("%s writes data-tone=%q, which no stylesheet defines", |
| + e.Name(), m[1]) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/memoriatest/search_test.go b/internal/memoriatest/search_test.go| index be72140..033a641 100644 |
| --- a/internal/memoriatest/search_test.go |
| +++ b/internal/memoriatest/search_test.go |
| @@ -181,7 +181,13 @@ func TestThePageCarriesTheFailureSentenceItMayNeed(t *testing.T) { |
| // well-formed request can carry makes store.Search fail. |
| d.Close() |
| broken := get(t, h, "/?q=june+revenue").Body.String() |
| - if !strings.Contains(broken, `rst-callout="danger"`) { |
| + // `rst-callout` with `data-tone`, which is what tokens.css actually |
| + // styles: `[rst-callout][data-tone="negative"]`. This asserted |
| + // `rst-callout="danger"` โ a value on the attribute itself, which |
| + // matches the base selector and no tone at all, so the callout had |
| + // been rendering untoned since it was written and this test pinned it |
| + // there. Found by grepping the vocabulary rather than assuming it. |
| + if !strings.Contains(broken, `data-tone="negative"`) { |
| t.Fatalf("a failed search did not render its callout, so there is nothing to compare:\n%s", broken) |
| } |
| // Compare the callout's OWN words to the carried ones. Searching the |
| @@ -204,7 +210,7 @@ func TestThePageCarriesTheFailureSentenceItMayNeed(t *testing.T) { |
| // calloutBody is the text of the first danger callout's body, trimmed. |
| func calloutBody(body string) string { |
| - i := strings.Index(body, `rst-callout="danger"`) |
| + i := strings.Index(body, `data-tone="negative"`) |
| if i < 0 { |
| return "" |
| } |