Compare commits
No commits in common. "9763b8980cdeff6f544ac59beec5fc6206264963" and "fa9acd30276f2f7c9991135887bd86147fa822e9" have entirely different histories.
9763b8980c
...
fa9acd3027
15 changed files with 55 additions and 4968 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,526 +0,0 @@
|
|||
# Stylesheet redesign
|
||||
|
||||
## Context
|
||||
|
||||
The current stylesheet is ~1,436 LOC across six files in `l4d2web/static/css/`:
|
||||
`tokens.css` (84 LOC), `layout.css` (42), `components.css` (1,177; ~196 class
|
||||
selectors), `logs.css` (13), `console-autocomplete.css` (88), `editor.css` (32).
|
||||
Tokens are real (CSS custom properties, light + dark via `prefers-color-scheme`),
|
||||
but `components.css` grew organically: 196 classes accumulated without a budget,
|
||||
specificity is uneven, and the same idea ("state pill") is expressed several
|
||||
ways in different places.
|
||||
|
||||
The result reads as "developer wrote some CSS" rather than "this is a system."
|
||||
That is a problem for human readers and a bigger problem for agentic
|
||||
development — when an LLM agent adds a feature, the cheapest path is to
|
||||
re-implement whichever pattern happens to be in its context window, which
|
||||
re-introduces the inconsistency the next agent then has to imitate. The
|
||||
incoherence compounds.
|
||||
|
||||
This redesign replaces the current stylesheet with a small, explicitly-tiered
|
||||
design system: two-tier tokens, an `@layer`-ordered cascade, a budgeted set of
|
||||
component classes, a handful of macros where markup correctness is load-bearing,
|
||||
and a live style guide that is the single canonical reference contributors (and
|
||||
agents) read before producing new UI.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Coherent visual** — every page in `l4d2web` reads as one system, in light
|
||||
and dark mode.
|
||||
- **Agentic-development friendly** — an agent given a UI task can produce
|
||||
styled output by reading one file (the style guide) and following the
|
||||
conventions there. No tribal knowledge required.
|
||||
- **Smaller surface area** — `components.css`'s 1,177 LOC + 196 classes
|
||||
collapses to a deliberate ~400 LOC budget covering exactly the primitives
|
||||
used across the app; project-specific widgets live in their own files.
|
||||
- **Enforced cascade** — specificity is determined by `@layer` order, not
|
||||
selector specificity, so adding a component never accidentally beats
|
||||
utilities or fights element defaults.
|
||||
- **Closed system** — every page element comes from the catalog. New widget?
|
||||
Extend the system, then use it. No inline `style=""`, no inventing class
|
||||
names off-system.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- Adopting any CSS framework as a base. Pico v2, Simple.css, Bulma, and
|
||||
Tailwind were considered and rejected; see "Decisions" below.
|
||||
- Changing the markup of project-specific widgets (file-tree, overlay-picker,
|
||||
console-autocomplete, CodeMirror skin, log viewer, live-state). They get
|
||||
relocated into the new file layout but keep their existing structure.
|
||||
- Changing routes, blueprints, or htmx contracts.
|
||||
- Adopting Open Props or any external token library. The refined `tokens.css`
|
||||
remains the single source of truth.
|
||||
- Build-step tooling. No Sass, no PostCSS, no bundler. Plain CSS using modern
|
||||
features (`@layer`, `color-mix()`, CSS custom properties) the production
|
||||
browsers already support.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The eight layers
|
||||
|
||||
The cascade order is declared once in the entry stylesheet:
|
||||
|
||||
```css
|
||||
@layer reset, tokens, elements, layout, components, widgets, utilities;
|
||||
```
|
||||
|
||||
Each layer has one responsibility:
|
||||
|
||||
| # | Layer | Contains | File(s) |
|
||||
|---|-------------|----------------------------------------------------------------|-------------------------------------------------------------|
|
||||
| 1 | `reset` | Modern reset (Andy-Bell / Josh-Comeau-style; ~30 LOC) | `reset.css` |
|
||||
| 2 | `tokens` | Primitives + semantic tokens, light + dark | `tokens/primitives.css`, `tokens/semantic.css` |
|
||||
| 3 | `elements` | Bare HTML defaults: `body`, `h1-h6`, `a`, `code`, `kbd`, `hr`, form elements, tables | `elements.css` |
|
||||
| 4 | `layout` | Composition primitives: `.container`, `.stack`, `.stack-h` | `layout.css` |
|
||||
| 5 | `components`| Named widgets: `.btn`, `.field`, `.table`, `.panel`, `.modal`, `.tabs`, `.badge`, `.nav`, `.dropdown` | `components/<name>.css` |
|
||||
| 6 | `widgets` | Project-specific composites: `.file-tree`, `.overlay-picker`, `.console-autocomplete`, log viewer, editor skin | `widgets/<name>.css` |
|
||||
| 7 | `utilities` | Single-purpose overrides: `.muted`, `.mono`, `.truncate`, `.sr-only` | `utilities.css` |
|
||||
|
||||
The single entry file (`main.css`) declares the layer order and `@import`s the
|
||||
rest. `base.html` swaps its current five `<link>` tags for one
|
||||
`<link rel="stylesheet" href="…/main.css">`.
|
||||
|
||||
### File layout
|
||||
|
||||
```
|
||||
l4d2web/static/css/
|
||||
main.css ← @layer order + @imports
|
||||
reset.css
|
||||
tokens/
|
||||
primitives.css ← raw palette: --gray-100, --blue-700, --space-4, --text-base, …
|
||||
semantic.css ← aliases: --color-primary, --color-bg, --space-m, …
|
||||
plus the data-theme="dark" branch
|
||||
elements.css ← bare HTML defaults
|
||||
layout.css
|
||||
components/
|
||||
button.css
|
||||
field.css
|
||||
table.css
|
||||
panel.css
|
||||
modal.css
|
||||
tabs.css
|
||||
badge.css
|
||||
nav.css
|
||||
dropdown.css
|
||||
widgets/
|
||||
file-tree.css ← moved from current components.css
|
||||
overlay-picker.css ← extracted from current components.css
|
||||
console-autocomplete.css ← moved (currently at static/css/ root)
|
||||
editor.css ← moved
|
||||
logs.css ← moved
|
||||
live-state.css ← extracted from components.css
|
||||
utilities.css
|
||||
```
|
||||
|
||||
### Tokens — three tiers
|
||||
|
||||
**Tier 1 (primitives)** in `tokens/primitives.css` is the raw palette: ~30
|
||||
custom properties for the grayscale ramp, brand blue ramp, semantic state
|
||||
colors (red / green / amber), spacing scale, type scale, radii, shadow scale,
|
||||
motion durations and easings, and font stacks. No semantic meaning. The
|
||||
primitives never appear in component CSS directly — they are consumed only
|
||||
by semantic tokens.
|
||||
|
||||
**Tier 2 (semantic)** in `tokens/semantic.css` aliases primitives by *role*:
|
||||
`--color-bg`, `--color-surface`, `--color-text`, `--color-muted`,
|
||||
`--color-border`, `--color-primary`, `--color-on-primary`, `--color-danger`,
|
||||
`--color-warning`, `--color-success`, `--color-info`, `--color-focus`, plus
|
||||
`--space-1` through `--space-7`, `--radius-1` / `-2` / `-full`, `--shadow-sm`
|
||||
/ `-md` / `-lg`, etc. Dark mode lives here, in a `[data-theme="dark"]`
|
||||
selector — the primitives do not change between modes; the semantic aliases
|
||||
re-point at different primitives. This keeps dark mode in one file instead
|
||||
of duplicated inside a `@media (prefers-color-scheme: dark)` block.
|
||||
|
||||
**Tier 3 (component-scoped)** lives inside each component's CSS file, never
|
||||
exposed beyond it. Example: `components/button.css` declares
|
||||
`--button-bg`, `--button-fg`, `--button-border` on `.button`, and each variant
|
||||
(`.button.primary`, `.button.danger`) re-points those local properties at
|
||||
semantic tokens. Modifiers like `.button.outline` then read the inherited
|
||||
component-scoped values, which is what lets variants **compose** —
|
||||
`.button.danger.outline` is a real outlined-danger button without a single
|
||||
rule mentioning the combination. Same pattern for `.tag` (`--tag-bg`,
|
||||
`--tag-fg`, `--tag-border`) and `.toast` (`--toast-bg`, etc.).
|
||||
|
||||
A rule the agent can follow mechanically: **components reference Tier 2
|
||||
semantic tokens** (`var(--color-*)`, `var(--space-*)`, …). **Tier 3
|
||||
component-scoped tokens are private** to each component and never appear
|
||||
in other components' CSS. **Raw hex codes appear only in
|
||||
`tokens/primitives.css`.** These three rules are codified in `AGENTS.md`
|
||||
and constitute the entire token contract.
|
||||
|
||||
Dark mode is opted into by setting `data-theme="dark"` on the `<html>`
|
||||
element. The initial value comes from `prefers-color-scheme` via a small
|
||||
inline script at the top of `<head>` (avoids a flash of wrong theme on
|
||||
first paint); a future user-preference toggle would write to localStorage
|
||||
and override the OS default. Implementing the toggle is out of scope for
|
||||
this redesign — the redesign only ensures the mechanism is in place.
|
||||
|
||||
### Components — class-based, CSS-only
|
||||
|
||||
**Naming convention** (two rules, no exceptions):
|
||||
|
||||
1. **Parts of a component use hyphenated child classes.** `.card-header`,
|
||||
`.field-hint`, `.dialog-body`. The part belongs to the parent; the
|
||||
hyphen makes the relationship visible.
|
||||
2. **Variant modifiers chain on the parent class.** `.button.primary`,
|
||||
`.tag.success`, `.button.outline`, `.dialog.wide`. A modifier alone
|
||||
is meaningless — it must be read together with the component class.
|
||||
|
||||
State stays on ARIA attributes, not modifier classes: `[disabled]`,
|
||||
`[aria-busy="true"]`, `[aria-selected="true"]`, `[aria-invalid="true"]`.
|
||||
This avoids the class-vs-aria drift that produces broken accessibility.
|
||||
|
||||
Vocabulary (the components layer in full):
|
||||
|
||||
- **`.button`** with modifiers `.primary`, `.outline`, `.ghost`, `.danger`,
|
||||
`.link`, `.small`. Modifiers compose: `.button.danger.outline` is an
|
||||
outlined-danger button, no new rule required. States: `[disabled]`,
|
||||
`[aria-busy="true"]` (loading; spinner via CSS animation, no extra markup).
|
||||
- **`.field`** — wrapper containing `.field-label`, `input` / `select` /
|
||||
`textarea`, optional `.field-hint`, optional `.field-error`. Error state
|
||||
uses native `[aria-invalid="true"]` on the input. Hint/error text wired
|
||||
to the input via `aria-describedby` (enforced by the `ui.field` macro).
|
||||
`.field-checkbox` handles the checkbox-with-label layout.
|
||||
- **`.card`** — bordered surface with `.card-header`, `.card-body`,
|
||||
`.card-footer`. Replaces the historically duplicated `.panel` / `.card`
|
||||
pair.
|
||||
- **`.table`** — data table. Modifier `.striped` for zebra rows.
|
||||
- **`.dialog`** — styles `<dialog>`. Parts: `.dialog-header`, `.dialog-body`,
|
||||
`.dialog-footer`, `.dialog-close`. Modifier `.wide` for the wider variant
|
||||
used by editor-style modals. The existing `modals.js` open/close machinery
|
||||
is unchanged; the close button keeps its `data-inline-modal-close`
|
||||
attribute (the JS hook).
|
||||
- **`.tabs`** with parts `.tab` and `.tab-panel`. State is `[aria-selected]`
|
||||
on each `.tab`; the existing `tabs.js` reads/writes the attribute.
|
||||
- **`.tag`** with modifiers `.success`, `.warning`, `.danger`, `.info`,
|
||||
`.muted`. Replaces the current `.badge` vocabulary AND the project-specific
|
||||
`.state-running` / `.state-stopped` / `.state-unknown` / `.state-transient`
|
||||
/ `.state-drift` classes — lifecycle states map to semantic tag variants
|
||||
via the `ui.lifecycle_tag(state)` macro (`"running"` → `.tag.success`,
|
||||
`"stopped"` → `.tag.muted`, `"unknown"` → `.tag.muted`, `"starting"` /
|
||||
`"stopping"` / `"resetting"` etc. → `.tag.warning`, `"drift"` →
|
||||
`.tag.danger`). One vocabulary; one mapping rule.
|
||||
- **`.toast`** (new) — flash-message notification, top-right positioned.
|
||||
Modifiers `.success`, `.warning`, `.danger`. Part `.toast-close`. Fills
|
||||
a real gap — flash messages currently improvise.
|
||||
- **`.spinner`** (new) — standalone loading indicator. Modifier `.small`.
|
||||
Used independently of `[aria-busy]`-on-buttons.
|
||||
- **`.app-header`** with parts `.app-header-inner`, `.brand`, `.nav`,
|
||||
`.account` — the top-of-page banner. Replaces `.site-header` +
|
||||
`.primary-nav` + `.account-nav` (one vocabulary instead of three).
|
||||
- **`.heading`** with part `.heading-actions` — formal page-title pattern
|
||||
(h1 + actions row). Replaces the current ad-hoc `.page-heading`.
|
||||
- **`.dropdown`** — small helper for native `<select>` + any future custom
|
||||
menu pattern.
|
||||
|
||||
**Variant composition pattern** (the mechanism behind chained modifiers):
|
||||
|
||||
Each component declares Tier-3 component-scoped tokens and consumes them.
|
||||
Variants re-point those tokens; modifiers can read inherited values. A
|
||||
trimmed `components/button.css` example:
|
||||
|
||||
```css
|
||||
.button {
|
||||
--button-bg: var(--color-surface);
|
||||
--button-fg: var(--color-text);
|
||||
--button-border: var(--color-border);
|
||||
background: var(--button-bg);
|
||||
color: var(--button-fg);
|
||||
border: 1px solid var(--button-border);
|
||||
/* …padding, radius, transitions… */
|
||||
}
|
||||
.button.primary { --button-bg: var(--color-primary); --button-fg: var(--color-on-primary); --button-border: var(--color-primary); }
|
||||
.button.danger { --button-bg: var(--color-danger); --button-fg: var(--color-on-danger); --button-border: var(--color-danger); }
|
||||
.button.outline { --button-bg: transparent; --button-fg: var(--button-border); }
|
||||
.button.ghost { --button-bg: transparent; --button-fg: var(--color-text); --button-border: transparent; }
|
||||
.button.link { --button-bg: transparent; --button-border: transparent; text-decoration: underline; color: var(--color-link); }
|
||||
.button.small { padding: 0.25rem 0.5rem; font-size: var(--text-sm); }
|
||||
```
|
||||
|
||||
`.button.danger.outline` works because `.outline` is declared *after*
|
||||
`.danger` and reads the inherited `--button-border` (which `.danger`
|
||||
just set). The same pattern is used for `.tag` and `.toast`.
|
||||
|
||||
### Macros — five high-leverage primitives
|
||||
|
||||
A new directory `l4d2web/templates/ui/` adds Jinja macros for primitives
|
||||
where markup correctness is load-bearing — i.e., where hand-assembling
|
||||
gets accessibility wrong easily:
|
||||
|
||||
| Macro file | Macros | Why |
|
||||
|-------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `ui/_field.html` | `ui.field`, `ui.checkbox`, `ui.select` | Owns `<label for>`/`<input id>` pairing, `aria-describedby` wiring for hint+error, `aria-invalid` on error |
|
||||
| `ui/_dialog.html` | `ui.dialog` | Owns the `<dialog>` + `.dialog-header`/`.dialog-body`/`.dialog-footer` structure and the `data-inline-modal-close` JS hook |
|
||||
| `ui/_tabs.html` | `ui.tabs`, `ui.tab_panel` | Owns the `role="tablist"` / `role="tab"` / `role="tabpanel"` + `aria-controls` / `aria-selected` wiring |
|
||||
| `ui/_confirm_form.html` | `ui.confirm_form` | Owns CSRF token, POST action, and standard button row for destructive actions |
|
||||
| `ui/_tag.html` | `ui.tag`, `ui.lifecycle_tag(state)` | Maps a server-lifecycle state string (`"running"`, `"starting"`, `"drift"`, …) to the right `.tag.<variant>` — one mapping rule total |
|
||||
|
||||
CSS-only is the default for trivial primitives — `<button class="btn
|
||||
btn-primary">Save</button>` is its own canonical form and does not need a
|
||||
macro wrapper. The macro tier is reserved for "if you forget a piece of
|
||||
this, the result is silently wrong."
|
||||
|
||||
Existing partials (`_console_line.html`, `_overlay_file_tree.html`,
|
||||
`_overlay_picker_*.html`, `_recent_players_modal_body.html`,
|
||||
`_server_actions.html`, `_live_state.html`) are unaffected. They are
|
||||
page-scoped composites that solve a different problem from `ui/*`; they
|
||||
keep their current shape and just reference the new class names.
|
||||
`_macros.html` continues to host CSRF/utility helpers.
|
||||
|
||||
## Workflow principle — the system is closed
|
||||
|
||||
The redesign codifies a hard rule:
|
||||
|
||||
> **Every page element comes from the catalog. If the catalog lacks what
|
||||
> you need, extend the catalog first, then use it.**
|
||||
|
||||
What this means in practice:
|
||||
|
||||
- **Adding a feature with existing widgets** → just use them.
|
||||
- **Adding a feature that needs a new widget** → three-step extension:
|
||||
1. Add the CSS to `components/<name>.css` or `widgets/<name>.css`.
|
||||
2. Add a style guide entry: rendered example + escaped source HTML, with
|
||||
at least one "do" example, and a "don't" entry if there's a foreseeable
|
||||
wrong way to use it.
|
||||
3. If the widget is composite or a11y-load-bearing, add a macro under
|
||||
`templates/ui/`.
|
||||
4. Only then use the widget on the page.
|
||||
- **New variant of an existing widget** → add the variant class to the
|
||||
widget's CSS, add a style guide entry showing the variant alongside the
|
||||
existing ones, then use it.
|
||||
- **Page-specific tweak that doesn't reuse** → it still gets a name and
|
||||
lives under `widgets/`. Documented in the style guide as "used only on
|
||||
X page." The bar is "documented" not "reused" — a singleton with a
|
||||
proper home is acceptable.
|
||||
- **One-off inline `style=""`** → forbidden.
|
||||
|
||||
Enforcement is light and layered:
|
||||
|
||||
1. **Cultural** — code-review checklist: "did this change add UI without
|
||||
going through the system?"
|
||||
2. **Documented** — `AGENTS.md` carries the rule verbatim so agents see it
|
||||
first.
|
||||
3. **Style guide is the source of truth** — if a widget isn't in
|
||||
`templates/styleguide.html`, it doesn't exist; PRs adding widgets must
|
||||
update the style guide in the same commit.
|
||||
4. **Optional lint** (deferred, post-redesign) — a small script scanning
|
||||
`templates/*.html` for `style="`, magic hex colors, and class names not
|
||||
present in the style guide. Cheap to add later; not blocking for this
|
||||
work.
|
||||
|
||||
The principle has an explicit cost: a feature that needs a new widget pays
|
||||
"add widget first" overhead. For human contributors that can feel slow;
|
||||
for agentic dev, the cost is much lower — an agent can extend the system
|
||||
in the same session, in the same commit, without context-switching. This
|
||||
is exactly the kind of friction that compounds positively.
|
||||
|
||||
## Style guide
|
||||
|
||||
A new public route at `/styleguide` rendering `templates/styleguide.html`.
|
||||
Mounted by a small new blueprint `l4d2web/routes/styleguide_routes.py`.
|
||||
Public access (no login required) — it leaks nothing sensitive and a
|
||||
linkable URL is genuinely useful in PR descriptions and chat.
|
||||
|
||||
Each primitive gets a section showing:
|
||||
|
||||
1. **The rendered widget**, multiple variants if applicable.
|
||||
2. **The source HTML**, captured once via `{% set src %}…{% endset %}` and
|
||||
rendered twice — once as `{{ src|safe }}` (the live widget), once inside
|
||||
`<pre><code>{{ src|trim|e }}</code></pre>` (copy-paste source). This
|
||||
guarantees the rendered example and the displayed source cannot drift.
|
||||
3. **Do / don't blocks** on at least `field`, `modal`, `tabs`, and `badge`
|
||||
(the most-likely-to-be-screwed-up primitives). Format:
|
||||
- ✅ DO — *correct example* + source + one sentence on why it's right
|
||||
- ❌ DON'T — *anti-pattern example* + source + one sentence on what
|
||||
breaks (a11y wiring missing, wrong state class, etc.)
|
||||
|
||||
A token reference table at the top (or in a dedicated section) shows color
|
||||
swatches, spacing scale, type scale, shadow scale. Manually maintained as
|
||||
a Jinja loop over a Python dict — not parsing CSS. Stays in sync via the
|
||||
"add widget = update style guide" rule.
|
||||
|
||||
A dark-mode toggle button at the top of the style guide sets
|
||||
`data-theme="dark"` on `<html>`. Useful well beyond the style guide;
|
||||
included here because the style guide is the natural place to introduce it.
|
||||
|
||||
An entry in `AGENTS.md`:
|
||||
|
||||
```markdown
|
||||
## UI work
|
||||
|
||||
Before adding any UI markup:
|
||||
|
||||
1. Read `l4d2web/templates/styleguide.html` — it lists every available widget
|
||||
with a canonical example.
|
||||
2. If your change needs a widget that isn't in the style guide, add it to
|
||||
the system FIRST:
|
||||
- CSS goes in `components/<name>.css` (generic) or `widgets/<name>.css` (project-specific)
|
||||
- Style guide entry with rendered example + source + at least one ✅ DO
|
||||
- If composite or a11y-load-bearing, add a macro in `templates/ui/_<name>.html`
|
||||
- Only then use the widget on the page
|
||||
3. Naming convention (no exceptions):
|
||||
- **Parts of a component use hyphenated child classes** — `.card-header`, `.field-hint`, `.dialog-body`
|
||||
- **Variant modifiers chain on the parent class** — `.button.primary`, `.tag.success`, `.dialog.wide`
|
||||
- **State stays on ARIA attributes**, not modifier classes — `[disabled]`, `[aria-busy="true"]`, `[aria-selected="true"]`, `[aria-invalid="true"]`
|
||||
4. Never inline `style="…"` attributes. Never invent class names outside the system.
|
||||
5. Token usage:
|
||||
- Component CSS references **only** Tier-2 semantic tokens (`var(--color-*)`, `var(--space-*)`, …)
|
||||
- Tier-3 component-scoped tokens (`--button-bg`, `--tag-fg`, etc.) are private to each component and never appear in other components' CSS
|
||||
- Raw hex codes appear **only** in `tokens/primitives.css`
|
||||
```
|
||||
|
||||
## What the spike validated
|
||||
|
||||
A throwaway spike at `/spike` (gated on `LEFT4ME_SPIKE=1`) compared three
|
||||
options on a representative page: Pico v2 as base, Simple.css as base, and
|
||||
the proposed pure-custom design with the 8-layer cascade. The spike
|
||||
artifacts live at `l4d2web/templates/spike.html` and
|
||||
`l4d2web/static/css/spike/{pico,simple,custom}.css`.
|
||||
|
||||
Findings:
|
||||
|
||||
- **Pico v2** required ~50 LOC to remap our semantic tokens onto its
|
||||
`--pico-*` variables, plus a `width: auto` override on buttons to undo
|
||||
Pico's full-width-in-form default. Every customization needs the
|
||||
"find Pico's variable → look up the docs → override" dance. The custom
|
||||
widgets (file-tree, overlay-picker) sit next to Pico-styled primitives
|
||||
in two-dialects mode.
|
||||
- **Simple.css** was smaller and less opinionated, but its blog-flavored
|
||||
typography (large H1/H2/H3) and per-`<section>` top borders surprised in
|
||||
ways that would keep biting. ~165 LOC custom on top.
|
||||
- **Pure custom** at ~280 LOC reads top-to-bottom as a design system:
|
||||
reset → primitives → semantic → elements → layout → components →
|
||||
widgets → utilities, each in its own `@layer`. No translation layer,
|
||||
no override-fights. The `@layer` cascade specifically means the agent
|
||||
writing new component CSS doesn't have to think about specificity at all.
|
||||
|
||||
Pure custom won on the criterion you weighted highest ("bad code feel
|
||||
leads to incoherent styling later"). The spike validated the
|
||||
**architecture** — `@layer` cascade, color-mix() in production browsers,
|
||||
the light/dark theme switching via `data-theme`, the aesthetic direction.
|
||||
The **vocabulary used in the spike (`.btn`, `.panel`, `.modal`, `.badge`,
|
||||
`.state-*`, …) is NOT carried forward** — the rewrite uses a redesigned
|
||||
vocabulary (`.button` + chained modifiers, `.card`, `.dialog`, `.tag`, …)
|
||||
chosen from first principles for the agentic-dev goal. The spike artifacts
|
||||
get deleted in the cleanup commit (see Migration plan).
|
||||
|
||||
## Migration plan
|
||||
|
||||
One branch, work directly on `master` (per user preference), staged as
|
||||
clean commits each of which leaves the site functional (visual transition
|
||||
may be visible mid-series, which is fine):
|
||||
|
||||
1. **Foundation** — add `main.css`, `reset.css`, `tokens/primitives.css`,
|
||||
`tokens/semantic.css`. Old files still in place; `base.html` unchanged.
|
||||
Nothing renders differently.
|
||||
|
||||
2. **Elements + layout** — add `elements.css`, restructure `layout.css`
|
||||
into the new shape. Switch `base.html` to a single
|
||||
`<link rel="stylesheet" href=".../main.css">`. Old `components.css`
|
||||
no longer loads — pages look bare. Intentional and visible.
|
||||
|
||||
3. **Core components** — add `components/button.css`, `components/card.css`,
|
||||
`components/table.css`, `components/tag.css`, `components/app-header.css`,
|
||||
`components/heading.css`. Half the surface starts looking right again
|
||||
(where templates use the new class names; many still use the old ones
|
||||
until step 7).
|
||||
|
||||
4. **Composite components + macros** — add `components/dialog.css`,
|
||||
`components/tabs.css`, `components/field.css`, `components/dropdown.css`,
|
||||
`components/toast.css`, `components/spinner.css`, plus
|
||||
`templates/ui/_field.html`, `_dialog.html`, `_tabs.html`,
|
||||
`_confirm_form.html`, `_tag.html`. The macros are available for use but
|
||||
templates aren't yet calling them (step 7 does that).
|
||||
|
||||
5. **Project widgets** — move file-tree, overlay-picker, console-autocomplete,
|
||||
editor, logs, live-state into `widgets/<name>.css` with renamed class
|
||||
names (`.overlay-picker` → `.overlay-list`, `.file-tree-row` →
|
||||
`.file-tree-item`, etc. — full mapping in the implementation plan).
|
||||
Rename token references to the new semantic-token namespace.
|
||||
|
||||
6. **Style guide** — add `routes/styleguide_routes.py`,
|
||||
`templates/styleguide.html` with every primitive + ✅ DO / ❌ DON'T
|
||||
blocks, token reference table, dark-mode toggle button. Update
|
||||
`AGENTS.md` with the UI workflow section.
|
||||
|
||||
7. **Template rewrite** — walk every template in `l4d2web/templates/`
|
||||
and update class attributes to the new vocabulary. This is a large but
|
||||
mechanical phase; the implementation plan splits it into reviewable
|
||||
chunks (one per template-family: page templates, partial templates,
|
||||
server-detail's complex cluster). Templates also switch to the new
|
||||
macros (`ui.field`, `ui.dialog`, `ui.lifecycle_tag`, etc.) where
|
||||
applicable. After this step, no template still references the old
|
||||
vocabulary.
|
||||
|
||||
8. **Cleanup** — delete the old `components.css`, the old `tokens.css`,
|
||||
the old root-level `logs.css` / `console-autocomplete.css` /
|
||||
`editor.css` (now under `widgets/`). Delete the spike artifacts in
|
||||
the same commit: `l4d2web/static/css/spike/`, `l4d2web/templates/spike.html`,
|
||||
`l4d2web/routes/spike_routes.py`, `l4d2web/static/vendor/css/`, and
|
||||
the `if spike_enabled(): app.register_blueprint(spike_bp)` block in
|
||||
`app.py` (plus the `spike_routes` import). Run the existing Chromium
|
||||
e2e suite and the pytest suite. Walk the major pages (dashboard,
|
||||
servers, server detail, overlay detail, blueprint detail, profile,
|
||||
admin) in light and dark mode, fixing any regressions in this commit.
|
||||
|
||||
The spike was scaffolding for the framework decision; once the design is
|
||||
in `main.css`, it is no longer load-bearing.
|
||||
|
||||
Per-commit verification: open the dev server (`scripts/dev-server.py`,
|
||||
which serves `LEFT4ME_ROOT=.tmp/dev-server` with seeded demo content),
|
||||
walk through the affected pages, fix any regressions in the same commit.
|
||||
Each commit should leave the *functionality* working even if mid-series
|
||||
visuals are transitional.
|
||||
|
||||
## Decisions log
|
||||
|
||||
A summary of the brainstorm decisions and why, in case future-me needs to
|
||||
revisit them.
|
||||
|
||||
- **Custom system, not a framework base.** Pico, Simple.css, and Bulma
|
||||
were each considered. Pico is the only "minimal classless framework"
|
||||
with admin-app component coverage; the rest are blog-scale typography
|
||||
systems. The spike showed that even Pico's coverage costs ~50 LOC of
|
||||
token translation + override-fights, while the pure-custom path costs
|
||||
only ~+100 LOC and yields a much cleaner code surface. The "code feel
|
||||
bites incoherence later" criterion tipped this decisively.
|
||||
- **No Tailwind / utility-first.** Off the table per user preference.
|
||||
- **No build step.** No Sass, no PostCSS, no bundler. Modern CSS
|
||||
(`@layer`, `color-mix()`, custom properties) suffices and matches the
|
||||
project's zero-build philosophy.
|
||||
- **Three-tier tokens** (primitives → semantic → component-scoped). The
|
||||
initial draft proposed two tiers; the third (component-scoped tokens
|
||||
like `--button-bg`) was added when we adopted chained-modifier variants
|
||||
(`.button.danger.outline`), because composing variants cleanly requires
|
||||
each variant to set local CSS custom properties that later modifiers
|
||||
can read. The third tier is *private to each component* — it doesn't
|
||||
bloat the global token namespace.
|
||||
- **Naming convention chosen from first principles.** The old vocabulary
|
||||
(`.btn` / `.btn-primary` / `.panel` / `.modal` / `.badge` / `.state-*` /
|
||||
`.site-header` / `.primary-nav` / `.link-button` / `.danger-outline` /
|
||||
`.sr-only` / `.overlay-picker` / …) was historically grown and
|
||||
inconsistent. The new vocabulary uses two rules: **parts are hyphenated
|
||||
child classes** (`.card-header`), **variants chain on the parent**
|
||||
(`.button.primary`). State stays on ARIA attributes. The agent learns
|
||||
the rules once and predicts the rest.
|
||||
- **`@layer` for cascade.** Underused, well-supported since 2022, exactly
|
||||
fits the "layered design system" mental model. Replaces specificity
|
||||
hacks with declared layer ordering.
|
||||
- **Macros for five high-leverage primitives, CSS-only for the rest.**
|
||||
The macro tier exists where forgetting a piece of markup silently
|
||||
breaks accessibility. Buttons and tags don't qualify; fields and
|
||||
dialogs do. The fifth, `ui.lifecycle_tag`, encapsulates the
|
||||
state-string → tag-variant mapping in one place.
|
||||
- **Style guide as enforcement, not just documentation.** Tied to a
|
||||
workflow rule ("the system is closed"), referenced in `AGENTS.md`,
|
||||
with ✅ DO / ❌ DON'T blocks. Most leveraged change for the
|
||||
agentic-dev goal.
|
||||
- **Template rewrite as a discrete migration phase.** The new vocabulary
|
||||
means every template's class attributes change; the implementation
|
||||
plan dedicates a phase to this mechanical-but-extensive work rather
|
||||
than mixing it into the CSS commits.
|
||||
- **Spike artifacts kept until cleanup commit.** They seeded the
|
||||
architecture decision; they go away when the production system lands.
|
||||
The spike used the *old* vocabulary — only its `@layer` structure +
|
||||
token shape + dark-mode mechanism + aesthetic direction carried
|
||||
forward.
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# Create Overlay Modal + Workshop Items Section — Redesign
|
||||
|
||||
## Context
|
||||
|
||||
Two UI surfaces still wear the pre-redesign vocabulary even though the global
|
||||
stylesheet was reworked from first principles in `308fa4e`:
|
||||
|
||||
1. **Create-overlay modal** (`templates/overlays.html`, `components.css:114-166`)
|
||||
— uses the old `.modal*` class names, a `<fieldset>` with a native `<legend>`
|
||||
border around the Type radios (looks notched and archaic), native checkbox
|
||||
markup that wraps the input on its own line above the label, and a field
|
||||
order that buries the most-important field (Name) under the most-cluttered
|
||||
one (Type). The "path is generated automatically" hint at the bottom is
|
||||
stale copy from an earlier version where users picked their own paths;
|
||||
paths are now derived from the internal id, so the hint describes behavior
|
||||
the user can no longer influence.
|
||||
|
||||
2. **Workshop items section** on the overlay detail page
|
||||
(`templates/overlay_detail.html:43-67`) — same fieldset-border issue on the
|
||||
Items/Collection radios, two right-aligned buttons (`Add`, `Refresh from
|
||||
Steam`) overlapping with no margin (visual regression), and a forced
|
||||
choice between "items" and "collection" input modes that exists purely to
|
||||
tell the backend which Steam API endpoint to call. The dual mode is also a
|
||||
silent footgun: a user pasting a collection URL into the items field today
|
||||
produces a broken overlay with no warning.
|
||||
|
||||
The goal is to bring both surfaces in line with the redesigned stylesheet
|
||||
*and*, while we're in there, simplify the workshop form structurally (drop the
|
||||
Input-mode radio entirely — see decision rationale below).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Create modal
|
||||
|
||||
- **Field order:** Name → Type → System-wide. Name is the most-typed field
|
||||
and the simplest input; it goes first. Type is the bigger decision but
|
||||
benefits from the user having committed to *something* before they confront
|
||||
three options.
|
||||
- **No fieldset border.** Type label becomes a regular `.field-label` (small
|
||||
uppercase or semibold, matches Name's label).
|
||||
- **Radios become a stacked custom-styled list.** Each row: a circular dot
|
||||
control (outer ring with a colored inner dot when selected), bold label on
|
||||
the right, muted second-line description below the label. All descriptions
|
||||
are visible at once — users can compare options without clicking.
|
||||
Considered and rejected: segmented control — breaks down past 3-4 options
|
||||
(the codebase is plausibly heading toward more overlay types: git, zip,
|
||||
mirror). Considered and rejected: full selectable cards — too much vertical
|
||||
space for what is ultimately a single radio group.
|
||||
- **System-wide checkbox becomes a switch.** Switch sits left-aligned in the
|
||||
row (same left edge as the radio dots), with bold label + muted second-line
|
||||
description to the right — visually consistent with the radio rows above.
|
||||
Switch is conceptually distinct from the type radios (binary on/off vs.
|
||||
one-of-three), so the different control shape reinforces the hierarchy.
|
||||
- **Drop the "path is generated automatically" paragraph.** Legacy copy.
|
||||
- **Buttons keep their current placement** (Cancel + Create, right-aligned in
|
||||
a bordered footer with a slightly muted background) — that part of the
|
||||
current modal already works.
|
||||
|
||||
### Workshop items section
|
||||
|
||||
- **Drop the Input-mode fieldset entirely.** Single textarea accepts any mix
|
||||
of item IDs, item URLs, and collection URLs. Backend autodetects.
|
||||
- **Autodetect strategy** (verified against live Steam API during
|
||||
brainstorming):
|
||||
1. Parse pasted input into a list of IDs (existing helper:
|
||||
`steam_workshop.parse_workshop_input()`).
|
||||
2. **One batched call** to `ISteamRemoteStorage/GetCollectionDetails/v1/`
|
||||
with all parsed IDs. Steam returns one entry per ID:
|
||||
- `result: 1` + `children: [...]` → it's a collection, expand to
|
||||
children IDs.
|
||||
- `result: 9` (k_EResultFileNotFound) → not a collection, keep ID
|
||||
as-is.
|
||||
3. **One batched call** to
|
||||
`ISteamRemoteStorage/GetPublishedFileDetails/v1/` with the flat list
|
||||
of final item IDs (collection-children + non-collection IDs).
|
||||
4. Persist.
|
||||
|
||||
Cost: one extra Steam round-trip on submission (~150 ms), regardless of
|
||||
input size. This is **simpler than today's code**, which has two
|
||||
separate handler branches in `routes/workshop_routes.py:36-99`. The
|
||||
unified flow deletes the `if input_mode == "items" / elif input_mode ==
|
||||
"collection"` branching.
|
||||
|
||||
- **"Refresh from Steam" relocates to a controls row below the items
|
||||
table** (not inside the table). The table ends with its last data row.
|
||||
Below the table sits a single row containing:
|
||||
- Left: a summary hint (`{n} items · {total_size} total` or `0 items`
|
||||
when empty).
|
||||
- Right: a normal-styled `↻ Refresh from Steam` button (disabled when
|
||||
the table is empty).
|
||||
- **Add button placement.** Right-aligned in its own row immediately below
|
||||
the textarea, with proper top margin (today's "no margin" overlap with
|
||||
Refresh is the bug being fixed by moving Refresh).
|
||||
- **Textarea uses monospace font** since pasted content is IDs and URLs.
|
||||
- **Header copy stays** as `Workshop items` (page has other sections —
|
||||
`Files`, `Used by` — and section headers aid scanning).
|
||||
- **Helper text under the label:** "Paste Steam Workshop IDs, item URLs, or
|
||||
collection URLs — one per line. Collections expand automatically." The
|
||||
last sentence is load-bearing — it tells the user they don't need to
|
||||
pre-classify their input.
|
||||
|
||||
## Implementation surface
|
||||
|
||||
### Files to modify
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `l4d2web/l4d2web/templates/overlays.html` | Reorder fields (Name → Type → System-wide); replace native fieldset+radios+checkbox with new `.field` / `.radio-row` / `.switch-row` markup; drop the path-hint `<p>`; rename `.modal*` → `.dialog*` if aligning with the redesign plan. |
|
||||
| `l4d2web/l4d2web/templates/overlay_detail.html:43-67` | Delete the `<fieldset class="workshop-input-mode">` block; keep textarea but rewrite its surrounding markup as a `.field` block with label + helper text; move `Refresh from Steam` out of its own `<form>` and put it in a single `.table-actions` row below the items table; add a summary span on the left of that row. |
|
||||
| `l4d2web/l4d2web/templates/_overlay_item_table.html` | Verify whether per-row actions exist (e.g., remove-item). Out of scope to change today, but flag for the implementation session: the design preserves whatever per-row actions are there. |
|
||||
| `l4d2web/l4d2web/static/css/components.css` | Add the new component CSS (see below). Existing `.modal*` rules either stay (if we keep old class names) or get renamed to `.dialog*`. Remove `.workshop-input-mode` (no rules to remove — fieldset class has zero CSS today anyway). |
|
||||
| `l4d2web/l4d2web/routes/workshop_routes.py:36-99` | Delete the input-mode branching; unify the handler to (1) parse input, (2) batch-resolve collections, (3) batch-fetch metadata. Existing helpers `parse_workshop_input()`, `resolve_collection()`, `fetch_metadata_batch()` all get reused. |
|
||||
| `l4d2web/l4d2web/steam/steam_workshop.py` | Add (or refactor): a `partition_collections_and_items(ids)` helper that does one `GetCollectionDetails` batch call and returns `(item_ids, collection_id_to_children)`. The exact shape can mirror existing module conventions. |
|
||||
|
||||
### Component CSS to add
|
||||
|
||||
These should live in `components.css` and be reusable beyond just these two
|
||||
surfaces — they form a small set of primitives the rest of the app can adopt
|
||||
as it migrates.
|
||||
|
||||
- `.field` (existing pattern, may already partially exist) — grid container
|
||||
with `gap: var(--space-xs)`; children are `.field-label`, optional
|
||||
`.field-hint`, and the control.
|
||||
- `.radio-row` — flex row, gap `var(--space-s)`, custom radio dot via
|
||||
`::after`; `.radio-row.is-selected` colors the inner dot.
|
||||
- `.radio-list` — grid container for a vertical stack of `.radio-row`s with
|
||||
`gap: var(--space-xs)`. Replaces the `<fieldset>` pattern.
|
||||
- `.switch` + `.switch-row` — pill-shaped toggle, on-state uses
|
||||
`--color-button-primary`. Left-aligned in its row, consistent with
|
||||
`.radio-row` left edge.
|
||||
- `.table-actions` — flex row with `justify-content: space-between`,
|
||||
`align-items: center`, top margin `var(--space-m)`. Sits below a
|
||||
`.table-wrap`.
|
||||
|
||||
Existing tokens (already in `tokens.css`) are sufficient. No new color or
|
||||
spacing tokens needed.
|
||||
|
||||
### Things explicitly **not** changing
|
||||
|
||||
- The `<dialog>` open/close JavaScript (`data-inline-modal-close` handlers)
|
||||
stays as-is.
|
||||
- The overlay detail page's `Files` and `Used by` sections.
|
||||
- The `Delete overlay` / `Rename` actions at the bottom of the page.
|
||||
- The Steam-side caching/refresh logic — only the UI placement of the
|
||||
refresh button is moving.
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end checks for the implementation session:
|
||||
|
||||
1. **Dev server**: `python scripts/dev-server.py` (not plain `flask run` —
|
||||
the latter misroutes `LEFT4ME_ROOT` on macOS).
|
||||
2. **Create modal**: open the overlays list page, click "Create overlay".
|
||||
- Verify field order: Name → Type → System-wide.
|
||||
- Verify no fieldset border around Type.
|
||||
- Verify custom radio dots fill with accent color on selection.
|
||||
- Verify switch toggles state and visually animates.
|
||||
- Verify no "path is generated automatically" copy anywhere.
|
||||
- Submit with each of the three types in turn; verify the overlay is
|
||||
created with the correct type each time.
|
||||
3. **Workshop section**:
|
||||
- Create a workshop overlay; navigate to its detail page.
|
||||
- Verify only one textarea + one `Add` button (no input-mode radio).
|
||||
- Paste a single item ID (e.g. `3726529483`); click Add. Item appears.
|
||||
- Paste a collection URL (e.g.
|
||||
`https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665`);
|
||||
click Add. The 6 children appear, not the collection itself.
|
||||
- Paste a mix of items and a collection in one submission; verify all
|
||||
resolve correctly.
|
||||
- Verify items table ends at its last row (no internal footer bar).
|
||||
- Verify summary + `↻ Refresh from Steam` sit below the table as a
|
||||
single row.
|
||||
- Click Refresh from Steam; verify metadata refresh fires.
|
||||
4. **Stale-content sweep**: `grep -rn "path is generated automatically"
|
||||
l4d2web/` should return no matches after the change.
|
||||
5. **i18n check** (if applicable): if the project uses i18n strings for
|
||||
these screens, verify the removed/changed strings are cleaned up.
|
||||
|
||||
## Open follow-ups (out of scope)
|
||||
|
||||
- Once the new component CSS (`.radio-row`, `.switch-row`, `.field`,
|
||||
`.table-actions`) lands, sweep the rest of the templates for fieldsets
|
||||
and native checkboxes that could adopt the same vocabulary. Don't do
|
||||
this in the same commit — surface it as a follow-up so the diff for this
|
||||
change stays scoped.
|
||||
- The `superpowers:brainstorming` skill's companion server has an
|
||||
owner-PID detection bug that kills the server when launched via
|
||||
`Bash(run_in_background: true)` on macOS. Workaround during this
|
||||
brainstorm was launching `node server.cjs` directly with
|
||||
`BRAINSTORM_OWNER_PID=1`. Small upstream PR opportunity, unrelated to
|
||||
this codebase.
|
||||
|
|
@ -480,17 +480,6 @@ def _build_overlay_build_status_context(db, overlay) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _humanize_bytes(n: int) -> str:
|
||||
if n < 1024:
|
||||
return f"{n} B"
|
||||
size = float(n)
|
||||
for unit in ("KB", "MB", "GB"):
|
||||
size /= 1024
|
||||
if size < 1024 or unit == "GB":
|
||||
return f"{size:.1f} {unit}"
|
||||
return f"{size:.1f} GB"
|
||||
|
||||
|
||||
@bp.get("/overlays/<int:overlay_id>")
|
||||
@require_login
|
||||
def overlay_detail(overlay_id: int):
|
||||
|
|
@ -526,16 +515,11 @@ def overlay_detail(overlay_id: int):
|
|||
|
||||
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
|
||||
|
||||
total_bytes = sum((wi.file_size or 0) for wi in workshop_items)
|
||||
workshop_items_total_size = _humanize_bytes(total_bytes) if total_bytes else ""
|
||||
|
||||
return render_template(
|
||||
"overlay_detail.html",
|
||||
overlay=overlay,
|
||||
using_blueprints=using_blueprints,
|
||||
workshop_items=workshop_items,
|
||||
workshop_items_count=len(workshop_items),
|
||||
workshop_items_total_size=workshop_items_total_size,
|
||||
file_tree_root_entries=file_tree_root_entries,
|
||||
file_tree_truncated=file_tree_truncated_count > 0
|
||||
if file_tree_root_entries is not None
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ def add_items(overlay_id: int) -> Response:
|
|||
assert user is not None
|
||||
|
||||
raw_input = request.form.get("input", "").strip()
|
||||
mode = request.form.get("input_mode", "items")
|
||||
if not raw_input:
|
||||
return Response("missing input", status=400)
|
||||
|
||||
|
|
@ -48,18 +49,21 @@ def add_items(overlay_id: int) -> Response:
|
|||
except ValueError as exc:
|
||||
return Response(str(exc), status=400)
|
||||
|
||||
try:
|
||||
ids = steam_workshop.expand_collections(ids)
|
||||
except requests.RequestException as exc:
|
||||
return Response(f"steam api error: {exc}", status=502)
|
||||
if not ids:
|
||||
return Response("no items to add (collections may be empty)", status=400)
|
||||
if mode == "collection":
|
||||
if len(ids) != 1:
|
||||
return Response("collection mode expects exactly one id or url", status=400)
|
||||
try:
|
||||
ids = steam_workshop.resolve_collection(ids[0])
|
||||
except Exception as exc:
|
||||
return Response(f"failed to resolve collection: {exc}", status=502)
|
||||
if not ids:
|
||||
return Response("collection has no items", status=400)
|
||||
|
||||
try:
|
||||
metas = steam_workshop.fetch_metadata_batch(ids, mode="add")
|
||||
except steam_workshop.WorkshopValidationError as exc:
|
||||
return Response(str(exc), status=400)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc:
|
||||
return Response(f"steam api error: {exc}", status=502)
|
||||
|
||||
with session_scope() as db:
|
||||
|
|
|
|||
|
|
@ -140,64 +140,6 @@ def resolve_collection(collection_id: str) -> list[str]:
|
|||
return children
|
||||
|
||||
|
||||
def expand_collections(ids: list[str]) -> list[str]:
|
||||
"""Resolve a mix of item and collection IDs into a flat list of item IDs.
|
||||
|
||||
Performs one batched POST to GetCollectionDetails. For each input ID:
|
||||
- If Steam returns result==1 with a children array, the ID is a
|
||||
collection — replace it with its non-nested child item IDs in order.
|
||||
- If Steam returns result==9 (k_EResultFileNotFound), the ID is not a
|
||||
collection — pass it through unchanged.
|
||||
|
||||
Result preserves input order; collection children are inserted at the
|
||||
position the collection ID held. Duplicates (across pass-throughs and
|
||||
expanded children) are removed, keeping first occurrence.
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
for sid in ids:
|
||||
if not _NUMERIC_ID_RE.fullmatch(sid):
|
||||
raise ValueError(f"steam id must be digits only: {sid!r}")
|
||||
|
||||
payload: dict[str, str | int] = {"collectioncount": len(ids)}
|
||||
for index, sid in enumerate(ids):
|
||||
payload[f"publishedfileids[{index}]"] = sid
|
||||
|
||||
response = _session().post(
|
||||
GET_COLLECTION_DETAILS_URL,
|
||||
data=payload,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
|
||||
by_id: dict[str, dict] = {
|
||||
str(entry.get("publishedfileid", "")): entry
|
||||
for entry in body.get("response", {}).get("collectiondetails", [])
|
||||
}
|
||||
|
||||
expanded: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(sid: str) -> None:
|
||||
if sid and sid not in seen:
|
||||
seen.add(sid)
|
||||
expanded.append(sid)
|
||||
|
||||
for sid in ids:
|
||||
entry = by_id.get(sid)
|
||||
if entry and entry.get("result") == 1 and "children" in entry:
|
||||
for child in entry["children"]:
|
||||
if child.get("filetype", 0) != 0:
|
||||
continue
|
||||
child_id = child.get("publishedfileid")
|
||||
if child_id is not None:
|
||||
_add(str(child_id))
|
||||
else:
|
||||
_add(sid)
|
||||
return expanded
|
||||
|
||||
|
||||
def fetch_metadata_batch(
|
||||
steam_ids: list[str], *, mode: Literal["add", "refresh"]
|
||||
) -> list[WorkshopMetadata]:
|
||||
|
|
|
|||
|
|
@ -94,12 +94,6 @@ a.button.danger {
|
|||
background: var(--color-button-danger);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
a.button[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -1195,157 +1189,3 @@ div.modal.modal-wide {
|
|||
#console-modal .modal-body .console-input-form {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* --- Form primitives (new vocabulary, 2026-05) -------------------------- */
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* .field-hint already defined earlier in this file. */
|
||||
|
||||
.radio-list {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-s);
|
||||
padding: var(--space-xs) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-row > input[type="radio"] {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
flex: 0 0 auto;
|
||||
margin: 0.2rem 0 0 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-row > input[type="radio"]:checked {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.radio-row > input[type="radio"]:checked::after {
|
||||
content: "";
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.radio-row > input[type="radio"]:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.radio-row-text {
|
||||
display: grid;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.radio-row-text strong {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.radio-row-text span {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-s);
|
||||
padding: var(--space-xs) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-row > input[type="checkbox"] {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 1.875rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
margin: 0.225rem 0 0 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.switch-row > input[type="checkbox"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.switch-row > input[type="checkbox"]:checked {
|
||||
background: var(--color-button-primary);
|
||||
}
|
||||
|
||||
.switch-row > input[type="checkbox"]:checked::after {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
|
||||
.switch-row > input[type="checkbox"]:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.switch-row-text {
|
||||
display: grid;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.switch-row-text strong {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.switch-row-text span {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-m);
|
||||
}
|
||||
|
||||
.workshop-input {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,15 +45,17 @@
|
|||
{% if can_edit and not latest_build_is_running %}
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<div class="field">
|
||||
<label class="field-label" for="workshop-input">Add items</label>
|
||||
<p class="field-hint">Paste Steam Workshop IDs, item URLs, or collection URLs — one per line. Collections expand automatically.</p>
|
||||
<textarea id="workshop-input" name="input" rows="3" class="workshop-input"
|
||||
placeholder="3726529483 https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665"></textarea>
|
||||
</div>
|
||||
<div class="form-actions-inline" style="justify-content: flex-end">
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
<fieldset class="workshop-input-mode">
|
||||
<legend>Input mode</legend>
|
||||
<label><input type="radio" name="input_mode" value="items" checked> Items (paste IDs or URLs; one or many)</label>
|
||||
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
|
||||
</fieldset>
|
||||
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789 https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="stack workshop-refresh-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit">Refresh from Steam</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -61,16 +63,6 @@
|
|||
{% include "_overlay_item_table.html" with context %}
|
||||
</div>
|
||||
|
||||
{% if can_edit and not latest_build_is_running %}
|
||||
<div class="table-actions">
|
||||
<span class="field-hint">{% if workshop_items_count %}{{ workshop_items_count }} item{{ "s" if workshop_items_count != 1 else "" }}{% if workshop_items_total_size %} · {{ workshop_items_total_size }} total{% endif %}{% else %}0 items{% endif %}</span>
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit" class="button-secondary" {% if not workshop_items_count %}disabled{% endif %}>↻ Refresh from Steam</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "_overlay_build_status.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,48 +34,17 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="create-overlay-name">Name</label>
|
||||
<input id="create-overlay-name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">Type</span>
|
||||
<div class="radio-list">
|
||||
<label class="radio-row">
|
||||
<input type="radio" name="type" value="workshop" checked>
|
||||
<span class="radio-row-text">
|
||||
<strong>Workshop</strong>
|
||||
<span>Downloads from Steam</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-row">
|
||||
<input type="radio" name="type" value="script">
|
||||
<span class="radio-row-text">
|
||||
<strong>Script</strong>
|
||||
<span>Runs sandboxed bash</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-row">
|
||||
<input type="radio" name="type" value="files">
|
||||
<span class="radio-row-text">
|
||||
<strong>Files</strong>
|
||||
<span>Upload / edit text files online</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="overlay-type-radio">
|
||||
<legend>Type</legend>
|
||||
<label><input type="radio" name="type" value="workshop" checked> Workshop (downloads from Steam)</label>
|
||||
<label><input type="radio" name="type" value="script"> Script (runs sandboxed bash)</label>
|
||||
<label><input type="radio" name="type" value="files"> Files (upload / edit text files online)</label>
|
||||
</fieldset>
|
||||
<label>Name <input name="name" required></label>
|
||||
{% if g.user and g.user.admin %}
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" name="system_wide" value="1">
|
||||
<span class="switch-row-text">
|
||||
<strong>System-wide</strong>
|
||||
<span>Visible to all users</span>
|
||||
</span>
|
||||
</label>
|
||||
<label><input type="checkbox" name="system_wide" value="1"> System-wide (visible to all users)</label>
|
||||
{% endif %}
|
||||
<p class="muted">The path is generated automatically.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
|
|
|
|||
|
|
@ -77,10 +77,6 @@ def login(page, base_url: str, username: str = "alice", password: str = "secret"
|
|||
|
||||
@pytest.fixture(scope="function")
|
||||
def live_server(tmp_path, monkeypatch):
|
||||
# Some routes (e.g. POST /overlays via create_overlay_directory) write
|
||||
# under $LEFT4ME_ROOT. Point it at tmp_path so the prod default
|
||||
# /var/lib/left4me doesn't kick in on dev machines.
|
||||
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||
app = _boot_app(tmp_path, monkeypatch)
|
||||
|
||||
with session_scope() as session:
|
||||
|
|
@ -170,45 +166,6 @@ def files_overlay_server(tmp_path, monkeypatch):
|
|||
shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def workshop_overlay_server(tmp_path, monkeypatch):
|
||||
"""live_server + a workshop-type Overlay owned by alice. The overlay
|
||||
starts with zero items — tests that need items should seed them via
|
||||
direct DB writes or via UI actions inside the test."""
|
||||
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||
app = _boot_app(tmp_path, monkeypatch)
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(
|
||||
username="alice",
|
||||
password_digest=hash_password("secret"),
|
||||
admin=False,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
overlay = Overlay(
|
||||
name="my-maps",
|
||||
path="_pending",
|
||||
type="workshop",
|
||||
user_id=user.id,
|
||||
)
|
||||
session.add(overlay)
|
||||
session.flush()
|
||||
overlay.path = str(overlay.id)
|
||||
user_id = user.id
|
||||
overlay_id = overlay.id
|
||||
|
||||
base_url, shutdown = _serve(app)
|
||||
try:
|
||||
yield {
|
||||
"base_url": base_url,
|
||||
"user_id": user_id,
|
||||
"overlay_id": overlay_id,
|
||||
}
|
||||
finally:
|
||||
shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def server_with_files(tmp_path, monkeypatch):
|
||||
"""live_server + a Server owned by alice with a populated runtime
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
"""E2E test for the redesigned create-overlay modal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from .conftest import login
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_create_overlay_modal_field_order_and_submission(page: Page, live_server) -> None:
|
||||
base_url = live_server["base_url"]
|
||||
login(page, base_url)
|
||||
page.goto(f"{base_url}/overlays")
|
||||
page.click('button[data-inline-modal-open="create-overlay-modal"]')
|
||||
|
||||
modal = page.locator("#create-overlay-modal")
|
||||
expect(modal).to_be_visible()
|
||||
|
||||
# Field order: Name input must appear before Type radios in DOM/visual order.
|
||||
name_input = modal.locator('input[name="name"]')
|
||||
type_workshop = modal.locator('input[name="type"][value="workshop"]')
|
||||
expect(name_input).to_be_visible()
|
||||
expect(type_workshop).to_be_visible()
|
||||
name_box = name_input.bounding_box()
|
||||
type_box = type_workshop.bounding_box()
|
||||
assert name_box is not None and type_box is not None
|
||||
assert name_box["y"] < type_box["y"], "Name should sit above Type"
|
||||
|
||||
# No legacy fieldset border around Type group.
|
||||
expect(modal.locator("fieldset.overlay-type-radio")).to_have_count(0)
|
||||
|
||||
# No legacy "path is generated automatically" copy anywhere.
|
||||
expect(modal).not_to_contain_text("path is generated automatically")
|
||||
|
||||
# Default workshop selection is checked.
|
||||
expect(type_workshop).to_be_checked()
|
||||
|
||||
# Submit with a unique name and the script type.
|
||||
name_input.fill("e2e-test-overlay")
|
||||
modal.locator('input[name="type"][value="script"]').check()
|
||||
modal.locator('button[type="submit"]:has-text("Create")').click()
|
||||
|
||||
# Handler redirects to /overlays/<id> on success.
|
||||
page.wait_for_url("**/overlays/*", timeout=5000)
|
||||
expect(page.locator("h1")).to_contain_text("e2e-test-overlay")
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"""E2E test for the redesigned workshop items section."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from l4d2web.services import steam_workshop
|
||||
|
||||
from .conftest import login
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def _meta(steam_id: str) -> steam_workshop.WorkshopMetadata:
|
||||
return steam_workshop.WorkshopMetadata(
|
||||
steam_id=steam_id,
|
||||
title=f"Item {steam_id}",
|
||||
filename=f"{steam_id}.vpk",
|
||||
file_url=f"https://example.com/{steam_id}.vpk",
|
||||
file_size=1024,
|
||||
time_updated=1700000000,
|
||||
preview_url="",
|
||||
consumer_app_id=550,
|
||||
result=1,
|
||||
)
|
||||
|
||||
|
||||
def test_workshop_section_renders_without_input_mode_radio(page: Page, workshop_overlay_server) -> None:
|
||||
base_url = workshop_overlay_server["base_url"]
|
||||
overlay_id = workshop_overlay_server["overlay_id"]
|
||||
login(page, base_url)
|
||||
page.goto(f"{base_url}/overlays/{overlay_id}")
|
||||
|
||||
# Legacy input-mode fieldset is gone.
|
||||
expect(page.locator("fieldset.workshop-input-mode")).to_have_count(0)
|
||||
body = page.locator("body")
|
||||
expect(body).not_to_contain_text("Input mode")
|
||||
expect(body).not_to_contain_text("Items (paste IDs or URLs; one or many)")
|
||||
expect(body).not_to_contain_text("Collection (one ID or URL)")
|
||||
|
||||
# Single textarea + Add button.
|
||||
expect(page.locator('textarea[name="input"]')).to_be_visible()
|
||||
expect(page.locator('button:has-text("Add")')).to_be_visible()
|
||||
|
||||
# Refresh button is below the items table.
|
||||
table = page.locator("#overlay-item-table")
|
||||
refresh = page.locator('button:has-text("Refresh from Steam")')
|
||||
expect(table).to_be_visible()
|
||||
expect(refresh).to_be_visible()
|
||||
table_y = table.bounding_box()["y"]
|
||||
refresh_y = refresh.bounding_box()["y"]
|
||||
assert table_y < refresh_y, "Refresh button should sit below the items table"
|
||||
|
||||
|
||||
def test_workshop_autodetect_expands_collection_url(page: Page, workshop_overlay_server) -> None:
|
||||
"""Pasting a collection URL into the textarea (no mode toggle) expands to
|
||||
children server-side. Verified by patching the Steam helpers."""
|
||||
base_url = workshop_overlay_server["base_url"]
|
||||
overlay_id = workshop_overlay_server["overlay_id"]
|
||||
login(page, base_url)
|
||||
|
||||
with patch.object(
|
||||
steam_workshop, "expand_collections", return_value=["1001", "1002"]
|
||||
), patch.object(
|
||||
steam_workshop, "fetch_metadata_batch", return_value=[_meta("1001"), _meta("1002")]
|
||||
):
|
||||
page.goto(f"{base_url}/overlays/{overlay_id}")
|
||||
page.locator('textarea[name="input"]').fill(
|
||||
"https://steamcommunity.com/sharedfiles/filedetails/?id=555"
|
||||
)
|
||||
page.locator('button:has-text("Add")').click()
|
||||
# Handler redirects to /jobs/<n>.
|
||||
page.wait_for_url("**/jobs/*", timeout=5000)
|
||||
|
|
@ -310,122 +310,3 @@ def test_refresh_all_uses_thread_pool_and_collects_errors(tmp_path: Path) -> Non
|
|||
assert report.downloaded == 2
|
||||
assert report.errors == 1
|
||||
assert "2" in report.per_item_errors
|
||||
|
||||
|
||||
def test_expand_collections_passes_through_items() -> None:
|
||||
fake_response = MagicMock(status_code=200)
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.json.return_value = {
|
||||
"response": {
|
||||
"collectiondetails": [
|
||||
{"publishedfileid": "1001", "result": 9},
|
||||
{"publishedfileid": "1002", "result": 9},
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||
result = steam_workshop.expand_collections(["1001", "1002"])
|
||||
assert result == ["1001", "1002"]
|
||||
|
||||
|
||||
def test_expand_collections_replaces_collection_with_children() -> None:
|
||||
fake_response = MagicMock(status_code=200)
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.json.return_value = {
|
||||
"response": {
|
||||
"collectiondetails": [
|
||||
{
|
||||
"publishedfileid": "555",
|
||||
"result": 1,
|
||||
"children": [
|
||||
{"publishedfileid": "1001", "filetype": 0},
|
||||
{"publishedfileid": "1002", "filetype": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||
result = steam_workshop.expand_collections(["555"])
|
||||
assert result == ["1001", "1002"]
|
||||
|
||||
|
||||
def test_expand_collections_mixed_items_and_collections() -> None:
|
||||
fake_response = MagicMock(status_code=200)
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.json.return_value = {
|
||||
"response": {
|
||||
"collectiondetails": [
|
||||
{"publishedfileid": "1001", "result": 9},
|
||||
{
|
||||
"publishedfileid": "555",
|
||||
"result": 1,
|
||||
"children": [
|
||||
{"publishedfileid": "2001", "filetype": 0},
|
||||
{"publishedfileid": "2002", "filetype": 0},
|
||||
],
|
||||
},
|
||||
{"publishedfileid": "1003", "result": 9},
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||
result = steam_workshop.expand_collections(["1001", "555", "1003"])
|
||||
# Input order preserved; collection's children inserted where the collection was.
|
||||
assert result == ["1001", "2001", "2002", "1003"]
|
||||
|
||||
|
||||
def test_expand_collections_skips_nested_collections() -> None:
|
||||
fake_response = MagicMock(status_code=200)
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.json.return_value = {
|
||||
"response": {
|
||||
"collectiondetails": [
|
||||
{
|
||||
"publishedfileid": "555",
|
||||
"result": 1,
|
||||
"children": [
|
||||
{"publishedfileid": "1001", "filetype": 0},
|
||||
{"publishedfileid": "9999", "filetype": 1},
|
||||
{"publishedfileid": "1002", "filetype": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||
result = steam_workshop.expand_collections(["555"])
|
||||
assert result == ["1001", "1002"]
|
||||
|
||||
|
||||
def test_expand_collections_deduplicates() -> None:
|
||||
fake_response = MagicMock(status_code=200)
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.json.return_value = {
|
||||
"response": {
|
||||
"collectiondetails": [
|
||||
{"publishedfileid": "1001", "result": 9},
|
||||
{
|
||||
"publishedfileid": "555",
|
||||
"result": 1,
|
||||
"children": [
|
||||
{"publishedfileid": "1001", "filetype": 0},
|
||||
{"publishedfileid": "1002", "filetype": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))):
|
||||
result = steam_workshop.expand_collections(["1001", "555"])
|
||||
assert result == ["1001", "1002"]
|
||||
|
||||
|
||||
def test_expand_collections_empty_input_returns_empty() -> None:
|
||||
result = steam_workshop.expand_collections([])
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_expand_collections_rejects_non_numeric_ids() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
steam_workshop.expand_collections(["1001", "not-a-number"])
|
||||
|
|
|
|||
|
|
@ -85,16 +85,6 @@ def _patch_steam(metas: Iterable[steam_workshop.WorkshopMetadata]):
|
|||
return patch.object(steam_workshop, "fetch_metadata_batch", return_value=list(metas))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_expand_collections():
|
||||
"""By default, expand_collections is a passthrough so existing item-only
|
||||
tests don't make real Steam API calls. Tests that exercise autodetect
|
||||
override this with their own patch.object(..., return_value=[...]) — the
|
||||
explicit per-test patch wins inside the `with` block."""
|
||||
with patch.object(steam_workshop, "expand_collections", side_effect=lambda x: list(x)):
|
||||
yield
|
||||
|
||||
|
||||
def test_add_single_item_creates_association_and_enqueues_build(overlay_for):
|
||||
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||
user_client = login(user_id)
|
||||
|
|
@ -102,7 +92,7 @@ def test_add_single_item_creates_association_and_enqueues_build(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
response = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
|
@ -128,7 +118,7 @@ def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for):
|
|||
with _patch_steam([_meta(s) for s in ("1001", "1002", "1003")]):
|
||||
response = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001\n1002\n1003"},
|
||||
data={"input": "1001\n1002\n1003", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
|
@ -140,54 +130,23 @@ def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for):
|
|||
assert len(jobs) == 1, "multi-item add should coalesce into a single build job"
|
||||
|
||||
|
||||
def test_add_collection_autodetects_and_expands_children(overlay_for):
|
||||
"""Pasting a collection ID expands to its children via autodetect — no
|
||||
input_mode field is needed in the request."""
|
||||
def test_add_collection_resolves_members(overlay_for):
|
||||
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||
user_client = login(user_id)
|
||||
|
||||
with patch.object(
|
||||
steam_workshop, "expand_collections", return_value=["1001", "1002", "1003"]
|
||||
) as expand, _patch_steam([_meta("1001"), _meta("1002"), _meta("1003")]):
|
||||
response = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "555"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
with patch.object(steam_workshop, "resolve_collection", return_value=["1001", "1002"]) as resolve:
|
||||
with _patch_steam([_meta("1001"), _meta("1002")]):
|
||||
response = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "555", "input_mode": "collection"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
expand.assert_called_once_with(["555"])
|
||||
assert response.headers["Location"].startswith("/jobs/")
|
||||
resolve.assert_called_once_with("555")
|
||||
|
||||
with session_scope() as session:
|
||||
steam_ids = {wi.steam_id for wi in session.query(WorkshopItem).all()}
|
||||
assert steam_ids == {"1001", "1002", "1003"}
|
||||
|
||||
|
||||
def test_add_mixed_items_and_collection_in_one_paste(overlay_for):
|
||||
"""A single submission can mix item IDs, item URLs, and a collection URL;
|
||||
expand_collections flattens collections in place, and metadata fetch covers
|
||||
the resulting flat ID list."""
|
||||
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||
user_client = login(user_id)
|
||||
|
||||
raw = (
|
||||
"1001\n"
|
||||
"https://steamcommunity.com/sharedfiles/filedetails/?id=555\n"
|
||||
"https://steamcommunity.com/sharedfiles/filedetails/?id=2001\n"
|
||||
)
|
||||
with patch.object(
|
||||
steam_workshop, "expand_collections", return_value=["1001", "3001", "3002", "2001"]
|
||||
) as expand, _patch_steam([_meta(s) for s in ("1001", "3001", "3002", "2001")]):
|
||||
response = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": raw},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
# Parse yields [1001, 555, 2001] from the mixed input; expand_collections
|
||||
# receives that flat list, flattens the collection in place.
|
||||
expand.assert_called_once_with(["1001", "555", "2001"])
|
||||
with session_scope() as session:
|
||||
steam_ids = {wi.steam_id for wi in session.query(WorkshopItem).all()}
|
||||
assert steam_ids == {"1001", "2001", "3001", "3002"}
|
||||
assert session.query(OverlayWorkshopItem).count() == 2
|
||||
|
||||
|
||||
def test_add_non_l4d2_item_returns_400(overlay_for):
|
||||
|
|
@ -200,7 +159,7 @@ def test_add_non_l4d2_item_returns_400(overlay_for):
|
|||
with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=raise_validation):
|
||||
response = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "9999"},
|
||||
data={"input": "9999", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
|
@ -218,7 +177,7 @@ def test_add_duplicate_item_does_not_500(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
first = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert first.status_code == 302
|
||||
|
|
@ -226,7 +185,7 @@ def test_add_duplicate_item_does_not_500(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
second = user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert second.status_code == 302
|
||||
|
|
@ -242,7 +201,7 @@ def test_remove_item_drops_association_and_enqueues_rebuild(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
|
||||
|
|
@ -320,7 +279,7 @@ def test_other_user_cannot_modify_workshop_overlay(overlay_for):
|
|||
intruder_client = login(intruder_id)
|
||||
response = intruder_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
|
@ -332,7 +291,7 @@ def test_overlay_refresh_owner_can_refresh_and_enqueues_build(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
with session_scope() as session:
|
||||
|
|
@ -397,7 +356,7 @@ def test_overlay_refresh_admin_can_refresh_anyone(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
|
||||
|
|
@ -416,7 +375,7 @@ def test_overlay_refresh_502_on_steam_error(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
with session_scope() as session:
|
||||
|
|
@ -450,7 +409,7 @@ def test_overlay_refresh_non_requests_exception_propagates(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
with session_scope() as session:
|
||||
|
|
@ -473,7 +432,7 @@ def test_overlay_refresh_missing_item_records_last_error(overlay_for):
|
|||
with _patch_steam([_meta("1001")]):
|
||||
user_client.post(
|
||||
f"/overlays/{overlay_id}/items",
|
||||
data={"input": "1001"},
|
||||
data={"input": "1001", "input_mode": "items"},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
with session_scope() as session:
|
||||
|
|
|
|||
Loading…
Reference in a new issue