Add shared save status presentation primitive
Co-Authored-By: Codex <noreply@openai.com>
7 files changed,
+237
−1
implementation/save-status.md+9 −1savestatus/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 −0
CI passed — run details
Intent
Human
Re-review the revised shared save-status spec in /home/paulca/amadan.net/oficina/estilo/.claude/worktrees/overnight-save-status/implementation/save-status.md at commit0cd800f. Your seven prior findings have been incorporated: exact Slides mapping, persistent live region, explicit module delivery and load-failure handling, print hiding, inline width, negative tone and pinned Slidesa863fb6. No implementation yet. Read revised spec and any original consumer source needed; return APPROVE or REQUEST_CHANGES with remaining actionable issues. User specifically requires reusable Estilo components and consistent UX. Existing text is reused without changes. No files edited by reviewer.
diff --git a/implementation/save-status.md b/implementation/save-status.md| index 93916ec..5412099 100644 |
| --- a/implementation/save-status.md |
| +++ b/implementation/save-status.md |
| @@ -1,6 +1,8 @@ |
| # Shared save status |
| -Status: revision 2, answering Fable review 1; re-review pending, 2026-09-07. |
| +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. |
| @@ -94,3 +96,9 @@ 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. |
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) |
| + } |
| +} |