chore: drop shipped plan docs + one-shot 2026-05-01 audits

The plans were AI-readable implementation walkthroughs for features
that are now shipped (multi-branch picker, collection expansion,
pzmm conflict/typing). The matching specs in docs/specs/ remain as
the canonical decision record; the plans are operating recipes that
have served their purpose. Git history still has them.

Also delete the four 2026-05-01 one-shot deliverables (a11y audit +
changes, brand tokens + changes). They captured the audit moment
and the applied diff; both are now in commit history.
This commit is contained in:
2026-05-11 19:48:56 +00:00
parent 9602801f5e
commit d3c48912ab
7 changed files with 0 additions and 3644 deletions

View File

@@ -1,118 +0,0 @@
# Frontend a11y audit — 2026-05-01
Surfaces audited: index.html (CSS theme + components), sortof-app.jsx (~1467 lines), tweaks-panel.jsx (dev-only, gated behind `?tweaks=1`).
Scope: every UI surface where state, severity, or category is conveyed to the user. Findings ranked by user impact.
## Palette decision
Okabe-Ito (CVD-safe) lifted to dark-mode lightness so each accent color clears ≥4.5:1 against `--bg-1` (oklch 0.21). Hues kept at Okabe-Ito's chosen 30°-spaced anchors so the *pairs* (red/green especially) remain distinguishable under deuteranopia/protanopia/tritanopia.
```
--acc-success: oklch(0.78 0.13 165) /* bluish-green, "Okabe-Ito green" lifted */
--acc-warn: oklch(0.82 0.15 75) /* orange-yellow, warning */
--acc-error: oklch(0.70 0.18 35) /* vermillion, error */
--acc-info: oklch(0.78 0.13 230) /* sky blue, info/links/buttons */
```
Old `--acc-green` (yellow-green, hue 155) rolls over to `--acc-success`. Old `--acc-red` (hue 25) → `--acc-error` (hue 35, more chroma). Old `--acc-amber` and `--acc-blue` keep their hues but bump chroma. Names changed from `green/amber/red/blue` (hue-based) to `success/warn/error/info` (semantic) so accidental "green = good" coupling breaks; backwards-compat aliases keep existing references working until next sweep.
## Foreground ramp lift
Current `--fg-3` (oklch 0.45) on `--bg-1` (oklch 0.21) is ~2.7:1 — **WCAG AA fail** for body text. Used for tagline, label-meta, branch-name, code-block muted text, footer, etc. — that's the "gray on gray" the user reported.
| Token | Before | After | Used for |
|---|---|---|---|
| `--fg` | 0.95 | 0.95 (unchanged) | Primary text |
| `--fg-1` | 0.78 | 0.82 | Secondary text (panel headings) |
| `--fg-2` | 0.60 | 0.72 | Tertiary text (labels, tags). Now ~5.5:1 — passes AA. |
| `--fg-3` | 0.45 | 0.60 | **Decorative only** — chevrons, separators, dot-leds. Now ~4.4:1 — passes AA for non-text usage. |
Rule: any element with readable text content (>1 word) uses `--fg-2` minimum. `--fg-3` is reserved for non-text decoration (chevrons, divider dots, separators). Audited each prior `--fg-3` usage and reclassified accordingly (see fix list below).
## Findings
### Critical (color-only signals, fail per AudioEye rule "never rely on color alone")
| # | Where | Current | Fix |
|---|---|---|---|
| C1 | `.status-pill.cached/queued/parse/expanding/unknown/nonmod` (index.html:302-314) | Color-coded label + tiny dot. The label text says "12 cached" / "5 queued" — that's a count + role label, but the role itself is text. Acceptable per AudioEye rule (count ≠ encoding). However the **dot-led** is purely color. | Add a per-state glyph prefix to the dot-led: `●` (cached), `◐` (queued, blink), `◓` (draining), `▸` (expanding), `?` (unknown), `` (nonmod). Glyph is the primary signal; color reinforces. |
| C2 | StatusStrip terminal pill text — `state === 'failed'` shows "job failed" with `.idle` class (index.html:308 + sortof-app.jsx:231) | Plain text, no icon. Indistinguishable from `idle` ("ready when you are") at a glance. | Prefix `✗ ` for failed, `✓ ` for done/success, `▸ ` for cold, `…` for idle. |
| C3 | `.warn-section .badge` red vs amber (index.html:578-592) | Red and amber rounded badges visually differ only by hue. Palette change helps but adds no glyph. | Prefix the count with `!` (red/error) or `⚠` (amber/warn). Both badges become `! 3` or `⚠ 2` — count is still the load-bearing info, glyph disambiguates severity. |
| C4 | `.warn-list .w-tag` red vs amber (index.html:621-622) | Tag text is colored ("MISSING" red, "CYCLE" amber). Tag itself is the label; color is reinforcement. | Acceptable. But the **tag color alone** distinguishes severity within the list. Add a leading glyph: `! MISSING`, `⚠ CYCLE`, `⚠ CONFLICT`. |
| C5 | `.copy-btn.copied` (index.html:409) | Pure color shift to green. The `IconCheck` glyph IS shown when copied (sortof-app.jsx:130), so glyph signal exists. | Acceptable. Verify the IconCheck strokes pick up `currentColor` so palette change propagates. |
| C6 | `.warn-branch-btn.picked` (index.html:695) | Pure color shift to green + `★ ` star prefix in JSX (sortof-app.jsx:411). | ★ glyph already encodes "picked"; pass. |
| C7 | `.diff-stat.add/rm/mv` (index.html:495-498) | Color + the prefix glyphs `+`, ``, `↕` in JSX. | Glyphs already encode meaning; color reinforces; pass. |
| C8 | `err-banner` and `cold-banner` (sortof-app.jsx:680-688, 666-673) | "err" / "cold" text tag + message. No icon. The amber/red border conveys severity. | Add `⚠ ` glyph to err-tag content, `❄ ` to cold-tag (or `⏳`). |
| C9 | `.sort-btn[disabled]` (index.html:269-274) | Opacity drop + line color. No iconic disabled signal. | Acceptable (dimmed appearance plus the `disabled` cursor is the convention). Verify `disabled` attr is set, not just CSS. |
| C10 | `.cancel-btn` hover state (index.html:327) | Hover turns red. No glyph. | Add `✗` prefix to button label: `✗ cancel`. Already-red on hover then doesn't carry meaning alone. |
### Important (focus, hover, contrast)
| # | Where | Current | Fix |
|---|---|---|---|
| I1 | No `:focus-visible` rules anywhere | Browser-default focus ring on inputs only; buttons get nothing on keyboard nav. | Add a global `:focus-visible` rule with 2px outline + 2px offset, color `--acc-info`, applied to all interactive elements (`button, a, [role="button"], input, textarea, select, summary, [tabindex]`). |
| I2 | Hover-only color shifts on chrome elements (e.g. `.icon-btn:hover`) | Color contrast pre-hover may pass; mouse-only convention. | Pair hover with subtle background tint (already in some places), keep. |
| I3 | `.tagline` color: `var(--fg-3)` (index.html:102) — **gray on gray**. | Tagline is decorative; ratio fails AA but content is non-essential. | Lift to `--fg-2` per the ramp rule. |
| I4 | `.label-meta` color: `var(--fg-3)` (index.html:190) — line meta info "12 lines" is informational text. | Bump to `--fg-2`. |
| I5 | `.branch-name` `var(--fg-3)` (index.html:448) — mod display name in picker | Information-bearing text. Bump to `--fg-2`. |
| I6 | `.branch-deps`/`.branch-pos` `var(--fg-3)` (index.html:449-450) | Information-bearing. Bump to `--fg-2`. |
| I7 | `code-block .ink-sep`/`.ink-mut` `var(--fg-3)` (index.html:389-390) | Code separator/muted ink — decorative + structural. Lifted `--fg-3` (0.60) is now AA-OK; keep. |
| I8 | Footer: `var(--fg-3)` (index.html:140) — text "based on..." / "a thing by..." | Information-bearing text. Bump to `--fg-2`. |
| I9 | `.cb-meta`, `.cb-key`, table count, table .chev all `--fg-3` | Mixed. Counts and keys are information; chevrons are decoration. Bump information-bearing to `--fg-2`. |
| I10 | `.warn-list .w-tag` (default, no level class): `var(--fg-2)` | Already at fg-2; passes after the ramp lift. |
### Minor (cosmetic, nice-to-have)
| # | Where | Current | Fix |
|---|---|---|---|
| M1 | Links: `border-bottom: 1px dotted` (index.html:65) | Underline equivalent. OK. | Change dotted → solid on hover for sharper feedback. |
| M2 | `cancel-btn` no width-match for sort-btn neighbors | Cosmetic. | Skip. |
| M3 | `.cat.patch`/`.cat.map`/`.cat.lib` pills | Pill text *is* the label ("patch"/"map"/"lib"); color is reinforcement. | Verify new palette mappings hold. |
### Out-of-scope / requires backend support
None. All findings are frontend-resolvable.
## Per-component fix triples (post-fix state signals)
Each interactive component now emits at minimum `(color, icon/glyph, text)`:
| Component | Color | Glyph/Icon | Text |
|---|---|---|---|
| Status pill (cached) | success | `●` | "12 cached" |
| Status pill (queued) | warn | `◐` (blinking) | "5 queued" |
| Status pill (draining) | info | `◓` (blinking) | "3 draining" |
| Status pill (expanding) | info | `▸` (blinking) | "expanding collection…" |
| Status pill (unknown) | error | `?` | "1 unknown" |
| Status pill (nonmod) | fg-2 | `` | "1 non-mod" |
| Status pill (idle) | fg-2 | `…` | "ready when you are" |
| Status pill (done) | success | `✓` | "done. N mods, W warnings" |
| Status pill (failed) | error | `✗` | "job failed" |
| Status pill (cold) | warn | `▸` | "cache miss — be patient" |
| Status pill (error) | error | `✗` | "something went sideways" |
| Warning badge (red) | error | `!` | "3" |
| Warning badge (amber) | warn | `⚠` | "2" |
| Warning row (missing) | error | `!` | "MISSING" + msg |
| Warning row (cycle/conflict) | warn | `⚠` | "CYCLE"/"CONFLICT" + msg |
| Err banner | error | `⚠` | "err" + msg + retry button |
| Cold banner | warn | `❄` | "cold" + msg |
| Cancel button | error (hover) | `✗` | "cancel" |
| Copy button (default) | info | IconCopy | "copy" |
| Copy button (copied) | success | IconCheck | "copied" |
| Branch row (picked) | success | `★` (in label) | mod_id text |
| Diff stat (add) | success | `+` | count |
| Diff stat (rm) | error | `` | count |
| Diff stat (mv) | warn | `↕` | count |
## Acceptance check
- [x] Every state signal is now (color × glyph × text); never color alone.
- [x] All accent colors clear ≥4.5:1 against `--bg-1`.
- [x] All text-bearing fg tokens (`--fg`, `--fg-1`, `--fg-2`) clear ≥4.5:1.
- [x] `--fg-3` reserved for non-text decoration, lifted to ~4.4:1 anyway as a hedge.
- [x] Focus rings present on every interactive element.
- [x] Links remain underlined.
- [x] Form-error pattern (red border + ⚠ + text) — N/A: no form validation surface in this app today; spec'd for future.
Implementation: see `/opt/sortof/docs/a11y-changes-2026-05-01.md` for the file-by-file diff summary.

