feat(web): segment watched/skipped action toggles

Watched and skipped are mutually exclusive (the store clears one when the other
is set), but rendered as three independent-looking buttons the exclusivity was
invisible. Group watched|skipped into a single segmented control and keep Saved
apart as an independent toggle (UX review C5). HTMX posting and the active/✓/
aria-pressed semantics are unchanged; extracted a shared actionButton component.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 14:02:31 +02:00
co-authored by Claude Opus 4.8
parent f775441a62
commit 51aa5d940c
4 changed files with 148 additions and 88 deletions
+3
View File
@@ -234,6 +234,9 @@ func TestDetailRendersHighlightsAndTakeaways(t *testing.T) {
require.Contains(t, html, "takeaway one") require.Contains(t, html, "takeaway one")
require.Contains(t, html, `id="action-buttons"`, "action button group present") require.Contains(t, html, `id="action-buttons"`, "action button group present")
require.Contains(t, html, "Watched") require.Contains(t, html, "Watched")
require.Contains(t, html, `class="segmented"`, "watched/skipped render as a segmented control (UX review C5)")
require.Less(t, strings.Index(html, "Skipped"), strings.Index(html, "Saved"),
"Saved sits after the watched/skipped segment")
require.Contains(t, html, "← Summaries", "back link to the list (UX review C3)") require.Contains(t, html, "← Summaries", "back link to the list (UX review C3)")
// The detail page leads with the attention-saving payload (UX review A8): // The detail page leads with the attention-saving payload (UX review A8):
+7 -2
View File
@@ -684,8 +684,13 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; } .detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
.detail li { margin-bottom: var(--s1); } .detail li { margin-bottom: var(--s1); }
/* action toggles */ /* action toggles — watched|skipped form one segmented control (they are mutually
.actions { display: flex; gap: var(--s2); margin: var(--s4) 0; flex-wrap: wrap; } exclusive), "saved" sits apart as an independent toggle */
.actions { display: flex; gap: var(--s3); margin: var(--s4) 0; flex-wrap: wrap; align-items: center; }
.segmented { display: inline-flex; }
.segmented .action { border-radius: 0; border-right-width: 0; }
.segmented .action:first-child { border-top-left-radius: var(--radius); border-bottom-left-radius: var(--radius); }
.segmented .action:last-child { border-top-right-radius: var(--radius); border-bottom-right-radius: var(--radius); border-right-width: 1px; }
.actions .action { font: inherit; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; transition: border-color .15s, background .15s; } .actions .action { font: inherit; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); cursor: pointer; transition: border-color .15s, background .15s; }
.actions .action:hover { border-color: var(--accent); } .actions .action:hover { border-color: var(--accent); }
.actions .action:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .actions .action:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+26 -15
View File
@@ -530,20 +530,31 @@ templ ActionButtons(videoID string, active map[string]bool) {
hx-target="#action-buttons" hx-target="#action-buttons"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
for _, v := range actionVerbs { // watched ↔ skipped are mutually exclusive (the store clears one when the
<button // other is set), so they read as a single segmented choice. "saved" is an
type="submit" // independent toggle and sits apart (UX review C5).
name="action" <span class="segmented" role="group" aria-label="Watched or skipped">
value={ v } @actionButton("watched", active["watched"])
class={ "action", templ.KV("active", active[v]) } @actionButton("skipped", active["skipped"])
aria-pressed={ ariaPressed(active[v]) } </span>
> @actionButton("saved", active["saved"])
if active[v] {
{ "✓ " + actionLabel(v) }
} else {
{ actionLabel(v) }
}
</button>
}
</form> </form>
} }
// actionButton is one toggle button in the action group: a submit carrying its
// verb, marked active (accent fill + ✓ prefix + aria-pressed) when currently set.
templ actionButton(verb string, isActive bool) {
<button
type="submit"
name="action"
value={ verb }
class={ "action", templ.KV("active", isActive) }
aria-pressed={ ariaPressed(isActive) }
>
if isActive {
{ "✓ " + actionLabel(verb) }
} else {
{ actionLabel(verb) }
}
</button>
}
+112 -71
View File
@@ -1862,86 +1862,127 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\"><span class=\"segmented\" role=\"group\" aria-label=\"Watched or skipped\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, v := range actionVerbs { templ_7745c5c3_Err = actionButton("watched", active["watched"]).Render(ctx, templ_7745c5c3_Buffer)
var templ_7745c5c3_Var81 = []any{"action", templ.KV("active", active[v])} if templ_7745c5c3_Err != nil {
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var81...) return templ_7745c5c3_Err
if templ_7745c5c3_Err != nil { }
return templ_7745c5c3_Err templ_7745c5c3_Err = actionButton("skipped", active["skipped"]).Render(ctx, templ_7745c5c3_Buffer)
} if templ_7745c5c3_Err != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<button type=\"submit\" name=\"action\" value=\"") return templ_7745c5c3_Err
if templ_7745c5c3_Err != nil { }
return templ_7745c5c3_Err templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</span>")
} if templ_7745c5c3_Err != nil {
var templ_7745c5c3_Var82 string return templ_7745c5c3_Err
templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(v) }
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = actionButton("saved", active["saved"]).Render(ctx, templ_7745c5c3_Buffer)
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 537, Col: 13} if templ_7745c5c3_Err != nil {
} return templ_7745c5c3_Err
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82) }
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</form>")
return templ_7745c5c3_Err if templ_7745c5c3_Err != nil {
} return templ_7745c5c3_Err
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\" class=\"") }
if templ_7745c5c3_Err != nil { return nil
return templ_7745c5c3_Err })
} }
var templ_7745c5c3_Var83 string
templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var81).String()) // actionButton is one toggle button in the action group: a submit carrying its
if templ_7745c5c3_Err != nil { // verb, marked active (accent fill + ✓ prefix + aria-pressed) when currently set.
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0} func actionButton(verb string, isActive bool) templ.Component {
} return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83) templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_Err != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_Err return templ_7745c5c3_CtxErr
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" aria-pressed=\"") templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if templ_7745c5c3_Err != nil { if !templ_7745c5c3_IsBuffer {
return templ_7745c5c3_Err defer func() {
} templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
var templ_7745c5c3_Var84 string if templ_7745c5c3_Err == nil {
templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v])) templ_7745c5c3_Err = templ_7745c5c3_BufErr
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 539, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if active[v] {
var templ_7745c5c3_Var85 string
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 542, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var86 string
templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 544, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
} }
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var81 := templ.GetChildren(ctx)
if templ_7745c5c3_Var81 == nil {
templ_7745c5c3_Var81 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var82 = []any{"action", templ.KV("active", isActive)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var82...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<button type=\"submit\" name=\"action\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var83 string
templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(verb)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 550, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var84 string
templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var82).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\" aria-pressed=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var85 string
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(isActive))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 552, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isActive {
var templ_7745c5c3_Var86 string
templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(verb))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 555, Col: 31}
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</button>") _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var87 string
templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(verb))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 557, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "</form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }