Skip to content

Content moderation queue

One report, one decision, next report. A moderation queue built as a single md-card that reloads with the next item after every verdict: the reporter’s md-avatar and metadata, the flagged content, report reasons as md-chips, the classifier’s confidence as an md-meter whose color role tracks severity, and a verdict row where md-segmented-button-set picks the outcome. Choosing Remove swaps the plain submit button for an md-split-button whose menu carries the harsher variants — every action is confirmed by an md-snackbar, and an md-badge counts down the queue.

Live preview — pick a verdict to advance through the four pending reports
Moderation queue Pending
Priya Desai Reported @glowdeals · 12 min ago · Report #4821
Submit verdict
Queue clear
All pending reports have been reviewed. New reports land here as they arrive.
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; align-items: center; justify-content: space-between;">
  <span>Moderation queue</span>
  <span style="position: relative;">Pending<md-badge value="4"></md-badge></span>
</div>

<md-card variant="outlined" full-width>
  <div style="display: flex; align-items: center; gap: 12px;">
    <md-avatar initials="PD"></md-avatar>
    <div>
      <span>Priya Desai</span>
      <span>Reported @glowdeals · 12 min ago · Report #4821</span>
    </div>
  </div>
  <blockquote>"FINAL HOURS!! Send a $50 gift card code to claim your prize before midnight."</blockquote>
  <div role="group" aria-label="Report reasons">
    <md-chip variant="assist" label="Scam or fraud" color="error"></md-chip>
    <md-chip variant="assist" label="Spam" color="warning"></md-chip>
  </div>
  <md-meter label="Model confidence — high" value="94" color="error" show-label show-value></md-meter>
  <md-divider></md-divider>
  <md-segmented-button-set aria-label="Verdict">
    <md-segmented-button value="approve" label="Approve"></md-segmented-button>
    <md-segmented-button value="escalate" label="Escalate"></md-segmented-button>
    <md-segmented-button value="remove" label="Remove"></md-segmented-button>
  </md-segmented-button-set>
  <md-button variant="filled" disabled>Submit verdict</md-button>
  <md-split-button id="remove-split" variant="filled" label="Remove post"
    menu-label="More removal options" controls="remove-menu"></md-split-button>
    <md-menu id="remove-menu" anchor="remove-split" placement="bottom-end">
      <md-menu-item headline="Remove and warn author"></md-menu-item>
      <md-menu-item headline="Remove and ban author"></md-menu-item>
    </md-menu>
  </md-card>

  <md-snackbar></md-snackbar>

<script type="module">
  const card = document.querySelector('[data-report-card]');
  const avatar = document.querySelector('[data-reporter-avatar]');
  const reporterEl = document.querySelector('[data-reporter]');
  const metaEl = document.querySelector('[data-meta]');
  const excerptEl = document.querySelector('[data-excerpt]');
  const reasonsEl = document.querySelector('[data-reasons]');
  const meter = document.querySelector('[data-confidence]');
  const verdictSet = document.querySelector('[data-verdict]');
  const submitBtn = document.querySelector('[data-submit]');
  const split = document.querySelector('[data-split]');
  const menu = document.getElementById('mq-remove-menu');
  const toast = document.querySelector('[data-toast]');
  const badge = document.querySelector('[data-queue-count]');
  const emptyEl = document.querySelector('[data-empty]');

  const reports = [
    { reporter: 'Priya Desai', handle: '@glowdeals', age: '12 min', id: 4821,
      excerpt: 'FINAL HOURS!! Send a $50 gift card code to claim your prize before midnight.',
      reasons: [['Scam or fraud', 'error'], ['Spam', 'warning']], confidence: 94 },
    { reporter: 'Marcus Webb', handle: '@kt_owens', age: '28 min', id: 4820,
      excerpt: 'Everyone from that neighborhood is a criminal, simple as that.',
      reasons: [['Hate speech', 'error']], confidence: 71 },
    { reporter: 'Sofia Marek', handle: '@dailyfit_max', age: '1 hr', id: 4817,
      excerpt: 'This supplement reverses diabetes in two weeks. Doctors will not tell you this.',
      reasons: [['Medical misinformation', 'warning'], ['Spam', 'warning']], confidence: 88 },
    { reporter: 'Devon Alvarez', handle: '@ravi.codes', age: '2 hr', id: 4809,
      excerpt: 'Nice tutorial. I posted the full source code at the link in my bio.',
      reasons: [['Self-promotion', 'info']], confidence: 23 },
  ];
  let index = 0;

  function initialsOf(name) {
    const parts = name.trim().split(/\s+/);
    const first = parts[0].charAt(0);
    const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
    return (first + last).toUpperCase();
  }

  function render() {
    const r = reports[index];
    badge.value = String(reports.length - index);
    avatar.initials = initialsOf(r.reporter);
    reporterEl.textContent = r.reporter;
    metaEl.textContent = 'Reported ' + r.handle + ' \u00b7 ' + r.age + ' ago \u00b7 Report #' + r.id;
    excerptEl.textContent = '\u201c' + r.excerpt + '\u201d';
    reasonsEl.textContent = '';
    r.reasons.forEach((pair) => {
      const chip = document.createElement('md-chip');
      chip.variant = 'assist';
      chip.label = pair[0];
      chip.color = pair[1];
      reasonsEl.appendChild(chip);
    });
    meter.value = r.confidence;
    const band = r.confidence >= 85 ? 'error' : r.confidence >= 50 ? 'warning' : 'success';
    meter.color = band;
    meter.label = 'Model confidence \u2014 ' +
      (band === 'error' ? 'high' : band === 'warning' ? 'medium' : 'low');
    // Reset the verdict controls for the fresh item.
    verdictSet.querySelectorAll('md-segmented-button').forEach((seg) => { seg.selected = false; });
    submitBtn.disabled = true;
    submitBtn.style.display = '';
    split.style.display = 'none';
  }

  function advance(message) {
    index += 1;
    toast.message = message;
    toast.show();
    if (index >= reports.length) {
      badge.value = '0';
      card.style.display = 'none';
      emptyEl.style.display = '';
    } else {
      render();
    }
  }

  // The set owns selection; mdChange carries the selected values as an array.
  verdictSet.addEventListener('mdChange', (e) => {
    const verdict = e.detail[0] || '';
    const isRemove = verdict === 'remove';
    submitBtn.style.display = isRemove ? 'none' : '';
    split.style.display = isRemove ? '' : 'none';
    submitBtn.disabled = verdict === '';
  });

  submitBtn.addEventListener('mdClick', () => {
    const r = reports[index];
    const selected = verdictSet.querySelector('md-segmented-button[selected]');
    if (!selected) return;
    if (selected.value === 'approve') {
      advance('Report #' + r.id + ' dismissed \u2014 post stays up');
    } else {
      advance('Report #' + r.id + ' escalated to a senior reviewer');
    }
  });

  // Split button: leading segment is the default removal; the menu holds variants.
  split.addEventListener('mdLeadingClick', () => {
    const r = reports[index];
    advance('Post removed \u2014 report #' + r.id + ' closed');
  });
  split.addEventListener('mdTrailingClick', (e) => {
    if (e.detail.checked) menu.show();
    else menu.close();
  });
  // Keep the chevron truthful when the menu closes by outside click or Escape.
  menu.addEventListener('mdClose', () => { split.trailingChecked = false; });

  document.querySelector('[data-remove-warn]').addEventListener('mdClick', () => {
    const r = reports[index];
    advance('Post removed, warning sent \u2014 report #' + r.id + ' closed');
  });
  document.querySelector('[data-remove-ban]').addEventListener('mdClick', () => {
    const r = reports[index];
    advance('Post removed, author banned \u2014 report #' + r.id + ' closed');
  });

  render();
