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:
@@ -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):
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
// other is set), so they read as a single segmented choice. "saved" is an
|
||||||
|
// independent toggle and sits apart (UX review C5).
|
||||||
|
<span class="segmented" role="group" aria-label="Watched or skipped">
|
||||||
|
@actionButton("watched", active["watched"])
|
||||||
|
@actionButton("skipped", active["skipped"])
|
||||||
|
</span>
|
||||||
|
@actionButton("saved", active["saved"])
|
||||||
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
name="action"
|
name="action"
|
||||||
value={ v }
|
value={ verb }
|
||||||
class={ "action", templ.KV("active", active[v]) }
|
class={ "action", templ.KV("active", isActive) }
|
||||||
aria-pressed={ ariaPressed(active[v]) }
|
aria-pressed={ ariaPressed(isActive) }
|
||||||
>
|
>
|
||||||
if active[v] {
|
if isActive {
|
||||||
{ "✓ " + actionLabel(v) }
|
{ "✓ " + actionLabel(verb) }
|
||||||
} else {
|
} else {
|
||||||
{ actionLabel(v) }
|
{ actionLabel(verb) }
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
</form>
|
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-28
@@ -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])}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var81...)
|
|
||||||
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, 154, "<button type=\"submit\" name=\"action\" value=\"")
|
templ_7745c5c3_Err = actionButton("skipped", active["skipped"]).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var82 string
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</span>")
|
||||||
templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(v)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 537, Col: 13}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
|
|
||||||
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, 155, "\" class=\"")
|
templ_7745c5c3_Err = actionButton("saved", active["saved"]).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionButton is one toggle button in the action group: a submit carrying its
|
||||||
|
// verb, marked active (accent fill + ✓ prefix + aria-pressed) when currently set.
|
||||||
|
func actionButton(verb string, isActive bool) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var83 string
|
var templ_7745c5c3_Var83 string
|
||||||
templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var81).String())
|
templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(verb)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
|
||||||
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, 156, "\" aria-pressed=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var84 string
|
var templ_7745c5c3_Var84 string
|
||||||
templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v]))
|
templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var82).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 539, Col: 41}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
|
||||||
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, 157, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\" aria-pressed=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if active[v] {
|
|
||||||
var templ_7745c5c3_Var85 string
|
var templ_7745c5c3_Var85 string
|
||||||
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v))
|
templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(isActive))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 542, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 552, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "\">")
|
||||||
var templ_7745c5c3_Var86 string
|
|
||||||
templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 544, Col: 21}
|
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 = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</button>")
|
_, 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user