From 160c11160844a108a6d8e6cf7b2f62dd08c24ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 09:03:39 +0000 Subject: [PATCH 1/6] prototype: active-scope switcher variants for #124/#122/#125 rethink Throwaway UI at /prototype/active-scope, 3 variants of a unified Active Group + Vote Scope model (Settings-pinned default, transient header override) surfaced during the grill session on issue #124. --- src/routeTree.gen.ts | 21 ++ src/routes/prototype/NOTES.md | 44 +++ src/routes/prototype/active-scope.tsx | 476 ++++++++++++++++++++++++++ 3 files changed, 541 insertions(+) create mode 100644 src/routes/prototype/NOTES.md create mode 100644 src/routes/prototype/active-scope.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b7ff53a5..3b417e28 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as CookiesRouteImport } from './routes/cookies' import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' import { Route as GroupsIndexRouteImport } from './routes/groups/index' +import { Route as PrototypeActiveScopeRouteImport } from './routes/prototype/active-scope' import { Route as GroupsGroupSlugRouteImport } from './routes/groups/$groupSlug' import { Route as FestivalsFestivalSlugRouteImport } from './routes/festivals/$festivalSlug' import { Route as AdminFestivalsRouteImport } from './routes/admin/festivals' @@ -71,6 +72,11 @@ const GroupsIndexRoute = GroupsIndexRouteImport.update({ path: '/groups/', getParentRoute: () => rootRouteImport, } as any) +const PrototypeActiveScopeRoute = PrototypeActiveScopeRouteImport.update({ + id: '/prototype/active-scope', + path: '/prototype/active-scope', + getParentRoute: () => rootRouteImport, +} as any) const GroupsGroupSlugRoute = GroupsGroupSlugRouteImport.update({ id: '/groups/$groupSlug', path: '/groups/$groupSlug', @@ -227,6 +233,7 @@ export interface FileRoutesByFullPath { '/admin/festivals': typeof AdminFestivalsRouteWithChildren '/festivals/$festivalSlug': typeof FestivalsFestivalSlugRouteWithChildren '/groups/$groupSlug': typeof GroupsGroupSlugRoute + '/prototype/active-scope': typeof PrototypeActiveScopeRoute '/groups': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren @@ -259,6 +266,7 @@ export interface FileRoutesByTo { '/admin/artists': typeof AdminArtistsRouteWithChildren '/admin/festivals': typeof AdminFestivalsRouteWithChildren '/groups/$groupSlug': typeof GroupsGroupSlugRoute + '/prototype/active-scope': typeof PrototypeActiveScopeRoute '/groups': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren @@ -292,6 +300,7 @@ export interface FileRoutesById { '/admin/festivals': typeof AdminFestivalsRouteWithChildren '/festivals/$festivalSlug': typeof FestivalsFestivalSlugRouteWithChildren '/groups/$groupSlug': typeof GroupsGroupSlugRoute + '/prototype/active-scope': typeof PrototypeActiveScopeRoute '/groups/': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren @@ -327,6 +336,7 @@ export interface FileRouteTypes { | '/admin/festivals' | '/festivals/$festivalSlug' | '/groups/$groupSlug' + | '/prototype/active-scope' | '/groups' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' @@ -359,6 +369,7 @@ export interface FileRouteTypes { | '/admin/artists' | '/admin/festivals' | '/groups/$groupSlug' + | '/prototype/active-scope' | '/groups' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' @@ -391,6 +402,7 @@ export interface FileRouteTypes { | '/admin/festivals' | '/festivals/$festivalSlug' | '/groups/$groupSlug' + | '/prototype/active-scope' | '/groups/' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' @@ -421,6 +433,7 @@ export interface RootRouteChildren { TermsRoute: typeof TermsRoute FestivalsFestivalSlugRoute: typeof FestivalsFestivalSlugRouteWithChildren GroupsGroupSlugRoute: typeof GroupsGroupSlugRoute + PrototypeActiveScopeRoute: typeof PrototypeActiveScopeRoute GroupsIndexRoute: typeof GroupsIndexRoute } @@ -468,6 +481,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GroupsIndexRouteImport parentRoute: typeof rootRouteImport } + '/prototype/active-scope': { + id: '/prototype/active-scope' + path: '/prototype/active-scope' + fullPath: '/prototype/active-scope' + preLoaderRoute: typeof PrototypeActiveScopeRouteImport + parentRoute: typeof rootRouteImport + } '/groups/$groupSlug': { id: '/groups/$groupSlug' path: '/groups/$groupSlug' @@ -815,6 +835,7 @@ const rootRouteChildren: RootRouteChildren = { TermsRoute: TermsRoute, FestivalsFestivalSlugRoute: FestivalsFestivalSlugRouteWithChildren, GroupsGroupSlugRoute: GroupsGroupSlugRoute, + PrototypeActiveScopeRoute: PrototypeActiveScopeRoute, GroupsIndexRoute: GroupsIndexRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/prototype/NOTES.md b/src/routes/prototype/NOTES.md new file mode 100644 index 00000000..e31f2d74 --- /dev/null +++ b/src/routes/prototype/NOTES.md @@ -0,0 +1,44 @@ +# Prototype: active-scope + +Route: `/prototype/active-scope?variant=A|B|C` + +## Question + +Does the "scope pin lives in Settings, header dropdown is a transient override" +model feel right, before touching `profiles` schema or real app code? + +Background: PR #273 (issue #124) patched a bug where `profiles.active_group_id` +being `NULL` was overloaded to mean both "never chosen" and "explicitly +Everyone." The grill session (see conversation) converged on a bigger reframe: +Active Group and the future Vote Scope toggle (#125) are the same underlying +concept — a flat "scope" with entries `{your groups... | Everyone | Me}` — and +the fix should be root-cause, not another flag. Votes have no `group_id` +(confirmed in `supabase/migrations/20250620065433_create_artists_table.sql`), +so groups are purely a viewing/aggregation lens, never an identity a vote is +recorded against. + +Product constraint from the user: the app should still centralize around "your +crew" as the default/sticky experience (that's the whole point of this epic), +so friction should sit on the _pin_ action (in Settings), not on casual +switching in the header. + +## Variants + +- **A — Star-marked dropdown**: one flat dropdown, pinned entry gets a star, + a "back to X" pill appears next to the trigger when overridden. +- **B — Two-row split**: "Your default" (always-visible, one click home) is + separated from a "Browse" dropdown for everything else. +- **C — Segmented + drawer**: a two-way segmented control (pinned vs. "More"), + with other scopes revealed as chips only on demand — makes the pinned crew + the single most prominent affordance, at the cost of one extra click to + reach Everyone/Me. + +## Verdict + +_Not yet decided — fill in after clicking through all three variants._ + +## Cleanup + +Once a variant wins (or the model is rejected), delete this whole +`src/routes/prototype/` directory and fold the winning interaction into the +real `ActiveGroupSwitcher` + a new Settings section, per PR #273's branch. diff --git a/src/routes/prototype/active-scope.tsx b/src/routes/prototype/active-scope.tsx new file mode 100644 index 00000000..58614aeb --- /dev/null +++ b/src/routes/prototype/active-scope.tsx @@ -0,0 +1,476 @@ +/** + * PROTOTYPE — throwaway. Not linked from any nav; visit /prototype/active-scope directly. + * Answers: does the "scope pin lives in Settings, header dropdown is a transient + * override" model (from the #124/#122/#125 grill session) feel right? + * Delete this whole route once a variant wins or the question is answered. + * + * Mock data only — no Supabase calls. State lives in memory (useState), reset on reload. + */ +import { + createFileRoute, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronDown, + Star, + X, + Users, + Globe, + User as UserIcon, + ChevronLeft, + ChevronRight, +} from "lucide-react"; + +const searchSchema = z.object({ + variant: z.enum(["A", "B", "C"]).catch("A"), +}); + +export const Route = createFileRoute("/prototype/active-scope")({ + component: ActiveScopePrototype, + validateSearch: searchSchema, +}); + +type Scope = + | { kind: "group"; id: string; name: string } + | { kind: "everyone" } + | { kind: "me" }; + +const MOCK_GROUPS = [ + { id: "g1", name: "Desert Crew" }, + { id: "g2", name: "Berlin Squad" }, +]; + +function scopeKey(s: Scope) { + return s.kind === "group" ? `group:${s.id}` : s.kind; +} + +function scopeLabel(s: Scope) { + if (s.kind === "group") return s.name; + if (s.kind === "everyone") return "Everyone"; + return "Me"; +} + +function scopeIcon(s: Scope) { + if (s.kind === "group") return Users; + if (s.kind === "everyone") return Globe; + return UserIcon; +} + +const ALL_SCOPES: Scope[] = [ + ...MOCK_GROUPS.map((g) => ({ + kind: "group" as const, + id: g.id, + name: g.name, + })), + { kind: "everyone" }, + { kind: "me" }, +]; + +// Shared mock state: pinned (Settings) scope + current (header override) scope. +function useMockScopeState() { + const [pinned, setPinned] = useState({ + kind: "group", + id: "g1", + name: "Desert Crew", + }); + const [current, setCurrent] = useState(pinned); + + function selectScope(s: Scope) { + setCurrent(s); + } + function returnToDefault() { + setCurrent(pinned); + } + function pinScope(s: Scope) { + setPinned(s); + setCurrent(s); + } + + const isOverridden = scopeKey(current) !== scopeKey(pinned); + + return { + pinned, + current, + isOverridden, + selectScope, + returnToDefault, + pinScope, + }; +} + +function ActiveScopePrototype() { + const { variant } = useSearch({ from: "/prototype/active-scope" }); + const navigate = useNavigate({ from: "/prototype/active-scope" }); + const state = useMockScopeState(); + + function setVariant(v: "A" | "B" | "C") { + navigate({ search: { variant: v } }); + } + + return ( +
+
+ Prototype — Active Group / Scope switcher · fake header + fake settings, + no real data +
+ + {variant === "A" && } + {variant === "B" && } + {variant === "C" && } + + +
+ ); +} + +type ScopeState = ReturnType; + +/* ---------------- Variant A ---------------- + * Standard header dropdown. Pinned entry gets a star + "default" label inline. + * "x return to default" pill appears next to the trigger when overridden. + * Settings mocked as a simple card below. + */ +function VariantA({ state }: { state: ScopeState }) { + const { + pinned, + current, + isOverridden, + selectScope, + returnToDefault, + pinScope, + } = state; + const Icon = scopeIcon(current); + + return ( +
+
+ Header: + + + + + + Viewing scope + + {ALL_SCOPES.map((s) => { + const ItemIcon = scopeIcon(s); + const isPinned = scopeKey(s) === scopeKey(pinned); + const isActive = scopeKey(s) === scopeKey(current); + return ( + selectScope(s)} + className={cn( + "flex items-center gap-2", + isActive && "bg-accent", + )} + > + + {scopeLabel(s)} + {isPinned && ( + + )} + + ); + })} + + + + {isOverridden && ( + + )} +
+ + +
+ ); +} + +/* ---------------- Variant B ---------------- + * Two-row split: top row = "Your default" (pinned, always shown, click to jump straight + * back — no dropdown needed for the common case). Second row = "Browse" dropdown for + * everything else. Tests whether separating "go to default" from "explore" reads clearer + * than one merged list. + */ +function VariantB({ state }: { state: ScopeState }) { + const { + pinned, + current, + isOverridden, + selectScope, + returnToDefault, + pinScope, + } = state; + const PinnedIcon = scopeIcon(pinned); + + return ( +
+
+
+ + Your default + + +
+ +
+ + Browse + + + + + + + {ALL_SCOPES.map((s) => { + const ItemIcon = scopeIcon(s); + return ( + selectScope(s)} + className="flex items-center gap-2" + > + + {scopeLabel(s)} + {scopeKey(s) === scopeKey(pinned) && ( + + default + + )} + + ); + })} + + +
+
+ + +
+ ); +} + +/* ---------------- Variant C ---------------- + * Segmented control for the pinned group vs "other" (Everyone/Me/other groups) tucked + * behind a single "More" affordance — tests whether making the group-pinned case the + * primary, always-visible control (matching "centralize around your crew") beats a + * generic dropdown outright, at the cost of one extra click to reach Everyone/Me. + */ +function VariantC({ state }: { state: ScopeState }) { + const { + pinned, + current, + isOverridden, + selectScope, + returnToDefault, + pinScope, + } = state; + const [moreOpen, setMoreOpen] = useState(false); + const others = ALL_SCOPES.filter((s) => scopeKey(s) !== scopeKey(pinned)); + + return ( +
+
+
+ + +
+ + {moreOpen && ( +
+ {others.map((s) => { + const ItemIcon = scopeIcon(s); + return ( + + ); + })} +
+ )} +
+ + +
+ ); +} + +function SettingsCard({ + pinned, + onPin, +}: { + pinned: Scope; + onPin: (s: Scope) => void; +}) { + return ( +
+

Settings (mock)

+

+ Your default scope — what you see every time you open the app. +

+
+ {ALL_SCOPES.map((s) => { + const ItemIcon = scopeIcon(s); + const isPinned = scopeKey(s) === scopeKey(pinned); + return ( + + ); + })} +
+
+ ); +} + +function PrototypeSwitcher({ + variants, + current, + onChange, +}: { + variants: { key: "A" | "B" | "C"; label: string }[]; + current: "A" | "B" | "C"; + onChange: (v: "A" | "B" | "C") => void; +}) { + const idx = variants.findIndex((v) => v.key === current); + + function cycle(dir: 1 | -1) { + const next = variants[(idx + dir + variants.length) % variants.length]; + onChange(next.key); + } + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + if (e.key === "ArrowLeft") cycle(-1); + if (e.key === "ArrowRight") cycle(1); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idx]); + + if (import.meta.env.PROD) return null; + + return ( +
+ + + {current} — {variants[idx]?.label} + + +
+ ); +} From 730e0646334968d0cf360e0fb3a3f59ae3195434 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 09:09:37 +0000 Subject: [PATCH 2/6] fix(prototype): guard active-scope route and listener in prod Address Copilot review on #288: block the route in production via beforeLoad, and skip attaching the keydown listener (with correct deps) when PROD instead of relying on a post-effect early return. --- src/routes/prototype/active-scope.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/routes/prototype/active-scope.tsx b/src/routes/prototype/active-scope.tsx index 58614aeb..09945894 100644 --- a/src/routes/prototype/active-scope.tsx +++ b/src/routes/prototype/active-scope.tsx @@ -8,6 +8,7 @@ */ import { createFileRoute, + notFound, useNavigate, useSearch, } from "@tanstack/react-router"; @@ -42,6 +43,9 @@ const searchSchema = z.object({ export const Route = createFileRoute("/prototype/active-scope")({ component: ActiveScopePrototype, validateSearch: searchSchema, + beforeLoad: () => { + if (import.meta.env.PROD) throw notFound(); + }, }); type Scope = @@ -432,6 +436,8 @@ function PrototypeSwitcher({ } useEffect(() => { + if (import.meta.env.PROD) return; + function onKeyDown(e: KeyboardEvent) { const target = e.target as HTMLElement | null; if ( @@ -447,8 +453,7 @@ function PrototypeSwitcher({ } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [idx]); + }, [idx, variants, onChange]); if (import.meta.env.PROD) return null; From c2aaf4eec69c53b6fe25d241dc2b6a8a8837d6ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:15:25 +0000 Subject: [PATCH 3/6] revert(prototype): drop prod route guard, this PR isn't merging to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PROD block on beforeLoad was addressing a real-production-risk concern, but this is a throwaway prototype PR that won't land on main — the guard just breaks the shareable Vercel preview link. --- src/routes/prototype/active-scope.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/routes/prototype/active-scope.tsx b/src/routes/prototype/active-scope.tsx index 09945894..b92bd14a 100644 --- a/src/routes/prototype/active-scope.tsx +++ b/src/routes/prototype/active-scope.tsx @@ -8,7 +8,6 @@ */ import { createFileRoute, - notFound, useNavigate, useSearch, } from "@tanstack/react-router"; @@ -43,9 +42,6 @@ const searchSchema = z.object({ export const Route = createFileRoute("/prototype/active-scope")({ component: ActiveScopePrototype, validateSearch: searchSchema, - beforeLoad: () => { - if (import.meta.env.PROD) throw notFound(); - }, }); type Scope = From 3d9f2929414440571710f4a0d879b290e1a25068 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:20:10 +0000 Subject: [PATCH 4/6] revert(prototype): keep the switcher bar visible on preview builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same reasoning as the route guard revert — this PR never merges to main, so hiding the variant switcher in PROD only breaks the shareable preview link instead of protecting anything. --- src/routes/prototype/active-scope.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/routes/prototype/active-scope.tsx b/src/routes/prototype/active-scope.tsx index b92bd14a..3286467b 100644 --- a/src/routes/prototype/active-scope.tsx +++ b/src/routes/prototype/active-scope.tsx @@ -432,8 +432,6 @@ function PrototypeSwitcher({ } useEffect(() => { - if (import.meta.env.PROD) return; - function onKeyDown(e: KeyboardEvent) { const target = e.target as HTMLElement | null; if ( @@ -451,8 +449,6 @@ function PrototypeSwitcher({ return () => window.removeEventListener("keydown", onKeyDown); }, [idx, variants, onChange]); - if (import.meta.env.PROD) return null; - return (
- Viewing scope - - {ALL_SCOPES.map((s) => { - const ItemIcon = scopeIcon(s); - const isPinned = scopeKey(s) === scopeKey(pinned); - const isActive = scopeKey(s) === scopeKey(current); - return ( - selectScope(s)} - className={cn( - "flex items-center gap-2", - isActive && "bg-accent", - )} - > - - {scopeLabel(s)} - {isPinned && ( - - )} - - ); - })} + @@ -215,26 +262,18 @@ function VariantA({ state }: { state: ScopeState }) { )}
- + ); } /* ---------------- Variant B ---------------- * Two-row split: top row = "Your default" (pinned, always shown, click to jump straight - * back — no dropdown needed for the common case). Second row = "Browse" dropdown for - * everything else. Tests whether separating "go to default" from "explore" reads clearer - * than one merged list. + * back — no dropdown needed for the common case). Second row = compact dropdown for + * everything else, same grouped ordering as variant A. */ function VariantB({ state }: { state: ScopeState }) { - const { - pinned, - current, - isOverridden, - selectScope, - returnToDefault, - pinScope, - } = state; + const { pinned, current, isOverridden, selectScope, returnToDefault } = state; const PinnedIcon = scopeIcon(pinned); return ( @@ -262,57 +301,43 @@ function VariantB({ state }: { state: ScopeState }) { - - {ALL_SCOPES.map((s) => { - const ItemIcon = scopeIcon(s); - return ( - selectScope(s)} - className="flex items-center gap-2" - > - - {scopeLabel(s)} - {scopeKey(s) === scopeKey(pinned) && ( - - default - - )} - - ); - })} + - + ); } /* ---------------- Variant C ---------------- * Segmented control for the pinned group vs "other" (Everyone/Me/other groups) tucked - * behind a single "More" affordance — tests whether making the group-pinned case the - * primary, always-visible control (matching "centralize around your crew") beats a - * generic dropdown outright, at the cost of one extra click to reach Everyone/Me. + * behind a single "More" affordance — makes the group-pinned case the primary, + * always-visible control, at the cost of one extra tap to reach Everyone/Me. */ function VariantC({ state }: { state: ScopeState }) { - const { - pinned, - current, - isOverridden, - selectScope, - returnToDefault, - pinScope, - } = state; + const { pinned, current, isOverridden, selectScope, returnToDefault } = state; const [moreOpen, setMoreOpen] = useState(false); - const others = ALL_SCOPES.filter((s) => scopeKey(s) !== scopeKey(pinned)); + const otherGroups = MOCK_GROUPS.filter( + (g) => !(pinned.kind === "group" && pinned.id === g.id), + ).map((g): Scope => ({ kind: "group", id: g.id, name: g.name })); + const others: Scope[] = [ + ...otherGroups, + { kind: "everyone" }, + { kind: "me" }, + ]; return (
@@ -369,47 +394,76 @@ function VariantC({ state }: { state: ScopeState }) { )}
- + ); } -function SettingsCard({ - pinned, - onPin, -}: { - pinned: Scope; - onPin: (s: Scope) => void; -}) { +/** + * Mocked Settings section — two independent controls per the model: + * "Active group" (which group, always settable) and "Active scope" + * (group / everyone / me — determines what the pin resolves to). + */ +function SettingsCard({ state }: { state: ScopeState }) { + const { activeGroupId, pinned, setActiveGroup, setPinnedScope } = state; + return ( -
-

Settings (mock)

-

- Your default scope — what you see every time you open the app. -

-
- {ALL_SCOPES.map((s) => { - const ItemIcon = scopeIcon(s); - const isPinned = scopeKey(s) === scopeKey(pinned); - return ( +
+

Settings (mock)

+ +
+

+ Active group — which of your groups +

+
+ {MOCK_GROUPS.map((g) => ( - ); - })} + ))} +
+
+ +
+

+ Active scope — your default steady-state view +

+
+ {[ + { kind: "group" as const, label: "Group" }, + { kind: "everyone" as const, label: "Everyone" }, + { kind: "me" as const, label: "Me" }, + ].map((opt) => { + const isPinned = pinned.kind === opt.kind; + return ( + + ); + })} +
); From 885dd7621c57ba07ba5be1f9abbe1211d0052daa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:29:11 +0000 Subject: [PATCH 6/6] feat(prototype): pin sorts to top of scope dropdown, record verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variant A won: single flat dropdown + two independent Settings controls. Refined per feedback — the pinned scope always sorts first in the list (still starred) so reverting to it never requires scanning past groups/Everyone/Me in their natural order. --- src/routes/prototype/NOTES.md | 15 ++++++--- src/routes/prototype/active-scope.tsx | 46 +++++++++++++-------------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/routes/prototype/NOTES.md b/src/routes/prototype/NOTES.md index e31f2d74..12eb8d73 100644 --- a/src/routes/prototype/NOTES.md +++ b/src/routes/prototype/NOTES.md @@ -35,10 +35,17 @@ switching in the header. ## Verdict -_Not yet decided — fill in after clicking through all three variants._ +**Variant A won** — a single flat dropdown, plus the two independent Settings +controls (Active group, Active scope). Refinement on top of the original A: +the pinned scope now always sorts to the **top of the dropdown list** (still +starred), so reverting to it never requires hunting through groups/Everyone/Me +in their natural order — it's always the first row. Variants B and C did not +win; their code is left in place for reference only, not as live alternatives. ## Cleanup -Once a variant wins (or the model is rejected), delete this whole -`src/routes/prototype/` directory and fold the winning interaction into the -real `ActiveGroupSwitcher` + a new Settings section, per PR #273's branch. +Delete this whole `src/routes/prototype/` directory once the winning +interaction (Variant A + pinned-to-top ordering + the two Settings controls) +is folded into the real `ActiveGroupSwitcher` + a new Settings section, per +PR #273's branch. PR #288 (this prototype) is throwaway and will not be +merged to `main`. diff --git a/src/routes/prototype/active-scope.tsx b/src/routes/prototype/active-scope.tsx index 2fac9970..8e96d368 100644 --- a/src/routes/prototype/active-scope.tsx +++ b/src/routes/prototype/active-scope.tsx @@ -157,14 +157,14 @@ function ActiveScopePrototype() { } /** - * Shared dropdown body, ordered per spec: - * active group (if pinned scope is group) — context line, not clickable - * pinned scope — context line, not clickable + * Shared dropdown body. WINNER (per user verdict): the pinned scope always + * sits first — starred, at the top — so reverting to it never requires + * hunting through the list. Ordered: + * pinned scope (starred) * ------- - * list of groups + * remaining groups * ---- - * everyone - * me + * remaining of everyone/me * Kept intentionally compact/flat (no nested submenus) for mobile. */ function ScopeMenuBody({ @@ -194,28 +194,26 @@ function ScopeMenuBody({ ); } + const otherGroups = MOCK_GROUPS.filter( + (g) => !(pinned.kind === "group" && pinned.id === g.id), + ).map((g): Scope => ({ kind: "group", id: g.id, name: g.name })); + const otherScopes = (["everyone", "me"] as const) + .filter((kind) => pinned.kind !== kind) + .map((kind): Scope => ({ kind })); + return ( <> - {pinned.kind === "group" && ( -
- Active group:{" "} - {pinned.name} -
- )} -
- Pinned scope: - - {scopeLabel(pinned)} - - -
+ - {MOCK_GROUPS.map((g) => ( - + {otherGroups.map((s) => ( + + ))} + {otherGroups.length > 0 && otherScopes.length > 0 && ( + + )} + {otherScopes.map((s) => ( + ))} - - - ); }