</script>
ComponentRole in this screen
md-cardThe report container — one subject (the flagged post) per card, per M3. It stays a plain, non-interactive container because its children are the controls.
md-avatarReporter identity. initials only, with label/name left empty — the name is adjacent text, so the avatar stays decorative and isn’t announced twice.
md-chipReport reasons as an assist-chip set inside a role="group". color takes a theme role name (error, warning, info), so severity comes from the theme, not hard-coded hex.
md-meterThe classifier’s confidence — a state, not an activity, which is exactly the meter/progress split. The script swaps color at the 50/85 thresholds and writes the band into label so color is never the only signal.
md-segmented-button-setThe verdict: three exclusive options, all visible. The set owns selection and reports it through mdChange; the initial state has nothing selected, so the submit button starts disabled.
md-split-buttonThe removal path: the leading segment is the default (“Remove post”), the trailing chevron opens an md-menu of genuine variations (warn, ban). controls wires aria-controls to the menu’s id.
md-snackbarPer-verdict confirmation. One element, re-show()n with a new message — never two at once.
md-badgeThe pending count, anchored to a position: relative wrapper and counting down as verdicts land.
  • The segmented set can’t be un-chosen by accident. Single-select mdChange always reports a selection — clicking the active segment re-selects it rather than clearing it — so once a reviewer picks a verdict, the action buttons never flicker back to disabled. Arrow keys move and select through the three verdicts per the radiogroup pattern.
  • The destructive path costs one more decision. “Remove” doesn’t fire on segment click; it reveals the split button, whose leading action is the mildest removal. The harsher variants (warn, ban) live behind the chevron — the M3 split-button contract of a default plus variations.
  • The chevron never lies. mdTrailingClick.detail.checked drives menu.show()/menu.close(), and the menu’s mdClose (fired on outside click, Escape, or item pick) resets trailingChecked — the one sync the component can’t do itself.
  • Confidence is never color-alone. The meter carries role="meter" with the full ARIA value contract, and the script writes the severity band into the visible label (“Model confidence — high”) whenever it swaps the color role, satisfying WCAG 1.4.1.
  • Snackbar timing respects the reader. Auto-hide pauses while the surface is hovered or focused (WCAG 2.2.1), and re-showing the single element for each verdict means confirmations serialize instead of stacking.
  • Undo the verdict: set action="Undo" on the snackbar, hide('action') from the mdAction handler, and re-insert the previous report at the front of the queue when mdClose reports reason: 'action'.
  • Multi-reason triage: switch the reason chips to variant="filter" and let reviewers toggle which reasons they’re confirming; the verdict payload then carries the checked subset.
  • Keyboard-first review: the segmented set is already three tab stops with arrow-key selection; add a keydown shortcut layer (A/E/R) on the card for high-volume queues.
  • Circular confidence: in a denser layout, md-meter variant="circular" size="56" show-value turns the reading into a compact ring beside the excerpt instead of a full-width bar.