oficina / estilo Public

Merge branch 'feat/home-connection' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 9873c648660c00bd12e2ddfa8c5eb3b924b49be7 parent ad0edf4parent 9fbeff9
20 files changed, +490 −79
  • .gitignore +1 −0
  • AGENTS.md +10 −13
  • README.md +20 −62
  • go.mod +2 −2
  • go.sum +2 −0
  • homeconnect/README.md +14 −0
  • homeconnect/templates/home_link.html +9 −0
  • homeconnect/templates/oauth.html +5 −0
  • homeconnect/view.go +17 −0
  • homeconnect/view_test.go +56 −0
  • implementation/save-status.md +114 −0
  • savestatus/doc.go +3 −0
  • savestatus/status.css +41 −0
  • savestatus/status.go +52 −0
  • savestatus/status.html +1 −0
  • savestatus/status.mjs +13 −0
  • savestatus/status.test.mjs +51 −0
  • savestatus/status_test.go +70 −0
  • theme/estilo.css +8 −1
  • theme/theme_test.go +1 −1

CI passed — run details

diff --git a/.gitignore b/.gitignore
index 1181530..3ba2e8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@
.claude/launch.json
.claude/settings.local.json
.superpowers/
+writing/
diff --git a/AGENTS.md b/AGENTS.md
index 52b1aaa..dabbd87 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,10 +45,11 @@ empty repo invites the opposite.)
|---|---|---|---|
| 1 | **Shared suite components live here, not in rastrillo's `ui`** | Proposing them to the framework | Paul, 2026-08-30 |
| 2 | **A separate repo, created before any primitive is written** | App-local until a second consumer; a nested module inside an app | Paul, 2026-08-30 |
-| 3 | **No Go code until the next rastrillo release** — §Dependency | Pinning a `main` pseudo-version | This session, 2026-08-30 |
+| 3 | **Resolved: use released Rastrillo v0.23.0** — §Dependency | Pinning a `main` pseudo-version | This session, 2026-08-30 |
| 4 | **Estilo's own components are written in the bare-attribute grammar, and estilo defines their styling** — §Vocabulary | `class="rst-*"` (right for an app consuming rastrillo's CSS, wrong for a library shipping its own) | Paul, 2026-08-30 |
| 5 | **The colour engine stays in rastrillo's `ui`** | Moving `Pair` and allocation here | ui-guidelines-52, 2026-08-30 |
| 6 | **Estilo's theme is `day`, unchanged — not "derived from" or "based on" it. Divergence is allowed but must be driven by a named need, not a preference** — §Theme | Restyling on the strength of "day as a base"; changing a token because it "would look better" | Paul, 2026-08-31 |
+| 7 | **Private module path is `amadan.net/oficina/estilo.git`**, using authenticated Git and `GOPRIVATE` on every builder; checked consumer `go.sum` records integrity. Preserve this path when discovery support changes. | Public visibility solely to obtain Go metadata; local filesystem replaces; a later cosmetic module rename | Overnight coordinator, 2026-09-07; Claude Fable 5.1 branch review |
**Two callers at design time is the bar for *extraction*. It says nothing
about *destination*.** Both consuming apps were told to propose their
@@ -95,17 +96,10 @@ get around it.
## Dependency — RULED, and check it before adding an import
-Estilo needs `ui.ThemeCSS`, `ThemeNames`, `Layout`, `LayoutNames`. Verified
-2026-08-30 at source:
-
-- **`v0.19.0`: none of them.** `main`: all four. `main` is 19 commits ahead.
-- **`Pair` and the colour-allocation entry point: in no tree.** Specified,
- unwritten. Every consumer keeps a local shim behind the real signature
- until they ship.
-
-**We wait for the release rather than pinning a pseudo-version.** Three apps
-pin estilo; estilo pinning unreleased code makes a four-deep chain resting
-on a moving target, and the apps inherit it without ever choosing it.
+Rastrillo v0.23.0 supplies the theme and layout APIs Estilo needs. The earlier
+wait for a release is resolved; this module pins that released version.
+Use authenticated Git and `GOPRIVATE=amadan.net/oficina/estilo.git` for the
+private Estilo module. Consumer go.sum files record its integrity.
**The two-pins trap.** Estilo pins rastrillo; the apps pin estilo. That is
the frozen-`tokens.css` problem one layer down — a component library
@@ -122,6 +116,9 @@ two-pin case and it is ours.
(`<div rst-inspector>`, variants in the value matched with `~=`), and
**estilo ships their styling.**
+**Historical context:** Rastrillo v0.23.0 now ships attribute grammar. The
+following paragraphs record the earlier decision, before that release.
+
**Read this before "correcting" it, because the surrounding conversation has
said both things this week and the right answer depends on which repo you
are in.**
@@ -146,7 +143,7 @@ not where it waits.
|---|---|---|
| Estilo's own components | Attribute grammar | Estilo |
| An app's own markup, using estilo components | Attribute grammar | Estilo |
-| An app's own markup, using rastrillo's vocabulary | **Whatever rastrillo emits — classes today** | rastrillo's `tokens.css` |
+| An app's own markup, using rastrillo's vocabulary | **The grammar emitted by the pinned release — attributes in v0.23.0** | rastrillo's `tokens.css` |
The third row is not our choice and not our migration. It changes when
rastrillo changes.
diff --git a/README.md b/README.md
index a32ff44..96db7ba 100644
--- a/README.md
+++ b/README.md
@@ -1,69 +1,27 @@
-# 🤖 estilo
+# Estilo
-> **Confirmed 2026-08-31.** Paul's ruling: if Docs, Sheets and other
-> Oficina apps are going to share a design system, estilo makes sense and
-> should be started, with rastrillo's `day` theme as its base.
+Estilo is the CARLOS suite's shared design system, at
+`amadan.net/oficina/estilo.git`. It's a private Go module: pull it with
+authenticated Git and `GOPRIVATE=amadan.net/oficina/estilo.git`.
-> **Ruled, same day.** Paul, verbatim: *"Let's have estilo just be day
-> for now, but we can add oficina specific changes and components to it
-> as we need to."* Estilo's theme **is** `day` — identical, not derived
-> from it or based on it — until a named need says otherwise. See
-> `AGENTS.md`, "Theme."
+The body of `theme/estilo.css` stays byte-identical to Rastrillo's
+released `day` theme, pinned at v0.23.0. A test keeps them aligned.
-The CARLOS suite's design-system variant. Components shared by
-[Sheets](https://amadan.net/oficina/sheets),
-[Docs](https://amadan.net/oficina/docs) and the
-[home](https://amadan.net/oficina/home).
+`savestatus` is a save-status indicator, already adopted by Docs and
+Slides. It renders state (saved, saving, retrying, failed) inside a
+polite status region for screen readers; callers supply the localized
+message text for each state.
-A **variant**, precisely: it depends on
-[rastrillo](https://rastrillo.org)'s `ui`, keeps its bones — the `--rst-*`
-tokens, the partial vocabulary, the measured accessibility floors — and adds
-components, themes and its own gallery on top. Not a fork, and more than a
-theme, because a theme cannot add a component.
+`homeconnect` holds templates and data for Home account-connection and
+OAuth screens, being adopted by the shared home/connect flow and other
+apps. It carries no authentication logic — callers validate the
+destination and supply translated labels. The screens run without
+scripts.
-The design system stays rastrillo's. An inspector panel shared by a document
-editor and a spreadsheet is the *suite's* component, not the framework's.
-(Paul's ruling, 2026-08-30.)
+App shells and permission/session logic stay app-owned. There is no
+shared component gallery yet.
-## There is no code yet, and that is deliberate
+## Building
-Estilo needs `ui.ThemeCSS`, `ThemeNames`, `Layout` and `LayoutNames`. Those
-exist only on rastrillo's `main` — **`v0.19.0` has none of them**, and `main`
-is 19 commits ahead of it. `Pair` and the colour-allocation entry point
-exist in no tree at all; they are specified and unwritten.
-
-So estilo could pin a `main` pseudo-version — and then three apps pin
-estilo, which pins a pseudo-version, and a four-deep chain rests on
-unreleased code. **We are waiting for the release instead**, which is
-approved and next. Until then this repo carries its decisions and no
-imports, which is honest rather than empty: the decisions are the part that
-is expensive to get wrong later.
-
-It also means the release now has **three consumers waiting on it**, not
-two.
-
-## What it is not
-
-- **Not an agreed interface.** Until two apps have adopted a primitive, this
- is a *home* for it, not a contract. See `AGENTS.md`.
-- **Not a place for app shells.** A spreadsheet's menu bar and formula bar
- are Sheets'. What lives here is what two apps genuinely share.
-- **No gallery yet, and `dsgen` will not give us one as it stands.**
- `rastrillo/cmd/dsgen` renders rastrillo's own vocabulary — its
- `internal/designsystem.Render` hardcodes imports of rastrillo's `ui`, so
- it cannot be pointed at another module (verified 2026-08-31; an earlier
- claim here said otherwise and was wrong). Ask rastrillo for the seam
- before considering anything else.
-
-## Ownership of the first primitives
-
-| Primitive | First build |
-|---|---|
-| Toolbar groups | Sheets |
-| Inspector panel | Docs |
-| Presence stack | Docs |
-| Comment thread | Docs |
-
-Docs owns comment threads because its anchoring case is harder — a thread
-anchored to a position inside text that can be edited away, plus suggestion
-mode — and the harder case should own the design.
+`make test` is the gate: `go vet`, a `gofmt` check, `go build`, then
+`go test`. `savestatus`'s own tests run under Node.
diff --git a/go.mod b/go.mod
index 17b1cc1..e351b3f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,8 @@
-module amadan.net/oficina/estilo
+module amadan.net/oficina/estilo.git
go 1.25.0
-require github.com/carlosframework/rastrillo v0.22.0
+require github.com/carlosframework/rastrillo v0.23.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index 23414ea..83f37f8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8=
github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
+github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
diff --git a/homeconnect/README.md b/homeconnect/README.md
new file mode 100644
index 0000000..59d265b
--- /dev/null
+++ b/homeconnect/README.md
@@ -0,0 +1,14 @@
+Home connection screens for Oficina apps. Parse `Templates()` with the app's
+page layout, then render `home-connect` with `PageData`. The app supplies every
+label, so its locale selection applies to the whole screen. Twelve caller
+locales are exercised by the component tests.
+
+For an OAuth continuation, render `home-oauth-head` in the document head and
+`home-oauth` in the body with the same `OAuthData`. Validate the destination
+before rendering; this package does not perform authentication or decide which
+hosts to trust. The shared Home connection package owns those checks.
+
+Both screens work with JavaScript disabled. They compose Rastrillo's existing
+attribute-based page, box and button primitives with the suite's day theme;
+there are no new colors or CSS selectors. The Back action uses the button target
+size, and addresses isolate bidirectional text and wrap at narrow widths.
diff --git a/homeconnect/templates/home_link.html b/homeconnect/templates/home_link.html
new file mode 100644
index 0000000..a4ea289
--- /dev/null
+++ b/homeconnect/templates/home_link.html
@@ -0,0 +1,9 @@
+{{define "home-connect"}}
+<header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header>
+<div rst-box>
+ <p>{{.Body}}</p>{{if .Address}}<p style="overflow-wrap: anywhere"><strong><bdi>{{.Address}}</bdi></strong></p>{{end}}
+ {{if .PostAction}}<form method="post" action="{{.Action}}"><button rst-btn="primary" type="submit">{{.ActionLabel}}</button></form>
+ {{else}}<a rst-btn="primary" href="{{.Action}}">{{.ActionLabel}}</a>{{end}}
+ {{if .CancelLabel}}<a rst-btn="ghost" href="/">{{.CancelLabel}}</a>{{end}}
+</div>
+{{end}}
diff --git a/homeconnect/templates/oauth.html b/homeconnect/templates/oauth.html
new file mode 100644
index 0000000..12aef7a
--- /dev/null
+++ b/homeconnect/templates/oauth.html
@@ -0,0 +1,5 @@
+{{define "home-oauth-head"}}<meta http-equiv="refresh" content="0;url={{.URL}}">{{end}}
+{{define "home-oauth"}}
+<header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header>
+<div rst-box><a rst-btn="primary" href="{{.URL}}">{{.ActionLabel}}</a></div>
+{{end}}
diff --git a/homeconnect/view.go b/homeconnect/view.go
new file mode 100644
index 0000000..7f25806
--- /dev/null
+++ b/homeconnect/view.go
@@ -0,0 +1,17 @@
+// Package homeconnect presents the suite's Home connection and OAuth screens.
+// Callers supply translated strings and already validated destinations.
+package homeconnect
+
+import "embed"
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+func Templates() embed.FS { return templateFS }
+
+type PageData struct {
+ Title, Body, Address, Action, ActionLabel, CancelLabel string
+ PostAction bool
+}
+
+type OAuthData struct{ URL, Title, ActionLabel string }
diff --git a/homeconnect/view_test.go b/homeconnect/view_test.go
new file mode 100644
index 0000000..d358223
--- /dev/null
+++ b/homeconnect/view_test.go
@@ -0,0 +1,56 @@
+package homeconnect
+
+import (
+ "bytes"
+ "html/template"
+ "strings"
+ "testing"
+)
+
+func render(t *testing.T, name string, data any) string {
+ t.Helper()
+ view, err := template.New("view").ParseFS(Templates(), "templates/*.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+ if err := view.ExecuteTemplate(&out, name, data); err != nil {
+ t.Fatal(err)
+ }
+ return out.String()
+}
+
+func TestCallerTranslationsAndScriptlessActions(t *testing.T) {
+ labels := map[string]string{"en": "Continue", "de": "Weiter", "es": "Continuar", "fr": "Continuer", "it": "Continua", "pt": "Continuar", "nl": "Doorgaan", "pl": "Kontynuuj", "ar": "متابعة", "ja": "続行", "zh": "继续", "ko": "계속"}
+ for locale, label := range labels {
+ t.Run(locale, func(t *testing.T) {
+ data := PageData{Title: label, Body: label, Address: "person@example.test", Action: "/account/home", ActionLabel: label, CancelLabel: label, PostAction: true}
+ html := render(t, "home-connect", data)
+ for _, want := range []string{label, `method="post" action="/account/home"`, `<bdi>person@example.test</bdi>`, `rst-btn="ghost"`} {
+ if !strings.Contains(html, want) {
+ t.Fatalf("missing %q", want)
+ }
+ }
+ if strings.Contains(html, "<script") {
+ t.Fatal("connection action requires script")
+ }
+ data.PostAction = false
+ if html = render(t, "home-connect", data); strings.Contains(html, "<form") || !strings.Contains(html, `href="/account/home"`) {
+ t.Fatal("navigation action is not a link")
+ }
+ })
+ }
+}
+func TestUntrustedTextAndDestinationAreEscaped(t *testing.T) {
+ html := render(t, "home-connect", PageData{Title: `<script>bad</script>`, Address: `<img src=x onerror=bad>`, Action: "javascript:bad", ActionLabel: "Go"})
+ if strings.Contains(html, "<script>") || strings.Contains(html, "<img") || !strings.Contains(html, "#ZgotmplZ") {
+ t.Fatal("unsafe connection markup", html)
+ }
+}
+func TestOAuthHeadAndBodyRetainValidatedDestination(t *testing.T) {
+ d := OAuthData{URL: "https://keymail.example.test/oauth/authorize?state=test&client_id=app", Title: "Continue", ActionLabel: "Continue"}
+ head, body := render(t, "home-oauth-head", d), render(t, "home-oauth", d)
+ if !strings.Contains(head, `http-equiv="refresh"`) || !strings.Contains(head, "keymail.example.test/oauth/authorize") || !strings.Contains(body, `href="https://keymail.example.test/oauth/authorize?state=test&amp;client_id=app"`) {
+ t.Fatal("scriptless OAuth continuation missing", head, body)
+ }
+}
diff --git a/implementation/save-status.md b/implementation/save-status.md
new file mode 100644
index 0000000..14a75ce
--- /dev/null
+++ b/implementation/save-status.md
@@ -0,0 +1,114 @@
+# Shared save status
+
+Status: revision 2 approved by Claude Fable 5.1 (claude-fable-5-1), 2026-09-07.
+Implementation and consumer adoption require branch review. Full initial and
+revised verdicts are recorded in Amadan Estilo discussion 1.
+User instruction: extract reusable components and maintain consistent suite UI/UX
+through Estilo during the overnight implementation run.
+
+## Concrete consumers
+
+Docs at 499d200 has a polite live region in views/show.html with two localized
+messages (retrying, failed), styled as a fixed notice. Its editor sets data-state;
+successful routine saves deliberately have no visible message.
+
+Slides slice-2-editor at a863fb6 has a status line in templates/editor.html and
+editor.css. editor-entry.js updates its text and data-state. It displays saved,
+saving, offline and error messages from the app's catalog. Existing session
+owns unfinished editor work; adoption will use an isolated branch after its
+current revision and be coordinated before merging.
+
+## Primitive
+
+Build the first shared component in Estilo, consumed by Docs first; Slides is
+the second adoption that validates/corrects the interface (AGENTS.md).
+
+Package savestatus exports an embedded html/template definition plus CSS.
+Data is a struct with localized text for Saved, Saving, Retrying, Failed and
+an optional localized accessible label. Missing text means that state stays
+visually silent. No English default or authored product copy lives here.
+The initial state is validated to a closed set, including idle. Unknown states
+are silent and never represented as saved.
+
+Markup uses estilo-save-status and estilo-save-message attributes, role=status,
+aria-live=polite, aria-atomic=true. Messages are ordinary template-escaped strings.
+The component is non-interactive. State changes are reflected by a small shared
+JavaScript function updating textContent in the already-present live region;
+it never injects HTML. Without script, the initial status text is visible.
+
+Slides maps saved/saving directly. Its offline state maps to retrying and retains
+the existing localized offline text. Its sticky dropped-batch case continues
+showing that same offline text, never saved; no new failed message is invented.
+Slide-load errors remain an app-owned element separate from save status, using
+the existing localized error text. The adopter must not claim a successful save
+clears a load error, or a successful load clears a dropped batch.
+
+Two placements are needed by the existing consumers: inline and floating.
+CSS inherits rastrillo tokens, wraps long text, respects 320px reflow and uses
+words plus weight for failure emphasis; it changes no theme value. App code
+continues to own save lifecycle, sticky failure, retry and recovery semantics.
+The floating failure retains Docs' negative background/foreground tone tokens.
+Inline placement retains Slides' minimum inline size of 8ch to avoid jitter.
+Both variants disappear for print via the component's own print rule.
+
+Silent status is an empty rendered live region: never display:none, visibility:
+hidden, hidden, or aria-hidden. Empty floating state has no padding/background/
+shadow, but the element remains present before a subsequent text mutation. This
+keeps the region in the accessibility tree before its first failure announcement.
+
+Do not ship a new all-green appearance for Docs routine saves, and do not
+change failure/retry wording. Do not infer save state from HTTP activity alone.
+
+## Integration
+
+Both app adopters import the same Estilo package revision and serve its CSS/JS
+through their existing embedded asset route. Their templates use the shared
+markup. Remove the replaced app-local status styling and DOM-writing functions.
+Retain an app-specific selector only where needed during the same migration;
+remove duplicate status DOM. No dynamic CDN imports, new framework, or bundler.
+
+Delivery is one same-origin ES module URL exposed in the editor's data attribute.
+The bundle loads it with dynamic import of that URL (kept external to bundling)
+and calls its exported mount function. The element holds template-escaped
+data-text-saved/saving/retrying/failed values; the module reads those once.
+Both apps' existing static routes serve the embedded module and CSS. Module-load
+failure must leave existing error/retry indication operational through a minimal
+app fallback or a pre-editor failure state; it must never silently run an editor
+whose persistence failure cannot be shown. The implementation review must verify
+this delivery/failure path in the actual bundle, not only the helper's unit test.
+
+## Verification
+
+Test HTML escaping, invalid state handling, missing labels/messages, role/live
+region and scriptless initial state. Exercise state transitions on a real DOM
+and prove failure wording is not replaced by stale save completion through app
+tests. Run Estilo theme/token-prefix tests unchanged and each consumer's gates.
+Verify both variants at 320px and keyboard/screen-reader semantics. Review full
+library and consumer diffs with Fable before CI/merge/deploy.
+Exercise repeated saving/offline/failed transitions; the live region remains in
+the DOM/accessibility tree while silent. Check Slides' sticky failure never
+returns to saved after a dropped batch; check load-error independence. Measure
+320px using Docs' longest existing failed text and widest available locale.
+
+No extraction is called complete until two apps consume it and their old
+duplicate implementation is removed. Until then it is an in-progress primitive.
+
+Fable review 1: claude-fable-5-1, /tmp/oficina-overnight-review/estilo.json.
+All seven findings accepted above: exact Slides mapping, persistent live region,
+explicit JS delivery, print, inline width, negative tone, pinned Slides revision.
+
+## Implementation evidence
+
+The floating notice uses pointer-events:none so it cannot block the editor.
+The full local make test gate passes. Mutation checks reject treating an unknown
+state as saved and replacing textContent with innerHTML.
+
+## Private module transport
+
+Estilo remains private. Use module path amadan.net/oficina/estilo.git,
+GOPRIVATE=amadan.net and authenticated Git transport. Go documents the VCS
+suffix as the supported way to resolve a private repository without public
+go-import metadata. A fresh direct download of5f712c7 via that path resolved
+the exact repository and commit. No public visibility change or local replace
+is part of the shipping dependency. Both first consumers adopt the qualified
+module path; no existing consumer pin was found.
diff --git a/savestatus/doc.go b/savestatus/doc.go
new file mode 100644
index 0000000..1b131ec
--- /dev/null
+++ b/savestatus/doc.go
@@ -0,0 +1,3 @@
+// Package savestatus presents localized save feedback shared by Oficina apps.
+// Initial text renders without scripts; live state changes use the supplied JS.
+package savestatus
diff --git a/savestatus/status.css b/savestatus/status.css
new file mode 100644
index 0000000..1f829b3
--- /dev/null
+++ b/savestatus/status.css
@@ -0,0 +1,41 @@
+[estilo-save-status] {
+ box-sizing: border-box;
+ display: inline-block;
+ font-size: var(--rst-fs-sm);
+ line-height: 1.4;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+[estilo-save-status~="inline"] {
+ color: var(--rst-text-muted);
+ min-inline-size: 8ch;
+ min-block-size: 1em;
+}
+[estilo-save-status~="inline"]:not([data-state="saved"]) {
+ color: var(--rst-text);
+ font-weight: 600;
+}
+[estilo-save-status~="floating"] {
+ position: fixed;
+ inset-inline-end: var(--rst-sp-4);
+ inset-block-end: var(--rst-sp-4);
+ max-inline-size: min(22em, calc(100vw - var(--rst-sp-4) - var(--rst-sp-4)));
+ min-inline-size: 1px;
+ min-block-size: 1px;
+ pointer-events: none;
+}
+[estilo-save-status~="floating"]:not(:empty) {
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+ border-radius: var(--rst-radius-sm);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
+ background: var(--rst-tone-neutral-bg);
+ color: var(--rst-tone-neutral-fg);
+}
+[estilo-save-status~="floating"][data-state="failed"]:not(:empty) {
+ background: var(--rst-tone-negative-bg);
+ color: var(--rst-tone-negative-fg);
+ font-weight: 600;
+}
+@media print {
+ [estilo-save-status] { display: none; }
+}
diff --git a/savestatus/status.go b/savestatus/status.go
new file mode 100644
index 0000000..484900e
--- /dev/null
+++ b/savestatus/status.go
@@ -0,0 +1,52 @@
+package savestatus
+
+import _ "embed"
+
+//go:embed status.html
+var markup string
+
+//go:embed status.css
+var css []byte
+
+//go:embed status.mjs
+var javascript []byte
+
+type Data struct {
+ State, Placement string
+ Saved, Saving, Retrying, Failed, Label string
+}
+
+func (d Data) CurrentState() string {
+ switch d.State {
+ case "saved", "saving", "retrying", "failed":
+ return d.State
+ default:
+ return "idle"
+ }
+}
+
+func (d Data) CurrentText() string {
+ switch d.CurrentState() {
+ case "saved":
+ return d.Saved
+ case "saving":
+ return d.Saving
+ case "retrying":
+ return d.Retrying
+ case "failed":
+ return d.Failed
+ default:
+ return ""
+ }
+}
+
+func (d Data) Mode() string {
+ if d.Placement == "floating" {
+ return "floating"
+ }
+ return "inline"
+}
+
+func Template() string { return markup }
+func CSS() []byte { return append([]byte(nil), css...) }
+func JS() []byte { return append([]byte(nil), javascript...) }
diff --git a/savestatus/status.html b/savestatus/status.html
new file mode 100644
index 0000000..089d7bf
--- /dev/null
+++ b/savestatus/status.html
@@ -0,0 +1 @@
+{{define "estilo-save-status"}}<span estilo-save-status="{{.Mode}}" estilo-save-message data-state="{{.CurrentState}}" role="status" aria-live="polite" aria-atomic="true"{{with .Label}} aria-label="{{.}}"{{end}} data-text-saved="{{.Saved}}" data-text-saving="{{.Saving}}" data-text-retrying="{{.Retrying}}" data-text-failed="{{.Failed}}">{{.CurrentText}}</span>{{end}}
diff --git a/savestatus/status.mjs b/savestatus/status.mjs
new file mode 100644
index 0000000..4e9e78f
--- /dev/null
+++ b/savestatus/status.mjs
@@ -0,0 +1,13 @@
+const states = new Set(["saved", "saving", "retrying", "failed"]);
+
+export function mount(element) {
+ const texts = new Map([...states].map(state => [state, element.getAttribute(`data-text-${state}`) || ""]));
+ function setState(requested) {
+ const state = states.has(requested) ? requested : "idle";
+ const text = texts.get(state) || "";
+ if (element.getAttribute("data-state") !== state) element.setAttribute("data-state", state);
+ if (element.textContent !== text) element.textContent = text;
+ }
+ setState(element.getAttribute("data-state"));
+ return setState;
+}
diff --git a/savestatus/status.test.mjs b/savestatus/status.test.mjs
new file mode 100644
index 0000000..1f3772e
--- /dev/null
+++ b/savestatus/status.test.mjs
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { mount } from "./status.mjs";
+
+function region(labels = {}) {
+ const attributes = new Map(Object.entries({"data-state": "idle", ...labels}));
+ let text = "";
+ let writes = 0;
+ return {
+ getAttribute: name => attributes.get(name) ?? null,
+ setAttribute: (name, value) => { attributes.set(name, value); writes++; },
+ get textContent() { return text; },
+ set textContent(value) { text = value; writes++; },
+ set innerHTML(_) { throw new Error("HTML mutation"); },
+ get writes() { return writes; },
+ };
+}
+
+test("mount keeps one region and renders only supplied state text", () => {
+ const element = region({"data-text-saved": "gespeichert", "data-text-saving": "guardando", "data-text-retrying": "再試行", "data-text-failed": "فشل"});
+ const update = mount(element);
+ for (const [state, text] of [["saving", "guardando"], ["retrying", "再試行"], ["failed", "فشل"], ["saved", "gespeichert"], ["idle", ""]]) {
+ update(state);
+ assert.equal(element.textContent, text);
+ assert.equal(element.getAttribute("data-state"), state);
+ const writes = element.writes;
+ update(state);
+ assert.equal(element.writes, writes);
+ }
+});
+
+test("missing messages and unknown states are silent, never success", () => {
+ const element = region({"data-text-saved": "saved fixture"});
+ const update = mount(element);
+ update("saved");
+ for (const state of ["failed", "saving", "retrying", "__proto__", "constructor", "SAVED", null, {toString: () => "saved"}]) {
+ update(state);
+ assert.equal(element.textContent, "");
+ }
+});
+
+test("status containing markup is assigned as text and labels stay stable", () => {
+ const text = '<img src=x onerror="fixture()">';
+ const element = region({"data-state": "failed", "data-text-failed": text});
+ const update = mount(element);
+ assert.equal(element.textContent, text);
+ element.setAttribute("data-text-failed", "changed externally");
+ update("idle");
+ update("failed");
+ assert.equal(element.textContent, text);
+});
diff --git a/savestatus/status_test.go b/savestatus/status_test.go
new file mode 100644
index 0000000..4771a0d
--- /dev/null
+++ b/savestatus/status_test.go
@@ -0,0 +1,70 @@
+package savestatus
+
+import (
+ "bytes"
+ "html/template"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func render(t *testing.T, data Data) string {
+ t.Helper()
+ view, err := template.New("test").Parse(Template())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var b bytes.Buffer
+ if err := view.ExecuteTemplate(&b, "estilo-save-status", data); err != nil {
+ t.Fatal(err)
+ }
+ return b.String()
+}
+
+func TestRenderStatesAndSilentUnknown(t *testing.T) {
+ for _, tc := range []struct{ state, want string }{
+ {"saved", "gespeichert"}, {"saving", "guardando"},
+ {"retrying", "再試行"}, {"failed", "فشل"},
+ {"idle", ""}, {"unsupported", ""},
+ } {
+ t.Run(tc.state, func(t *testing.T) {
+ got := render(t, Data{State: tc.state, Saved: "gespeichert", Saving: "guardando", Retrying: "再試行", Failed: "فشل"})
+ if !strings.HasSuffix(got, ">"+tc.want+"</span>") {
+ t.Fatalf("wrong initial text: %s", got)
+ }
+ for _, attr := range []string{`role="status"`, `aria-live="polite"`, `aria-atomic="true"`, `estilo-save-status="inline"`} {
+ if !strings.Contains(got, attr) {
+ t.Errorf("missing %s in %s", attr, got)
+ }
+ }
+ if strings.Contains(got, "aria-hidden") || strings.Contains(got, " hidden") || strings.Contains(got, "aria-label") {
+ t.Errorf("silent or unnamed region changed semantics: %s", got)
+ }
+ if tc.want == "" && !strings.Contains(got, `data-state="idle"`) {
+ t.Errorf("unknown state was not normalized: %s", got)
+ }
+ })
+ }
+ got := render(t, Data{State: "failed", Placement: "floating"})
+ if !strings.HasSuffix(got, "></span>") || !strings.Contains(got, `estilo-save-status="floating"`) {
+ t.Fatalf("missing state text must stay empty: %s", got)
+ }
+}
+
+func TestLabelsAndStatusTextAreEscaped(t *testing.T) {
+ unsafe := `"><script>globalThis.fixture=1</script>&`
+ got := render(t, Data{State: "failed", Failed: unsafe, Label: unsafe, Placement: unsafe})
+ if strings.Contains(got, "<script>") || strings.Count(got, "<span ") != 1 || strings.Count(got, "</span>") != 1 {
+ t.Fatalf("text became markup: %s", got)
+ }
+ if !strings.Contains(got, `estilo-save-status="inline"`) || !strings.Contains(got, `aria-label="&#34;&gt;&lt;script&gt;`) || !strings.HasSuffix(got, "&lt;/script&gt;&amp;</span>") {
+ t.Fatalf("attribute or text escaping failed: %s", got)
+ }
+}
+
+func TestJavaScriptStateTransitions(t *testing.T) {
+ output, err := exec.Command("node", "--test", "status.test.mjs").CombinedOutput()
+ if err != nil {
+ t.Fatalf("status module: %v\n%s", err, output)
+ }
+}
diff --git a/theme/estilo.css b/theme/estilo.css
index 751b20f..9782398 100644
--- a/theme/estilo.css
+++ b/theme/estilo.css
@@ -1,5 +1,5 @@
/* estilo — the CARLOS suite's shared theme. It IS rastrillo's day
- (v0.22.0), not derived from it and not based on it — identical, in
+ (v0.23.0), not derived from it and not based on it — identical, in
every declaration and every value (Paul's ruling, AGENTS.md,
"Theme"). Structure lives in rastrillo's tokens.css, unchanged; this
file is colour, type and shape, carried across from day token for
@@ -162,6 +162,13 @@
--rst-line: light-dark(#e5e7eb, #2a3038);
--rst-line-strong: light-dark(#7f8792, #79828e);
+ /* The page header's rule, derived rather than authored: the accent
+ folded into the line at 18%, so a theme that changes its accent
+ gets a matching rule with no second value to keep in step. day's
+ is barely there — a hairline you would call grey until you put it
+ beside plain's. Decorative: it carries no contrast floor. */
+ --rst-header-rule: color-mix(in oklab, var(--rst-accent) 18%, var(--rst-line));
+
/* Text */
--rst-text: light-dark(#1a1d21, #e8eaed);
--rst-text-muted: light-dark(#4b5563, #a8b0ba);
diff --git a/theme/theme_test.go b/theme/theme_test.go
index 98aa6fd..bceafd2 100644
--- a/theme/theme_test.go
+++ b/theme/theme_test.go
@@ -130,7 +130,7 @@ func TestMatchesDayByteForByte(t *testing.T) {
sort.Strings(extra)
var b strings.Builder
- b.WriteString("theme/estilo.css has drifted from rastrillo's day.css (v0.22.0, as pinned in go.mod), past the header comment:\n")
+ b.WriteString("theme/estilo.css has drifted from rastrillo's day.css (the version pinned in go.mod), past the header comment:\n")
for _, name := range changed {
fmt.Fprintf(&b, " %s: day has %q, estilo has %q\n", name, dayTokens[name], estiloTokens[name])
}