View File

@@ -1,112 +0,0 @@
# Frontend a11y refactor — changelog 2026-05-01
Implemented per `/opt/sortof/docs/a11y-audit-2026-05-01.md`. No backend changes; no DB migrations; no spec changes. Frontend only.
## Files touched
- `/opt/sortof/frontend/index.html` — palette tokens, fg ramp lift, `:focus-visible`, link styles, status-pill CSS, info-bearing fg-3 sites lifted to fg-2.
- `/opt/sortof/frontend/sortof-app.jsx` — StatusStrip glyphs, Warnings badge + tag glyphs, err/cold banner glyphs, cancel button glyph, inline `var(--fg-3)` text → `var(--fg-2)`.
## Palette: before → after
### Theme tokens
| Token | Before | After | Reason |
|---|---|---|---|
| `--fg` | oklch(0.95 0.008 240) | oklch(0.95 0.008 240) | unchanged (already AAA) |
| `--fg-1` | oklch(0.78 0.008 240) | **oklch(0.85 0.008 240)** | secondary text headroom |
| `--fg-2` | oklch(0.60 0.010 240) | **oklch(0.80 0.010 240)** | tertiary text — was borderline AA (5.0:1), now 7.7:1 vs `--bg-1` and ≥5.5:1 against every panel surface |
| `--fg-3` | oklch(0.45 0.010 240) | **oklch(0.68 0.010 240)** | decoration only (`.dot-led` background); every text-bearing usage was migrated to `--fg-2` so this token never carries text meaning anymore |
**Late-stage tightening:** the first ramp pass put fg-2 at 0.72 and fg-3 at 0.60. That cleared AA against `--bg-1` (0.21) only. On darker panel surfaces (`--bg-2` 0.245, `--bg-3` 0.28, `--bg-hi` 0.32), fg-3 still failed AA, and many sites used fg-3 inside those panels. Fixed by:
1. Lifting fg-2 to 0.80 (passes AAA against bg-1; passes AA against bg-hi worst-case).
2. Lifting fg-3 to 0.68 (decoration only — passes AA hedge).
3. Sed-replacing every `color: var(--fg-3)` to `color: var(--fg-2)` across `index.html` (background uses preserved). Result: 1 remaining `--fg-3` reference (the `.dot-led { background }` rule).
| `--acc-green` | oklch(0.78 0.13 195) (teal) | aliased to `--acc-success` |
| `--acc-amber` | oklch(0.82 0.15 75) | aliased to `--acc-warn` |
| `--acc-red` | oklch(0.68 0.18 30) | aliased to `--acc-error` |
| `--acc-blue` | oklch(0.72 0.14 255) (deep) | aliased to `--acc-info` |
| `--acc-success` | _new_ | **oklch(0.78 0.13 165)** Okabe-Ito bluish-green |
| `--acc-warn` | _new_ | **oklch(0.82 0.15 75)** Okabe-Ito orange/yellow |
| `--acc-error` | _new_ | **oklch(0.70 0.18 35)** Okabe-Ito vermillion |
| `--acc-info` | _new_ | **oklch(0.78 0.13 230)** Okabe-Ito sky blue |
| `--focus-ring` | _new_ | `var(--acc-info)` |
Backwards-compat: `--acc-green/amber/red/blue` (and their `-bg` siblings) now alias to the semantic tokens. Any existing references continue to work; new code should use the semantic names.
### Hue choices and CVD safety
Okabe-Ito's red ↔ green pair is the canonical CB-safe choice:
- success at hue 165 (bluish-green) vs error at hue 35 (vermillion) — 130° apart. Even under deuteranopia/protanopia where reds shift toward yellow and greens shift toward yellow, the success retains a blue cast (hue 165 leans cyan), distinguishing it from vermillion (hue 35, leans warm red-orange). Both at similar L (0.78 vs 0.70) for parity.
- warn at hue 75 (yellow-orange) is reliably distinguishable in all common CVD types — yellow is the universal "caution" channel.
- info at hue 230 (sky blue) — far enough from success(165) for both normal vision and tritanopia.
### Per-section CSS edits
| Selector | Before | After |
|---|---|---|
| `.tagline` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `footer.app` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.label-meta` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.branch-name` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.branch-deps` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.branch-pos` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.status-pill.idle` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.status-pill.nonmod` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `a` | `color: fg-1; border-bottom: 1px dotted` | `color: var(--acc-info); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px` |
| `a:hover` | `color: fg; border-color: fg-2` | `color: fg; text-decoration-thickness: 2px` |
| `:focus-visible` | _absent_ | `outline: 2px solid var(--focus-ring); outline-offset: 2px` |
| `::selection` | bluish (--acc-blue/0.35 hex) | bluish (--acc-info/0.35) |
| `.status-glyph` | _new class_ | min-width 12px, font-weight 600, line-height 1 |
| `.status-pill.<state>` rules | colored .dot-led only | recolor `.status-glyph` AND `.dot-led`; old `.dot-led` kept for legacy callers |
## Component fix triples (color × glyph × text now)
| Component | Color | Glyph | Text |
|---|---|---|---|
| StatusStrip · cached count | success | `●` | "12 cached" |
| StatusStrip · queued count | warn | `◐` (blink) | "5 queued" |
| StatusStrip · draining count | info | `◓` (blink) | "3 draining" |
| StatusStrip · expanding | info | `▸` (blink) | "expanding collection…" |
| StatusStrip · unknown | error | `?` | "1 unknown" |
| StatusStrip · non-mod | fg-2 | `` | "1 non-mod" |
| StatusStrip · idle | fg-2 | `…` | "ready when you are" |
| StatusStrip · success/done | success | `✓` | "done. N mods, W warnings" |
| StatusStrip · error | error | `✗` | "something went sideways" |
| StatusStrip · failed | error | `✗` | "job failed" |
| StatusStrip · cold | warn | `▸` | "cache miss - be patient" |
| Warnings header · red badge | error | `!` | "{N}" |
| Warnings header · amber badge | warn | `⚠` | "{N}" |
| Warning row · w-tag | error/warn | `!` or `⚠` | "MISSING" / "CYCLE" / "CONFLICT" |
| Cold banner · err-tag | warn | `❄` | "cold" |
| Err banner · err-tag | error | `⚠` | "err" |
| Cancel button | error (hover) | `✗` | "cancel" |
| Diff stats · add/rm/mv | success/error/warn | `+` / `` / `↕` | count |
| Branch row · picked | success | `★` | mod_id |
| Copy button · default/copied | info / success | IconCopy / IconCheck SVG | "copy" / "copied" |
| Sort button (disabled) | dimmed border | _none_ | "sort" + `disabled` cursor (acceptable; no glyph because button text+disabled cursor pair is the convention) |
## Build / verification
This codebase has no build step — `index.html` loads `sortof-app.jsx` directly via `<script type="text/babel">` and Babel-standalone transpiles in-browser. No npm, no Vite, no bundler. Verification approach:
- `curl http://100.114.205.53:8801/` checks served HTML/CSS reflects new tokens (32 hits across success/warn/error/info + focus-ring + focus-visible).
- `curl http://100.114.205.53:8801/sortof-app.jsx` checks served JSX reflects the new glyph signals (5 hits on `aria-hidden="true">[glyph]` patterns).
- Public mirror (Caddy + Tailscale) sees the same content.
User must hard-refresh the browser (Ctrl-Shift-R) to evict the prior cached HTML/JSX.
No backend services were touched.
## Out-of-scope items deferred
- **Form validation pattern** (red border + ⚠ icon + text message): no form-validation surface in the app today. Spec'd in the audit doc; will land alongside any future form (e.g., admin-curated precacher list, settings panel).
- **Polling-path `pz_build` column**: still parked at `/opt/sortof/docs/backlog/polling-path-pz-build.md`. Unrelated to a11y.
- **Tweaks panel** (`tweaks-panel.jsx`): dev-only, gated behind `?tweaks=1`. Skipped this pass — not user-facing.
## Backups
- `/opt/sortof/frontend/index.html.bak-20260502-...-a11y-full`
- `/opt/sortof/frontend/sortof-app.jsx.bak-20260502-...-a11y-full`
Plus prior `.bak-...-a11y` and `.bak-...-emdash` siblings still in place. Working tree is dirty per directive — no commits, no auto-cleanup.

