Compare commits
12 commits
fa9acd3027
...
9763b8980c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9763b8980c | ||
|
|
a18e96eec9 | ||
|
|
fa394c1f7a | ||
|
|
34b65fcbbe | ||
|
|
6cce8b7be7 | ||
|
|
5c56f18d0c | ||
|
|
6a04594c19 | ||
|
|
f1b0cbb5f1 | ||
|
|
0ffc3fde3d | ||
|
|
308fa4eb26 | ||
|
|
536c3384bf | ||
|
|
a0501a20fb |
15 changed files with 4968 additions and 55 deletions
2379
docs/superpowers/plans/2026-05-17-stylesheet-redesign.md
Normal file
2379
docs/superpowers/plans/2026-05-17-stylesheet-redesign.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
526
docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md
Normal file
526
docs/superpowers/specs/2026-05-17-stylesheet-redesign-design.md
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
# 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,6 +480,17 @@ 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>")
|
@bp.get("/overlays/<int:overlay_id>")
|
||||||
@require_login
|
@require_login
|
||||||
def overlay_detail(overlay_id: int):
|
def overlay_detail(overlay_id: int):
|
||||||
|
|
@ -515,11 +526,16 @@ def overlay_detail(overlay_id: int):
|
||||||
|
|
||||||
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
|
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(
|
return render_template(
|
||||||
"overlay_detail.html",
|
"overlay_detail.html",
|
||||||
overlay=overlay,
|
overlay=overlay,
|
||||||
using_blueprints=using_blueprints,
|
using_blueprints=using_blueprints,
|
||||||
workshop_items=workshop_items,
|
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_root_entries=file_tree_root_entries,
|
||||||
file_tree_truncated=file_tree_truncated_count > 0
|
file_tree_truncated=file_tree_truncated_count > 0
|
||||||
if file_tree_root_entries is not None
|
if file_tree_root_entries is not None
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ def add_items(overlay_id: int) -> Response:
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
||||||
raw_input = request.form.get("input", "").strip()
|
raw_input = request.form.get("input", "").strip()
|
||||||
mode = request.form.get("input_mode", "items")
|
|
||||||
if not raw_input:
|
if not raw_input:
|
||||||
return Response("missing input", status=400)
|
return Response("missing input", status=400)
|
||||||
|
|
||||||
|
|
@ -49,21 +48,18 @@ def add_items(overlay_id: int) -> Response:
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response(str(exc), status=400)
|
return Response(str(exc), status=400)
|
||||||
|
|
||||||
if mode == "collection":
|
|
||||||
if len(ids) != 1:
|
|
||||||
return Response("collection mode expects exactly one id or url", status=400)
|
|
||||||
try:
|
try:
|
||||||
ids = steam_workshop.resolve_collection(ids[0])
|
ids = steam_workshop.expand_collections(ids)
|
||||||
except Exception as exc:
|
except requests.RequestException as exc:
|
||||||
return Response(f"failed to resolve collection: {exc}", status=502)
|
return Response(f"steam api error: {exc}", status=502)
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response("collection has no items", status=400)
|
return Response("no items to add (collections may be empty)", status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
metas = steam_workshop.fetch_metadata_batch(ids, mode="add")
|
metas = steam_workshop.fetch_metadata_batch(ids, mode="add")
|
||||||
except steam_workshop.WorkshopValidationError as exc:
|
except steam_workshop.WorkshopValidationError as exc:
|
||||||
return Response(str(exc), status=400)
|
return Response(str(exc), status=400)
|
||||||
except Exception as exc:
|
except requests.RequestException as exc:
|
||||||
return Response(f"steam api error: {exc}", status=502)
|
return Response(f"steam api error: {exc}", status=502)
|
||||||
|
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,64 @@ def resolve_collection(collection_id: str) -> list[str]:
|
||||||
return children
|
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(
|
def fetch_metadata_batch(
|
||||||
steam_ids: list[str], *, mode: Literal["add", "refresh"]
|
steam_ids: list[str], *, mode: Literal["add", "refresh"]
|
||||||
) -> list[WorkshopMetadata]:
|
) -> list[WorkshopMetadata]:
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,12 @@ a.button.danger {
|
||||||
background: var(--color-button-danger);
|
background: var(--color-button-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled,
|
||||||
|
a.button[aria-disabled="true"] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.link-button {
|
.link-button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -1189,3 +1195,157 @@ div.modal.modal-wide {
|
||||||
#console-modal .modal-body .console-input-form {
|
#console-modal .modal-body .console-input-form {
|
||||||
flex: 0 0 auto;
|
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,17 +45,15 @@
|
||||||
{% if can_edit and not latest_build_is_running %}
|
{% if can_edit and not latest_build_is_running %}
|
||||||
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
|
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<fieldset class="workshop-input-mode">
|
<div class="field">
|
||||||
<legend>Input mode</legend>
|
<label class="field-label" for="workshop-input">Add items</label>
|
||||||
<label><input type="radio" name="input_mode" value="items" checked> Items (paste IDs or URLs; one or many)</label>
|
<p class="field-hint">Paste Steam Workshop IDs, item URLs, or collection URLs — one per line. Collections expand automatically.</p>
|
||||||
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
|
<textarea id="workshop-input" name="input" rows="3" class="workshop-input"
|
||||||
</fieldset>
|
placeholder="3726529483 https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665"></textarea>
|
||||||
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789 https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
|
</div>
|
||||||
|
<div class="form-actions-inline" style="justify-content: flex-end">
|
||||||
<button type="submit">Add</button>
|
<button type="submit">Add</button>
|
||||||
</form>
|
</div>
|
||||||
<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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -63,6 +61,16 @@
|
||||||
{% include "_overlay_item_table.html" with context %}
|
{% include "_overlay_item_table.html" with context %}
|
||||||
</div>
|
</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" %}
|
{% include "_overlay_build_status.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,48 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<fieldset class="overlay-type-radio">
|
|
||||||
<legend>Type</legend>
|
<div class="field">
|
||||||
<label><input type="radio" name="type" value="workshop" checked> Workshop (downloads from Steam)</label>
|
<label class="field-label" for="create-overlay-name">Name</label>
|
||||||
<label><input type="radio" name="type" value="script"> Script (runs sandboxed bash)</label>
|
<input id="create-overlay-name" name="name" required>
|
||||||
<label><input type="radio" name="type" value="files"> Files (upload / edit text files online)</label>
|
</div>
|
||||||
</fieldset>
|
|
||||||
<label>Name <input name="name" required></label>
|
<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>
|
||||||
|
|
||||||
{% if g.user and g.user.admin %}
|
{% if g.user and g.user.admin %}
|
||||||
<label><input type="checkbox" name="system_wide" value="1"> System-wide (visible to all users)</label>
|
<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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="muted">The path is generated automatically.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,10 @@ def login(page, base_url: str, username: str = "alice", password: str = "secret"
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def live_server(tmp_path, monkeypatch):
|
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)
|
app = _boot_app(tmp_path, monkeypatch)
|
||||||
|
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
|
|
@ -166,6 +170,45 @@ def files_overlay_server(tmp_path, monkeypatch):
|
||||||
shutdown()
|
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")
|
@pytest.fixture(scope="function")
|
||||||
def server_with_files(tmp_path, monkeypatch):
|
def server_with_files(tmp_path, monkeypatch):
|
||||||
"""live_server + a Server owned by alice with a populated runtime
|
"""live_server + a Server owned by alice with a populated runtime
|
||||||
|
|
|
||||||
47
l4d2web/tests/e2e/test_overlays_create.py
Normal file
47
l4d2web/tests/e2e/test_overlays_create.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""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")
|
||||||
75
l4d2web/tests/e2e/test_workshop_section.py
Normal file
75
l4d2web/tests/e2e/test_workshop_section.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""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,3 +310,122 @@ def test_refresh_all_uses_thread_pool_and_collects_errors(tmp_path: Path) -> Non
|
||||||
assert report.downloaded == 2
|
assert report.downloaded == 2
|
||||||
assert report.errors == 1
|
assert report.errors == 1
|
||||||
assert "2" in report.per_item_errors
|
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,6 +85,16 @@ def _patch_steam(metas: Iterable[steam_workshop.WorkshopMetadata]):
|
||||||
return patch.object(steam_workshop, "fetch_metadata_batch", return_value=list(metas))
|
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):
|
def test_add_single_item_creates_association_and_enqueues_build(overlay_for):
|
||||||
app, login, user_id, _admin_id, overlay_id = overlay_for
|
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||||
user_client = login(user_id)
|
user_client = login(user_id)
|
||||||
|
|
@ -92,7 +102,7 @@ def test_add_single_item_creates_association_and_enqueues_build(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
response = user_client.post(
|
response = user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
@ -118,7 +128,7 @@ def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for):
|
||||||
with _patch_steam([_meta(s) for s in ("1001", "1002", "1003")]):
|
with _patch_steam([_meta(s) for s in ("1001", "1002", "1003")]):
|
||||||
response = user_client.post(
|
response = user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001\n1002\n1003", "input_mode": "items"},
|
data={"input": "1001\n1002\n1003"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
@ -130,23 +140,54 @@ 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"
|
assert len(jobs) == 1, "multi-item add should coalesce into a single build job"
|
||||||
|
|
||||||
|
|
||||||
def test_add_collection_resolves_members(overlay_for):
|
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."""
|
||||||
app, login, user_id, _admin_id, overlay_id = overlay_for
|
app, login, user_id, _admin_id, overlay_id = overlay_for
|
||||||
user_client = login(user_id)
|
user_client = login(user_id)
|
||||||
|
|
||||||
with patch.object(steam_workshop, "resolve_collection", return_value=["1001", "1002"]) as resolve:
|
with patch.object(
|
||||||
with _patch_steam([_meta("1001"), _meta("1002")]):
|
steam_workshop, "expand_collections", return_value=["1001", "1002", "1003"]
|
||||||
|
) as expand, _patch_steam([_meta("1001"), _meta("1002"), _meta("1003")]):
|
||||||
response = user_client.post(
|
response = user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "555", "input_mode": "collection"},
|
data={"input": "555"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response.headers["Location"].startswith("/jobs/")
|
expand.assert_called_once_with(["555"])
|
||||||
resolve.assert_called_once_with("555")
|
|
||||||
|
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
assert session.query(OverlayWorkshopItem).count() == 2
|
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"}
|
||||||
|
|
||||||
|
|
||||||
def test_add_non_l4d2_item_returns_400(overlay_for):
|
def test_add_non_l4d2_item_returns_400(overlay_for):
|
||||||
|
|
@ -159,7 +200,7 @@ def test_add_non_l4d2_item_returns_400(overlay_for):
|
||||||
with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=raise_validation):
|
with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=raise_validation):
|
||||||
response = user_client.post(
|
response = user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "9999", "input_mode": "items"},
|
data={"input": "9999"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
@ -177,7 +218,7 @@ def test_add_duplicate_item_does_not_500(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
first = user_client.post(
|
first = user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert first.status_code == 302
|
assert first.status_code == 302
|
||||||
|
|
@ -185,7 +226,7 @@ def test_add_duplicate_item_does_not_500(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
second = user_client.post(
|
second = user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert second.status_code == 302
|
assert second.status_code == 302
|
||||||
|
|
@ -201,7 +242,7 @@ def test_remove_item_drops_association_and_enqueues_rebuild(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
user_client.post(
|
user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -279,7 +320,7 @@ def test_other_user_cannot_modify_workshop_overlay(overlay_for):
|
||||||
intruder_client = login(intruder_id)
|
intruder_client = login(intruder_id)
|
||||||
response = intruder_client.post(
|
response = intruder_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
@ -291,7 +332,7 @@ def test_overlay_refresh_owner_can_refresh_and_enqueues_build(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
user_client.post(
|
user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
|
|
@ -356,7 +397,7 @@ def test_overlay_refresh_admin_can_refresh_anyone(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
user_client.post(
|
user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -375,7 +416,7 @@ def test_overlay_refresh_502_on_steam_error(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
user_client.post(
|
user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
|
|
@ -409,7 +450,7 @@ def test_overlay_refresh_non_requests_exception_propagates(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
user_client.post(
|
user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
|
|
@ -432,7 +473,7 @@ def test_overlay_refresh_missing_item_records_last_error(overlay_for):
|
||||||
with _patch_steam([_meta("1001")]):
|
with _patch_steam([_meta("1001")]):
|
||||||
user_client.post(
|
user_client.post(
|
||||||
f"/overlays/{overlay_id}/items",
|
f"/overlays/{overlay_id}/items",
|
||||||
data={"input": "1001", "input_mode": "items"},
|
data={"input": "1001"},
|
||||||
headers={"X-CSRF-Token": "test-token"},
|
headers={"X-CSRF-Token": "test-token"},
|
||||||
)
|
)
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue