From 8b7b7478664c435e2371b82f9ad5d60bb9389dc8 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 15:42:34 -0400 Subject: [PATCH] Add reusable segmented inbox filters --- AGENTS.md | 16 +++++ docs/DESIGN_SYSTEM.md | 16 +++++ src/app.css | 3 + src/lib/components/SegmentedChoice.svelte | 65 +++++++++++++++++++ .../account/notifications/+page.server.ts | 22 +++++-- src/routes/account/notifications/+page.svelte | 13 +++- src/routes/admin/protocols/+page.svelte | 7 +- 7 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 src/lib/components/SegmentedChoice.svelte diff --git a/AGENTS.md b/AGENTS.md index 2b3f2cc..69ad6ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,22 @@ or revise a reusable design decision do not require a documentation update. If uncertain whether a visual change is significant, update the design system. +## Reusable interface patterns + +Favor shared, generic components whenever a visual or interaction pattern is +used in more than one place or is likely to recur. Before adding page-local +markup and styles, check `src/lib/components` and extend an appropriate shared +component when practical. When introducing a reusable pattern, migrate the +clearest existing duplicate in the same change so the component has more than +one real consumer. + +For small, finite option sets, prefer the shared segmented-choice pattern over +a dropdown. Use radio semantics for exactly one choice and checkbox semantics +for additive independent choices. Reserve dropdowns for long, dynamic, or +search-oriented option sets. Preserve native form semantics, keyboard access, +focus-visible, selected, disabled, pressed, responsive, and reduced-motion +behavior in shared controls. + ## ISAC editor lifecycle All ISAC data-management editors must follow the same successful-save behavior: diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index c146d3b..65f9ec1 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -735,3 +735,19 @@ language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or `VIEW BUILD & REPLY`; never substitute a generic destination label when the notification type is known. The same response label appears in the detail modal. On mobile, response actions become full-width 44-pixel targets. + +Inbox filtering uses additive segmented selectors derived from the OS Protocol +quality control. Channels and message states are independent checkbox-backed +segments, all selected by default, and each selection change applies +immediately without a submit button. Selected segments use the orange lower +rail and filled square indicator; deselected segments remain readable and +available. The channel rail collapses from six to three and then two columns as +space narrows, with minimum 44-pixel mobile targets. + +Use the shared `SegmentedChoice` component for compact, finite option sets. +Radio mode represents one required choice; checkbox mode represents additive +independent choices. Prefer this visible segmented treatment over a dropdown +when the complete option set is small enough to scan comfortably. Dropdowns +remain appropriate for long, dynamic, or search-oriented lists. Both modes +share hover, pressed, focus-visible, selected, disabled, responsive, and +44-pixel mobile-target behavior so feature modules do not reimplement it. diff --git a/src/app.css b/src/app.css index 18207d7..15dfd41 100644 --- a/src/app.css +++ b/src/app.css @@ -147,6 +147,9 @@ .notification-severity{flex:0 0 auto;padding:3px 5px;border:1px solid #8a6731;color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.6px}.notification-severity.error-severity,.notification-detail-state .error-severity{border-color:#6d3f39;color:var(--shd-danger)} .system-message-body{display:grid;gap:6px;margin:8px 0 7px;padding:8px 10px;border-left:2px solid #68a9c4;background:linear-gradient(90deg,rgba(49,104,128,.12),rgba(49,104,128,.025))}.system-message-kicker{display:flex;align-items:center;gap:6px;color:#72b8d5}.system-message-kicker span{font-size:9px}.system-message-kicker b{font-size:5px;letter-spacing:1.15px}.system-message-body>p,.notification-list article .system-message-body>p{margin:0;color:#a8b1b3;font:11px/1.5 Georgia,serif}.system-message-detail{display:grid;grid-template-columns:96px minmax(0,1fr);gap:9px;padding-top:6px;border-top:1px solid rgba(112,185,214,.18)}.system-message-detail>span{padding-top:2px;color:#79bcd6;font-size:5px;font-weight:900;letter-spacing:.9px}.system-message-detail>p,.notification-list article .system-message-detail>p{margin:0;color:#8f999b;font:10px/1.5 Georgia,serif}.system-message-body.expanded{margin:0;min-height:110px;padding:15px 16px;gap:10px;border-left-color:#68a9c4;background:linear-gradient(90deg,rgba(49,104,128,.16),rgba(14,17,18,.9))}.system-message-body.expanded .system-message-kicker b{font-size:6px}.system-message-body.expanded>p{color:#c0c7c8;font-size:13px;line-height:1.7}.system-message-body.expanded .system-message-detail{grid-template-columns:125px minmax(0,1fr);padding-top:10px}.system-message-body.expanded .system-message-detail>span{font-size:6px}.system-message-body.expanded .system-message-detail>p{color:#adb6b8;font-size:12px;line-height:1.65}@media(max-width:700px){.system-message-detail,.system-message-body.expanded .system-message-detail{grid-template-columns:1fr;gap:4px}} .system-message-body:not(.expanded)>p,.system-message-body:not(.expanded) .system-message-detail>p,.notification-body-preview{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}.notification-actions{display:flex;align-items:center;gap:7px}.notification-actions .notification-response-action{min-height:28px;display:inline-flex;align-items:center;padding:0 10px;border:1px solid #6b5329;background:#1b160d;color:var(--shd-orange);transition:var(--shd-transition)}.notification-actions .notification-response-action:hover{border-color:var(--shd-orange);background:#241b0d;color:#fff}.notification-actions .notification-response-action:active{transform:translateY(1px);filter:brightness(.9)}.notification-actions .notification-response-action:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.profile-anchor{position:relative;top:calc(-1 * var(--shd-header-height));visibility:hidden}@media(max-width:700px){.notification-actions{align-items:stretch;flex-direction:column}.notification-actions .notification-response-action{min-height:44px;justify-content:center}} +.notification-filter-console{flex:1;display:grid!important;grid-template-columns:minmax(520px,1fr) minmax(190px,.34fr);align-items:end!important;gap:8px!important}.notification-filter-console>input{display:none}.notification-filter-group{min-width:0;margin:0;padding:0;border:0}.notification-filter-group legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.notification-filter-group>div{height:40px;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.notification-filter-group label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.notification-filter-group label:first-child{border-left:0}.notification-filter-group input{position:absolute;opacity:0;pointer-events:none}.notification-filter-group span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.notification-filter-group span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.notification-filter-group b{overflow:hidden;text-overflow:ellipsis;color:inherit;font-size:6px;letter-spacing:.6px;white-space:nowrap}.notification-filter-group label:hover span{background:#171b1c;color:#c4c8c9}.notification-filter-group label:active span{transform:translateY(1px);filter:brightness(.88)}.notification-filter-group input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.notification-filter-group input:checked+span i{background:currentColor}.notification-filter-group input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.state-filter-group>div{grid-template-columns:repeat(2,minmax(0,1fr))}@media(max-width:900px){.notification-filter-console{grid-template-columns:1fr}.notification-filter-group>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-group span{min-height:44px}.state-filter-group>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-group>div{grid-template-columns:repeat(2,1fr)}} +.segmented-choice{min-width:0;margin:0;padding:0;border:0}.segmented-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.segmented-choice>div{height:32px;display:grid;grid-template-columns:repeat(var(--segmented-columns),minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.segmented-choice label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.segmented-choice label:first-child{border-left:0}.segmented-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.segmented-choice span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.segmented-choice span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.segmented-choice .game-icon{width:18px;height:18px;opacity:.55}.segmented-choice b{overflow:hidden;color:inherit;font-size:6px;letter-spacing:.6px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.segmented-choice label:hover span{background:#171b1c;color:#c4c8c9}.segmented-choice label:active span{transform:translateY(1px);filter:brightness(.88)}.segmented-choice input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.segmented-choice input:checked+span i{background:currentColor}.segmented-choice input:checked+span .game-icon{opacity:1}.segmented-choice input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.segmented-choice input:disabled+span{opacity:.45;cursor:not-allowed}.protocol-classification-stack .segmented-choice legend{font-size:8px}.protocol-classification-stack .segmented-choice>div{margin-top:5px}.protocol-classification-stack .quality-standard i,.protocol-classification-stack .quality-specialized i,.protocol-classification-stack .quality-superior i,.protocol-classification-stack .quality-highend i{background:currentColor} +.notification-filter-console{min-width:0;grid-template-columns:minmax(0,3fr) minmax(180px,1fr)}.notification-filter-console .segmented-choice>div{height:40px}@media(max-width:900px){.notification-toolbar .notification-filter-console{grid-template-columns:1fr}.notification-filter-console .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-console .segmented-choice span{min-height:44px}.notification-filter-console .segmented-choice:last-child>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-console .segmented-choice>div{grid-template-columns:repeat(2,1fr)}} .profile-metrics>a{display:flex;flex-direction:column;justify-content:center;padding:0 16px;border-left:1px solid var(--shd-border);color:inherit}.profile-metrics>a:hover,.profile-metrics>a:focus-visible{background:#171b1c}.profile-metrics>a strong{color:var(--shd-orange)}.profile-notifications{border-top:2px solid #4f4329}.profile-notification-list{display:grid;border:1px solid var(--shd-border)}.profile-notification-list>a{min-width:0;display:grid;grid-template-columns:145px minmax(0,1fr) 55px;align-items:center;gap:10px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0f1213;color:#aeb4b5}.profile-notification-list>a:first-child{border-top:0}.profile-notification-list>a.unread{background:#151718;box-shadow:inset 2px 0 var(--shd-orange)}.profile-notification-list>a.mandatory{box-shadow:inset 2px 0 #68a9c4}.profile-notification-list>a:hover,.profile-notification-list>a:focus-visible{background:#191d1e}.profile-notification-list>a>div,.profile-notification-list>a>span{min-width:0;display:grid;gap:3px}.profile-notification-list>a b{color:var(--shd-orange);font-size:6px}.profile-notification-list>a strong{overflow:hidden;font-size:8px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a em{justify-self:end;color:var(--shd-orange);font-size:6px;font-style:normal;font-weight:900}.profile-empty.compact{min-height:90px}@media(max-width:700px){.profile-notification-list>a{grid-template-columns:1fr auto}.profile-notification-list>a>div{grid-column:1/3}.profile-notification-list>a>span small{white-space:normal}.profile-metrics>a{min-height:55px;border-top:1px solid var(--shd-border)}} .profile-metrics>a{min-width:77px;height:59px;align-items:center;padding:7px;border:1px solid var(--shd-border);background:#111415} .notification-command{align-items:center}.notification-command>div:first-child{display:grid;align-content:center}.notification-command>div:last-child{align-self:center}.notification-preference-head>span:not(:first-child){text-align:center}.notification-preference-row>label{justify-content:center}.notification-list>article{grid-template-columns:190px minmax(0,1fr);transition:border-color .14s ease,background .14s ease}.notification-list>article.isac-message{border-left:2px solid #68a9c4;background:linear-gradient(90deg,#101a1e 0,#101314 34%)}.notification-list>article.positive{border-left-color:var(--shd-success)}.notification-list>article.negative{border-left-color:var(--shd-danger)}.notification-list>article.positive:not(.isac-message){background:linear-gradient(90deg,#101914 0,#101314 34%)}.notification-list>article.negative:not(.isac-message){background:linear-gradient(90deg,#1b1111 0,#101314 34%)}.notification-source{grid-template-columns:38px minmax(0,1fr);align-items:center;align-content:center;gap:9px}.notification-source>img{width:38px;height:38px;object-fit:cover;border:1px solid #3a4548;border-radius:50%;background:#111}.notification-source>span{min-width:0;display:grid;gap:4px}.notification-source>span b,.notification-source>span small{overflow:hidden;text-overflow:ellipsis}.notification-source>span b{white-space:normal}.notification-source>span small{color:#667073;font-size:5px;white-space:nowrap}.notification-list article header>div{display:flex;align-items:center;gap:8px;min-width:0}.notification-tone{flex:0 0 auto;padding:3px 5px;border:1px solid #50585a;color:#899193;font-size:5px;font-weight:900;letter-spacing:.6px}.positive .notification-tone{border-color:#41684b;color:var(--shd-success)}.negative .notification-tone{border-color:#6d3f39;color:var(--shd-danger)}.isac-message .notification-source b{color:#8ecce5}.notification-list article footer>button:first-child{color:var(--shd-orange)}.notification-detail-modal{width:min(720px,calc(100vw - 24px));border-top:3px solid #687174}.notification-detail-modal.isac-message{border-top-color:#68a9c4}.notification-detail-modal.positive{border-top-color:var(--shd-success)}.notification-detail-modal.negative{border-top-color:var(--shd-danger)}.notification-detail-sender{display:grid;grid-template-columns:48px 1fr 34px;align-items:center;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.notification-detail-sender>img{width:48px;height:48px;object-fit:cover;border:1px solid #485052;border-radius:50%;background:#111}.notification-detail-sender>div{display:grid;gap:4px}.notification-detail-sender span{color:#697174;font-size:6px;letter-spacing:1px}.notification-detail-sender b{font-size:10px}.notification-detail-sender>button{height:34px;border:1px solid var(--shd-border);background:#111415;color:#aeb4b5;cursor:pointer}.notification-detail-state{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:12px;margin:10px 0;padding:7px 9px;border:1px solid var(--shd-border);background:#0d1011}.notification-detail-state span,.notification-detail-state time{color:#6f7779;font-size:6px;letter-spacing:.7px}.notification-detail-state b{color:#899193;font-size:7px}.notification-detail-modal.positive .notification-detail-state b{color:var(--shd-success)}.notification-detail-modal.negative .notification-detail-state b{color:var(--shd-danger)}.notification-detail-modal h2{margin:15px 0 8px;font-size:20px}.notification-detail-modal>p{min-height:90px;margin:0;padding:13px;border-left:2px solid var(--shd-border);background:#0e1112;color:#c0c5c6;font:13px/1.7 Georgia,serif}.notification-detail-modal.isac-message>p{border-left-color:#68a9c4}.notification-detail-modal.positive>p{border-left-color:var(--shd-success)}.notification-detail-modal.negative>p{border-left-color:var(--shd-danger)}.notification-detail-modal .form-actions{align-items:center}.notification-detail-modal .form-actions form{margin-right:auto}.notification-detail-modal .form-actions .button{min-height:40px}@media(max-width:700px){.notification-list>article{grid-template-columns:1fr}.notification-source{display:grid;grid-template-columns:38px 1fr;justify-content:initial}.notification-list article header>div{align-items:flex-start;flex-direction:column}.notification-detail-state{grid-template-columns:1fr auto}.notification-detail-state time{grid-column:1/3}.notification-detail-modal .form-actions{display:grid;grid-template-columns:1fr}.notification-detail-modal .form-actions form,.notification-detail-modal .form-actions button,.notification-detail-modal .form-actions a{width:100%;margin:0}} diff --git a/src/lib/components/SegmentedChoice.svelte b/src/lib/components/SegmentedChoice.svelte new file mode 100644 index 0000000..89c744d --- /dev/null +++ b/src/lib/components/SegmentedChoice.svelte @@ -0,0 +1,65 @@ + + +
+ {legend} +
+ {#each options as option} + + {/each} +
+
diff --git a/src/routes/account/notifications/+page.server.ts b/src/routes/account/notifications/+page.server.ts index 6a2fbb5..d99452a 100644 --- a/src/routes/account/notifications/+page.server.ts +++ b/src/routes/account/notifications/+page.server.ts @@ -9,12 +9,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); const database = await getDatabase(); - const category = NOTIFICATION_CATEGORIES.includes(url.searchParams.get('category') as any) ? String(url.searchParams.get('category')) : ''; - const unreadOnly = url.searchParams.get('state') === 'unread'; + const filtering = url.searchParams.get('filtering') === '1'; + const categories = filtering + ? url.searchParams.getAll('channel').filter((value) => NOTIFICATION_CATEGORIES.includes(value as any)) + : [...NOTIFICATION_CATEGORIES]; + const states = filtering + ? url.searchParams.getAll('state').filter((value) => value === 'unread' || value === 'read') + : ['unread', 'read']; const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)']; const args: unknown[] = [user.id]; - if (category) { conditions.push('n.category=?'); args.push(category); } - if (unreadOnly) conditions.push('n.read_at IS NULL'); + if (categories.length === 0 || states.length === 0) { + conditions.push('1=0'); + } else { + if (categories.length < NOTIFICATION_CATEGORIES.length) { + conditions.push(`n.category IN (${categories.map(() => '?').join(',')})`); + args.push(...categories); + } + if (states.length === 1) conditions.push(states[0] === 'unread' ? 'n.read_at IS NULL' : 'n.read_at IS NOT NULL'); + } const where = `WHERE ${conditions.join(' AND ')}`; const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0); const pagination = getPagination(url, total); @@ -34,7 +46,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0); - return { notifications, preferences, pagination, filters: { category, unreadOnly }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) }; + return { notifications, preferences, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) }; }; export const actions: Actions = { diff --git a/src/routes/account/notifications/+page.svelte b/src/routes/account/notifications/+page.svelte index bb8d876..1c910a3 100644 --- a/src/routes/account/notifications/+page.svelte +++ b/src/routes/account/notifications/+page.svelte @@ -3,6 +3,7 @@ import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import SystemMessageBody from '$lib/components/SystemMessageBody.svelte'; + import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; let { data, form } = $props(); let settingsOpen = $state(false); let selected = $state(null); @@ -40,6 +41,9 @@ if (!('Notification' in window)) { browserPermission = 'unsupported'; return; } browserPermission = await Notification.requestPermission(); } + function applyFilters() { + document.querySelector('.notification-filter-console')?.requestSubmit(); + } Notification inbox — SHD.build @@ -72,7 +76,14 @@ {/if} -
{#if data.unread}{/if}
+
+
+ + ({ value, label }))} selected={data.filters.categories} onchange={applyFilters}/> + + + {#if data.unread}{/if} +
{#each data.notifications as notification} diff --git a/src/routes/admin/protocols/+page.svelte b/src/routes/admin/protocols/+page.svelte index 76764aa..b052609 100644 --- a/src/routes/admin/protocols/+page.svelte +++ b/src/routes/admin/protocols/+page.svelte @@ -6,6 +6,7 @@ import GameIcon from '$lib/components/GameIcon.svelte'; import type { GameIconName } from '$lib/game-icons'; import IconUploadField from '$lib/components/IconUploadField.svelte'; + import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; let { data, form } = $props(); type TalentEffect = { stat_attribute_id: string; base_value: number | string }; @@ -26,6 +27,8 @@ let handledResultToken = $state(''); const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`; const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName; + const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) })); + const attributeTypeOptions = attributeTypes.map((type) => ({ value: type, label: type, icon: iconName(type) })); const stat = (id: string) => data.stats.find((item) => item.id === id); const format = (value: number | string, statId: string) => { const numeric = Number(value); @@ -131,8 +134,8 @@
-
QUALITY
{#each qualities as quality}{/each}
-
TYPE
{#each attributeTypes as type}{/each}
+ +
{@const selectedProfile = profileFor(editing.quality, editing.primary_attribute_type)}