oficina / estilo Public

Add shared save status presentation primitive

Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by paul@keymail.dev 5f712c7b08009cbcb44acdb59f437d8f683075cf parent 0cd800f
7 files changed, +237 −1
  • implementation/save-status.md +9 −1
  • 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

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="&#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)
+ }
+}