Improve moderation controls and term management

This commit is contained in:
JB
2026-07-27 16:44:53 -04:00
parent 27cbeca0ed
commit fe4acec191
8 changed files with 1065 additions and 362 deletions
@@ -0,0 +1,74 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
id,
kicker,
title,
status,
statusTone = 'default',
open = $bindable(true),
class: className = '',
children
}: {
id: string;
kicker: string;
title: string;
status?: string;
statusTone?: 'default' | 'success' | 'danger';
open?: boolean;
class?: string;
children: Snippet;
} = $props();
</script>
<section class={`collapsible-section ${className}`} aria-labelledby={`${id}-heading`}>
<h3 id={`${id}-heading`}>
<button
type="button"
aria-expanded={open}
aria-controls={`${id}-content`}
onclick={() => open = !open}
>
<span class="collapsible-section-identity">
<small>{kicker}</small>
<b>{title}</b>
</span>
{#if status}<strong class:success={statusTone === 'success'} class:danger={statusTone === 'danger'}>{status}</strong>{/if}
<span class="collapsible-section-action">
<span>{open ? 'COLLAPSE' : 'EXPAND'}</span>
<i aria-hidden="true">{open ? '−' : '+'}</i>
</span>
</button>
</h3>
<div id={`${id}-content`} class="collapsible-section-content" hidden={!open}>
{@render children()}
</div>
</section>
<style>
.collapsible-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
h3{margin:0}
h3>button{width:100%;min-height:58px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:12px;padding:9px 11px;border:0;background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}
h3>button:hover{background:#141819}
h3>button:active{filter:brightness(.88)}
h3>button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}
.collapsible-section-identity{min-width:0;display:grid;gap:2px}
.collapsible-section-identity small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.collapsible-section-identity b{overflow:hidden;font-size:var(--shd-type-body);letter-spacing:.5px;text-overflow:ellipsis;white-space:nowrap}
h3>button>strong{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px;white-space:nowrap}
h3>button>strong.success{color:var(--shd-success)}
h3>button>strong.danger{color:var(--shd-danger)}
.collapsible-section-action{display:flex;align-items:center;gap:8px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.collapsible-section-action i{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--shd-border-strong);font-size:16px;font-style:normal}
.collapsible-section-content{padding:11px;border-top:1px solid var(--shd-border)}
.collapsible-section-content[hidden]{display:none}
@media(max-width:650px){
h3>button{min-height:64px;grid-template-columns:minmax(0,1fr) auto;gap:8px}
h3>button>strong{grid-column:1;grid-row:2}
.collapsible-section-action{grid-column:2;grid-row:1/3}
.collapsible-section-action>span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.collapsible-section-content{padding:9px}
}
@media(prefers-reduced-motion:reduce){h3>button{transition:none}}
</style>
@@ -3,6 +3,7 @@ import {
assessCommunityModeration,
findLocalModerationMatches,
normalizeModerationTerm,
parseModerationTermBatch,
type CommunityModerationPolicy
} from './community-moderation';
@@ -34,6 +35,27 @@ describe('community moderation policy', () => {
expect(normalizeModerationTerm(' Rogue\u00a0Agent ')).toBe('rogue agent');
});
it('parses pasted terms with automatic word and phrase matching', () => {
expect(parseModerationTermBatch('weapon\nkill, machine gun; assault rifle')).toEqual({
terms: [
{ value: 'weapon', normalizedValue: 'weapon', matchMode: 'word' },
{ value: 'kill', normalizedValue: 'kill', matchMode: 'word' },
{ value: 'machine gun', normalizedValue: 'machine gun', matchMode: 'phrase' },
{ value: 'assault rifle', normalizedValue: 'assault rifle', matchMode: 'phrase' }
],
duplicateCount: 0
});
});
it('deduplicates pasted terms after compatibility and case normalization', () => {
const parsed = parseModerationTermBatch('Weapon\nweapon\nMACHINE GUN\nmachine gun');
expect(parsed.terms).toEqual([
{ value: 'Weapon', normalizedValue: 'weapon', matchMode: 'word' },
{ value: 'MACHINE GUN', normalizedValue: 'machine gun', matchMode: 'phrase' }
]);
expect(parsed.duplicateCount).toBe(2);
});
it('allows an exact local exception to suppress a matching banned term', () => {
const matches = findLocalModerationMatches('Rogue Agent tactics', 'comment', policy({
detectPii: false,
+31
View File
@@ -107,6 +107,37 @@ export function normalizeModerationTerm(value: string) {
return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase();
}
export type ParsedModerationTermBatch = {
terms: Array<{
value: string;
normalizedValue: string;
matchMode: 'word' | 'phrase';
}>;
duplicateCount: number;
};
export function parseModerationTermBatch(value: string): ParsedModerationTermBatch {
const entries = value.normalize('NFKC')
.split(/[\r\n,;]+/u)
.map((entry) => entry.trim().replace(/\s+/g, ' '))
.filter(Boolean);
const terms: ParsedModerationTermBatch['terms'] = [];
const seen = new Set<string>();
let duplicateCount = 0;
for (const entry of entries) {
const normalizedValue = normalizeModerationTerm(entry);
const matchMode = /\s/u.test(normalizedValue) ? 'phrase' : 'word';
const key = `${matchMode}:${normalizedValue}`;
if (seen.has(key)) {
duplicateCount += 1;
continue;
}
seen.add(key);
terms.push({ value: entry, normalizedValue, matchMode });
}
return { terms, duplicateCount };
}
export function hashModeratedContent(value: string) {
return createHash('sha256').update(value.normalize('NFC')).digest('hex');
}