| name | ContextMod Devvit | ||||||
|---|---|---|---|---|---|---|---|
| description | Devvit Web port of FoxxMD's PRAW-era ContextMod moderation bot | ||||||
| spec | design-md/v1 | ||||||
| audience | AI agents (Claude Code, Gemini, Codex) + future maintainers + asset generators | ||||||
| last-updated | 2026-05-19 | ||||||
| related |
|
Brand and visual source-of-truth. AI agents loading this repo should read this file before generating new assets, writing UI, or producing marketing copy. Stitch's open-source DESIGN.md spec announced 2026-05-13 inspired the format.
- Name: ContextMod (full) ·
cm-devvit(slug, ≤16 chars per Devvit constraint) - Tagline (60 char): Rule-engine moderation, ported from PRAW to Devvit.
- One-line elevator (200 char): FoxxMD's PRAW mod bot, ported to Reddit Devvit Web. JSON5 rules in your sub's wiki, live action dashboard, per-sub install — no hosting, no API tokens, no shared bottleneck.
- Audience: Reddit subreddit moderators. Secondary: judges + Reddit dev reviewers.
- Positioning: Not a replacement for AutoMod. AutoMod handles regex; ContextMod handles context (author history, sub-distribution, image-hash, rule composition). The two coexist on the same sub.
- Personality: Technical, scrappy, honest. Dark warm-charcoal surface with thin white hairlines + single terminal-green accent dot — reads like a telemetry console, not a marketing page. No marketing prose. No AI-tone words (see
scripts/check-ai-tone.shblocklist).
- "Rule engine," "wiki config," "trigger," "action," "filter," "named rule"
- Specific numbers with citations ("60K weekly visitors," "73% bot-driven")
- First person ("I built," "I learned," "I cut")
- Honest gating ("Phase 4 ✅ shipped 2026-05-18 live-verified", "Phase 4.7 image-repost ✅ shipped 2026-05-18 via pure-JS blockhash spike", "MHS rule ✂️ cut per Reddit PR #96 AI-provider allowlist")
- AI-tone words — canonical list lives in
scripts/check-ai-tone.shBLOCKLISTarray (do not duplicate here; the script is the source of truth). - Marketing absolutes: "always," "never," "guaranteed," "100%"
- Vague abstractions: "ecosystem," "platform-native," "first-class," "battle-tested"
- "We" when describing solo work (this is Stephen's solo build with Vinh on backend)
The CI workflow runs ./scripts/check-ai-tone.sh on every push as a soft check. Strict mode (--strict) blocks the pre-submission gate.
Single source of truth: tailwind.config.ts. Hex values mirrored here for AI asset generators.
ink.950#0A0A0B— base background, dashboard canvas, social-preview backgroundink.900#131316— elevated card surfaceink.800#1B1B20— modal surfaceink.700#26262C— pressed / active state
bone.50#F5F5F4— primary textbone.100#E7E5E4— secondary textbone.200#A8A29E— tertiary / mutedbone.300#71717A— disabled / hint
signal.ok#4ADE80— success, active rule, telemetry-positive, brand accentsignal.warn#FBBF24— warning, attention, pendingsignal.err#FB7185— error, removed action, blockedsignal.info#60A5FA— info, neutral mention, link hover
linergba(255,255,255,0.06)— default divider on dark surfacelineStrongrgba(255,255,255,0.12)— emphasized divider
html[data-theme='light'] selector block in src/client/styles.css inverts the surface palette to a near-white canvas (#fafaf9 body, #18181b text) for moderators who prefer light UIs. The block mirrors ink.* + bone.* + border-line + .glass rules with dark-on-light equivalents:
.glassbackground →rgba(24,24,27,0.02)(wasrgba(255,255,255,0.015)) — AE CRITICAL #1 fix; cards stayed invisible white-on-white before this.text-bone-*→#18181b/#52525bdarkenedpre,.bg-ink-*,.border-line→ light equivalents- Signal accents (ok/warn/err/info) intentionally keep their hues for color-coding parity across themes
ThemeToggle component (
src/client/components/ThemeToggle.tsx) flipsdata-themeon<html>+ persists choice inlocalStorage.
Three dashboard components (RuleStatsTable, RuleCountChips, ModActivityFeed) reserve vertical space when empty instead of returning null — prevents first-paint layout shift when the first rule firing / mod action lands. Each renders its heading + a discoverability hint matching the populated layout's approximate height.
Concentric rings + green dot motif. White hairlines on warm-charcoal. Single green accent. No additional colors. Use the in-app tokens above (ink + bone + signal).
The architecture flowchart in README.md uses four colors that are not Tailwind tokens — they are diagram-only conventions chosen for WCAG-AA on both light + dark GitHub themes:
#FF4500(Reddit orange) — Devvit platform nodes#0079D3(Devvit blue) — server nodes#10B981(dashboard green) — client nodes#6B7280(gray) — external nodes
Loaded via Google Fonts CDN in src/client/index.html. Three families:
- Geist (sans, 300–700) — primary UI font. Stat values, headings, buttons.
- Geist Mono (400–600) — code, technical labels, numeric values, JSON config snippets.
- Instrument Serif (italic, 0/1) — accent / display only. Pull-quotes, taglines, hero copy. Italic-first.
Letter spacing:
letterSpacing.tightest-0.04em— display headers (Instrument Serif)letterSpacing.tighter-0.025em— UI headers (Geist 500–700)
- Border radius scale:
0 / 6 / 10 / 14 / 18 / 22 px(token namesnone / sm / DEFAULT / lg / xl / 2xl). - Cards default to
lg(14px) radius. - Stat-card grid: 12-column responsive, gap-4 minimum.
- Mobile breakpoint: Tailwind defaults; primary target is 375px webview viewport.
- Hairline-only dividers (no shadows, no heavy borders). Use
border-lineTailwind class.
Two named keyframes in tailwind.config.ts:
- pulse-dot (2s ease-in-out infinite): green signal dot indicating "live." Used on Observatory dashboard "Live" indicator + map markers.
- shimmer (8s linear infinite): loading shimmer on stat cards before data lands.
- cm-ai-pulse (1.2s ease-in-out infinite, staggered 0/0.2/0.4s per-dot): 3-dot ripple for AI-explainer button loading state. Replaced the static "thinking…" text label (AE Polish #4).
All animations respect prefers-reduced-motion (Y2-X55 — global @media block in styles.css drops every cm-* animation duration to 0.01ms for vestibular-sensitive users).
CSS-only animations. Never use libraries that depend on runtime code-string evaluation — Devvit's CSP blocks them. (Framer Motion's older versions were the specific case that surfaced this constraint.)
Custom hand-rolled keyframes for hero animation (in client CSS): cmFadeUp, cmFadeLeft, cmFadeIn, cmDrawLine, cmRefreshPulse, cmEventArrive. ~600–1000ms ease-out durations.
Lucide React only. Stroke width 1.5–1.6 (see EventRow.tsx for the canonical 1.6 application). Default 13px in dense event rows, 16px in mod-menu, 20px in cards, 24px in hero areas. Matches Geist's stroke weight aesthetic.
ink.900background, hairline border- Geist Mono number (28px, weight 500,
letterSpacing.tighter) - Geist label (12px,
bone.200, uppercase) - Optional
signal.okaccent dot for "live" state
- ~36px row height (
py-2.5),ink.900background with hairline border - Status dot on left (1.5×1.5 rounded):
signal.okif all actions succeeded,signal.errif any failed - 13px Lucide icon next to status dot — colored per action kind
- Action chip color mapping (canonical in
src/client/components/EventRow.tsx):remove=signal.err(#FB7185)approve=signal.ok(#4ADE80)lock=signal.warn(#FBBF24)comment=signal.info(#60A5FA)report=signal.warn(#FBBF24)ban=signal.err(#FB7185)userFlair=#A78BFA(violet — note: outside theink/bone/signaltoken set; consider promoting to asignal.authortoken if it sticks)
- Rule name (Geist 12px medium) in middle column
- Activity ID (Geist Mono 10.5px) muted next to rule name
- Timestamp (Geist Mono 11px,
bone.300, tabular-nums) on right
- 24h volume, hourly bins
signal.okline, 1.25px stroke (seesrc/client/components/Sparkline.tsx)ink.800background, no axis labels- Math:
reduce()notMath.max(...arr)— spread on large arrays throws (call-stack overflow). The rationale lives in the code comment.
signal.errleft-border (3px),ink.900background- Geist 14px body,
signal.erricon - Dismissable, persists until reload
| Asset | Path | Spec | Purpose |
|---|---|---|---|
| App icon | assets/icon.png |
256×256 RGBA PNG | Developer Portal upload + README hero |
| Social preview | assets/social-preview.png |
1280×640 RGBA PNG | GitHub OG card, Twitter / Discord link previews |
| Devpost thumbnail | assets/thumbnail.png |
1200×800 RGBA PNG (3:2) | Devpost Step 2 thumbnail slot |
All Banana-generated (Gemini 3.1 Flash Image / Nano Banana 2), re-encoded via PIL to real PNG (RGBA, optimized). Never ship JPEG bytes in a .png file — Devvit upload validation fails on magic-byte check (file reports JPEG even when extension is .png). Always verify with file assets/*.png after PIL re-encode.
devvit.json post entrypoint:
height: "tall"— Observatory needs vertical room for stream + cards + sparklineentry: "index.html"(Devvit Web bundle output atdist/client/)textFallbackprovided (mobile / no-JS path)
Redis-only per Devvit constraints. Strings + hashes + sorted sets — no Lists, no Sets.
Full key inventory + retention policy lives in data-retention.md and PRIVACY.md. Highlights:
cm:proc:{thingId}24h NX — trigger-level idempotencycm:action:pending:{hash}5m NX — action reservationcm:action:done:{hash}7d — action completion marker (now written even on dry-run per AE CRITICAL #7 — prevents post-toggle double-fire)cm:lock:{task}60s NX with ownership token — cron single-flight guard (acquireLockinsrc/lib/idem.ts)cm:{sub}:cfg:current_rev/cm:{sub}:cfg:rev:{n}/cm:{sub}:cfg:rev-counter— atomic config publish (INCR-allocated rev + monotonic pointer guard, W4) — wraps each phase inPublishErrordiscriminated union per AE CRITICAL #6 to prevent rev-leakcm:{sub}:events:recent50ZSET — 50-deep ring buffer for dashboard, score=ts member=event-json (shape-validated per AE Polish #5)cm:{sub}:author:hist:{name}— JSON-blob 1h cache (Phase 4 — getPostsByUser + getCommentsByUser, FETCH_LIMIT=100).degraded:truewritten on Reddit throw (NOT cached — AE CRITICAL #5 prevents mass false-positive moderation during 429/5xx blip)cm:{sub}:img:hash:recent— JSON-list of last 500 image-post {postId, hash, ts} entries (Phase 4.7 — perceptual blockhash 64-hex, 30d TTL refreshed on write)cm:mod-activity:{sub}ZSET — 50-deep ring of mod-menu actions (actor + ts + kind)cm:muted-rules:{sub}hash — mute set (hard-mute now wired into runCheck per AE CRITICAL #4)cm:openai-key:{sub}— encrypted-at-rest OpenAI key set via mod menu (Wave V)cm:rl:{bucket}:{sub}+cm:rl:{bucket}:{sub}:{username}— fixed-window rate-limit counters (1h TTL, X1). Per-user layer added per AE Pull-Forward #7 closes the "malicious mod burns sub's whole quota" holecm:cb:{bucket}:{failures,opened-at}— circuit breaker state (X37, per-sub bucket per X43)cm:{sub}:stats:rollup:7d— hourly snapshot, corrupt-key auto-deleted on parse-fail per AE Polish #6
- Generating a new image asset (Banana / Magic / Stitch): pull from "Color tokens" + "Asset palette" sections. Keep the concentric rings + central green dot motif — that's the brand mark.
- Writing UI code (React + Tailwind): reference
tailwind.config.tsfor tokens; don't hardcode hex. - Producing copy (writeup / outreach / video script): apply "Voice" rules + run
./scripts/check-ai-tone.sh --strictbefore pasting. - Building diagrams (Mermaid / FigJam): use the 4-color classDef palette from "Diagram classDef palette" — Reddit orange / Devvit blue / dashboard green / external gray.
- Onboarding a new contributor: read this file +
tailwind.config.ts+src/client/App.tsx.
Format inspired by Stitch's open-source DESIGN.md spec. Authored using the tokens already extracted in tailwind.config.ts + components in src/client/components/. Canonical-source pointers throughout so this doc points at the code instead of duplicating it — when in doubt, the code wins.
This doc duplicates information from tailwind.config.ts, src/client/components/*.tsx, devvit.json, and scripts/check-ai-tone.sh. If any of those change, manually update the relevant section here OR add a check in scripts/ that diffs the two. The canonical source is always the code; this doc is the human-readable mirror.