Add reusable segmented inbox filters

This commit is contained in:
JB
2026-07-25 15:42:34 -04:00
parent 29f4de807a
commit 8b7b747866
7 changed files with 134 additions and 8 deletions
+16
View File
@@ -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:
+16
View File
@@ -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.
+3
View File
@@ -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}}
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import GameIcon from './GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
export type SegmentedChoiceOption = {
value: string;
label: string;
className?: string;
icon?: GameIconName;
};
let {
legend,
name,
options,
mode = 'radio',
value = $bindable(''),
selected = $bindable([]),
columns = options.length,
required = false,
onchange
}: {
legend: string;
name: string;
options: SegmentedChoiceOption[];
mode?: 'radio' | 'checkbox';
value?: string;
selected?: string[];
columns?: number;
required?: boolean;
onchange?: (values: string[]) => void;
} = $props();
function change(optionValue: string, checked: boolean) {
if (mode === 'radio') {
value = optionValue;
onchange?.([optionValue]);
return;
}
selected = checked ? [...selected, optionValue] : selected.filter((current) => current !== optionValue);
onchange?.(selected);
}
</script>
<fieldset class:multi={mode === 'checkbox'} class="segmented-choice">
<legend>{legend}</legend>
<div style={`--segmented-columns:${columns}`}>
{#each options as option}
<label>
<input
type={mode}
{name}
value={option.value}
checked={mode === 'radio' ? value === option.value : selected.includes(option.value)}
{required}
onchange={(event) => change(option.value, event.currentTarget.checked)}
/>
<span class={option.className ?? ''}>
{#if option.icon}<GameIcon name={option.icon} decorative/>{:else}<i aria-hidden="true"></i>{/if}
<b>{option.label}</b>
</span>
</label>
{/each}
</div>
</fieldset>
@@ -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 = {
+12 -1
View File
@@ -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<any>(null);
@@ -40,6 +41,9 @@
if (!('Notification' in window)) { browserPermission = 'unsupported'; return; }
browserPermission = await Notification.requestPermission();
}
function applyFilters() {
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
}
</script>
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
@@ -72,7 +76,14 @@
</PendingForm>
</section>
{/if}
<div class="notification-toolbar"><form><select name="category"><option value="">ALL CHANNELS</option>{#each Object.entries(labels) as [value,label]}<option {value} selected={data.filters.category === value}>{label}</option>{/each}</select><select name="state"><option value="">ALL MESSAGES</option><option value="unread" selected={data.filters.unreadOnly}>UNREAD ONLY</option></select><button class="secondary">APPLY FILTER →</button></form>{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}</div>
<div class="notification-toolbar">
<form class="notification-filter-console">
<input type="hidden" name="filtering" value="1"/>
<SegmentedChoice legend="CHANNELS" name="channel" mode="checkbox" options={Object.entries(labels).map(([value, label]) => ({ value, label }))} selected={data.filters.categories} onchange={applyFilters}/>
<SegmentedChoice legend="MESSAGE STATE" name="state" mode="checkbox" options={[{value:'unread',label:'UNREAD'},{value:'read',label:'READ'}]} selected={data.filters.states} onchange={applyFilters}/>
</form>
{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}
</div>
<RegistryPagination pagination={data.pagination}/>
<section class="notification-list">
{#each data.notifications as notification}
+5 -2
View File
@@ -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 @@
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON"/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
<fieldset class="field protocol-quality-picker"><legend>QUALITY</legend><div>{#each qualities as quality}<label><input type="radio" name="quality" value={quality} bind:group={editing.quality}/><span class={qualityClass(quality)}><i></i><b>{quality}</b></span></label>{/each}</div></fieldset>
<fieldset class="field protocol-type-picker"><legend>TYPE</legend><div>{#each attributeTypes as type}<label><input type="radio" name="attributeType" value={type} bind:group={editing.primary_attribute_type}/><span><GameIcon name={iconName(type)} decorative/><b>{type}</b></span></label>{/each}</div></fieldset>
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/>
</div>
</div>
{@const selectedProfile = profileFor(editing.quality, editing.primary_attribute_type)}