Merge branch 'feat/home-connection' into main
20 files changed,
+490
−79
.gitignore+1 −0AGENTS.md+10 −13README.md+20 −62go.mod+2 −2go.sum+2 −0homeconnect/README.md+14 −0homeconnect/templates/home_link.html+9 −0homeconnect/templates/oauth.html+5 −0homeconnect/view.go+17 −0homeconnect/view_test.go+56 −0implementation/save-status.md+114 −0savestatus/doc.go+3 −0savestatus/status.css+41 −0savestatus/status.go+52 −0savestatus/status.html+1 −0savestatus/status.mjs+13 −0savestatus/status.test.mjs+51 −0savestatus/status_test.go+70 −0theme/estilo.css+8 −1theme/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&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=""><script>`) || !strings.HasSuffix(got, "</script>&</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]) |
| } |