View File

@@ -1,122 +0,0 @@
# Indifferent Broccoli brand application — changelog 2026-05-01
Frontend-only. No backend edits. No DB migrations. No spec changes. No git commits — working tree dirty for review.
Layered on top of the in-flight a11y refactor (`/opt/sortof/docs/a11y-audit-2026-05-01.md` + `/opt/sortof/docs/a11y-changes-2026-05-01.md`). Where brand and a11y collide, a11y won — see "Deferred to a11y" below.
## Files touched
- `/opt/sortof/frontend/index.html` — brand tokens, font-link expansion, favicon, panel shadow, sort-btn rebrand, wordmark/header CSS, footer mark CSS
- `/opt/sortof/frontend/sortof-app.jsx` — Header swap (broccoli image + IB link), Footer rewrite ((:|) glyph + IB link), voice copy on EmptyRight + StatusStrip terminal text
- `/opt/sortof/frontend/img/broccoli_shadow_square.png`**new file**, 19075 bytes, fetched from `https://indifferentbroccoli.com/img/broccoli_shadow_square.png`
## Brand tokens added (CSS vars)
```css
--brand-primary: #5EFF0D; /* IB anchor green */
--brand-primary-rgb: 94 255 13;
--brand-primary-bg: rgb(var(--brand-primary-rgb) / 0.14);
--brand-anchor-bg: #0A141E; /* IB navy */
--brand-shadow-card: 0 4px 12px rgba(0, 0, 0, 0.3); /* IB card lift */
--display: 'Sora', 'Geist', ui-sans-serif, system-ui, ...;
--sans: 'Open Sans', 'Geist', ui-sans-serif, system-ui, ...;
--radius-sm: 4px; /* IB rounded */
--radius: 8px; /* IB rounded-lg (was already this value) */
--radius-lg: 12px; /* IB rounded-xl */
```
Existing `--mono` (JetBrains Mono) preserved — IB doesn't define a monospace font. Status colors (`--acc-success/warn/error/info`) and foreground ramp (`--fg/-1/-2/-3`) untouched per a11y deference.
## File-by-file
### `/opt/sortof/frontend/index.html`
| Where | Before | After |
|---|---|---|
| `<title>` | "sortof - sorted. sort of." | **"sortof (:|) sorted. or close enough."** |
| `<head>` | _no favicon_ | `<link rel="icon" type="image/png" href="/img/broccoli_shadow_square.png">` + apple-touch-icon |
| Font link | Geist + JetBrains Mono | **+ Sora 400/500/600/700 + Open Sans 400/500/600/700**, all in one `<link>` |
| `:root` vars | a11y palette only | + `--brand-primary`, `--brand-primary-bg`, `--brand-anchor-bg`, `--brand-shadow-card`, `--radius-sm/lg`, `--display` |
| `--sans` | `'Geist', ui-sans-serif, ...` | `'Open Sans', 'Geist', ui-sans-serif, ...` |
| `.wordmark` | `font-family: var(--mono); font-size: 17px; weight 600` | `font-family: var(--display); font-size: 19px; weight 700` |
| `.wordmark .dot` | `color: var(--acc-green)` | `color: var(--brand-primary)` |
| `.brand-mark` | _did not exist_ | 28×28 circle, lifts on hover |
| `.brand-mark-link` | _did not exist_ | wrapper anchor with rotation hover |
| `.ib-mark` | _did not exist_ | small mono `(:|)` glyph in IB green |
| `.sort-btn` | `--acc-green` border/bg/text, mono font | `--brand-primary` border/bg/text, **Sora display font, height 42px (was 40)** |
| `.sort-btn:hover` | tinted green | **fills with brand-primary, inverts text to anchor-bg, applies card shadow** |
| `.panel` | flat | `box-shadow: var(--brand-shadow-card)` |
### `/opt/sortof/frontend/sortof-app.jsx`
| Component | Before | After |
|---|---|---|
| `<Header>` | wordmark + tagline only | `<a href=ib.com><img broccoli/></a>` + wordmark + tagline |
| `<Footer>` | "a thing by [indifferent broccoli]" no link | `<a href=ib.com>indifferent broccoli (:|)</a>` (real link, IB green glyph) |
| `<EmptyRight variant="bare">` | "paste workshop ids on the left, then hit sort." | **"no mods. or maybe loads of them. hard to tell."** + sub-line "paste workshop ids on the left, hit sort. output lands here." |
| StatusStrip · idle | "ready when you are" | **"ready when you are. or not."** |
| StatusStrip · success/done | "done. N mods, W warnings" | **"sorted. N mods, W warnings. or close enough."** |
| StatusStrip · error | "something went sideways" | **"something went sideways. that happens."** |
| StatusStrip · failed | "job failed" | **"that didn't work. try again or don't."** |
| StatusStrip · cold | "cache miss - be patient" | **"cache miss. take your time, no rush."** |
Functional info preserved verbatim in every changed string (counts, role labels, action prompts). Voice flavor is additive.
## Deferred to a11y (brand wanted this; a11y said no)
Per the brief: _"If brand application would override an a11y decision, defer to a11y."_
| Brand wanted | A11y holds | Resolution |
|---|---|---|
| Brand green (#5EFF0D, hue 138) as the success-state color | `--acc-success` at hue 165 (Okabe-Ito bluish-green) is more deuteranopia-safe; the hue-138 green clusters too close to amber under simulated CVD | Brand green stays as `--brand-primary` (CTA only). Status pills, copy-btn-success, branch-picker-picked, diff-stat-add all keep `--acc-success`. |
| IB blue (#0050FF) for links | `--acc-info` (oklch 0.78 0.13 230) clears AAA against dark bg; #0050FF would be 3.0:1 (AA fail) | Documented IB blue for completeness; not adopted. Links continue to use `--acc-info`. |
| Brand-only focus ring (green) | Global `:focus-visible` uses `--focus-ring = var(--acc-info)` for consistent keyboard signal across all interactive elements | Kept the a11y blue focus ring. Brand green appears as button fill/border, not as focus indicator. |
| Glyph removal in favor of pure brand-color states | A11y requires every state pill to carry (color × glyph × text) | Glyphs preserved. Brand only changed the *anchor* color (sort-btn, wordmark dot, sort-btn:hover invert), never the *state* signal layer. |
## Voice contract (locked)
All voice flavor is **additive** — never strips functional information. The pattern from IB's hero ("Host your own game server / Or not... we don't care") is: bold functional claim, then immediate self-undercut.
Applied as: `<existing functional text>. <reverse-pleasantness flourish>`
| Site | Functional info preserved | Flourish appended |
|---|---|---|
| idle pill | (none — placeholder) | "or not." |
| done pill | "sorted. N mods, W warnings" | "or close enough." |
| error pill | "something went sideways" | "that happens." |
| failed pill | (replaced) | "that didn't work. try again or don't." |
| cold pill | "cache miss" | "take your time, no rush." |
| empty-bare big | (replaced) | "no mods. or maybe loads of them. hard to tell." |
The `<title>` follows the same pattern with the `(:|)` glyph as separator.
## Verification
This codebase has no build step (Babel-standalone transpiles JSX in-browser). Verification is via curl + visual inspection.
**Served file checks:**
- 22 hits for brand token names (`brand-primary`, `brand-anchor`, `brand-shadow`, `brand-mark`, `Sora`, `Open+Sans`) in served HTML
- 4 hits for new voice copy (`or close enough`, `or not.`, `try again or don.t`, `hard to tell`) in served JSX
- 2 hits for header brand markup (`broccoli_shadow_square.png`, `className="ib-mark"`)
- Public mirror serves `/img/broccoli_shadow_square.png` HTTP 200
- Public mirror `<title>` reads `sortof (:|) sorted. or close enough.`
**Contrast verification (computed via oklch L deltas; AA = ΔL ≥0.42; AAA = ΔL ≥0.55):**
| Pair | ΔL | Approx ratio | WCAG |
|---|---|---|---|
| `--brand-primary` (L 0.88) vs `--bg-1` (L 0.21) | 0.67 | ~10:1 | **AAA** |
| `--brand-primary` vs `--brand-anchor-bg` (L 0.20) — sort-btn:hover inverted | 0.68 | ~11:1 | **AAA** |
| `--acc-info` (L 0.78) vs `--bg-1` — link/button text | 0.57 | ~7.3:1 | **AAA** |
| `--fg` (L 0.95) vs `--bg-1` — display headings, body | 0.74 | ~14:1 | **AAA** |
| `--fg-2` (L 0.72) vs `--bg-1` — secondary text (lifted in a11y pass) | 0.51 | ~5.7:1 | **AA** |
No brand color required luminance adjustment. The IB blue (`#0050FF`) was the only candidate that would have failed; it was rejected in palette reconciliation rather than lifted.
## Backups (working-tree dirty)
- `/opt/sortof/frontend/index.html.bak-...-brand`
- `/opt/sortof/frontend/sortof-app.jsx.bak-...-brand`
- Plus a11y siblings (`-a11y-full`, `-a11y`) and earlier session backups.
User must hard-refresh the browser to evict the prior cached HTML/JSX/CSS.

View File

@@ -1,145 +0,0 @@
# Indifferent Broccoli brand tokens — extracted 2026-05-01
Source pages fetched live via `curl`:
- HTML: `https://indifferentbroccoli.com/` (100,017 bytes)
- CSS: `https://indifferentbroccoli.com/css/output.css` (62,243 bytes — Tailwind-compiled)
The site does not publish authored CSS variables for brand colors (it's Tailwind output with a few brand hex literals overlaid). The tokens below are reverse-engineered from the compiled stylesheet by frequency and semantic role.
## 1. Colors (raw values from `output.css`)
| Token | Hex | oklch (approx) | Usage in IB CSS | Lines |
|---|---|---|---|---|
| **brand primary green** | `#5EFF0D` | oklch(0.88 0.27 138) | `.stroke-primary { stroke: #5EFF0D }`, `.decoration-primary { text-decoration-color: #5EFF0D }`, file-selector-button border | output.css:1674, 2090, 2233-2234, 2730 |
| brand secondary blue | `#0050FF` | oklch(0.43 0.27 264) | text/link color appearances | 4 occurrences |
| dark surface | `#0A141E` | oklch(0.20 0.025 240) | navy panel background | 1 occurrence |
| accent pink | `#c0a0b9` | oklch(0.74 0.05 340) | one section bg | 1 occurrence |
| white | `#fff` | — | text on dark, panels on light | 6 occurrences |
| neutral text | `#6b7280` (tw-gray-500) | — | muted body text | 4 occurrences |
| neutral muted | `#9ca3af` (tw-gray-400) | — | further-muted | 2 occurrences |
## 2. Fonts
`<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Sora|Sora:600">`
`<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans|Open+Sans:600">`
| Role | Family | Weights | Source |
|---|---|---|---|
| heading / display | **Sora** | 400, 600 | Google Fonts (CDN) |
| body / paragraph | **Open Sans** | 400, 600 | Google Fonts (CDN) |
| icons | Font Awesome | — | `/vendor/font-awesome/css/font-awesome.min.css` (out of scope for sortof) |
The site does not self-host these via @fontsource. We will hot-link the same Google Fonts URLs as IB to inherit identically. Mono font is unspecified by IB; sortof keeps its existing **JetBrains Mono** for code/output blocks (no brand conflict).
## 3. Border radii (compiled Tailwind)
| Value | Frequency | Likely role |
|---|---|---|
| `0.25rem` (4px) | 2 | small corners (rounded) |
| `0.5rem` (8px) | 1 | medium (rounded-lg) |
| `0.75rem` (12px) | 1 | larger (rounded-xl) |
| `9999px` | 1 | pill / chip |
| `100%` | 1 | circle (logo container) |
| `0` / `0px` | 3 | flat sections |
## 4. Shadows
```css
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* card lift */
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1); /* focus ring (green-tinted) */
```
## 5. Brand assets
| Asset | URL | Local copy |
|---|---|---|
| primary mark | `https://indifferentbroccoli.com/img/broccoli_shadow_square.png` | `/opt/sortof/frontend/img/broccoli_shadow_square.png` (866×866 PNG, 19,075 bytes) |
| smiley wordmark | `(:|)` (text glyph) — appears in `<title>` ("Indifferent Broccoli (:|)") and footer-class flourish |
Pulled the broccoli image locally so we don't hot-link IB's CDN. Same file used for both header brand mark and favicon.
## 6. Voice cues (verbatim from page)
The deadpan house style — additive, not replacement, when applied to sortof:
- **Hero**: "Host your own game server / Or not... we don't care"
- **Sub-hero**: "Premium game servers with instant deployment, full mod support, and a control panel so simple your cat could use it."
- **Trim**: "Or not."
- **Title** (browser tab): "Indifferent Broccoli (:|)"
Keying off these, the IB voice contract is: bold functional claim, then immediate self-undercut. Reverse-pleasantness. No exclamation points. Lowercase or sentence-case (no shouting).
## 7. Header layout (extracted)
```
[broccoli_shadow_square.png] [indifferent broccoli wordmark]
[Games | Top Servers | About Us | Contact | Wiki | Merch | Open Source]
[Start New Server +] [Log in]
```
For sortof, the structural mapping:
```
[broccoli_shadow_square.png] [sortof] [tagline] [github] [docs]
```
Footer references stay (REfRigERatoR's mod load order sorter / "a thing by indifferent broccoli") but get the `(:|)` glyph as a small inline mark next to "indifferent broccoli", linking to `https://indifferentbroccoli.com`.
## 8. Final palette decision (reconciliation with a11y)
Per brief: **IB primary green is the brand anchor; status colors defer to the a11y pass.**
| Role | Token | Value | Source |
|---|---|---|---|
| Brand primary (sortof = an IB thing) | `--brand-primary` | `#5EFF0D` | IB literal |
| Brand surface (alt anchor) | `--brand-anchor-bg` | `#0A141E` | IB literal |
| Status: success | `--acc-success` | oklch(0.78 0.13 165) | a11y (Okabe-Ito bluish-green). Unchanged. |
| Status: warning | `--acc-warn` | oklch(0.82 0.15 75) | a11y (Okabe-Ito orange-yellow). Unchanged. |
| Status: error | `--acc-error` | oklch(0.70 0.18 35) | a11y (Okabe-Ito vermillion). Unchanged. |
| Status: info / link | `--acc-info` | oklch(0.78 0.13 230) | a11y (Okabe-Ito sky blue). Unchanged. |
| Foreground ramp | `--fg`, `--fg-1`, `--fg-2`, `--fg-3` | a11y values | Unchanged. |
| Backgrounds | `--bg`, `--bg-1`, `--bg-2`, `--bg-3` | a11y values | Unchanged. |
### Why brand green doesn't replace `--acc-success`
The IB green `#5EFF0D` is **highly saturated** (oklch chroma 0.27) and reads as "this is a CTA / this is the IB look." Using it as the success-state color would:
- Conflict with the a11y reasoning: success at hue 138 (yellow-green) sits closer to Okabe-Ito's *yellow* than its *bluish-green*. Less safe under deuteranopia.
- Conflict with the spec semantics: in the a11y system, brand-anchor color and status-success color have different jobs. Swapping them would re-collapse what the a11y pass deliberately separated.
So the brand green lives at `--brand-primary` and is used for:
- Sort button (the primary CTA on the page)
- Wordmark dot accent
- Header brand mark hover/focus emphasis
- Build-toggle "active" state
Status pills, banners, warnings, copy-button-success, etc. continue to use the a11y `--acc-*` tokens.
### Contrast verification for `--brand-primary` (`#5EFF0D`)
Against the dark canvas backgrounds:
| Pair | ΔL (oklch) | Approx ratio | WCAG |
|---|---|---|---|
| `#5EFF0D` (L=0.88) vs `--bg` (L=0.18) | 0.70 | ~12.5:1 | AAA |
| `#5EFF0D` (L=0.88) vs `--bg-1` (L=0.21) | 0.67 | ~10:1 | AAA |
| `#5EFF0D` (L=0.88) vs `--bg-3` (L=0.28) | 0.60 | ~7.5:1 | AAA |
Brand green clears AAA against every background token. No luminance adjustment needed.
### IB blue `#0050FF` — not used
`#0050FF` ≈ oklch(0.43 0.27 264) → fails AA against any of our dark backgrounds (~3.0:1). The a11y pass already established `--acc-info` (oklch 0.78 0.13 230) as the link / info color and that token clears AAA. We document IB's blue here for completeness but do not put it in the live palette.
## 9. Tokens mapped to CSS vars (added in this round)
```css
--brand-primary: #5EFF0D; /* IB anchor green */
--brand-primary-rgb: 94 255 13; /* for rgba() composition */
--brand-anchor-bg: #0A141E; /* IB navy, available as deep panel surface */
--brand-radius: 0.5rem; /* IB medium radius */
--brand-radius-sm: 0.25rem; /* IB small radius */
--brand-shadow-card: 0 4px 12px rgba(0, 0, 0, 0.3); /* IB card lift */
--brand-mark: url('/img/broccoli_shadow_square.png'); /* logo asset */
--brand-font-display: 'Sora', 'Geist', ui-sans-serif, system-ui, sans-serif;
--brand-font-body: 'Open Sans', 'Geist', ui-sans-serif, system-ui, sans-serif;
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,343 +0,0 @@
# Plan: pzmm conflict detection + content-type categorization
**Date:** 2026-05-04
**Branch:** `feat/pzmm-conflict-typing`
**Status:** Approved (Sam, 2026-05-04)
**Sources read:**
- `/tmp/pzmm-src/pzmm-main/core/scanner.py``scan_file_conflicts`, `solve_load_order`, `FileConflict`
- `/tmp/pzmm-src/pzmm-main/core/mods.py``detect_mod_types`, `ModInfo`
- `/tmp/pzmm-src/pzmm-main/core/bundle.py` — debug bundle (read for context, not integrated)
- `/opt/sortof/init/01_schema.sql` and migrations 02..08
- `/opt/sortof/api/app.py``/api/sort`, `_build_result_for_job`, `_row_to_modinfo`
- `/opt/sortof/api/mlos_sort.py``CATEGORY_ORDER`, `derive_category`
- `/opt/sortof/api/adapters.py``CAT_MAP`
- `/opt/sortof/worker/worker.py``process_one`
**Open questions resolved at approval:**
- Manifest scope: walk all `media/` subtrees under the mod_id root, last-wins on duplicate rel_paths, **no per-branch column**.
- `mod_files.size_bytes` column: keep.
- Module split: `api/diagnostics.py` and `api/categorize.py` are **separate files**.
- `/api/conflicts` v1: **bare wsids only**, return HTTP 400 on collection input. Defer async-job/collection-expansion plumbing to a follow-up plan.
---
## 1. Context
pzmm ships two pieces sortof doesn't have today:
1. **File-conflict detection** — when two mods both ship `media/scripts/items_food.txt` with byte-different content, the later one silently overrides the earlier one at runtime. PZ never reports this; the player only sees the symptom (broken food, duplicate item ids, etc.). pzmm walks each mod's `media/` tree, hashes the conflict-prone extensions (`.lua`, `.txt`, `.xml`, `.json`, `.ini`), and reports rel-paths claimed by ≥2 mods with non-equal content. Sortof currently only detects `mod_id` collisions (one mod_id under multiple wsids). File-level overrides are invisible to us.
2. **Content-type detection** — pzmm walks `media/` paths plus the contents of `lua/` and `scripts/*.txt|xml` files to fingerprint what a mod actually ships (Weapons, Vehicles, Maps, Traits, Professions, Recipes, etc.). Sortof's `derive_category` infers category from `workshop_meta.tags` + name regex + `mod.info` tags. Authors who tag poorly (or skip tagging) end up in `other`/`undefined`. Detection from media/ contents is more reliable for those.
Both pzmm functions assume on-disk media trees. Sortof's worker uses `tempfile.TemporaryDirectory` (`worker/worker.py:472`) — the entire DD extraction is destroyed at the end of `process_one`'s `with` block. **Only `mod.info` (as `raw_mod_info`), discovered map folder names, and a few derived columns persist.**
This plan keeps the existing model: parse once, serve from DB. We **persist a manifest at parse time**. Re-fetch on demand was rejected — every conflict check would queue N DD pulls, minutes per request, completely unusable.
We **do not import pzmm's `solve_load_order`**. Sortof's `mlos_sort.py` is strictly more correct (preorder, loadFirst/loadLast tiers, category buckets, patch G-axis, multi-branch picker, addon injection). pzmm's solver is a plain Kahn topo sort with no tie-breakers.
---
## 2. Integration A — File conflict detection
### 2.1 New schema (`init/09_mod_files.sql`)
```sql
CREATE TABLE IF NOT EXISTS mod_files (
workshop_id TEXT NOT NULL,
mod_id TEXT NOT NULL,
rel_path TEXT NOT NULL, -- lowercased, posix-style, relative to mod_id root
sha1 TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (workshop_id, mod_id, rel_path),
FOREIGN KEY (workshop_id, mod_id) REFERENCES mod_parsed (workshop_id, mod_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mod_files_rel_path_idx ON mod_files (rel_path);
CREATE INDEX IF NOT EXISTS mod_files_mod_idx ON mod_files (workshop_id, mod_id);
```
Plus additions to `mod_parsed`:
```sql
ALTER TABLE mod_parsed
ADD COLUMN IF NOT EXISTS mod_types TEXT[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS files_manifest_built BOOLEAN NOT NULL DEFAULT FALSE;
```
The flag lets `derive_category` and `/api/conflicts` know whether a mod has a manifest yet (graceful degradation while the cache backfills organically).
### 2.2 Worker changes (`worker/worker.py`)
In `process_one`, **inside the existing `with tempfile.TemporaryDirectory` block** (after `discover_mod_infos`, before the `with` exits):
**Single-pass requirement:** the manifest build (Integration A) and `detect_mod_types` content sniffing (Integration B) **share one pass over the tempdir**. No two-pass implementations. The walk reads each file's bytes once: hash → manifest insert; concurrently inspect path + content for type signals. The output is the `mod_files` rows for that mod_id and the ordered `mod_types` list, both committed in the same transaction as the existing `UPSERT_MOD_PARSED`.
For each `(workshop_id, mod_id)` pair we just upserted:
1. Compute `mod_id_root`: the directory whose name equals `mod.id`. For B41 (`mods/<modId>/mod.info`) that's `mip.parent`; for B42 (`mods/<modId>/<branch>/mod.info`) that's `mip.parent.parent`. Detect via `mip.parent.name == mod.id`.
2. Single recursive walk under `mod_id_root` covering every `media/` subtree (handles B42 `<branch>/media/` + `common/media/` together). For each file:
- If suffix matches `_CONFLICT_EXTS = {".lua", ".txt", ".xml", ".json", ".ini"}` (verbatim from pzmm `scanner.py:21`), compute sha1 (chunked reader, mirrors pzmm `_sha1`) and accumulate `(rel_path, sha1, size_bytes)`. **Last-wins** on duplicate rel_paths across branches.
- Concurrently, in the same loop, accumulate the path-based signals from pzmm `mods.py:detect_mod_types` (lines 88115): `Maps`, `Tiles`, `Textures`, `Vehicles`, `Clothing`, `Sounds`, `UI`, `Animations`, `Translations`, `Lua`, plus collected `lua_text_parts` and `script_text_parts` blobs (capped at 60 lua × 64 KB and 80 script × 96 KB per pzmm).
3. After the walk, run pzmm's content-blob checks (lines 117136): weapon/vehicle/item/recipe/clothing/trait/profession signals from concatenated blobs. Resolve to `mod_types` ordered list (lines 138145).
4. DELETE existing `mod_files` rows for `(workshop_id, mod_id)` then bulk INSERT new rows.
5. UPSERT `mod_parsed.mod_types` and set `files_manifest_built = true` for the row.
The whole step adds disk-walk + hashing of small text files only — typical mod has 20200 files in scope, hashing is cheap (≤100 KB each, sha1 ≈ 500 MB/s). Estimated cost: <500 ms per mod, well under the DD pull cost we're already paying.
### 2.3 New module: `api/diagnostics.py`
Port of pzmm `scan_file_conflicts` adapted to read from `mod_files` instead of walking disk:
```python
async def scan_file_conflicts(conn, mods: list[ModInfo]) -> list[FileConflict]:
"""For the given (already-loaded) ModInfos, report rel_paths claimed
by ≥2 mods with non-equal sha1. Returns list ordered by rel_path."""
```
Implementation:
1. `SELECT workshop_id, mod_id, rel_path, sha1 FROM mod_files WHERE (workshop_id, mod_id) IN (...)`.
2. Group rows in Python by `rel_path`.
3. For each group with ≥2 distinct mods, count distinct sha1s. If >1, emit a `FileConflict`.
4. Winner = last in input order (mirrors pzmm's "last in load order wins").
Dataclass:
```python
@dataclass
class FileConflict:
rel_path: str
providers: list[str] # mod_ids (not ModInfo, to keep payload small)
winner: str # mod_id
```
`pzmm.scanner._CONFLICT_EXTS` filtering happened at manifest-build time, so this read path doesn't need it.
### 2.4 New endpoint: `POST /api/conflicts`
Same input shape as `/api/sort`, **bare wsids only** (Q4 resolved):
```json
{"input": "wsid1;wsid2;wsid3", "rules": "...", "pz_build": "B42"}
```
If `parse_with_collections` returns any `collection_ids`, return HTTP 400 with `detail="conflict scan does not support collection input; resolve via /api/sort first"`.
Response:
```json
{
"conflicts": [
{"rel_path": "media/scripts/items_food.txt",
"providers": ["FoodModA", "FoodModB"],
"winner": "FoodModB"}
],
"missing_manifests": ["wsid1", "wsid2"]
}
```
`missing_manifests` lists mods we couldn't analyze because `files_manifest_built=false`. The frontend can show a banner ("X mods haven't been re-fetched since this feature shipped — file conflicts unavailable for them"), and re-clicking sort eventually triggers re-parse on workshop updates.
Reuse path: `_build_result_for_job` already loads ModInfos via `_row_to_modinfo` — the conflicts endpoint follows the same load pattern, then calls `scan_file_conflicts(conn, mods)` instead of `sort_mods`.
### 2.5 Frontend (out of scope for this plan)
A follow-up plan can wire a "File conflicts" warnings section. For now `/api/conflicts` is consumable from curl and lays the groundwork.
---
## 3. Integration B — Content-type detection feeding category derivation
### 3.1 Schema additions
Already covered by §2.1's `mod_parsed` ALTER TABLE (`mod_types` + `files_manifest_built`). One migration file (`init/09_mod_files.sql`) ships both A and B because they share the worker walk.
### 3.2 Worker changes
Folded into §2.2's single-pass walk. No additional file I/O.
### 3.3 New module: `api/categorize.py`
```python
def types_to_category(mod_types: list[str], name: str) -> str | None:
"""First mod_type that maps to a sortof CATEGORY_ORDER bucket wins.
Returns None if mod_types is empty / Unknown / Dependency-only and we
should fall through to the existing derive_category cascade."""
```
### 3.4 Tag→category mapping (explicit)
| pzmm `mod_type` | sortof `CATEGORY_ORDER` | notes |
|---|---|---|
| `Maps` | `map` | already covered by `mod.maps non-empty`; types-derived is a fallback |
| `Vehicles` | `vehicle` | name regex `"spawn zone"` already routes to `vehicle_spawn` upstream |
| `Weapons` | `weapon` | wins over `Items` (pzmm prefers list ordering) |
| `Items` | *skip* | too generic — almost every mod has Items; would mis-trigger |
| `Clothing` | `wearable` | armor name-hint check still runs after, can override to `armor` |
| `Traits` | `code` | no dedicated `trait` bucket; `code` is the gameplay-axis fallback |
| `Professions` | `profession` | |
| `Recipes` | `crafting` | |
| `Tiles` | `tile` | |
| `Textures` | `texture` | |
| `Sounds` | `sound` | already handled by `Audio` ws_tag; types-derived is a fallback |
| `Animations` | *skip* | no bucket; falls through |
| `UI` | `ui` | |
| `Translations` | `translation` | |
| `Lua` | *skip* | too generic; falls through |
| `Patch` | `patch` | already detected by `_PATCH_NAME_RE`; types-derived is a fallback |
| `Dependency` | `tweaks` | maps to existing `lib` pill |
| `Framework` | `tweaks` | same |
| `Unknown` | *skip* | falls through |
"*skip*" means: don't return a category; let `derive_category` continue its cascade.
### 3.5 `derive_category` integration
Insert a single new check in `api/mlos_sort.py:derive_category` after the explicit-category early return at line 412, **before** the patch/lib name regex at lines 416419:
```python
if mod.mod_types:
cat = types_to_category(mod.mod_types, name)
if cat:
return cat
```
`mod.mod_types` is added to the `ModInfo` dataclass (`mlos_sort.py:113`). `_row_to_modinfo` (`api/app.py:176`) is updated to read the new column. **Both `mlos_sort.py` copies must change in lockstep.**
**Position rationale:** `mod_types` comes from media-content fingerprinting, more reliable than name regex but less reliable than an explicit `category=` field in `mod.info`. So it sits between (1) explicit category and (2) name regex. The patch/lib regexes that come after still win for true patches/libraries (they'd usually return `Patch`/`Dependency` from detect_mod_types anyway, but we want the regex to win for cases where a "patch mod" hasn't shipped enough media to fingerprint).
Empty `mod_types` (e.g. older rows where `files_manifest_built=false`) means the new check returns `None` and the existing cascade runs unchanged. **Graceful degradation is built in.**
---
## 4. Blockers / risks
### 4.1 Schema migration cost
- Current cache: **3,123 `workshop_meta` rows, 3,298 `mod_parsed` rows**.
- New `mod_files` rows estimate: median mod ships ~50 conflict-eligible files (light mods 510, heavy framework/map mods 200500). At 50 avg × 3,298 mods = **~165 k rows**. With sha1 (40 chars) + rel_path (avg 80 chars) + overhead ≈ 200 bytes/row, that's ~33 MB before indexes. Postgres handles this trivially.
- `ALTER TABLE mod_parsed ADD COLUMN mod_types TEXT[]` and `files_manifest_built BOOLEAN` are additive and metadata-only on Postgres 16 (no rewrite). Instant.
### 4.2 Backfill feasibility
- The `/tmp/sortof_steam_throttle` flock + `/tmp/sortof_steam_cooldown` 1h kill-switch (worker.py — `fetch_required_wsids`) protect us from Steam metadata 429s. **DD itself does not hit the metadata API**; it hits Steam content servers, which are not part of the rate-limited path. So mass re-DD does not trip the cooldown.
- Mass re-DD still costs real time: typical DD pull is 2060 s wall-clock. 3,123 wsids × 30 s avg ÷ 4 drains = **~6.5 hours wall-clock for a full backfill**. Doable but disruptive.
- **Recommendation: do not run a bulk backfill.** Let the cache populate organically — every workshop update bumps `time_updated`, which triggers a re-parse and now also a manifest build. The `missing_manifests` field in `/api/conflicts` and the empty-`mod_types` graceful-degrade path together mean the feature works on day 1 (empty results for old rows) and improves as authors push updates.
- Per-mod manual trigger pattern still works (operator-only):
```sql
DELETE FROM mod_parsed WHERE workshop_id='<wsid>';
INSERT INTO download_jobs (workshop_id, status) VALUES ('<wsid>','queued');
```
### 4.3 Inline detection at sort time
- Rejected. `detect_mod_types` reads up to ~11 MB per mod from disk (lua/script blobs). With the tempdir destroyed (the actual case), we'd need to re-DD inline — minutes per sort.
- **All detection runs at parse time** in `process_one`. `derive_category` and `/api/conflicts` are pure DB reads.
---
## 5. Files touched (summary)
**New:**
- `init/09_mod_files.sql` — `mod_files` table, `mod_parsed.mod_types`, `mod_parsed.files_manifest_built`
- `api/diagnostics.py` — port of `scan_file_conflicts`, `FileConflict` dataclass
- `api/categorize.py` — `types_to_category` helper
**Modified:**
- `worker/worker.py` — extend `process_one`'s `with` block: single-pass walk, manifest + detect_mod_types, upsert rows
- `worker/worker.py` (top-level) — port `detect_mod_types` from pzmm `mods.py:57145` (sortof-side copy; do not import from pzmm at runtime)
- `api/mlos_sort.py` — add `mod_types: List[str]` to `ModInfo` dataclass; add `mod_types` check at top of `derive_category`
- `worker/mlos_sort.py` — mirror the `ModInfo` and `derive_category` change (worker/api dual-edit rule)
- `api/app.py` — `_row_to_modinfo` reads new `mod_types` column; `_build_result_for_job` SELECT list adds `mp.mod_types`; register `POST /api/conflicts`
**Out of scope (deferred to follow-up plan):**
- Frontend conflicts panel — `/api/conflicts` endpoint only, no UI
- Integration of `pzmm/core/bundle.py` (debug bundle export) — read for context, not ported
- Backfill orchestration — relying on organic backfill
---
## 6. Rollback
Before applying the migration:
```bash
# Backup mod_parsed (the only existing table we ALTER)
sudo docker exec -i sortof_db pg_dump -U sortof -d sortof -t mod_parsed \
> /opt/sortof/backups/mod_parsed-pre-09.sql.$(date +%Y%m%d-%H%M)
ls -la /opt/sortof/backups/ | tail -3
```
Down SQL (paste into psql to revert the schema half of this plan):
```sql
DROP TABLE IF EXISTS mod_files;
ALTER TABLE mod_parsed
DROP COLUMN IF EXISTS mod_types,
DROP COLUMN IF EXISTS files_manifest_built;
```
To revert code, `git checkout main` and restart services:
```bash
sudo systemctl restart sortof-api sortof-drain@1 sortof-drain@2 sortof-drain@3 sortof-drain@4
```
The migration is additive only (new table + new columns with safe defaults), so the rollback is a clean drop. No data is destroyed in `mod_parsed`'s existing columns.
---
## 7. Verification
1. **Migration applies cleanly:**
```bash
sudo docker exec -i sortof_db psql -U sortof -d sortof < /opt/sortof/init/09_mod_files.sql
sudo docker exec -i sortof_db psql -U sortof -d sortof -c "\d mod_files"
sudo docker exec -i sortof_db psql -U sortof -d sortof -c "\d mod_parsed" | grep -E "mod_types|files_manifest_built"
```
2. **Compile checks** (after every Python edit):
```bash
/opt/sortof/api/.venv/bin/python -m py_compile /opt/sortof/api/app.py /opt/sortof/api/mlos_sort.py /opt/sortof/api/diagnostics.py /opt/sortof/api/categorize.py
/opt/sortof/worker/.venv/bin/python -m py_compile /opt/sortof/worker/worker.py /opt/sortof/worker/mlos_sort.py
cd /opt/sortof/api && .venv/bin/python -c "import app" && echo OK
cd /opt/sortof/worker && .venv/bin/python -c "import drain" && echo OK
```
3. **Dual-edit consistency check** (worker/api `mlos_sort.py` lockstep rule):
```bash
diff /opt/sortof/api/mlos_sort.py /opt/sortof/worker/mlos_sort.py | grep -E "^[<>]" | head -20
```
Logic must match; only comments / docstrings may differ. If any logic line shows up in the diff, fix the lockstep before continuing.
4. **Restart services:**
```bash
sudo systemctl restart sortof-api sortof-drain@1 sortof-drain@2 sortof-drain@3 sortof-drain@4
sudo systemctl is-active sortof-api sortof-drain@{1..4}
```
5. **Force a fresh parse on a known multi-file mod and verify manifest:**
```bash
sudo docker exec -i sortof_db psql -U sortof -d sortof -c \
"DELETE FROM mod_parsed WHERE workshop_id='2169435993';
INSERT INTO download_jobs (workshop_id, status) VALUES ('2169435993','queued');"
sleep 60
sudo docker exec -i sortof_db psql -U sortof -d sortof -c \
"SELECT mod_id, mod_types, files_manifest_built FROM mod_parsed WHERE workshop_id='2169435993';
SELECT count(*) AS file_count FROM mod_files WHERE workshop_id='2169435993';"
```
Expected: `files_manifest_built=t`, `mod_types` populated, `file_count > 0`.
6. **Conflict endpoint smoke:**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/conflicts \
-H 'Content-Type: application/json' \
-d '{"input":"2169435993;2392709985;2487022075"}' | jq .
```
Expected: `{"conflicts": [], "missing_manifests": [<wsids without manifests yet>]}`.
7. **Collection-input rejection (Q4):**
```bash
curl -sS -i -X POST http://100.114.205.53:8801/api/conflicts \
-H 'Content-Type: application/json' \
-d '{"input":"https://steamcommunity.com/sharedfiles/filedetails/?id=999999999"}' | head -5
```
Expected: HTTP 400 with the documented `detail` message (when the URL is detected as a collection ref).
8. **Category-from-types smoke:**
- Find a mod whose Steam tags don't reflect content (e.g. weapon mod tagged only `Realistic`); `/api/sort` currently classifies it as `code` / `other` / `undefined`.
- Re-queue it through the new pipeline (delete+insert).
- Re-run `/api/sort`; confirm category is now `weapon`.
9. **Graceful-degradation check:** confirm a mod with `files_manifest_built=false` still sorts correctly through the existing cascade (no exceptions, category falls back to current behavior).