diff --git a/Dockerfile b/Dockerfile index e7ee793..0ab989a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,6 @@ COPY alembic.ini ./ COPY migrations ./migrations RUN mkdir -p /app/assets/generated -EXPOSE 8000 8501 +EXPOSE 8000 CMD ["uvicorn", "ecommerce_agent.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 92e0031..5e42508 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ TEST_DATABASE_URL ?= postgresql+asyncpg://ecommerce:ecommerce@postgres:5432/$(TE .DEFAULT_GOAL := help -.PHONY: help env install install-hooks lock up down restart build ps logs logs-api logs-dashboard \ +.PHONY: help env install install-hooks lock up down restart build ps logs logs-api \ migrate lint typecheck format format-check test test-unit \ test-smoke test-integration test-all shell-api db-shell health openapi \ reset-local-data @@ -59,9 +59,6 @@ logs: ## Follow logs for all services. logs-api: ## Follow FastAPI logs. $(COMPOSE) logs -f --tail=200 api -logs-dashboard: ## Follow Streamlit logs. - $(COMPOSE) logs -f --tail=200 dashboard - migrate: env ## Apply application database migrations. $(COMPOSE) run --rm migrate alembic upgrade head @@ -115,10 +112,10 @@ shell-api: ## Open a shell in a one-off API container. db-shell: ## Open psql in the local application database. $(COMPOSE) exec postgres psql -U ecommerce -d ecommerce -health: ## Check local API and dashboard HTTP endpoints. +health: ## Check the local API and web UI HTTP endpoints. curl -fsS http://127.0.0.1:8000/api/health @printf "\n" - curl -fsSI http://127.0.0.1:8501 | head -n 1 + curl -fsSI http://127.0.0.1:8000/trend-inbox | head -n 1 openapi: ## Write the current OpenAPI schema to /tmp/ecommerce-agent-openapi.json. @test -x "$(PYTHON)" || { echo "Run 'make install' first."; exit 1; } diff --git a/assets/design_handoff_b1_workshop/B1 Workshop.html b/assets/design_handoff_b1_workshop/B1 Workshop.html new file mode 100644 index 0000000..67ba1f9 --- /dev/null +++ b/assets/design_handoff_b1_workshop/B1 Workshop.html @@ -0,0 +1,38 @@ + + + + + +Workshop — B1 (Concrete · Amber Dark) · Handoff + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/assets/design_handoff_b1_workshop/README.md b/assets/design_handoff_b1_workshop/README.md new file mode 100644 index 0000000..b698cb6 --- /dev/null +++ b/assets/design_handoff_b1_workshop/README.md @@ -0,0 +1,105 @@ +# Workshop — B1 (Concrete · Amber Dark) · Claude Code Handoff + +This folder is a **complete handoff package** for implementing the Workshop B1 design in a +real codebase. It is written so a developer (or Claude Code) who was **not** part of the +original design conversation can build the product from these documents alone. + +--- + +## What Workshop is +An internal, single-operator tool for an AI-assisted print-on-demand business. It runs a +human-in-the-loop pipeline: surface market **trends** → generate **research reports** → +**design** print artwork → push **products** to Etsy/Printful. Tagline: *Human-driven commerce.* + +## What "B1" is +B1 (code-named **Concrete · Amber Dark**) is the chosen visual direction: a **brutalist**, +monospace-accented UI — hard 0-radius corners, 1.5px structural borders, solid offset +"hard shadows," a single **vermilion** signal color (`#fb5a28`) over **warm amber-orange +near-blacks**. It ships **dark by default** but is fully **light/dark compatible** (a toggle +lives in every topbar). The other directions explored earlier have been intentionally +stripped from this package. + +--- + +## How to use this package + +1. Open **`B1 Workshop.html`** in a browser to see all four screens live (stacked, dark mode; + use the LIGHT/DARK toggle in any topbar to flip the whole study). This is your visual + source of truth. +2. Read the docs below **in order**. They are the build spec. +3. The `.jsx`/`.html` files are **design references**, not production code — see *Fidelity* below. + +### Documents +| Doc | Contents | +|---|---| +| [`docs/01-design-tokens.md`](docs/01-design-tokens.md) | Both color palettes (dark + light), type scale, geometry, spacing, shadows, image placeholders. Start here. | +| [`docs/02-components.md`](docs/02-components.md) | The app shell (sidebar, topbar), every shared primitive, and reusable patterns with exact specs. | +| [`docs/03-screens.md`](docs/03-screens.md) | Per-screen layout breakdowns: Design Studio, Trend Inbox, Research Reports, Etsy Posting. | +| [`docs/04-interactions-and-state.md`](docs/04-interactions-and-state.md) | Hover/active/selection behavior, transitions, theming mechanism, and per-screen state. | +| [`docs/05-data-model.md`](docs/05-data-model.md) | The shape of all mock content (`WS_DATA`) — your provisional data contract. | +| [`docs/06-implementation-guide.md`](docs/06-implementation-guide.md) | Recommended build approach, component tree, theming strategy, what's mocked, acceptance checklist. | + +### Reference screenshots +`screenshots/` contains each screen rendered in **both** dark and light mode — use them to +sanity-check your implementation: +- `design-studio-{dark,light}.png` +- `trend-inbox-{dark,light}.png` +- `research-reports-{dark,light}.png` +- `etsy-posting-{dark,light}.png` + +(These are captured at the design width; the topbar's right edge may be cropped — see the live +`B1 Workshop.html` for the full topbar cluster.) + +--- + +## About the design files (read this) +The `.jsx` and `.html` files are **design references created in HTML/React** — prototypes that +faithfully show the intended look and behavior. They are **not production code to copy +directly.** Your task is to **recreate these designs in your target codebase** using its +existing framework, component library, state management, and conventions (React, Vue, +SwiftUI, native, etc.). If no environment exists yet, choose the most appropriate framework +and implement them there. + +The prototype takes shortcuts a real app must not: all content is hard-coded mock data, there +is no routing, and detail panes sometimes show shared sample content regardless of the list +selection. Treat the **tokens, layouts, components, and interactions** as the spec; treat the +running HTML as the pixel reference. + +## Fidelity +**High-fidelity.** Colors, typography, spacing, borders, and interactions are final. Recreate +the UI pixel-accurately using your codebase's libraries and patterns. Exact values are in +`docs/01-design-tokens.md`. + +--- + +## File map +``` +design_handoff_b1_workshop/ +├── README.md ← you are here (index + overview) +├── docs/ +│ ├── 01-design-tokens.md +│ ├── 02-components.md +│ ├── 03-screens.md +│ ├── 04-interactions-and-state.md +│ ├── 05-data-model.md +│ └── 06-implementation-guide.md +├── screenshots/ ← reference renders (dark + light) +│ +├── B1 Workshop.html ← entry point; mounts all four screens +├── b1-mount.jsx ← handoff mount: stacks the screens, owns the theme toggle +├── theme.b1.jsx ← WS_B1 theme object + all mock content (WS_DATA) +├── wschrome.jsx ← shared app shell (WSChrome) + shared primitives + icons +├── studio.jsx ← Design Studio screen + the one-time interaction CSS +├── screen-inbox.jsx ← Trend Inbox screen +├── screen-reports.jsx ← Research Reports screen +└── screen-etsy.jsx ← Etsy Posting Workflow screen +``` + +## Screens at a glance +1. **Design Studio** — compose & version print artwork (composer + library + focus detail). +2. **Trend Inbox** — triage agent-surfaced market signals (master/detail). +3. **Research Reports** — read agent-written market reports (list + reading pane + TOC). +4. **Etsy Posting Workflow** — pipeline kanban + listing editor. + +> All screens are **desktop layouts** sized as **1440 × 1180** artboards. No responsive +> breakpoints were designed — define those in implementation if the product needs them. diff --git a/assets/design_handoff_b1_workshop/b1-mount.jsx b/assets/design_handoff_b1_workshop/b1-mount.jsx new file mode 100644 index 0000000..4053c03 --- /dev/null +++ b/assets/design_handoff_b1_workshop/b1-mount.jsx @@ -0,0 +1,49 @@ +// b1-mount.jsx — handoff entry. Renders the four B1 (CONCRETE · AMBER DARK) +// screens stacked, fixed dark mode. No design canvas, no tweaks, no other +// directions — just the B1 designs a developer needs to recreate. + +const B1_SCREENS = [ + { id: 'studio', label: 'Design Studio', Comp: window.StudioScreen }, + { id: 'inbox', label: 'Trend Inbox', Comp: window.TrendInboxScreen }, + { id: 'reports', label: 'Research Reports', Comp: window.ResearchReportsScreen }, + { id: 'etsy', label: 'Etsy Posting Workflow', Comp: window.EtsyPostingScreen }, +]; + +function B1Handoff() { + const theme = window.WS_B1; + const [mode, setMode] = React.useState(() => localStorage.getItem('ws-b1-mode') || 'dark'); + const c = mode === 'dark' ? theme.dark : theme.light; + const onSetMode = (m) => { setMode(m); localStorage.setItem('ws-b1-mode', m); }; + + React.useEffect(() => { document.body.style.background = c.bg; }, [c.bg]); + + return ( +
+
+
+ Workshop · B1 — Concrete · Amber Dark +
+
+ Design reference · {B1_SCREENS.length} screens · see README.md for the build spec +
+
+ + {B1_SCREENS.map(({ id, label, Comp }) => ( +
+
+ + {label} +
+
+ +
+
+ ))} +
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/assets/design_handoff_b1_workshop/docs/01-design-tokens.md b/assets/design_handoff_b1_workshop/docs/01-design-tokens.md new file mode 100644 index 0000000..a809cb7 --- /dev/null +++ b/assets/design_handoff_b1_workshop/docs/01-design-tokens.md @@ -0,0 +1,165 @@ +# 01 · Design Tokens + +All values are exact. The product ships **dark by default**; both palettes are required because +the UI is light/dark switchable. + +--- + +## Color + +Tokens are the same names across both palettes — swap the values by mode. In the prototype they +live on the theme object as `theme.dark` / `theme.light` (see `theme.b1.jsx`). Implement them as +CSS custom properties or your framework's theming layer. + +### Dark palette — `ws.dark` (default / signature mode) +| Token | Hex | Role | +|---|---|---| +| `bg` | `#1c1108` | App background — warm near-black | +| `panel` | `#271810` | Sidebar, cards, primary surfaces | +| `panel2` | `#312115` | Recessed columns, table headers, inset wells | +| `ink` | `#f4ece1` | Primary text **and** structural borders | +| `sub` | `#a89a84` | Secondary text, labels | +| `faint` | `#6e624d` | Tertiary text, eyebrows, meta | +| `hairline` | `rgba(244,236,225,0.13)` | Subtle internal dividers (within a bordered box) | +| `accent` | `#fb5a28` | THE signal — primary actions, active nav, scores, selection, highlights | +| `accent2` | `#f4ece1` | Secondary accent. In B1 this equals `ink` — **B1 is a single-accent system** | +| `ok` | `#6cae66` | Success / "live" / low competition | +| `warn` | `#e0a24a` | Caution / "in review" / destructive / "mock" | +| `onAccent` | `#1c1108` | Text & icons placed on an `accent` fill | +| `hover` | `rgba(244,236,225,0.06)` | Hover wash on nav items / segments | +| `thumbBg` | `#382616` | Image-placeholder fill | + +### Light palette — `ws.light` (bone / clay) +| Token | Hex | Role | +|---|---|---| +| `bg` | `#e9e6dd` | App background — warm bone | +| `panel` | `#f7f5ef` | Surfaces | +| `panel2` | `#efece3` | Recessed | +| `ink` | `#16130d` | Primary text + borders | +| `sub` | `#736d60` | Secondary | +| `faint` | `#a39c8c` | Tertiary | +| `hairline` | `rgba(22,19,13,0.14)` | Dividers | +| `accent` | `#e23c0c` | Signal | +| `accent2` | `#16130d` | = ink | +| `ok` | `#3f7a44` | Success | +| `warn` | `#b8761f` | Caution | +| `onAccent` | `#f7f5ef` | On-accent text | +| `hover` | `rgba(22,19,13,0.06)` | Hover wash | +| `thumbBg` | `#dedace` | Placeholder fill | + +**Notes** +- `ink` doubles as the structural **border** color — a defining brutalist trait. Borders are + full-strength ink, not a tint. `hairline` is the lighter divider used *inside* a bordered box. +- There is one accent. `accent2` exists only so multi-accent themes could plug in; in B1 it is + ink. Do not introduce a second hue. +- `theme.id` is `'concrete'`. Some prototype code branches on other ids (`'phosphor'`, + `'greenhouse'`) for glow/shadow variants — **ignore those branches**, they belong to discarded + directions. B1 has no glow (`c.glow` is falsy). + +--- + +## Typography + +Two families, loaded from Google Fonts. + +| Family | Use | Weights | +|---|---|---| +| **Archivo** | Display headings + body copy | 400–900 | +| **JetBrains Mono** | Labels, eyebrows, metrics, chips, buttons, all-caps meta | 400–700 | + +### Display style (headings) +``` +font-family: Archivo; +font-weight: 800; +text-transform: uppercase; +letter-spacing: -0.02em; +line-height: 0.98; +color: ink; +``` +Applied at: screen titles ~26–30px, panel titles 16–17px, big numeric readouts 34–48px +(weight 800, often in `accent`). + +### Body copy +Archivo, weight 400–600. Paragraph text 13–15px / `line-height` 1.5–1.6. Dense list text +13–14.5px / `line-height` ~1.2. `text-overflow: ellipsis` + `white-space: nowrap` on +single-line titles in cards. + +### Mono / label style +JetBrains Mono, 9–13px, usually `text-transform: uppercase`, `letter-spacing` 0.04–0.1em. +Used for eyebrows, field labels, chips, button text, table headers, metrics, and all numeric +values (often `font-variant-numeric: tabular-nums`). + +--- + +## Geometry + +| Token | Value | Notes | +|---|---|---| +| `radius` | **0** | Hard corners everywhere. The one exception is small status dots (full circle). | +| `borderW` | **1.5px** | Structural border weight (color = `ink`). | +| hairline border | **1px** | Inner dividers (color = `hairline`). | + +--- + +## Elevation / shadow + +There is no soft blur. Elevation is a **solid offset block** in the accent color: + +``` +--ws-hardshadow: 5px 5px 0 ; /* hover lift on cards/buttons */ +4px 4px 0 /* selected list cards */ +6px 6px 0 /* open dropdown menus */ +``` + +Applied on hover/selection only; the resting state is flat. + +--- + +## Spacing + +No formal numeric scale was defined; values cluster consistently: + +| Context | Padding / gap | +|---|---| +| Controls (buttons, chips, dropdowns) | `6–11px` vertical, `9–16px` horizontal | +| Cards | `10–14px` | +| Panel headers | `16px 20–24px` | +| Section body padding | `18–30px` | +| Card grid / list gaps | `8–16px` | +| Section-to-section gaps | `16–26px` | + +### Fixed dimensions +| Element | Size | +|---|---| +| Artboard | 1440 × 1180 | +| Sidebar | width **250px** | +| Topbar | height **74px** | +| Trend Inbox signal list | width **452px** | +| Reports list column | width **364px**; TOC nav **188px** | +| Etsy pipeline column | width **372px** | +| Studio composer column | width **396px** | +| Studio focus thumbnail rail | width **220px** | + +--- + +## Imagery / placeholders + +The prototype ships **no real images**. Every image surface is a placeholder you will replace +with real generated artwork / product photography: + +- Fill: `thumbBg`. +- Overlaid hatch: `repeating-linear-gradient(45deg, 14 0 2px, transparent 2px 9px)` + (i.e. ink at ~8% alpha, 2px stripes every 9px, 45°). +- Optional mono caption top-left (e.g. `ref`, `1024²`) on a `thumbBg` chip with a hairline border. +- Optional accent label bottom-right (e.g. `REF·1`, `v3`, `LISTING`), weight 700. +- 1.5px ink border, 0 radius. Hover scales to `1.04` with the hard shadow. + +--- + +## Iconography + +Icons are **inline 20×20 stroke SVGs** (`stroke-width` ~1.6, `currentColor`), defined as a path +map in `wschrome.jsx` (`WSIcon`) and `studio.jsx` (`Icon`). No icon font / no SVG illustration. +Map them to your codebase's icon set. Names in use: `inbox, doc, check, image, box, shirt, tag, +chart, pulse, plus, search, bolt, eye, down, branch, trash, arrow, clock, flame, cal, star, +sliders, ext, edit, x, layers, dollar`. diff --git a/assets/design_handoff_b1_workshop/docs/02-components.md b/assets/design_handoff_b1_workshop/docs/02-components.md new file mode 100644 index 0000000..f8079f0 --- /dev/null +++ b/assets/design_handoff_b1_workshop/docs/02-components.md @@ -0,0 +1,131 @@ +# 02 · Components + +Specs for the shared shell and every reusable primitive. Colors are referenced by token name +(see `01-design-tokens.md`); they resolve against the active mode. `radius` = 0, `borderW` = +1.5px, `border` = `1.5px solid ink`, `hairBorder` = `1px solid hairline`. + +--- + +## App shell — `WSChrome` +The frame every workflow screen renders inside. Three of the four screens use it directly +(Trend Inbox, Research Reports, Etsy Posting). **Design Studio reimplements the same shell +inline** (historical reason) — keep them visually identical in your build by extracting one +shared layout component. + +Structure: a flex row — **Sidebar (250px, fixed)** + **main column** (Topbar 74px + content). +The content area is itself a flex row that the screen fills with its own panels. + +### Sidebar (250px) +Top → bottom: +1. **Brand block** (padding `22px 20px`, bottom border): + - 26×26 `accent` tile, `onAccent` bolt icon. + - Wordmark `WORKSHOP` in display style, 19px. + - Sub-line: `Human-driven commerce` — mono 10.5px, `faint`, uppercase, letter-spacing 0.08em. +2. **Nav list** (padding `12px`, gap 2): 9 items. Each item is a flex row (gap 11, padding + `9px 11px`): 16px line-icon · label (Archivo 13.5px) · optional count badge. + - **Active item:** background `accent`, text `onAccent`, weight 600. + - **Inactive:** text `sub`, weight 500, icon opacity 0.85. + - **Badge:** mono 10.5px weight 700, padding `2px 5px`; active item inverts (bg `onAccent`, + text `accent`), otherwise bg `accent` text `onAccent`. + - Nav items: `Trend Inbox` (badge 6), `Research Reports`, `Approval Queue` (badge 3), + `Design Studio`, `Product Drafts`, `Printful Workshop`, `Etsy Posting`, `Analytics`, + `Agent Runs · Logs`. +3. **Footer** (top border, padding `14px 16px`, gap 9): + - "Image budget" row: mono label + `$3.84 / $25`. + - Budget meter: 5px track (`panel2`, hairline border) filled 15% in `accent`. + - Operator row: 28×28 `KR` monogram tile (border, `panel2`) + "Operator" / "local · no auth". + +### Topbar (74px, bottom border, bg `panel`, padding `0 24px`, gap 20) +Left: **screen title** (display style, but most screens render the title in their own header +band — see each screen) + subtitle. Right cluster (flex, gap 7, vertically centered), +**in this order**: +1. `LIVE` status chip (`WSChip tone="live"`). +2. **TXT** model dropdown (`ModelDropdown`) — default `gpt-5`. +3. **IMG** model dropdown — default `gpt-image-2`. +4. **Light/Dark toggle** (`ModeToggle`). +5. `cap $0.50` chip (`WSChip`, plain). + +--- + +## Shared primitives + +### `WSIcon` / `Icon` +`` rendering one path from the +icon map. Props: `name`, `size` (default 16), `sw` (stroke width, default 1.6). + +### `WSChip` — status pill +Inline-flex, gap 5, mono 11px, uppercase, letter-spacing 0.04em, padding `3px 7px`, 0 radius, +`white-space: nowrap`. Border = `1px solid` (tone color if toned, else `hairline`). +- Optional leading **dot**: 6×6 full-circle in the tone color (omitted for plain/`tone="plain"`). +- Tone → color: `live`/`accent` → `accent`; `ok` → `ok`; `warn` → `warn`; default/none → `sub`. + +### `ModelDropdown` +A button that opens a menu; only one open at a time (managed by `openDD` state); closes on +outside `pointerdown`. +- **Button** (`.wsx-dd`): inline-flex gap 7, mono 11px uppercase letter-spacing 0.04em, padding + `6px 9px`, 0 radius, `1px solid hairline` (→ `accent` when open), bg transparent (→ `hover` + when open). Content: dim `label` (`faint`) + bold `value` (`ink`) + chevron that rotates 180° + when open. +- **Menu** (`6px 6px 0 accent` shadow, `1.5px solid accent` border, bg `panel`, padding 4, + min-width 210, z 60): a faint "
`. Use real ` +
+ changes saved · draft + + +
+ + ); + + return ( + + {Pipeline}{Editor} + + ); + } + + window.EtsyPostingScreen = EtsyPostingScreen; +})(); diff --git a/assets/design_handoff_b1_workshop/screen-inbox.jsx b/assets/design_handoff_b1_workshop/screen-inbox.jsx new file mode 100644 index 0000000..07fb36f --- /dev/null +++ b/assets/design_handoff_b1_workshop/screen-inbox.jsx @@ -0,0 +1,176 @@ +// screen-inbox.jsx — Trend Inbox workflow screen. Exports: TrendInboxScreen. +(function () { + const { WSChrome, WSIcon, WSChip, WSScore, WSThumb, wsBtn, wsBorder, wsDisplay } = window; + + function TrendInboxScreen({ theme, mode, scan = true, onSetMode }) { + const c = mode === 'dark' ? theme.dark : theme.light; + const F = theme.fonts; + const D = window.WS_DATA.inbox; + const border = wsBorder(theme, c); + const display = wsDisplay(theme, c); + const [filter, setFilter] = React.useState(D.filters[0]); + const [sel, setSel] = React.useState(D.trends[0].id); + const t = D.trends.find((x) => x.id === sel) || D.trends[0]; + + const stateTone = (s) => (s === 'RISING' || s === 'NEW' ? c.accent : s === 'COOLING' ? c.sub : c.warn); + + const Spark = ({ data, color, h = 30, w = 92 }) => { + const max = Math.max(...data); + const bw = w / data.length; + return ( + + {data.map((v, i) => ( + + ))} + + ); + }; + + // ── Left: signal queue ────────────────────────────────── + const List = ( +
+
+
+ Signals + {D.trends.length} new · agent · 11m ago +
+
+ {D.filters.map((f, i) => { + const on = f === filter; + return ( +
setFilter(f)} style={{ + fontFamily: F.mono, fontSize: 10.5, fontWeight: 600, padding: '8px 10px', cursor: 'pointer', + border, borderLeft: i === 0 ? border : 'none', background: on ? c.accent : c.panel, color: on ? c.onAccent : c.sub, + whiteSpace: 'nowrap' }}>{f}
+ ); + })} +
+
+
+ {D.trends.map((x) => { + const on = x.id === sel; + return ( +
setSel(x.id)} style={{ + background: c.panel, border: on ? `${theme.borderW}px solid ${c.accent}` : border, + boxShadow: on ? `4px 4px 0 ${c.accent}` : 'none', borderRadius: theme.radius, padding: '13px 14px', + display: 'flex', gap: 13, alignItems: 'center' }}> +
+
{x.momentum}
+
mom.
+
+
+
{x.title}
+
+ {x.state} + {x.delta} + · + {x.niche} +
+
+
+
+ ); + })} +
+
+ ); + + // ── Right: trend detail ───────────────────────────────── + const Detail = ( +
+
+
+
+
+ {t.state} + {t.niche} +
+

{t.title}

+
+
+
{t.momentum}
+
momentum {t.delta}
+
+
+ + {/* metric row */} +
+ {[['Search volume', t.vol], ['First seen', t.age + ' ago'], ['Sources', t.sources.length + ' channels']].map(([k, v], i) => ( +
+
{k}
+
{v}
+
+ ))} +
+ +
+
Why it matters
+
{t.summary}
+
+ +
+
+
+ Keyword openings + vol · competition +
+
+ {t.keywords.map((kw, i) => ( +
+ {kw[0]} + {kw[1]} + {kw[2]} +
+ ))} +
+
+
+
Momentum · 7 wk
+
+ +
+ 7w agonow +
+
+
+
+ +
+
+ Suggested design directions + agent · from this signal +
+
+ {[['Linework', 'hand-drawn'], ['Duotone', '2-colour'], ['Specimen', 'herbarium']].map(([lab, sub]) => ( +
+ +
+ ))} +
+
+
+ +
+ + +
+ +
+
+ ); + + return ( + + {List}{Detail} + + ); + } + + window.TrendInboxScreen = TrendInboxScreen; +})(); diff --git a/assets/design_handoff_b1_workshop/screen-reports.jsx b/assets/design_handoff_b1_workshop/screen-reports.jsx new file mode 100644 index 0000000..1cb01f4 --- /dev/null +++ b/assets/design_handoff_b1_workshop/screen-reports.jsx @@ -0,0 +1,167 @@ +// screen-reports.jsx — Research Reports workflow screen. Exports: ResearchReportsScreen. +(function () { + const { WSChrome, WSIcon, WSChip, WSScore, wsBtn, wsBorder, wsDisplay } = window; + + function ResearchReportsScreen({ theme, mode, scan = true, onSetMode }) { + const c = mode === 'dark' ? theme.dark : theme.light; + const F = theme.fonts; + const D = window.WS_DATA.reports; + const border = wsBorder(theme, c); + const display = wsDisplay(theme, c); + const [sel, setSel] = React.useState(D.list[0].id); + const [section, setSection] = React.useState(D.focus.toc[0]); + const r = D.focus; // detail content is shared mock; selection drives the header + + const selItem = D.list.find((x) => x.id === sel) || D.list[0]; + + const SectionHead = ({ children }) => ( +
+ {children} +
+ ); + const Body = ({ children }) => ( +
{children}
+ ); + + // ── Left: report list ─────────────────────────────────── + const List = ( +
+
+ Reports + {D.list.length} total +
+
+ {D.list.map((x) => { + const on = x.id === sel; + return ( +
setSel(x.id)} style={{ + background: c.panel, border: on ? `${theme.borderW}px solid ${c.accent}` : border, + boxShadow: on ? `4px 4px 0 ${c.accent}` : 'none', borderRadius: theme.radius, padding: 14, + display: 'flex', flexDirection: 'column', gap: 10 }}> +
+
+
{x.title}
+
{x.niche} · {x.secs} sections
+
+ {x.status} +
+
+ + {x.date} +
+
+ ); + })} +
+
+ +
+
+ ); + + // ── Right: reading pane ───────────────────────────────── + const Reader = ( +
+ {/* report header */} +
+
+
+
+ {selItem.status} + {r.model} · {r.words} words · {r.date} +
+

{selItem.title}

+
+
+
{selItem.score}
+
opportunity
+
+
+
+ +
+ {/* TOC */} + + + {/* body */} +
+
+ Market summary + {r.summary} +
+
+ Competitive landscape + {r.competition} +
+
+ Keyword opportunities +
+
+ KeywordVol + CompCPC +
+ {r.keywords.map((kw) => ( +
+ {kw[0]} + {kw[1]} + {kw[2]} + {kw[3]} +
+ ))} +
+
+
+ Recommended products +
+ {r.products.map((p) => ( + {p} + ))} +
+
+
+ Risks & cautions + {r.risks} +
+
+
+ +
+ + +
+ +
+
+ ); + + return ( + + {List}{Reader} + + ); + } + + window.ResearchReportsScreen = ResearchReportsScreen; +})(); diff --git a/assets/design_handoff_b1_workshop/screenshots/design-studio-dark.png b/assets/design_handoff_b1_workshop/screenshots/design-studio-dark.png new file mode 100644 index 0000000..a8b0b1c Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/design-studio-dark.png differ diff --git a/assets/design_handoff_b1_workshop/screenshots/design-studio-light.png b/assets/design_handoff_b1_workshop/screenshots/design-studio-light.png new file mode 100644 index 0000000..a8b0b1c Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/design-studio-light.png differ diff --git a/assets/design_handoff_b1_workshop/screenshots/etsy-posting-dark.png b/assets/design_handoff_b1_workshop/screenshots/etsy-posting-dark.png new file mode 100644 index 0000000..8720256 Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/etsy-posting-dark.png differ diff --git a/assets/design_handoff_b1_workshop/screenshots/etsy-posting-light.png b/assets/design_handoff_b1_workshop/screenshots/etsy-posting-light.png new file mode 100644 index 0000000..8720256 Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/etsy-posting-light.png differ diff --git a/assets/design_handoff_b1_workshop/screenshots/research-reports-dark.png b/assets/design_handoff_b1_workshop/screenshots/research-reports-dark.png new file mode 100644 index 0000000..609e489 Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/research-reports-dark.png differ diff --git a/assets/design_handoff_b1_workshop/screenshots/research-reports-light.png b/assets/design_handoff_b1_workshop/screenshots/research-reports-light.png new file mode 100644 index 0000000..609e489 Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/research-reports-light.png differ diff --git a/assets/design_handoff_b1_workshop/screenshots/trend-inbox-dark.png b/assets/design_handoff_b1_workshop/screenshots/trend-inbox-dark.png new file mode 100644 index 0000000..19bec50 Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/trend-inbox-dark.png differ diff --git a/assets/design_handoff_b1_workshop/screenshots/trend-inbox-light.png b/assets/design_handoff_b1_workshop/screenshots/trend-inbox-light.png new file mode 100644 index 0000000..19bec50 Binary files /dev/null and b/assets/design_handoff_b1_workshop/screenshots/trend-inbox-light.png differ diff --git a/assets/design_handoff_b1_workshop/studio.jsx b/assets/design_handoff_b1_workshop/studio.jsx new file mode 100644 index 0000000..4d09f5b --- /dev/null +++ b/assets/design_handoff_b1_workshop/studio.jsx @@ -0,0 +1,611 @@ +// studio.jsx — the Workshop "Design Studio" screen, themeable. +// Exports to window: StudioScreen. Renders one full 1440×1180 admin screen. + +(function () { + // One-time interaction CSS. Theme colours flow in via CSS vars set on the + // screen root, so these rules stay theme-agnostic. + if (!document.getElementById('wsx-styles')) { + const s = document.createElement('style'); + s.id = 'wsx-styles'; + s.textContent = ` + .wsx *{box-sizing:border-box} + .wsx-nav{position:relative;transition:background .12s,color .12s,box-shadow .12s,transform .1s} + .wsx-nav:hover{background:var(--ws-hover);color:var(--ws-ink)} + .wsx-nav:not(.is-active):hover{transform:translateX(2px)} + .wsx-nav:active{transform:translateX(2px) scale(.99)} + .wsx-btn{transition:background .14s,color .14s,border-color .14s,transform .08s,filter .14s,box-shadow .12s;cursor:pointer;user-select:none} + .wsx-btn:active{transform:translateY(1px) scale(.985)} + .wsx-primary:hover{filter:saturate(1.1) brightness(.97);transform:translateY(-1px);box-shadow:var(--ws-hardshadow)} + .wsx-primary:active{transform:translateY(0) scale(.985);filter:saturate(1.12) brightness(.9);box-shadow:none} + .wsx-ghost:hover{background:var(--ws-hover);border-color:var(--ws-accent);color:var(--ws-ink)} + .wsx-ghost:active{background:var(--ws-hover)} + .wsx-card{transition:transform .16s cubic-bezier(.2,.7,.3,1),box-shadow .16s,border-color .16s;cursor:pointer} + .wsx-card:hover{transform:translate(-2px,-2px);box-shadow:var(--ws-hardshadow);border-color:var(--ws-accent)} + .wsx-card:active{transform:translate(0,0);box-shadow:none} + .wsx-thumb{transition:transform .18s,filter .18s,box-shadow .18s} + .wsx-thumb:hover{transform:scale(1.04);z-index:3;box-shadow:var(--ws-hardshadow)} + .wsx-thumb:active{transform:scale(.99)} + .wsx-seg{transition:background .12s,color .12s,box-shadow .1s;cursor:pointer} + .wsx-seg:not(.is-on):hover{background:var(--ws-hover);color:var(--ws-ink)} + .wsx-seg:active{box-shadow:inset 0 2px 5px rgba(0,0,0,.18)} + .wsx-link{transition:opacity .12s,color .12s;cursor:pointer} + .wsx-link:hover{opacity:.55} + .wsx-drop{transition:border-color .15s,background .15s} + .wsx-drop:hover{border-color:var(--ws-accent);background:var(--ws-hover)} + .wsx-dd{transition:background .12s,border-color .12s,color .12s,transform .08s;cursor:pointer;user-select:none} + .wsx-dd:hover{border-color:var(--ws-accent)!important;color:var(--ws-ink)} + .wsx-dd:active{transform:translateY(1px)} + .wsx-opt{transition:background .1s,color .1s} + .wsx-opt:hover{background:var(--ws-hover)} + .wsx-check{transition:transform .08s,filter .12s} + .wsx-check:hover{filter:brightness(1.06)} + .wsx-check:active{transform:scale(.92)} + `; + document.head.appendChild(s); + } + + const Icon = ({ name, size = 16, sw = 1.6 }) => { + const p = { + inbox: 'M3 13h4l1 2h4l1-2h4M4 13l2-8h8l2 8', + doc: 'M5 2h7l3 3v11H5zM12 2v3h3', + check: 'M4 9l3 3 6-7', + image: 'M3 4h14v12H3zM3 13l4-4 3 3 3-4 4 5', + box: 'M3 6l7-3 7 3-7 3zM3 6v8l7 3 7-3V6M10 9v8', + shirt: 'M6 3l4 2 4-2 3 3-2 2v8H5V8L3 6z', + tag: 'M3 3h6l8 8-6 6-8-8zM6.5 6.5h.01', + chart: 'M3 16V4M3 16h13M7 13V9M11 13V6M15 13v-3', + pulse: 'M3 10h3l2-5 3 10 2-5h3', + plus: 'M10 4v12M4 10h12', + search: 'M9 3a6 6 0 100 12A6 6 0 009 3zM14 14l3 3', + ref: 'M4 14l4-5 3 3 2-3 3 5zM4 4h12v10H4z', + bolt: 'M11 2L4 11h5l-1 7 7-9h-5z', + eye: 'M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z M10 8a2 2 0 100 4 2 2 0 000-4z', + down: 'M10 3v10M5 9l5 5 5-5M4 17h12', + branch: 'M6 4v8a3 3 0 003 3h2M6 4a2 2 0 100-.1zM14 4a2 2 0 100-.1zM12 15a2 2 0 100 .1z', + trash: 'M4 5h12M8 5V3h4v2M6 5l1 12h6l1-12', + dot: 'M10 7a3 3 0 100 6 3 3 0 000-6z', + }[name] || ''; + return ( + + + + ); + }; + + // Hatched / dithered design-thumbnail placeholder. Pattern keys off theme. + const Thumb = ({ c, theme, label, sub, ratio = 1, big = false }) => { + const seedHue = (label.charCodeAt(0) * 37) % 360; + let bg; + if (theme.texture === 'scan') { + bg = `repeating-linear-gradient(0deg, ${c.accent}22 0 1px, transparent 1px 3px), radial-gradient(circle at 30% 30%, ${c.accent}1f, transparent 60%)`; + } else if (theme.id === 'concrete') { + bg = theme.duo + ? `radial-gradient(circle at 72% 26%, ${c.accent2}2b, transparent 56%), repeating-linear-gradient(45deg, ${c.ink}14 0 2px, transparent 2px 9px)` + : `repeating-linear-gradient(45deg, ${c.ink}14 0 2px, transparent 2px 9px)`; + } else if (theme.id === 'greenhouse') { + bg = `radial-gradient(circle at 50% 40%, ${c.accent}26, transparent 62%), repeating-linear-gradient(90deg, ${c.ink}0d 0 1px, transparent 1px 12px)`; + } else { + bg = `linear-gradient(135deg, ${c.accent}26, ${c.accent2}1f), repeating-linear-gradient(45deg, ${c.ink}10 0 3px, transparent 3px 12px)`; + } + return ( +
+
+
{sub}
+
{label}
+
+ ); + }; + + const Chip = ({ c, theme, children, tone }) => { + const col = tone === 'live' ? c.accent : tone === 'ok' ? c.ok : c.sub; + return ( + + {tone && } + {children} + + ); + }; + + const FieldLabel = ({ c, theme, children, hint }) => ( +
+ {children} + {hint && {hint}} +
+ ); + + const ScoreBar = ({ c, theme, score }) => { + const sc = theme.duo ? c.accent2 : c.accent; + return ( +
+
+
+
+ {score} +
+ ); + }; + + function StudioScreen({ theme, mode, scan = true, onNav, onSetMode }) { + const c = mode === 'dark' ? theme.dark : theme.light; + const D = window.WS_DATA; + const F = theme.fonts; + + // ── interactive state ──────────────────────────────────── + const [activeNav, setActiveNav] = React.useState( + (D.nav.find((n) => n.active) || D.nav[0]).label); + const [aspect, setAspect] = React.useState(D.composer.aspect); + const [version, setVersion] = React.useState(D.focus.version); + const [bgFriendly, setBgFriendly] = React.useState(true); + const [selCard, setSelCard] = React.useState(D.focus.line); + const [textModel, setTextModel] = React.useState(D.status.textModel); + const [imgModel, setImgModel] = React.useState(D.status.model); + const [openDD, setOpenDD] = React.useState(null); + React.useEffect(() => { + if (!openDD) return; + const off = () => setOpenDD(null); + document.addEventListener('pointerdown', off); + return () => document.removeEventListener('pointerdown', off); + }, [openDD]); + + // interaction vars + const hard = theme.id === 'greenhouse' + ? `0 10px 26px ${mode === 'dark' ? 'rgba(0,0,0,.5)' : 'rgba(21,36,27,.16)'}` + : theme.id === 'phosphor' + ? `0 0 0 1.5px ${c.accent}, 0 0 20px ${c.accent}55` + : `5px 5px 0 ${c.accent}`; + + const rootVars = { + '--ws-hover': c.hover, '--ws-accent': c.accent, '--ws-ink': c.ink, + '--ws-hardshadow': hard, + }; + + const border = `${theme.borderW}px solid ${c.line}`; + const hairBorder = `1px solid ${c.hairline}`; + + const displayStyle = { + fontFamily: F.display, fontWeight: theme.displayWeight, + textTransform: theme.displayTransform, letterSpacing: theme.displayTracking, + color: c.ink, margin: 0, lineHeight: 0.98, + }; + + const btn = (variant) => ({ + fontFamily: F.mono, fontSize: 12, fontWeight: 600, letterSpacing: '0.06em', + textTransform: 'uppercase', padding: '11px 16px', borderRadius: theme.radius, + display: 'inline-flex', alignItems: 'center', gap: 7, lineHeight: 1, + border, cursor: 'pointer', + ...(variant === 'primary' + ? { background: c.accent, color: c.onAccent, borderColor: c.accent, + boxShadow: c.glow ? `0 0 16px ${c.accent}66` : 'none' } + : { background: 'transparent', color: c.ink }), + }); + + // ── Model dropdown (text / image generation models) ────── + const ModelDropdown = ({ ddKey, label, value, options, onSelect }) => { + const open = openDD === ddKey; + return ( +
e.stopPropagation()}> + + {open && ( +
+
{label} model
+ {options.map((o) => { + const on = o === value; + return ( + + ); + })} +
+ )} +
+ ); + }; + + // ── Sidebar ────────────────────────────────────────────── + const Sidebar = ( + + ); + + // ── Composer ───────────────────────────────────────────── + const cm = D.composer; + const inputBox = { + width: '100%', background: c.panel, border, borderRadius: theme.radius, + color: c.ink, fontFamily: F.body, fontSize: 13.5, padding: '10px 12px', outline: 'none', + }; + const Composer = ( +
+
+ New design + v1 → generate +
+ +
+
+ Title +
{cm.title}
+
+ +
+ Prompt +
{cm.prompt}
+
+ +
+ Reference images +
+ + +
+ + DROP +
+
+
+ +
+ Aspect ratio +
+ {['Square 1:1', 'Portrait 4:5', 'Landscape 2:1', 'Custom'].map((a, i) => { + const on = a === aspect; + return ( +
setAspect(a)} style={{ + fontFamily: F.mono, fontSize: 10.5, fontWeight: 600, letterSpacing: '0.02em', + textAlign: 'center', padding: '9px 4px', cursor: 'pointer', + border, borderLeft: i === 0 ? border : 'none', + background: on ? c.accent : c.panel, color: on ? c.onAccent : c.sub, + }}>{a.split(' ')[1] || a}
+ ); + })} +
+
+ +
+
+ Format +
+ {cm.format}▾ +
+
+
+ Est. cost +
+ ${cm.estCost} + under cap +
+
+
+ + +
+ +
+ + +
+
+ ); + + // ── Library + focus detail ─────────────────────────────── + const fo = D.focus; + const LibCard = (item) => { + const sel = selCard === item.title; + return ( +
setSelCard(item.title)} + style={{ background: c.panel, + border: sel ? `${theme.borderW}px solid ${c.accent}` : border, + boxShadow: sel ? (theme.id === 'phosphor' ? `0 0 0 1px ${c.accent}` : `4px 4px 0 ${c.accent}`) : 'none', + borderRadius: theme.radius, + padding: 14, display: 'flex', flexDirection: 'column', gap: 11 }}> +
+
+
{item.title}
+
{item.niche}
+
+
+ {item.latest} + +
+
+ +
+ {Array.from({ length: Math.min(item.versions, 4) }).map((_, i) => ( + + ))} +
+ +
+
+ WORKSHOP SCORE + {item.versions} ver · {item.updated} +
+ +
+ +
+ + {item.lineage} + LINEAGE → +
+
+ ); + }; + + const Library = ( +
+
+
+ Design Library + 4 lines · 11 versions +
+
+
+ filter lines +
+ all succeeded +
+
+ +
+
+ {D.library.map((it) => {LibCard(it)})} +
+ + {/* Focused version detail */} +
+
+ +
+ {['v1', 'v2', 'v3'].map((v) => ( +
setVersion(v)} style={{ flex: 1, textAlign: 'center', fontFamily: F.mono, fontSize: 10.5, + fontWeight: 700, padding: '5px 0', border, + background: v === version ? c.accent : 'transparent', + color: v === version ? c.onAccent : c.sub, cursor: 'pointer' }}>{v}
+ ))} +
+
+ +
+
+
+
Selected version
+
{fo.line} · {version}
+
+
+
86
+
/ 100 score
+
+
+ +
{fo.prompt}
+ +
+ {[fo.id, imgModel, fo.quality, fo.size, fo.format, '$' + fo.estCost].map((m) => ( + {m} + ))} + {fo.bgFriendly && bg-removal friendly} +
+ +
+ + + + +
+
+
+
+
+ ); + + // ── Topbar ─────────────────────────────────────────────── + const st = D.status; + const Topbar = ( +
+
+

Design Studio

+
+ Create standalone designs, edit versions, and track each design line.
+
+
+
+ {st.integrations} + + + {window.ModeToggle && } + cap ${st.capUsd} +
+ +
+ ); + + return ( +
+ {Sidebar} +
+ {Topbar} +
+ {Composer} + {Library} +
+
+ + {/* retro CRT texture overlay */} + {theme.texture === 'scan' && scan && ( +
+
+
+ )} +
+ ); + } + + window.StudioScreen = StudioScreen; +})(); diff --git a/assets/design_handoff_b1_workshop/theme.b1.jsx b/assets/design_handoff_b1_workshop/theme.b1.jsx new file mode 100644 index 0000000..564984e --- /dev/null +++ b/assets/design_handoff_b1_workshop/theme.b1.jsx @@ -0,0 +1,154 @@ +// theme.b1.jsx — the single B1 (CONCRETE · AMBER DARK) theme + shared mock +// content for the Workshop handoff. Trimmed from the exploration: this is the +// ONLY theme, no canvas / tweaks / other directions. +// Exports to window: WS_B1, WS_DATA. + +const WS_B1 = { + id: 'concrete', + code: 'B1', + name: 'CONCRETE · AMBER DARK', + tag: 'Single vermilion signal · warm amber-orange darks', + duo: false, + fonts: { + display: '"Archivo", system-ui, sans-serif', + body: '"Archivo", system-ui, sans-serif', + mono: '"JetBrains Mono", ui-monospace, monospace', + }, + displayWeight: 800, + displayTransform: 'uppercase', + displayTracking: '-0.02em', + radius: 0, + borderW: 1.5, + texture: 'none', + // Light palette (bone / clay). The product ships dark by default; light is + // included for completeness. + light: { + bg: '#e9e6dd', panel: '#f7f5ef', panel2: '#efece3', ink: '#16130d', + sub: '#736d60', faint: '#a39c8c', line: '#16130d', hairline: 'rgba(22,19,13,0.14)', + accent: '#e23c0c', accent2: '#16130d', ok: '#3f7a44', warn: '#b8761f', + onAccent: '#f7f5ef', hover: 'rgba(22,19,13,0.06)', thumbBg: '#dedace', + }, + // Dark palette (warm amber-orange near-blacks) — the signature B1 mode. + dark: { + bg: '#1c1108', panel: '#271810', panel2: '#312115', ink: '#f4ece1', + sub: '#a89a84', faint: '#6e624d', line: '#f4ece108', hairline: 'rgba(244,236,225,0.13)', + accent: '#fb5a28', accent2: '#f4ece1', ok: '#6cae66', warn: '#e0a24a', + onAccent: '#1c1108', hover: 'rgba(244,236,225,0.06)', thumbBg: '#382616', + }, +}; + +const WS_DATA = { + app: 'WORKSHOP', + nav: [ + { label: 'Trend Inbox', glyph: 'inbox', badge: '6' }, + { label: 'Research Reports', glyph: 'doc' }, + { label: 'Approval Queue', glyph: 'check', badge: '3' }, + { label: 'Design Studio', glyph: 'image', active: true }, + { label: 'Product Drafts', glyph: 'box' }, + { label: 'Printful Workshop', glyph: 'shirt' }, + { label: 'Etsy Posting', glyph: 'tag' }, + { label: 'Analytics', glyph: 'chart' }, + { label: 'Agent Runs · Logs', glyph: 'pulse' }, + ], + status: { + integrations: 'LIVE', openai: 'real', model: 'gpt-image-2', textModel: 'gpt-5', + quality: 'medium', capUsd: '0.50', etsy: 'mock', printful: 'real', + }, + models: { + text: ['gpt-5', 'gpt-5-mini', 'claude-opus-4.5', 'claude-sonnet-4.5', 'gemini-2.5-pro', 'llama-4-70b'], + image: ['gpt-image-2', 'gpt-image-2-mini', 'dall-e-3', 'flux-1.1-pro', 'imagen-4', 'sd-3.5-large'], + }, + composer: { + title: 'Cyanotype Botanical Study', + prompt: + 'Detailed cyanotype-style botanical print of a wild fennel frond, deep Prussian blue on bone-white, fine herbarium linework, centered, generous negative space. Refer to Reference 1 for leaf structure.', + aspect: 'Portrait 4:5', size: '1024 × 1280', format: 'PNG', bgFriendly: true, refs: 2, estCost: '0.04', + }, + library: [ + { title: 'Cottagecore Mushroom Forager', niche: 'apparel · cottagecore', versions: 3, latest: 'v3', updated: '2h ago', lineage: 'v1 → v2 → v3', score: 86 }, + { title: 'Retro Sunset Trail Runner', niche: 'apparel · outdoors', versions: 2, latest: 'v2', updated: 'yesterday', lineage: 'v1 → v2', score: 78 }, + { title: 'Brutalist Type Specimen Tee', niche: 'apparel · typographic', versions: 4, latest: 'v2·b', updated: '3d ago', lineage: 'Fork 2 of 2 · v1 → v2', score: 71 }, + { title: 'Vintage Seed Packet · Heirloom Tomato', niche: 'apparel · garden', versions: 2, latest: 'v2', updated: '4d ago', lineage: 'v1 → v2', score: 64 }, + ], + focus: { + line: 'Cottagecore Mushroom Forager', version: 'v3', id: 'gen_8f2a91c4', + model: 'gpt-image-2', quality: 'medium', size: '1024 × 1024', format: 'PNG', estCost: '0.04', bgFriendly: true, + prompt: + 'Hand-drawn cottagecore illustration of a fox foraging amanita mushrooms among ferns, muted sage + ochre palette, vintage storybook linework, isolated on cream for background removal.', + }, + + inbox: { + filters: ['All signals', 'Rising', 'Peaking', 'New'], + trends: [ + { id: 't1', title: 'Mushroom foraging core', niche: 'cottagecore · apparel', momentum: 92, + delta: '+34%', state: 'RISING', age: '2h', sources: ['etsy', 'pinterest', 'reddit'], vol: '18.4k/mo', + spark: [3, 4, 5, 6, 8, 9, 12], + summary: 'Forager aesthetic is spiking off late-summer mushroom season; amanita + fern motifs over-index on tees and totes. Hand-drawn linework faces low competition versus stock photography.', + keywords: [['amanita tee', '6.2k', 'low'], ['mushroom forager', '4.1k', 'med'], ['mycology gift', '3.0k', 'low'], ['cottagecore fungi', '2.4k', 'low']] }, + { id: 't2', title: 'Retro national parks', niche: 'outdoors · apparel', momentum: 81, + delta: '+12%', state: 'RISING', age: '5h', sources: ['etsy', 'google'], vol: '22.7k/mo', + spark: [6, 6, 7, 7, 8, 9, 10], + summary: 'Evergreen outdoors demand lifting on early fall travel planning. Crowded category — win on a distinctive limited palette and typographic system rather than generic mountain scenes.', + keywords: [['national park shirt', '9.1k', 'high'], ['hiking tee', '5.4k', 'med'], ['retro camping', '3.2k', 'med'], ['trail runner gift', '1.8k', 'low']] }, + { id: 't3', title: 'Brutalist type specimens', niche: 'typographic · apparel', momentum: 74, + delta: '+8%', state: 'PEAKING', age: 'yesterday', sources: ['pinterest', 'reddit'], vol: '9.3k/mo', + spark: [8, 9, 9, 10, 9, 9, 8], + summary: 'Design-literate buyers responding to stark grotesk lockups and grid systems. Smaller but high-intent audience; strong repeat-purchase and gifting within design community.', + keywords: [['type specimen tee', '3.1k', 'low'], ['helvetica shirt', '2.6k', 'med'], ['brutalist poster', '2.0k', 'low'], ['graphic design gift', '1.6k', 'med']] }, + { id: 't4', title: 'Heirloom seed packets', niche: 'garden · apparel', momentum: 67, + delta: '+5%', state: 'PEAKING', age: '1d', sources: ['etsy'], vol: '7.1k/mo', + spark: [5, 6, 7, 7, 8, 7, 7], + summary: 'Vintage seed-packet illustration carries from spring into fall planting. Nostalgic palette and botanical accuracy drive conversion; pairs well with mug and tote extensions.', + keywords: [['seed packet art', '2.8k', 'low'], ['heirloom tomato', '2.2k', 'low'], ['garden lover gift', '4.0k', 'med'], ['vintage botanical', '3.5k', 'med']] }, + { id: 't5', title: 'Coastal grandmother', niche: 'lifestyle · apparel', momentum: 58, + delta: '-3%', state: 'COOLING', age: '2d', sources: ['pinterest'], vol: '11.2k/mo', + spark: [10, 9, 9, 8, 7, 6, 6], + summary: 'Past peak but still high volume. Enter only with a sharply differentiated take — saturated competition and falling momentum make generic entries unprofitable.', + keywords: [['coastal grandmother', '6.4k', 'high'], ['linen aesthetic', '2.1k', 'med'], ['hamptons style', '1.4k', 'low'], ['beach house decor', '3.3k', 'high']] }, + { id: 't6', title: 'Cyanotype botanicals', niche: 'art · apparel', momentum: 54, + delta: 'NEW', state: 'NEW', age: '3d', sources: ['google'], vol: '3.8k/mo', + spark: [1, 2, 2, 3, 4, 5, 6], + summary: 'Early signal — Prussian-blue herbarium prints emerging from the art-print crowd into apparel. Untapped on Etsy; first-mover window open for a clean linework series.', + keywords: [['cyanotype print', '2.4k', 'low'], ['botanical blue', '1.1k', 'low'], ['herbarium art', '0.9k', 'low'], ['blueprint flower', '0.7k', 'low']] }, + ], + }, + + reports: { + list: [ + { id: 'r1', title: 'Cottagecore mushroom apparel', niche: 'cottagecore', status: 'FINAL', score: 86, date: 'Jun 14', secs: 5 }, + { id: 'r2', title: 'Retro national parks revival', niche: 'outdoors', status: 'FINAL', score: 78, date: 'Jun 12', secs: 5 }, + { id: 'r3', title: 'Brutalist type specimen tees', niche: 'typographic', status: 'DRAFT', score: 71, date: 'Jun 11', secs: 4 }, + { id: 'r4', title: 'Heirloom seed packet prints', niche: 'garden', status: 'DRAFT', score: 64, date: 'Jun 09', secs: 5 }, + ], + focus: { + title: 'Cottagecore mushroom apparel', niche: 'cottagecore · apparel', status: 'FINAL', + score: 86, date: 'Jun 14, 2026', model: 'gpt-5', words: '1,240', + toc: ['Market summary', 'Competitive landscape', 'Keyword opportunities', 'Recommended products', 'Risks & cautions'], + summary: 'Demand for hand-drawn mushroom and forager motifs is rising sharply into late summer, with strong cross-over between cottagecore and mycology-hobbyist audiences. Competition is concentrated on photographic prints, leaving a clear opening for original linework in a tight, limited palette.', + competition: '37 established shops rank for the core terms; top listings lean on stock photography and generic clip-art. Differentiation through original illustration and a 2–3 colour palette is achievable within a single design cycle.', + keywords: [['amanita tee', '6.2k', 'low', '$0.41'], ['mushroom forager', '4.1k', 'med', '$0.58'], ['mycology gift', '3.0k', 'low', '$0.36'], ['cottagecore fungi', '2.4k', 'low', '$0.33'], ['forager shirt', '1.9k', 'med', '$0.49']], + products: ['Unisex heavyweight tee', 'Organic cotton tote', 'Ceramic mug', 'Die-cut sticker sheet'], + risks: 'Seasonal demand likely tapers after September — front-load launches. Frame amanita as decorative only; avoid foraging or edibility claims in listing copy.', + }, + }, + + etsy: { + stages: [ + { name: 'Draft', tone: 'sub', items: [ { id: 'e1', title: 'Amanita Forager Tee', price: '28.00' }, { id: 'e2', title: 'Mycology Society Tote', price: '22.00' } ] }, + { name: 'In review', tone: 'warn', items: [ { id: 'e3', title: 'Cottagecore Fox & Ferns', price: '26.00' } ] }, + { name: 'Scheduled', tone: 'accent', items: [ { id: 'e4', title: 'Heirloom Tomato Packet', price: '24.00' } ] }, + { name: 'Live', tone: 'ok', items: [ { id: 'e5', title: 'Retro Trail Runner', price: '29.00' }, { id: 'e6', title: 'Brutalist Specimen Tee', price: '27.00' } ] }, + ], + focus: { + title: 'Amanita Forager Heavyweight Tee', stage: 'Draft', status: 'mock', price: '28.00', qty: 25, + category: 'Clothing › Tops & Tees › T-shirts', section: 'Cottagecore Apparel', + whoMade: 'I did', whenMade: 'Made to order', renewal: 'Automatic', images: 4, + materials: ['Ringspun cotton', 'Water-based ink', 'DTG print'], + tags: ['amanita tee', 'mushroom forager', 'cottagecore', 'mycology gift', 'foraging shirt', 'botanical tee', 'dark academia', 'goblincore', 'witchy apparel', 'fungi art', 'nature lover'], + tagCap: 13, + summary: 'Hand-drawn amanita muscaria with fern detailing, screen-print look on a heavyweight unisex tee. Background-removed art on a bone-white blank.', + }, + }, +}; + +Object.assign(window, { WS_B1, WS_DATA }); diff --git a/assets/design_handoff_b1_workshop/wschrome.jsx b/assets/design_handoff_b1_workshop/wschrome.jsx new file mode 100644 index 0000000..a4112b7 --- /dev/null +++ b/assets/design_handoff_b1_workshop/wschrome.jsx @@ -0,0 +1,291 @@ +// wschrome.jsx — shared Workshop app shell (B1 and any theme). +// Sidebar + topbar frame + interactive model dropdowns + brutalist +// primitives, so each workflow screen only writes its own main content. +// Reuses the .wsx-* interaction CSS injected by studio.jsx. +// Exports to window: WSChrome, WSIcon, WSChip, WSField, WSScore, WSThumb, +// wsBtn, wsBorder, wsDisplay. + +(function () { + const ICONS = { + inbox: 'M3 13h4l1 2h4l1-2h4M4 13l2-8h8l2 8', + doc: 'M5 2h7l3 3v11H5zM12 2v3h3', + check: 'M4 9l3 3 6-7', + image: 'M3 4h14v12H3zM3 13l4-4 3 3 3-4 4 5', + box: 'M3 6l7-3 7 3-7 3zM3 6v8l7 3 7-3V6M10 9v8', + shirt: 'M6 3l4 2 4-2 3 3-2 2v8H5V8L3 6z', + tag: 'M3 3h6l8 8-6 6-8-8zM6.5 6.5h.01', + chart: 'M3 16V4M3 16h13M7 13V9M11 13V6M15 13v-3', + pulse: 'M3 10h3l2-5 3 10 2-5h3', + plus: 'M10 4v12M4 10h12', + search: 'M9 3a6 6 0 100 12A6 6 0 009 3zM14 14l3 3', + bolt: 'M11 2L4 11h5l-1 7 7-9h-5z', + eye: 'M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z M10 8a2 2 0 100 4 2 2 0 000-4z', + down: 'M10 3v10M5 9l5 5 5-5M4 17h12', + branch: 'M6 4v8a3 3 0 003 3h2M6 4a2 2 0 100-.1zM14 4a2 2 0 100-.1zM12 15a2 2 0 100 .1z', + trash: 'M4 5h12M8 5V3h4v2M6 5l1 12h6l1-12', + arrow: 'M4 10h12M11 5l5 5-5 5', + clock: 'M10 5v5l3 2M10 3a7 7 0 100 14 7 7 0 000-14z', + flame: 'M10 2c1 3 4 4 4 8a4 4 0 11-8 0c0-2 1-3 2-4 0 2 1 2 2 1 0-2-1-4-0-6z', + cal: 'M3 5h14v12H3zM3 8h14M7 3v3M13 3v3', + star: 'M10 2l2.4 5 5.6.6-4 3.8 1 5.6L10 14.8 4 17l1-5.6-4-3.8 5.6-.6z', + sliders: 'M4 6h8M14 6h2M4 14h2M8 14h8M11 4v4M6 12v4', + ext: 'M8 4H4v12h12v-4M12 4h4v4M16 4l-7 7', + edit: 'M4 13l9-9 3 3-9 9H4z', + x: 'M5 5l10 10M15 5L5 15', + layers: 'M10 3l7 4-7 4-7-4zM3 11l7 4 7-4', + dollar: 'M10 3v14M13 6c0-1.5-1.5-2-3-2s-3 .5-3 2 1.5 2 3 2 3 .5 3 2-1.5 2-3 2-3-.5-3-2', + }; + + const WSIcon = ({ name, size = 16, sw = 1.6 }) => ( + + + + ); + + // Light/dark toggle — single button, sized to match the TXT/IMG dropdowns. + const ModeToggle = ({ c, theme, F, mode, onSetMode }) => { + const next = mode === 'dark' ? 'light' : 'dark'; + return ( + + ); + }; + + const wsBorder = (theme, c) => `${theme.borderW}px solid ${c.line}`; + const wsDisplay = (theme, c) => ({ + fontFamily: theme.fonts.display, fontWeight: theme.displayWeight, + textTransform: theme.displayTransform, letterSpacing: theme.displayTracking, + color: c.ink, margin: 0, lineHeight: 0.98, + }); + const wsBtn = (theme, c, variant) => ({ + fontFamily: theme.fonts.mono, fontSize: 12, fontWeight: 600, letterSpacing: '0.06em', + textTransform: 'uppercase', padding: '11px 16px', borderRadius: theme.radius, + display: 'inline-flex', alignItems: 'center', gap: 7, lineHeight: 1, + border: wsBorder(theme, c), cursor: 'pointer', + ...(variant === 'primary' + ? { background: c.accent, color: c.onAccent, borderColor: c.accent, + boxShadow: c.glow ? `0 0 16px ${c.accent}66` : 'none' } + : { background: 'transparent', color: c.ink }), + }); + + const WSChip = ({ c, theme, children, tone }) => { + const col = tone === 'live' || tone === 'accent' ? c.accent : tone === 'ok' ? c.ok + : tone === 'warn' ? c.warn : c.sub; + return ( + + {tone && tone !== 'plain' && } + {children} + + ); + }; + + const WSField = ({ c, theme, children, hint }) => ( +
+ {children} + {hint && {hint}} +
+ ); + + const WSScore = ({ c, theme, score, w = 80 }) => ( +
+
+
+
+ {score} +
+ ); + + const WSThumb = ({ c, theme, label, sub, ratio = 1, big = false }) => { + const bg = `repeating-linear-gradient(45deg, ${c.ink}14 0 2px, transparent 2px 9px)`; + return ( +
+
+ {sub &&
{sub}
} + {label &&
{label}
} +
+ ); + }; + + // ── The shell ──────────────────────────────────────────────────────────── + function WSChrome({ theme, mode, scan = true, active, title, subtitle, primary, children, onNav, onSetMode }) { + const c = mode === 'dark' ? theme.dark : theme.light; + const D = window.WS_DATA; + const F = theme.fonts; + const border = wsBorder(theme, c); + const hairBorder = `1px solid ${c.hairline}`; + const display = wsDisplay(theme, c); + + const [activeNav, setActiveNav] = React.useState(active); + const [textModel, setTextModel] = React.useState(D.status.textModel); + const [imgModel, setImgModel] = React.useState(D.status.model); + const [openDD, setOpenDD] = React.useState(null); + React.useEffect(() => { + if (!openDD) return; + const off = () => setOpenDD(null); + document.addEventListener('pointerdown', off); + return () => document.removeEventListener('pointerdown', off); + }, [openDD]); + + const hard = theme.id === 'phosphor' ? `0 0 0 1.5px ${c.accent}, 0 0 20px ${c.accent}55` + : `5px 5px 0 ${c.accent}`; + const rootVars = { '--ws-hover': c.hover, '--ws-accent': c.accent, '--ws-ink': c.ink, '--ws-hardshadow': hard }; + + const ModelDropdown = ({ ddKey, label, value, options, onSelect }) => { + const open = openDD === ddKey; + return ( +
e.stopPropagation()}> + + {open && ( +
+
{label} model
+ {options.map((o) => { + const on = o === value; + return ( + + ); + })} +
+ )} +
+ ); + }; + + const Sidebar = ( + + ); + + const st = D.status; + const Topbar = ( +
+
+

{title}

+ {subtitle &&
{subtitle}
} +
+
+
+ {st.integrations} + + + + cap ${st.capUsd} +
+ {primary && ( + + )} +
+ ); + + return ( +
+ {Sidebar} +
+ {Topbar} +
{children}
+
+
+ ); + } + + Object.assign(window, { WSChrome, WSIcon, WSChip, WSField, WSScore, WSThumb, wsBtn, wsBorder, wsDisplay, ModeToggle }); +})(); diff --git a/docker-compose.yml b/docker-compose.yml index 28510d9..484766f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,21 +35,7 @@ services: volumes: - generated_assets:/app/assets/generated restart: unless-stopped - - dashboard: - build: . - env_file: .env - command: > - streamlit run src/ecommerce_agent/dashboard/app.py - --server.address=0.0.0.0 - --server.port=8501 - --server.headless=true - depends_on: - api: - condition: service_started - ports: - - "127.0.0.1:8501:8501" - restart: unless-stopped + # The B1 web UI (Jinja + HTMX) is served by this same service at http://localhost:8000/ volumes: postgres_data: diff --git a/pyproject.toml b/pyproject.toml index fb69a5c..ec76548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,11 @@ dependencies = [ "cryptography>=44,<46", "fastapi>=0.115,<1", "httpx>=0.28,<1", + "jinja2>=3,<4", "pillow>=11,<13", "pydantic-settings>=2.7,<3", "python-multipart>=0.0.20,<1", "sqlalchemy[asyncio]>=2.0.36,<3", - "streamlit>=1.41,<2", "uvicorn[standard]>=0.34,<1", ] @@ -51,11 +51,6 @@ markers = [ python_version = "3.12" strict = true -[[tool.mypy.overrides]] -module = "ecommerce_agent.dashboard.*" -disallow_untyped_defs = false -disallow_untyped_calls = false - [tool.ruff] line-length = 100 target-version = "py312" diff --git a/src/ecommerce_agent/api/main.py b/src/ecommerce_agent/api/main.py index 064ced1..e42d58c 100644 --- a/src/ecommerce_agent/api/main.py +++ b/src/ecommerce_agent/api/main.py @@ -26,6 +26,8 @@ from ecommerce_agent.services.openai_images import OpenAIImageError from ecommerce_agent.services.printful import PrintfulError from ecommerce_agent.services.research import ResearchProviderError +from ecommerce_agent.web import router as web_router +from ecommerce_agent.web.routes import _TEMPLATES_DIR def create_app(settings: Settings | None = None) -> FastAPI: @@ -78,6 +80,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(research_runs_router, prefix="/api") app.include_router(research_router, prefix="/api") + app.mount( + "/static", + StaticFiles(directory=str(_TEMPLATES_DIR.parent / "static")), + name="static", + ) + app.include_router(web_router) + @app.exception_handler(NotFoundError) async def not_found_handler(request: Request, error: NotFoundError) -> JSONResponse: return JSONResponse(status_code=404, content={"detail": str(error)}) diff --git a/src/ecommerce_agent/dashboard/__init__.py b/src/ecommerce_agent/dashboard/__init__.py deleted file mode 100644 index 5a302f9..0000000 --- a/src/ecommerce_agent/dashboard/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Streamlit admin dashboard.""" diff --git a/src/ecommerce_agent/dashboard/app.py b/src/ecommerce_agent/dashboard/app.py deleted file mode 100644 index c978186..0000000 --- a/src/ecommerce_agent/dashboard/app.py +++ /dev/null @@ -1,32 +0,0 @@ -import streamlit as st - -from ecommerce_agent.dashboard.client import ApiError, get, post -from ecommerce_agent.dashboard.ui import page_header - -page_header( - "Ecommerce Agent", - "A human-approved workflow for Etsy and Printful product creation.", -) - -st.markdown( - """ -Use the pages in the sidebar to review research, approve designs and copy, -inspect product drafts, curate Printful products, and monitor published listings. -Each provider can be enabled independently behind explicit integration switches. -""" -) - -col1, col2 = st.columns(2) -with col1: - if st.button("Start trend research", type="primary"): - try: - run = post("/trend-discovery-runs") - st.success(f"Trend discovery {run['id']} was queued.") - except ApiError as error: - st.error(str(error)) -with col2: - try: - health = get("/health") - st.metric("API", health["status"]) - except ApiError: - st.metric("API", "unavailable") diff --git a/src/ecommerce_agent/dashboard/client.py b/src/ecommerce_agent/dashboard/client.py deleted file mode 100644 index a5798de..0000000 --- a/src/ecommerce_agent/dashboard/client.py +++ /dev/null @@ -1,87 +0,0 @@ -import os -from typing import Any - -import httpx - -API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000").rstrip("/") - - -class ApiError(RuntimeError): - pass - - -def _error_detail(response: httpx.Response) -> str: - try: - payload = response.json() - except (TypeError, ValueError): - payload = None - if isinstance(payload, dict) and payload.get("detail"): - return str(payload["detail"]) - text = response.text.strip() - if text and not text.lstrip().startswith("<"): - return text - return f"API request failed with status {response.status_code}." - - -def request(method: str, path: str, **kwargs: Any) -> Any: - timeout = kwargs.pop("timeout", 20) - try: - response = httpx.request( - method, - f"{API_BASE_URL}/api{path}", - timeout=timeout, - **kwargs, - ) - response.raise_for_status() - except httpx.HTTPStatusError as error: - raise ApiError(_error_detail(error.response)) from error - except httpx.HTTPError as error: - raise ApiError(f"API is unavailable: {error}") from error - return None if response.status_code == 204 else response.json() - - -def get(path: str) -> Any: - return request("GET", path) - - -def get_text(path: str) -> str: - try: - response = httpx.get(f"{API_BASE_URL}/api{path}", timeout=20) - response.raise_for_status() - except httpx.HTTPStatusError as error: - raise ApiError(_error_detail(error.response)) from error - except httpx.HTTPError as error: - raise ApiError(f"API is unavailable: {error}") from error - return response.text - - -def post(path: str, payload: dict[str, Any] | None = None, *, timeout: float = 20) -> Any: - return request("POST", path, json=payload or {}, timeout=timeout) - - -def patch(path: str, payload: dict[str, Any], *, timeout: float = 20) -> Any: - return request("PATCH", path, json=payload, timeout=timeout) - - -def post_multipart( - path: str, - *, - data: dict[str, str], - files: list[tuple[str, tuple[str, bytes, str]]] | None = None, - timeout: float = 20, -) -> Any: - return request("POST", path, data=data, files=files or [], timeout=timeout) - - -def delete(path: str) -> None: - request("DELETE", path) - - -def get_asset(uri: str) -> bytes: - url = uri if uri.startswith(("http://", "https://")) else f"{API_BASE_URL}{uri}" - try: - response = httpx.get(url, timeout=20) - response.raise_for_status() - except httpx.HTTPError as error: - raise ApiError(f"Generated asset is unavailable: {error}") from error - return response.content diff --git a/src/ecommerce_agent/dashboard/pages/1_Trend_Inbox.py b/src/ecommerce_agent/dashboard/pages/1_Trend_Inbox.py deleted file mode 100644 index f342b62..0000000 --- a/src/ecommerce_agent/dashboard/pages/1_Trend_Inbox.py +++ /dev/null @@ -1,98 +0,0 @@ -import streamlit as st - -from ecommerce_agent.dashboard.client import get, post -from ecommerce_agent.dashboard.ui import page_header, show_error - -page_header( - "Trend Inbox", - "Discover emerging apparel themes, inspect the evidence, and start deeper research.", -) - -try: - sources = get("/research-sources/status") - with st.expander("Research source status"): - columns = st.columns(5) - columns[0].metric( - "Web research", - "Live" if sources["web_research"]["enabled"] else "Mock", - ) - columns[1].metric( - "Etsy API", - ( - "Connected" - if sources["etsy_api"]["connected"] - else ("Configured" if sources["etsy_api"]["enabled"] else "Mock") - ), - ) - columns[2].metric("Etsy Stats imports", sources["etsy_stats"]["import_count"]) - columns[3].metric( - "GA4", - "Connected" if sources["google_analytics"]["connected"] else "Optional", - ) - columns[4].metric( - "Printful products", - sources["printful_catalog"]["cached_product_count"], - ) - st.caption( - "Google Trends is not enabled because its official API remains limited-access alpha." - ) - - action, refresh = st.columns([1, 1]) - with action: - if st.button("Run trend research", type="primary"): - run = post("/trend-discovery-runs") - st.success(f"Trend discovery {run['id']} was queued.") - st.rerun() - with refresh: - st.button("Refresh") - - runs = get("/trend-discovery-runs") - if runs: - latest = runs[0] - status = latest["status"].replace("_", " ").title() - st.caption( - f"Latest discovery: {status} · {latest['trend_count']} trend(s) · " - f"{latest['created_at']}" - ) - if latest.get("error_message"): - st.error(latest["error_message"]) - - trends = get("/trends") - discovered = [trend for trend in trends if trend["status"] == "discovered"] - if not discovered: - st.info("Run trend research to populate the inbox.") - for trend in discovered: - with st.container(border=True): - heading, score, confidence = st.columns([5, 1, 1]) - heading.subheader(trend["title"]) - heading.caption(trend["niche"]) - score.metric( - "Apparel", - f"{float(trend['apparel_score']):.1f}/5" - if trend.get("apparel_score") is not None - else "—", - ) - confidence.metric( - "Confidence", - f"{float(trend['confidence']):.0%}" if trend.get("confidence") is not None else "—", - ) - st.write(trend.get("blurb") or "") - if trend.get("evidence"): - st.markdown("**Evidence**") - for evidence in trend["evidence"]: - st.markdown(f"- {evidence}") - if trend.get("risk_warnings"): - st.warning("\n\n".join(trend["risk_warnings"])) - sources_for_trend = trend.get("verified_sources") or [] - if sources_for_trend: - st.markdown( - "**Sources:** " - + " · ".join( - f"[{source['title']}]({source['url']})" for source in sources_for_trend - ) - ) - if st.button("Research", key=f"research-{trend['id']}"): - st.query_params["trend_id"] = trend["id"] - st.switch_page("pages/2_Research_Reports.py") -except Exception as error: - show_error(error) diff --git a/src/ecommerce_agent/dashboard/pages/2_Research_Reports.py b/src/ecommerce_agent/dashboard/pages/2_Research_Reports.py deleted file mode 100644 index 4473881..0000000 --- a/src/ecommerce_agent/dashboard/pages/2_Research_Reports.py +++ /dev/null @@ -1,189 +0,0 @@ -import streamlit as st - -from ecommerce_agent.dashboard.client import ( - API_BASE_URL, - get, - get_text, - post, - post_multipart, -) -from ecommerce_agent.dashboard.ui import page_header, show_error - -page_header( - "Research Reports", - "Run in-depth topic research and review consistent, downloadable Markdown reports.", -) - -try: - trends = get("/trends") - trend_id = st.query_params.get("trend_id") - selected_trend = next( - (trend for trend in trends if trend["id"] == trend_id), - None, - ) - - with st.expander( - "Start research" + (f": {selected_trend['title']}" if selected_trend else ""), - expanded=bool(selected_trend), - ): - with st.form("research-topic-form"): - topic_title = st.text_input( - "Topic title", - value=selected_trend["title"] if selected_trend else "", - ) - default_context = "" - if selected_trend: - default_context = ( - f"Trend summary: {selected_trend.get('blurb', '')}\n\n" - f"Niche: {selected_trend.get('niche', '')}\n\n" - "Please validate the current momentum and focus on wearable, differentiated " - "ideas suitable for hats, shirts, and hoodies." - ) - additional_context = st.text_area( - "Additional context", - value=default_context, - height=180, - help="Optional guidance about audience, style, products, exclusions, or timing.", - ) - if st.form_submit_button("Submit", type="primary"): - run = post( - "/research-runs", - { - "topic_title": topic_title, - "additional_context": additional_context, - "trend_id": selected_trend["id"] if selected_trend else None, - }, - ) - st.query_params.clear() - st.query_params["run_id"] = run["id"] - st.success("Research run queued.") - st.rerun() - - with st.expander("Etsy Stats search-term import"): - st.markdown( - """ -Etsy keeps shopper search terms inside Shop Manager rather than its current public API. - -1. Open **Etsy.com → Shop Manager → Stats**. -2. Choose the date range you want to analyze. -3. Under **How shoppers found you**, select **Etsy search**. -4. Copy each visible search term and its visit count into the Workshop CSV template. -5. Add a listing ID or title when you know which listing the term belongs to, then upload it here. - -Use the same date range on every row from one export. Workshop keeps import history and rejects -an identical file if it has already been uploaded. -""" - ) - template = get_text("/research-sources/etsy-stats-imports/template") - st.download_button( - "Download Etsy Stats CSV template", - data=template, - file_name="workshop-etsy-stats-template.csv", - mime="text/csv", - ) - upload = st.file_uploader("Upload completed CSV", type=["csv"]) - if upload is not None and st.button("Import Etsy Stats"): - result = post_multipart( - "/research-sources/etsy-stats-imports", - data={}, - files=[ - ( - "upload", - ( - upload.name, - upload.getvalue(), - "text/csv", - ), - ) - ], - ) - st.success(f"Imported {result['row_count']} Etsy search-term rows.") - if result["warnings"]: - st.warning("\n".join(result["warnings"])) - imports = get("/research-sources/etsy-stats-imports") - if imports: - st.dataframe(imports, use_container_width=True, hide_index=True) - - with st.expander("Google Analytics 4"): - source_status = get("/research-sources/status")["google_analytics"] - if not source_status["configured"]: - st.info( - "Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and " - "GOOGLE_TOKEN_ENCRYPTION_KEY to enable GA4." - ) - elif not source_status["connected"]: - st.link_button( - "Connect Google Analytics", - f"{API_BASE_URL}/api/research-sources/google-analytics/oauth/start", - ) - else: - st.success(f"Connected as {source_status['account_id']}.") - properties = get("/research-sources/google-analytics/properties") - if properties: - labels = { - f"{item['display_name']} · {item['property_id']}": item for item in properties - } - selected_label = st.selectbox("GA4 property", list(labels)) - selected_property = labels[selected_label] - if st.button("Use this GA4 property"): - post( - "/research-sources/google-analytics/property", - selected_property, - ) - st.success("GA4 property saved.") - st.rerun() - else: - st.warning("No accessible GA4 properties were returned.") - if st.button("Disconnect Google Analytics"): - post("/research-sources/google-analytics/disconnect") - st.rerun() - - st.divider() - heading, refresh = st.columns([4, 1]) - heading.subheader("Research history") - refresh.button("Refresh") - runs = get("/research-runs") - if not runs: - st.info("No research runs yet.") - else: - run_labels = { - f"{run['topic_title']} · {run['status'].replace('_', ' ')} · {run['created_at']}": run - for run in runs - } - requested_run_id = st.query_params.get("run_id") - default_index = next( - ( - index - for index, run in enumerate(run_labels.values()) - if run["id"] == requested_run_id - ), - 0, - ) - selected_label = st.selectbox( - "Research run", - list(run_labels), - index=default_index, - ) - selected_run = get(f"/research-runs/{run_labels[selected_label]['id']}") - status_value = selected_run["status"].replace("_", " ").title() - st.caption( - f"Status: {status_value} · Created {selected_run['created_at']} · " - f"Run `{selected_run['id']}`" - ) - if selected_run.get("error_message"): - st.error(selected_run["error_message"]) - if st.button("Retry research run"): - post(f"/research-runs/{selected_run['id']}/retry") - st.rerun() - elif selected_run["status"] in {"queued", "running"}: - st.info("Research is running in the background. Use Refresh to check progress.") - elif selected_run.get("markdown_report"): - st.download_button( - "Download Markdown", - data=selected_run["markdown_report"], - file_name=f"{selected_run['topic_title']}.md", - mime="text/markdown", - ) - st.markdown(selected_run["markdown_report"]) -except Exception as error: - show_error(error) diff --git a/src/ecommerce_agent/dashboard/pages/4_Design_Studio.py b/src/ecommerce_agent/dashboard/pages/4_Design_Studio.py deleted file mode 100644 index 9bd4526..0000000 --- a/src/ecommerce_agent/dashboard/pages/4_Design_Studio.py +++ /dev/null @@ -1,647 +0,0 @@ -from math import gcd -from typing import Any, Literal - -import streamlit as st - -from ecommerce_agent.dashboard.client import delete, get, get_asset, patch, post_multipart -from ecommerce_agent.dashboard.design_lineage import branch_label, lineage_paths, version_label -from ecommerce_agent.dashboard.ui import page_header, show_error -from ecommerce_agent.domain.dtos import ImageGenerationRequest, ImageReference -from ecommerce_agent.services.openai_images import build_effective_prompt - -OUTPUT_FORMATS = { - "PNG": "png", - "JPEG": "jpeg", - "WebP": "webp", -} -OUTPUT_DOWNLOADS = { - "png": ("png", "image/png"), - "jpeg": ("jpg", "image/jpeg"), - "webp": ("webp", "image/webp"), -} -ASPECT_OPTIONS = ("Square 1:1", "Portrait 4:5", "Landscape 2:1", "Custom") -EDIT_ASPECT_OPTIONS = ("Same as source", *ASPECT_OPTIONS) - -page_header( - "Design Studio", - "Create standalone image designs, edit versions, and track each design line.", -) - -DEFAULT_STATE = { - "design_title": "Untitled design", - "design_prompt": "", - "design_output_format": "PNG", - "design_background_friendly": True, - "design_aspect": "Square 1:1", - "design_custom_width": 4, - "design_custom_height": 5, - "focused_generation_id": None, - "focused_lineage_leaf_id": None, - "editing_generation_id": None, - "deleting_generation_id": None, - "deleting_lineage_leaf_id": None, -} -for key, value in DEFAULT_STATE.items(): - if key not in st.session_state: - st.session_state[key] = value - - -def reuse_generation(generation: dict[str, Any]) -> None: - st.session_state.design_title = generation.get("title") or "Untitled design" - st.session_state.design_prompt = generation["prompt"] - st.session_state.design_output_format = _format_label(generation["output_format"]) - st.session_state.design_background_friendly = generation["background_removal_friendly"] - st.session_state.design_aspect = _aspect_label(generation["width"], generation["height"]) - st.session_state.design_custom_width = generation["width"] - st.session_state.design_custom_height = generation["height"] - - -def start_edit(generation_id: str) -> None: - st.session_state.editing_generation_id = generation_id - - -def cancel_edit() -> None: - st.session_state.editing_generation_id = None - - -def start_delete(generation_id: str) -> None: - st.session_state.deleting_generation_id = generation_id - - -def cancel_delete() -> None: - st.session_state.deleting_generation_id = None - - -def confirm_delete(generation_id: str) -> None: - delete(f"/image-generations/{generation_id}") - if st.session_state.editing_generation_id == generation_id: - st.session_state.editing_generation_id = None - st.session_state.deleting_generation_id = None - - -def start_delete_lineage(leaf_id: str) -> None: - st.session_state.deleting_lineage_leaf_id = leaf_id - - -def cancel_delete_lineage() -> None: - st.session_state.deleting_lineage_leaf_id = None - - -def confirm_delete_lineage(path: list[dict[str, Any]]) -> None: - for generation in reversed(path): - delete(f"/image-generations/{generation['id']}") - if st.session_state.editing_generation_id in {item["id"] for item in path}: - st.session_state.editing_generation_id = None - st.session_state.deleting_lineage_leaf_id = None - - -def upload_files(uploaded: list[Any]) -> list[tuple[str, tuple[str, bytes, str]]]: - return [ - ( - "reference_images", - ( - item.name, - item.getvalue(), - item.type or "application/octet-stream", - ), - ) - for item in uploaded - ] - - -def show_upload_previews(uploaded: list[Any]) -> None: - if not uploaded: - return - st.caption("Refer to these in your prompt as Reference 1, Reference 2, and so on.") - columns = st.columns(min(len(uploaded), 4)) - for index, item in enumerate(uploaded): - with columns[index % len(columns)]: - st.image(item.getvalue(), caption=f"Reference {index + 1}", width=140) - - -def show_generated_image( - asset_uri: str, *, width: Literal["stretch", "content"] | int = "stretch" -) -> bytes: - content = get_asset(asset_uri) - st.image(content, width=width) - return content - - -def line_title(path: list[dict[str, Any]]) -> str: - leaf = path[-1] - return str(leaf.get("title") or "Untitled design") - - -def save_line_title(path: list[dict[str, Any]], title: str) -> None: - clean_title = title.strip() or "Untitled design" - patch(f"/image-generations/{path[-1]['id']}", {"title": clean_title}) - - -def aspect_dimensions( - choice: str, - custom_width: int, - custom_height: int, - *, - source: dict[str, Any] | None = None, -) -> tuple[int, int]: - if choice == "Same as source" and source is not None: - return int(source["width"]), int(source["height"]) - if choice == "Square 1:1": - return 1024, 1024 - if choice == "Portrait 4:5": - return 1024, 1280 - if choice == "Landscape 2:1": - return 1536, 768 - ratio = max(custom_width, 1) / max(custom_height, 1) - if ratio >= 1: - width = 1536 - height = int(round(width / ratio)) - else: - height = 1536 - width = int(round(height * ratio)) - return _round_dimension(width), _round_dimension(height) - - -def _round_dimension(value: int) -> int: - return max(64, min(4096, round(value / 64) * 64)) - - -def _aspect_label(width: int, height: int) -> str: - ratio = round(width / height, 2) - if ratio == 1: - return "Square 1:1" - if ratio == 0.8: - return "Portrait 4:5" - if ratio == 2: - return "Landscape 2:1" - return "Custom" - - -def ratio_parts(width: int, height: int) -> tuple[int, int]: - divisor = gcd(width, height) - return min(width // divisor, 20), min(height // divisor, 20) - - -def prompt_preview( - *, - prompt: str, - width: int, - height: int, - output_format: str, - background_removal_friendly: bool, - reference_count: int, -) -> str: - references = tuple( - ImageReference(f"reference-{index}.png", "image/png", b"") - for index in range(1, reference_count + 1) - ) - effective_prompt = build_effective_prompt( - ImageGenerationRequest( - prompt=prompt, - width=width, - height=height, - references=references, - output_format=output_format, - background_removal_friendly=background_removal_friendly, - product_concept_id="preview", - ) - ) - return ( - f"size: {width}x{height}\n" - f"output_format: {output_format}\n" - f"reference_images: {reference_count}\n\n" - f"prompt:\n{effective_prompt}" - ) - - -def _format_label(output_format: str) -> str: - return {"png": "PNG", "jpeg": "JPEG", "webp": "WebP"}[output_format] - - -def show_details(generation: dict[str, Any]) -> None: - st.caption( - f"{generation['id']} · {generation['model']} · {generation['quality']} · " - f"{generation['width']}x{generation['height']} · " - f"{generation['output_format'].upper()} · " - f"estimated ${generation['estimated_cost_usd']}" - ) - if generation["background_removal_friendly"]: - st.caption("Background-removal friendly") - if generation.get("usage"): - st.json(generation["usage"], expanded=False) - if generation.get("error_message"): - st.error(generation["error_message"]) - - -def thumbnail_rows( - path: list[dict[str, Any]], *, key_prefix: str, open_dialog: bool = True -) -> None: - for row_start in range(0, len(path), 4): - row = path[row_start : row_start + 4] - widths = [1] * len(row) - if len(row) < 4: - widths.append(4 - len(row)) - columns = st.columns(widths, gap="small") - for column, generation in zip(columns, row, strict=False): - with column: - if generation.get("asset_uri"): - show_generated_image(generation["asset_uri"], width=120) - else: - st.caption(generation["status"]) - label = version_label(path, generation) - st.caption(label) - if open_dialog: - if st.button("Open", key=f"{key_prefix}-open-{generation['id']}"): - version_dialog(path, generation) - elif st.button("Select", key=f"{key_prefix}-select-{generation['id']}"): - st.session_state.focused_generation_id = generation["id"] - - -def version_actions( - path: list[dict[str, Any]], generation: dict[str, Any], *, key_prefix: str -) -> None: - extension, content_type = OUTPUT_DOWNLOADS[generation["output_format"]] - image_content = get_asset(generation["asset_uri"]) if generation.get("asset_uri") else b"" - actions = st.columns(4) - with actions[0]: - if st.button( - f"Edit from {version_label(path, generation)}", - key=f"{key_prefix}-edit-{generation['id']}", - disabled=generation["status"] != "succeeded" or not generation.get("asset_uri"), - ): - start_edit(generation["id"]) - st.rerun() - with actions[1]: - if st.button("Reuse prompt", key=f"{key_prefix}-reuse-{generation['id']}"): - reuse_generation(generation) - st.success("Prompt copied to New design.") - with actions[2]: - st.download_button( - "Download", - data=image_content, - file_name=( - f"{line_title(path).lower().replace(' ', '-')}-" - f"{version_label(path, generation)}.{extension}" - ), - mime=content_type, - key=f"{key_prefix}-download-{generation['id']}", - disabled=not bool(image_content), - on_click="ignore", - ) - with actions[3]: - if st.button("Delete", key=f"{key_prefix}-delete-{generation['id']}"): - start_delete(generation["id"]) - - if st.session_state.deleting_generation_id == generation["id"]: - st.warning("Delete this version? Edited descendants will be preserved.") - confirm, cancel = st.columns(2) - with confirm: - if st.button( - "Confirm delete", - key=f"{key_prefix}-confirm-delete-{generation['id']}", - type="primary", - ): - confirm_delete(generation["id"]) - st.rerun() - with cancel: - if st.button("Cancel", key=f"{key_prefix}-cancel-delete-{generation['id']}"): - cancel_delete() - st.rerun() - - -@st.dialog("Design version", width="large") -def version_dialog(path: list[dict[str, Any]], generation: dict[str, Any]) -> None: - title = line_title(path) - label = version_label(path, generation) - st.subheader(f"{title} · {label}") - image_col, info_col = st.columns([1, 1]) - with image_col: - if generation.get("asset_uri"): - show_generated_image(generation["asset_uri"]) - else: - st.caption(f"Generation status: {generation['status']}") - with info_col: - st.write(generation["prompt"]) - show_details(generation) - version_actions(path, generation, key_prefix=f"version-dialog-{generation['id']}") - - -@st.dialog("Design lineage", width="large") -def lineage_dialog(path: list[dict[str, Any]], lineage_summary: str) -> None: - title = line_title(path) - st.subheader(f"{title} · Lineage") - st.caption(lineage_summary) - rename, save = st.columns([3, 1]) - with rename: - new_title = st.text_input("Design title", value=title, key=f"title-{path[-1]['id']}") - with save: - st.write("") - if st.button("Save title", key=f"save-title-{path[-1]['id']}"): - save_line_title(path, new_title) - st.rerun() - - thumbnail_rows(path, key_prefix=f"lineage-dialog-{path[-1]['id']}", open_dialog=False) - - selected = next( - (item for item in path if item["id"] == st.session_state.focused_generation_id), - path[-1], - ) - st.divider() - st.caption(f"Selected: {version_label(path, selected)}") - image_col, info_col = st.columns([1, 1]) - with image_col: - if selected.get("asset_uri"): - show_generated_image(selected["asset_uri"]) - else: - st.caption(f"Generation status: {selected['status']}") - with info_col: - st.write(selected["prompt"]) - show_details(selected) - version_actions(path, selected, key_prefix=f"lineage-selected-{selected['id']}") - - -@st.dialog("Edit design", width="large") -def edit_dialog(selected: dict[str, Any], path: list[dict[str, Any]] | None) -> None: - label = version_label(path, selected) if path is not None and selected in path else "version" - st.subheader(f"Edit from {selected.get('title') or 'Untitled design'} · {label}") - edit_left, edit_right = st.columns([1, 1]) - with edit_left: - show_generated_image(selected["asset_uri"]) - st.caption(f"Source {selected['width']}x{selected['height']}") - with edit_right: - edit_instruction = st.text_area( - "Edit instruction", - key=f"edit-instruction-{selected['id']}", - height=150, - placeholder="Describe what should change while preserving the rest...", - ) - edit_references = st.file_uploader( - "Additional reference images", - type=["png", "jpg", "jpeg", "webp"], - accept_multiple_files=True, - key=f"edit-references-{selected['id']}", - help="Upload up to three additional references.", - ) - show_upload_previews(edit_references) - edit_aspect = st.radio( - "Aspect ratio", - EDIT_ASPECT_OPTIONS, - key=f"edit-aspect-{selected['id']}", - horizontal=True, - ) - if edit_aspect == "Custom": - ratio_width, ratio_height = ratio_parts(selected["width"], selected["height"]) - custom_left, custom_right = st.columns(2) - with custom_left: - edit_custom_width = st.number_input( - "Ratio width", - min_value=1, - max_value=20, - value=ratio_width, - key=f"edit-custom-width-{selected['id']}", - ) - with custom_right: - edit_custom_height = st.number_input( - "Ratio height", - min_value=1, - max_value=20, - value=ratio_height, - key=f"edit-custom-height-{selected['id']}", - ) - else: - edit_custom_width = selected["width"] - edit_custom_height = selected["height"] - edit_width, edit_height = aspect_dimensions( - edit_aspect, - edit_custom_width, - edit_custom_height, - source=selected, - ) - st.caption(f"Output size: {edit_width}x{edit_height}") - edit_format_label = st.selectbox( - "Edited output format", - options=list(OUTPUT_FORMATS), - index=list(OUTPUT_FORMATS).index(_format_label(selected["output_format"])), - key=f"edit-format-{selected['id']}", - ) - edit_background = st.checkbox( - "Background-removal friendly", - value=selected["background_removal_friendly"], - key=f"edit-background-{selected['id']}", - ) - too_many_edit = len(edit_references) > 3 - if too_many_edit: - st.error("A maximum of three additional reference images is allowed.") - preview_edit, render, cancel = st.columns(3) - with preview_edit: - show_edit_preview = st.button( - "Preview full prompt", - disabled=not edit_instruction.strip(), - key=f"preview-edit-prompt-{selected['id']}", - ) - with render: - render_edit = st.button( - f"Generate from {label}", - type="primary", - disabled=not edit_instruction.strip() or too_many_edit, - ) - with cancel: - if st.button("Cancel", on_click=cancel_edit): - st.rerun() - if show_edit_preview: - st.code( - prompt_preview( - prompt=edit_instruction.strip(), - width=edit_width, - height=edit_height, - output_format=OUTPUT_FORMATS[edit_format_label], - background_removal_friendly=edit_background, - reference_count=len(edit_references) + 1, - ) - ) - if render_edit: - with st.spinner("Rendering edit with OpenAI..."): - result = post_multipart( - f"/image-generations/{selected['id']}/edits", - data={ - "prompt": edit_instruction.strip(), - "width": str(edit_width), - "height": str(edit_height), - "output_format": OUTPUT_FORMATS[edit_format_label], - "background_removal_friendly": str(edit_background).lower(), - }, - files=upload_files(edit_references), - timeout=150, - ) - if result["status"] == "succeeded": - st.success("Edited version generated and saved.") - st.session_state.editing_generation_id = None - elif result["status"] == "unknown": - st.warning(result["error_message"]) - else: - st.error(result["error_message"]) - st.rerun() - - -try: - generations = get("/image-generations") - - with st.expander("New design", expanded=not generations): - title = st.text_input("Title", key="design_title") - prompt = st.text_area( - "Prompt", - key="design_prompt", - height=180, - placeholder="Describe the image you want to generate...", - ) - initial_references = st.file_uploader( - "Reference images", - type=["png", "jpg", "jpeg", "webp"], - accept_multiple_files=True, - help="Upload up to four images. Upload order determines reference numbering.", - ) - show_upload_previews(initial_references) - - aspect = st.radio("Aspect ratio", ASPECT_OPTIONS, key="design_aspect", horizontal=True) - if aspect == "Custom": - custom_left, custom_right = st.columns(2) - with custom_left: - custom_width = st.number_input( - "Ratio width", min_value=1, max_value=20, key="design_custom_width" - ) - with custom_right: - custom_height = st.number_input( - "Ratio height", min_value=1, max_value=20, key="design_custom_height" - ) - else: - custom_width = st.session_state.design_custom_width - custom_height = st.session_state.design_custom_height - width, height = aspect_dimensions(aspect, custom_width, custom_height) - st.caption(f"Output size: {width}x{height}") - - control_left, control_right = st.columns(2) - with control_left: - output_label = st.selectbox( - "Output format", - options=list(OUTPUT_FORMATS), - key="design_output_format", - ) - with control_right: - background_friendly = st.checkbox( - "Background-removal friendly", - key="design_background_friendly", - help="Requests an isolated subject on a uniform, high-contrast background.", - ) - - too_many_initial = len(initial_references) > 4 - if too_many_initial: - st.error("A maximum of four reference images is allowed.") - preview, generate = st.columns([1, 1]) - with preview: - show_preview = st.button( - "Preview full prompt", - disabled=not prompt.strip(), - key="preview-create-prompt", - ) - with generate: - render_image = st.button( - "Generate v1", - type="primary", - disabled=not prompt.strip() or too_many_initial, - ) - if show_preview: - st.code( - prompt_preview( - prompt=prompt.strip(), - width=width, - height=height, - output_format=OUTPUT_FORMATS[output_label], - background_removal_friendly=background_friendly, - reference_count=len(initial_references), - ) - ) - if render_image: - with st.spinner("Generating with OpenAI..."): - result = post_multipart( - "/image-generations", - data={ - "title": title.strip() or "Untitled design", - "prompt": prompt.strip(), - "width": str(width), - "height": str(height), - "output_format": OUTPUT_FORMATS[output_label], - "background_removal_friendly": str(background_friendly).lower(), - }, - files=upload_files(initial_references), - timeout=150, - ) - if result["status"] == "succeeded": - st.success("Image generated and saved.") - elif result["status"] == "unknown": - st.warning(result["error_message"]) - else: - st.error(result["error_message"]) - st.rerun() - - paths = lineage_paths(generations) - selected = next( - (item for item in generations if item["id"] == st.session_state.editing_generation_id), - None, - ) - if selected is not None: - selected_path = next((path for path in paths if selected in path), None) - edit_dialog(selected, selected_path) - - st.divider() - st.subheader("Design Library") - if not generations: - st.info("No designs have been generated.") - - root_counts: dict[str, int] = {} - for path in paths: - root_counts[str(path[0]["id"])] = root_counts.get(str(path[0]["id"]), 0) + 1 - root_seen: dict[str, int] = {} - for path in paths: - latest = path[-1] - title = line_title(path) - root_id = str(path[0]["id"]) - root_seen[root_id] = root_seen.get(root_id, 0) + 1 - lineage_summary = branch_label(path) - if root_counts[root_id] > 1: - lineage_summary = ( - f"{lineage_summary} · Fork {root_seen[root_id]} of {root_counts[root_id]}" - ) - with st.container(border=True): - st.markdown(f"**{title}**") - st.caption( - f"{len(path)} version{'s' if len(path) != 1 else ''} · " - f"Latest {version_label(path, latest)} · Updated {latest['created_at']}" - ) - st.caption(lineage_summary) - thumbnail_rows(path, key_prefix=f"library-{latest['id']}") - inspect_col, delete_col = st.columns([1, 1]) - with inspect_col: - if st.button("View lineage", key=f"view-lineage-{latest['id']}"): - st.session_state.focused_lineage_leaf_id = latest["id"] - st.session_state.focused_generation_id = latest["id"] - lineage_dialog(path, lineage_summary) - with delete_col: - if st.session_state.deleting_lineage_leaf_id == latest["id"]: - if st.button( - "Confirm delete", - key=f"confirm-delete-lineage-{latest['id']}", - type="primary", - ): - confirm_delete_lineage(path) - st.rerun() - if st.button("Cancel", key=f"cancel-delete-lineage-{latest['id']}"): - cancel_delete_lineage() - st.rerun() - elif st.button( - "Delete...", - key=f"delete-lineage-{latest['id']}", - ): - start_delete_lineage(latest["id"]) - st.rerun() -except Exception as error: - show_error(error) diff --git a/src/ecommerce_agent/dashboard/pages/6_Etsy_Listings.py b/src/ecommerce_agent/dashboard/pages/6_Etsy_Listings.py deleted file mode 100644 index 13f09ff..0000000 --- a/src/ecommerce_agent/dashboard/pages/6_Etsy_Listings.py +++ /dev/null @@ -1,330 +0,0 @@ -from datetime import UTC, datetime -from typing import Any - -import streamlit as st - -from ecommerce_agent.dashboard.client import get, patch, post, request -from ecommerce_agent.dashboard.ui import page_header, show_error - -page_header("Etsy Listings", "Draft and published listings from Etsy.") - - -def money(value: Any) -> str: - if isinstance(value, dict): - amount = value.get("amount") - divisor = value.get("divisor") or 100 - currency = value.get("currency_code") or "" - if amount is not None: - return f"{amount / divisor:.2f} {currency}".strip() - if value not in (None, ""): - return str(value) - return "" - - -def price_number(value: Any) -> float: - if isinstance(value, dict): - amount = value.get("amount") - divisor = value.get("divisor") or 100 - return float(amount or 0) / float(divisor) - return float(value or 0) - - -def image_url(listing: dict[str, Any]) -> str: - images = listing.get("images") or [] - if not images: - return "" - image = sorted(images, key=lambda row: int(row.get("rank") or 999))[0] - return str(image.get("url_170x135") or image.get("url_fullxfull") or "") - - -def timestamp(value: Any) -> str: - if not value: - return "" - return datetime.fromtimestamp(int(value), UTC).strftime("%Y-%m-%d") - - -def tags_text(value: list[str] | None) -> str: - return ", ".join(value or []) - - -def split_text(value: str) -> list[str]: - return [item.strip() for item in value.split(",") if item.strip()] - - -def first_offering(inventory: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]] | None: - products = inventory.get("products") or [] - if not products: - return None - product = products[0] - offerings = product.get("offerings") or [] - if not offerings: - return None - return product, offerings[0] - - -def listing_rows(listings: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [ - { - "thumbnail": image_url(listing), - "title": listing.get("title"), - "listing_id": str(listing.get("listing_id")), - "price": money(listing.get("price")), - "quantity": listing.get("quantity"), - "state": listing.get("state"), - "favorites": listing.get("num_favorers"), - "views": listing.get("views"), - "updated": timestamp( - listing.get("updated_timestamp") or listing.get("last_modified_timestamp") - ), - } - for listing in listings - ] - - -def filtered(listings: list[dict[str, Any]], search: str) -> list[dict[str, Any]]: - if not search.strip(): - return listings - needle = search.casefold() - return [ - listing - for listing in listings - if needle - in " ".join( - [ - str(listing.get("listing_id", "")), - str(listing.get("title", "")), - " ".join(listing.get("tags") or []), - ] - ).casefold() - ] - - -def changed(original: dict[str, Any], edited: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in edited.items() if original.get(key) != value} - - -def edit_listing(summary: dict[str, Any]) -> None: - detail = get(f"/etsy/listings/{summary['listing_id']}") - url = image_url(detail) - if url: - st.image(url, width="stretch") - if detail.get("url"): - st.link_button("Open in Etsy", detail["url"]) - - metrics = st.columns(3) - metrics[0].metric("Favorites", detail.get("num_favorers") or 0) - metrics[1].metric("Views", detail.get("views") or 0) - metrics[2].metric("Quantity", detail.get("quantity") or 0) - - with st.form(f"listing-{detail['listing_id']}"): - st.subheader("Copy") - title = st.text_input("Title", value=detail.get("title") or "", max_chars=140) - description = st.text_area("Description", value=detail.get("description") or "", height=220) - tags = st.text_input("Tags", value=tags_text(detail.get("tags"))) - materials = st.text_input("Materials", value=tags_text(detail.get("materials"))) - - st.subheader("Setup") - setup_columns = st.columns(2) - with setup_columns[0]: - taxonomy_id = st.number_input( - "Taxonomy ID", - min_value=0, - value=int(detail.get("taxonomy_id") or 0), - step=1, - ) - shipping_profile_id = st.number_input( - "Shipping profile ID", - min_value=0, - value=int(detail.get("shipping_profile_id") or 0), - step=1, - ) - return_policy_id = st.number_input( - "Return policy ID", - min_value=0, - value=int(detail.get("return_policy_id") or 0), - step=1, - ) - readiness_state_id = st.number_input( - "Readiness state ID", - min_value=0, - value=int(detail.get("readiness_state_id") or 0), - step=1, - ) - with setup_columns[1]: - who_made = st.selectbox( - "Who made it", - ["i_did", "someone_else", "collective"], - index=["i_did", "someone_else", "collective"].index( - detail.get("who_made") - if detail.get("who_made") in {"i_did", "someone_else", "collective"} - else "someone_else" - ), - ) - when_options = [ - "made_to_order", - "2020_2026", - "2010_2019", - "2007_2009", - "before_2007", - "2000_2006", - "1990s", - "1980s", - "1970s", - "1960s", - "1950s", - "1940s", - "1930s", - "1920s", - "1910s", - "1900s", - "1800s", - "1700s", - "before_1700", - ] - when_made = st.selectbox( - "When made", - when_options, - index=when_options.index( - detail.get("when_made") - if detail.get("when_made") in set(when_options) - else "made_to_order" - ), - ) - is_supply = st.checkbox("Supply", value=bool(detail.get("is_supply"))) - should_auto_renew = st.checkbox( - "Auto renew", value=bool(detail.get("should_auto_renew")) - ) - production_partner_ids = st.text_input( - "Production partner IDs", - value=", ".join(str(value) for value in detail.get("production_partner_ids", [])), - ) - - st.subheader("Inventory") - inventory = detail.get("inventory") or {} - first = first_offering(inventory) - inventory_changed = False - if first is None: - st.info("No editable inventory returned.") - else: - product, offering = first - inventory_columns = st.columns(4) - sku = inventory_columns[0].text_input("SKU", value=str(product.get("sku") or "")) - price = inventory_columns[1].number_input( - "Price", - min_value=0.01, - value=price_number(offering.get("price")) or 1.0, - step=1.0, - ) - quantity = inventory_columns[2].number_input( - "Quantity", - min_value=1, - value=int(offering.get("quantity") or 1), - step=1, - ) - enabled = inventory_columns[3].checkbox( - "Enabled", value=bool(offering.get("is_enabled", True)) - ) - inventory_changed = ( - product.get("sku") != sku - or price_number(offering.get("price")) != price - or int(offering.get("quantity") or 0) != quantity - or bool(offering.get("is_enabled", True)) != enabled - ) - - st.subheader("Images") - for image in sorted(detail.get("images") or [], key=lambda row: int(row.get("rank") or 0)): - st.caption(f"#{image.get('rank')} | {image.get('alt_text') or 'No alt text'}") - if image.get("url_170x135") or image.get("url_fullxfull"): - st.image(image.get("url_170x135") or image.get("url_fullxfull"), width=170) - - saved = st.form_submit_button("Save changes", type="primary") - - if not saved: - with st.expander("Raw Etsy JSON"): - st.json(detail) - return - - listing_payload = changed( - detail, - { - "title": title.strip(), - "description": description, - "tags": split_text(tags), - "materials": split_text(materials), - "taxonomy_id": int(taxonomy_id) or None, - "shipping_profile_id": int(shipping_profile_id) or None, - "return_policy_id": int(return_policy_id) or None, - "readiness_state_id": int(readiness_state_id) or None, - "who_made": who_made, - "when_made": when_made, - "is_supply": is_supply, - "should_auto_renew": should_auto_renew, - "production_partner_ids": [int(value) for value in split_text(production_partner_ids)], - }, - ) - if listing_payload: - patch(f"/etsy/listings/{detail['listing_id']}", listing_payload, timeout=45) - if first is not None and inventory_changed: - product, offering = first - product["sku"] = sku - offering["price"] = price - offering["quantity"] = quantity - offering["is_enabled"] = enabled - request( - "PUT", f"/etsy/listings/{detail['listing_id']}/inventory", json=inventory, timeout=45 - ) - st.success("Listing saved.") - st.rerun() - - -try: - auth = get("/etsy/oauth/status") - status_columns = st.columns([3, 1, 1]) - with status_columns[0]: - if auth["connected"]: - st.caption(f"Connected to Etsy shop {auth['shop_id']}.") - else: - st.warning("Etsy OAuth is not connected. Mock mode can still show sample listings.") - with status_columns[1]: - if st.button("Refresh OAuth", width="stretch", disabled=not auth["connected"]): - post("/etsy/oauth/refresh") - st.rerun() - with status_columns[2]: - st.link_button( - "Connect Etsy", "http://localhost:8000/api/etsy/oauth/start", width="stretch" - ) - - search = st.text_input("Search listings", placeholder="Title, listing ID, or tag") - tabs = st.tabs(["Draft", "Published"]) - for tab, state in zip(tabs, ["draft", "active"], strict=True): - with tab: - listings = filtered(get(f"/etsy/listings?state={state}"), search) - left, right = st.columns([3, 2]) - with left: - rows = listing_rows(listings) - st.dataframe( - rows, - hide_index=True, - width="stretch", - column_config={ - "thumbnail": st.column_config.ImageColumn(""), - "title": "Title", - "listing_id": "Listing ID", - }, - ) - if rows: - labels = {row["listing_id"]: row["title"] for row in rows} - selected_id = st.radio( - "Selected listing", - [row["listing_id"] for row in rows], - format_func=labels.__getitem__, - key=f"selected-{state}", - ) - else: - st.info("No listings found.") - selected_id = None - with right: - if selected_id: - edit_listing({"listing_id": selected_id}) -except Exception as error: - show_error(error) diff --git a/src/ecommerce_agent/dashboard/pages/6_Etsy_Posting_Workflow.py b/src/ecommerce_agent/dashboard/pages/6_Etsy_Posting_Workflow.py deleted file mode 100644 index 264ba86..0000000 --- a/src/ecommerce_agent/dashboard/pages/6_Etsy_Posting_Workflow.py +++ /dev/null @@ -1,572 +0,0 @@ -from typing import Any - -import streamlit as st - -from ecommerce_agent.dashboard.client import get, get_asset, patch, post -from ecommerce_agent.dashboard.ui import page_header, show_error - -STEPS = [ - ("select_products", "Select products"), - ("review_copy", "Review copy"), - ("build_images", "Build images"), - ("final_review", "Final review"), - ("submit_drafts", "Submit drafts"), -] - -page_header( - "Etsy Posting Workflow", - "Turn Printful product templates into reviewed Etsy draft listings.", -) - - -def results(value: Any) -> list[dict[str, Any]]: - if isinstance(value, dict): - rows = value.get("results", []) - return rows if isinstance(rows, list) else [] - return value if isinstance(value, list) else [] - - -def taxonomy_options(value: Any) -> list[tuple[int, str]]: - options: list[tuple[int, str]] = [] - - def walk(node: dict[str, Any], parents: list[str]) -> None: - path = [*parents, str(node["name"])] - children = node.get("children", []) - if children: - for child in children: - walk(child, path) - else: - options.append((int(node["id"]), " > ".join(path))) - - for root in results(value): - walk(root, []) - return options - - -def remote_or_local_image(image: dict[str, Any]) -> None: - if image.get("asset_uri"): - st.image(get_asset(image["asset_uri"]), width="stretch") - elif image.get("source_url"): - st.image(image["source_url"], width="stretch") - - -def move(session: dict[str, Any], step: str) -> None: - patch( - f"/posting/sessions/{session['id']}", - {"version": session["version"], "current_step": step}, - ) - st.rerun() - - -try: - auth = get("/etsy/oauth/status") - if not auth["connected"]: - st.error("Connect Etsy before using the posting workflow.") - st.link_button("Connect Etsy", "http://localhost:8000/api/etsy/oauth/start") - st.stop() - st.caption(f"Connected to Etsy shop {auth['shop_id']} with all required scopes.") - - sessions = get("/posting/sessions") - new_tab, resume_tab = st.tabs(["New session", "Resume session"]) - with new_tab: - source = get("/posting/templates") - templates = source["templates"] - selected_ids: list[int] = [] - st.subheader("Printful product templates") - for template in templates: - with st.container(border=True): - preview, detail = st.columns([1, 2]) - with preview: - st.image(template["mockup_file_url"], width="stretch") - with detail: - selected = st.checkbox( - template["title"], - key=f"template-{template['id']}", - ) - if selected: - selected_ids.append(int(template["id"])) - swatches = " ".join( - f"`{color['color_name']}`" for color in template.get("colors", []) - ) - st.markdown(swatches or "No colors reported") - st.caption( - f"Sizes: {', '.join(template.get('sizes', []))} | " - f"Variants: {len(template.get('available_variant_ids', []))}" - ) - placements = template.get("placements", []) - if placements: - st.caption( - " | ".join( - f"{row.get('display_name')}: {row.get('technique_display_name')}" - for row in placements - ) - ) - price = st.number_input( - "Default retail price", - min_value=1.0, - value=34.99, - step=1.0, - ) - if st.button( - "Create posting session", - type="primary", - disabled=not selected_ids, - ): - created = post( - "/posting/sessions", - { - "name": "Etsy posting session", - "template_ids": selected_ids, - "retail_price": price, - }, - timeout=180, - ) - st.session_state.posting_session_id = created["id"] - st.rerun() - - with st.expander("Existing Etsy-synced Printful products"): - synced = source["etsy_synced_products"] - if synced: - st.dataframe(synced, hide_index=True, width="stretch") - else: - st.info("No existing synced products were returned.") - - with resume_tab: - if sessions: - selected_session = st.selectbox( - "Posting session", - [row["id"] for row in sessions], - format_func=lambda value: next( - f"{row['name']} | {row['status']} | {row['updated_at']}" - for row in sessions - if row["id"] == value - ), - ) - if st.button("Open session"): - st.session_state.posting_session_id = selected_session - st.rerun() - else: - st.info("No posting sessions exist yet.") - - session_id = st.session_state.get("posting_session_id") - if not session_id: - st.stop() - posting = get(f"/posting/sessions/{session_id}") - st.divider() - step_labels = [label for _, label in STEPS] - current_index = [key for key, _ in STEPS].index(posting["current_step"]) - st.progress((current_index + 1) / len(STEPS), text=step_labels[current_index]) - - if posting["current_step"] == "select_products": - st.subheader("Selected products") - for item in posting["items"]: - left, right = st.columns([1, 2]) - with left: - st.image(item["template"]["mockup_file_url"], width="stretch") - with right: - st.write(item["template"]["title"]) - st.write("Colors:", ", ".join(item["selected_colors"])) - st.write("Sizes:", ", ".join(item["selected_sizes"])) - if st.button("Continue to copy", type="primary"): - move(posting, "review_copy") - - elif posting["current_step"] == "review_copy": - designs = get("/printful/designs") - designs_by_id = {design["id"]: design for design in designs} - for item in posting["items"]: - with st.expander(item["template"]["title"], expanded=True): - brief_col, generate_col = st.columns([4, 1], vertical_alignment="bottom") - with brief_col: - copy_context = st.text_area( - "Product and design context", - value=item["copy_context"] or "", - placeholder=( - "Hat with an embroidered blue BMW M5 viewed from an angle, " - "near-isometric perspective" - ), - help=( - "Describe the artwork, subject, perspective, audience, mood, and " - "important details the listing copy should emphasize." - ), - max_chars=4000, - key=f"context-{item['id']}", - ) - with generate_col: - generate_clicked = st.button( - "Generate copy", - key=f"generate-{item['id']}", - type="primary" if not item["title"] else "secondary", - width="stretch", - ) - - design_ids = [None, *designs_by_id] - current_design_id = item["copy_design_id"] - design_index = ( - design_ids.index(current_design_id) if current_design_id in design_ids else 0 - ) - selected_design_id = st.selectbox( - "Original design reference", - design_ids, - index=design_index, - format_func=lambda value: ( - "No linked Workshop design" - if value is None - else designs_by_id[value]["title"] - ), - help=( - "Workshop auto-selects a design when this Printful template was created " - "from a linked draft. For legacy templates, select the matching design." - ), - key=f"copy-design-{item['id']}", - ) - if selected_design_id is None: - st.info( - "Printful does not expose the reusable artwork for this legacy template. " - "Select the matching Workshop design above if it is in the design library." - ) - - candidates = item["copy_reference_candidates"] - candidate_labels = {candidate["id"]: candidate["label"] for candidate in candidates} - available_candidate_ids = list(candidate_labels) - selected_reference_ids = st.multiselect( - "Additional product references", - available_candidate_ids, - default=[ - reference_id - for reference_id in item["copy_reference_ids"] - if reference_id in candidate_labels - ], - max_selections=2, - format_func=candidate_labels.__getitem__, - help=( - "The primary Printful mockup is always included. Select up to two more " - "catalog, color, or product images when available." - ), - key=f"copy-references-{item['id']}", - ) - if not candidates: - st.caption( - "No additional Printful product images are available for this template." - ) - - st.caption("Images sent to ChatGPT") - preview_items: list[tuple[str, Any]] = [] - if selected_design_id is not None: - preview_items.append( - ( - "Original design", - get_asset(designs_by_id[selected_design_id]["asset_uri"]), - ) - ) - preview_items.append( - ( - "Primary Printful mockup (always included)", - item["template"]["mockup_file_url"], - ) - ) - selected_candidates = {candidate["id"]: candidate for candidate in candidates} - preview_items.extend( - ( - selected_candidates[reference_id]["label"], - selected_candidates[reference_id]["url"], - ) - for reference_id in selected_reference_ids - ) - preview_columns = st.columns(min(len(preview_items), 4)) - for index, (label, image_source) in enumerate(preview_items): - with preview_columns[index]: - st.image(image_source, width="stretch") - st.caption(label) - - generation_payload = { - "copy_context": copy_context.strip(), - "copy_design_id": selected_design_id, - "copy_reference_ids": selected_reference_ids, - } - save_inputs, reference_count = st.columns([1, 3], vertical_alignment="center") - if save_inputs.button( - "Save generation inputs", - key=f"save-generation-inputs-{item['id']}", - ): - patch( - f"/posting/items/{item['id']}", - {"version": item["version"], **generation_payload}, - ) - st.rerun() - reference_count.caption( - f"{len(preview_items)} image reference" - f"{'' if len(preview_items) == 1 else 's'} selected" - ) - if generate_clicked: - post( - f"/posting/items/{item['id']}/generate-copy", - generation_payload, - timeout=180, - ) - st.rerun() - - with st.form(f"copy-{item['id']}"): - title = st.text_input("Title", value=item["title"] or "", max_chars=140) - description = st.text_area( - "Description", value=item["description"] or "", height=260 - ) - tags = st.text_input("Tags, comma separated", value=", ".join(item["tags"])) - materials = st.text_input( - "Materials, comma separated", value=", ".join(item["materials"]) - ) - alt = st.text_input( - "Image alt text", value=item["image_alt_text"] or "", max_chars=500 - ) - price = st.number_input( - "Retail price", - value=float(item["retail_price"]), - min_value=1.0, - key=f"price-{item['id']}", - ) - if st.form_submit_button("Save copy", type="primary"): - patch( - f"/posting/items/{item['id']}", - { - "version": item["version"], - "title": title, - "description": description, - "tags": [ - value.strip() for value in tags.split(",") if value.strip() - ], - "materials": [ - value.strip() for value in materials.split(",") if value.strip() - ], - "image_alt_text": alt, - "retail_price": price, - }, - ) - st.success("Saved.") - st.rerun() - st.caption(f"Saved version {item['version']}") - nav = st.columns(2) - nav[0].button( - "Back", - on_click=move, - args=(posting, "select_products"), - ) - if nav[1].button( - "Continue to images", - type="primary", - disabled=any(not item["title"] for item in posting["items"]), - ): - move(posting, "build_images") - - elif posting["current_step"] == "build_images": - for item in posting["items"]: - st.subheader(item["title"] or item["template"]["title"]) - columns = st.columns(min(max(len(item["images"]), 1), 4)) - for index, image in enumerate(item["images"]): - with columns[index % len(columns)]: - remote_or_local_image(image) - st.caption( - "Accurate Printful mockup" - if image["source"] == "printful" - else "AI marketing image: verify design details" - ) - approved = st.checkbox( - "Approved", - value=image["approved"], - key=f"approved-{image['id']}", - ) - drift = image["ai_drift_acknowledged"] - if image["source"] == "ai_lifestyle": - drift = st.checkbox( - "I checked for design drift", - value=drift, - key=f"drift-{image['id']}", - ) - if approved != image["approved"] or drift != image["ai_drift_acknowledged"]: - patch( - f"/posting/images/{image['id']}", - { - "version": image["version"], - "approved": approved, - "ai_drift_acknowledged": drift, - }, - ) - st.rerun() - with st.form(f"lifestyle-{item['id']}"): - prompt = st.text_input( - "Optional lifestyle scene", - placeholder="On a clean studio table beside tasteful gift packaging", - ) - if st.form_submit_button("Generate AI lifestyle image"): - if not prompt.strip(): - st.warning("Describe the lifestyle scene before generating an image.") - else: - post( - f"/posting/items/{item['id']}/lifestyle-images", - {"prompt": prompt.strip()}, - timeout=360, - ) - st.rerun() - back, next_col = st.columns(2) - if back.button("Back to copy"): - move(posting, "review_copy") - if next_col.button("Continue to final review", type="primary"): - move(posting, "final_review") - - elif posting["current_step"] == "final_review": - metadata = get("/posting/metadata") - defaults = posting["defaults"] - shipping = results(metadata["shipping_profiles"]) - returns = results(metadata["return_policies"]) - processing = results(metadata["processing_profiles"]) - partners = results(metadata["production_partners"]) - taxonomy = taxonomy_options(metadata["seller_taxonomy"]) - shipping_ids = [row["shipping_profile_id"] for row in shipping] - return_ids = [row["return_policy_id"] for row in returns] - readiness_ids = [row["readiness_state_id"] for row in processing] - partner_ids = [row["production_partner_id"] for row in partners] - taxonomy_ids = [value for value, _ in taxonomy] - shipping_labels = { - row["shipping_profile_id"]: str(row.get("title") or row["shipping_profile_id"]) - for row in shipping - } - readiness_labels = { - row["readiness_state_id"]: str( - row.get("processing_days_display_label") - or row.get("display_name") - or row["readiness_state_id"] - ) - for row in processing - } - partner_labels = { - row["production_partner_id"]: str( - row.get("partner_name") or row["production_partner_id"] - ) - for row in partners - } - taxonomy_labels = dict(taxonomy) - if not returns and not metadata["shop"].get("is_using_structured_policies"): - st.info( - "This shop does not use Etsy structured return policies. " - "A return policy ID is not required for its draft listings." - ) - with st.form("listing-defaults"): - shipping_id = st.selectbox( - "Shipping profile", - [None, *shipping_ids], - index=( - shipping_ids.index(defaults["shipping_profile_id"]) + 1 - if defaults.get("shipping_profile_id") in shipping_ids - else 0 - ), - format_func=lambda value: ( - "Select a shipping profile" if value is None else shipping_labels[value] - ), - ) - return_id = st.selectbox( - "Return policy", - [None, *return_ids], - index=( - return_ids.index(defaults["return_policy_id"]) + 1 - if defaults.get("return_policy_id") in return_ids - else 0 - ), - format_func=lambda value: ( - "No structured return policy" if value is None else str(value) - ), - ) - readiness_id = st.selectbox( - "Processing profile", - [None, *readiness_ids], - index=( - readiness_ids.index(defaults["readiness_state_id"]) + 1 - if defaults.get("readiness_state_id") in readiness_ids - else (1 if len(readiness_ids) == 1 else 0) - ), - format_func=lambda value: ( - "Select a processing profile" if value is None else readiness_labels[value] - ), - ) - partner_id = st.selectbox( - "Production partner", - [None, *partner_ids], - index=( - partner_ids.index(defaults["production_partner_id"]) + 1 - if defaults.get("production_partner_id") in partner_ids - else (1 if len(partner_ids) == 1 else 0) - ), - format_func=lambda value: ( - "Select a production partner" if value is None else partner_labels[value] - ), - ) - taxonomy_id = st.selectbox( - "Etsy category", - [None, *taxonomy_ids], - index=( - taxonomy_ids.index(defaults["taxonomy_id"]) + 1 - if defaults.get("taxonomy_id") in taxonomy_ids - else 0 - ), - format_func=lambda value: ( - "Select an Etsy category" if value is None else taxonomy_labels[value] - ), - ) - if st.form_submit_button("Save listing defaults", type="primary"): - patch( - f"/posting/sessions/{posting['id']}", - { - "version": posting["version"], - "defaults": { - "shipping_profile_id": shipping_id, - "return_policy_id": return_id, - "readiness_state_id": readiness_id, - "production_partner_id": partner_id, - "taxonomy_id": taxonomy_id, - }, - }, - ) - post(f"/posting/sessions/{posting['id']}/validate") - st.rerun() - for item in posting["items"]: - with st.expander(item["title"] or item["template"]["title"], expanded=True): - st.write(item["description"]) - st.write("Tags:", item["tags"]) - st.write( - f"${item['retail_price']} | {len(item['selected_variant_ids'])} variants | " - f"{len([image for image in item['images'] if image['approved']])} images" - ) - for error in item["validation_errors"]: - st.error(error) - for error in posting["validation_errors"]: - st.error(error) - back, next_col = st.columns(2) - if back.button("Back to images"): - move(posting, "build_images") - if next_col.button( - "Continue to submission", - type="primary", - disabled=bool( - posting["validation_errors"] - or any(item["validation_errors"] for item in posting["items"]) - ), - ): - move(posting, "submit_drafts") - - else: - if st.button("Submit Etsy drafts", type="primary"): - post(f"/posting/sessions/{posting['id']}/submit", timeout=180) - st.rerun() - for item in posting["items"]: - with st.container(border=True): - st.write(item["title"]) - st.write(f"Etsy listing: {item['etsy_listing_id'] or 'not created'}") - st.write(f"Status: {item['status']}") - st.write(f"Printful native sync: {item['printful_sync_status']}") - if item["etsy_listing_id"] and st.button( - "Refresh Printful sync", - key=f"sync-{item['id']}", - ): - post(f"/posting/items/{item['id']}/sync-status", timeout=60) - st.rerun() - if item["error_message"]: - st.error(item["error_message"]) -except Exception as error: - show_error(error) diff --git a/src/ecommerce_agent/dashboard/pages/6_Printful_Workshop.py b/src/ecommerce_agent/dashboard/pages/6_Printful_Workshop.py deleted file mode 100644 index 4b14825..0000000 --- a/src/ecommerce_agent/dashboard/pages/6_Printful_Workshop.py +++ /dev/null @@ -1,456 +0,0 @@ -from decimal import Decimal -from typing import Any -from urllib.parse import urlencode - -import streamlit as st - -from ecommerce_agent.dashboard.client import get, get_asset, post -from ecommerce_agent.dashboard.ui import page_header, show_error - -page_header( - "Printful Workshop", - "Curate Printful blanks, pair them with reusable designs, preview mockups, " - "and create sync products.", -) - - -def product_label(product: dict[str, Any]) -> str: - price = product.get("cheapest_us_price") or "price unavailable" - return f"{product['title']} · score {product['workshop_score']} · from ${price}" - - -def draft_label(draft: dict[str, Any]) -> str: - return f"{draft['title']} · {draft['status']} · {draft['id'][:8]}" - - -try: - status_tab, catalog_tab, designs_tab, drafts_tab, templates_tab, sync_tab = st.tabs( - ["Status", "Catalog", "Designs", "Drafts", "Templates", "Sync products"] - ) - - with status_tab: - connection = get("/printful/status") - store = connection["store"] - metrics = st.columns(4) - metrics[0].metric("Mode", connection["mode"]) - metrics[1].metric("Store", store.get("name", store.get("id"))) - metrics[2].metric("Writes", "enabled" if connection["writes_enabled"] else "disabled") - metrics[3].metric( - "Asset delivery", - connection["asset_delivery_mode"] or "not configured", - ) - st.caption(f"Store ID {store.get('id')} · type {store.get('type')}") - if not connection["submission_ready"]: - st.warning( - "Catalog and template reads are live. Sync submission requires " - "`PRINTFUL_WRITES_ENABLED=true` plus signed public asset delivery." - ) - with st.expander("Token scopes"): - st.write(connection.get("scopes", [])) - - with catalog_tab: - refresh_col, note_col = st.columns([1, 4]) - with refresh_col: - if st.button("Refresh catalog", type="primary"): - with st.spinner("Reading Printful catalog and variant pricing..."): - result = post("/printful/catalog/refresh", {"category_ids": None}, timeout=180) - st.success(f"Refreshed {result['refreshed']} products.") - st.rerun() - with note_col: - st.caption( - "Price points compare the cheapest in-stock US variant only against " - "products in the same leaf category." - ) - - categories = get("/printful/categories") - category_options = {item["catalog_category_id"]: item["title"] for item in categories} - filters = st.columns([2, 2, 1]) - with filters[0]: - selected_category = st.selectbox( - "Category", - options=[None, *category_options], - format_func=lambda value: ( - "All categories" if value is None else category_options[value] - ), - ) - with filters[1]: - search = st.text_input("Search products") - with filters[2]: - favorites_only = st.checkbox("Favorites only") - query = urlencode( - { - key: value - for key, value in { - "category_id": selected_category, - "search": search.strip() or None, - "favorites_only": str(favorites_only).lower(), - }.items() - if value is not None - } - ) - catalog = get(f"/printful/catalog?{query}") - if not catalog: - st.info("Refresh the catalog or broaden the filters.") - else: - st.dataframe( - [ - { - "★": "★" if item["favorite"] else "", - "Product": item["title"], - "Brand": item["brand"], - "Price": item["cheapest_us_price"], - "Rating": item["manual_rating"], - "Score": item["workshop_score"], - "US variants": item["variant_count"], - } - for item in catalog - ], - hide_index=True, - width="stretch", - ) - selected_product_id = st.selectbox( - "Curate product", - options=[item["catalog_product_id"] for item in catalog], - format_func=lambda value: product_label( - next(item for item in catalog if item["catalog_product_id"] == value) - ), - ) - selected_product = get(f"/printful/catalog/{selected_product_id}") - detail = selected_product["raw_payload"]["detail"] - variants = detail.get("variants", []) - product_data = detail.get("product", {}) - left, right = st.columns([1, 2]) - with left: - if selected_product.get("image_url"): - st.image(selected_product["image_url"], width="stretch") - st.metric("Workshop score", selected_product["workshop_score"]) - st.json(selected_product["score_breakdown"], expanded=False) - with right: - with st.form(f"curate-{selected_product_id}"): - favorite = st.checkbox("Favorite", value=selected_product["favorite"]) - rating = st.select_slider( - "Manual rating", - options=[0, 1, 2, 3, 4, 5], - value=selected_product["manual_rating"] or 0, - help="0 means unrated and receives the neutral 15-point score.", - ) - notes = st.text_area("Notes", value=selected_product["notes"] or "") - variant_labels = { - int(item["id"]): ( - f"{item.get('color')} · {item.get('size')} · ${item.get('price', '?')}" - ) - for item in variants - } - preferred_variants = st.multiselect( - "Preferred variants", - options=list(variant_labels), - default=[ - item - for item in selected_product["preferred_variant_ids"] - if item in variant_labels - ], - format_func=lambda value: variant_labels[value], - ) - placements = [ - str(item["type"]) - for item in product_data.get("files", []) - if item.get("type") != "mockup" - ] - preferred_placement = st.selectbox( - "Preferred placement", - options=["", *placements], - index=( - placements.index(selected_product["preferred_placement"]) + 1 - if selected_product["preferred_placement"] in placements - else 0 - ), - ) - if st.form_submit_button("Save curation", type="primary"): - post( - f"/printful/catalog/{selected_product_id}/curation", - { - "favorite": favorite, - "manual_rating": rating or None, - "clear_rating": rating == 0, - "notes": notes.strip() or None, - "preferred_variant_ids": preferred_variants, - "preferred_placement": preferred_placement or None, - }, - ) - st.success("Curation and score updated.") - st.rerun() - - with designs_tab: - st.subheader("Promote a Design Studio image") - generations = [ - item - for item in get("/image-generations") - if item["status"] == "succeeded" and item.get("asset_uri") - ] - promoted = get("/printful/designs") - promoted_sources = {(item["source_type"], item["source_id"]) for item in promoted} - for generation in generations: - with st.container(border=True): - preview, info = st.columns([1, 2]) - with preview: - st.image(get_asset(generation["asset_uri"]), width="stretch") - with info: - st.write(generation["prompt"]) - is_promoted = ( - "image_generation", - generation["id"], - ) in promoted_sources - if st.button( - "Promoted" if is_promoted else "Promote to merch library", - key=f"promote-generation-{generation['id']}", - disabled=is_promoted, - ): - post(f"/printful/designs/from-generation/{generation['id']}") - st.rerun() - - st.subheader("Promote an approved workflow design") - workflow_designs = [item for item in get("/designs") if item["approved"] is True] - for design in workflow_designs: - is_promoted = ("design_asset", design["id"]) in promoted_sources - if st.button( - f"{'Promoted' if is_promoted else 'Promote'} workflow design {design['id'][:8]}", - key=f"promote-workflow-{design['id']}", - disabled=is_promoted, - ): - post(f"/printful/designs/from-workflow/{design['id']}") - st.rerun() - - st.subheader("Merch design library") - promoted = get("/printful/designs") - for design in promoted: - with st.container(border=True): - image_col, data_col = st.columns([1, 2]) - with image_col: - st.image(get_asset(design["asset_uri"]), width="stretch") - with data_col: - st.write(design["title"]) - st.caption( - f"{design['width']}×{design['height']} · {design['source_type']} · " - f"{'Printful-ready' if design['print_asset_uri'] else 'local only'}" - ) - - with drafts_tab: - designs = get("/printful/designs") - catalog = get("/printful/catalog") - st.subheader("Create design × product drafts") - if not designs or not catalog: - st.info("Promote at least one design and refresh the catalog first.") - else: - selected_designs = st.multiselect( - "Designs", - options=[item["id"] for item in designs], - format_func=lambda value: next( - item["title"] for item in designs if item["id"] == value - ), - ) - selected_products = st.multiselect( - "Products", - options=[item["catalog_product_id"] for item in catalog], - default=[206] if any(item["catalog_product_id"] == 206 for item in catalog) else [], - format_func=lambda value: product_label( - next(item for item in catalog if item["catalog_product_id"] == value) - ), - ) - retail_price = st.number_input("Retail price", min_value=0.01, value=34.99, step=1.00) - variant_ids_by_product: dict[str, list[int]] = {} - placement_by_product: dict[str, str] = {} - technique_by_product: dict[str, str] = {} - for product_id in selected_products: - item = get(f"/printful/catalog/{product_id}") - detail = item["raw_payload"]["detail"] - variants = [ - variant - for variant in detail.get("variants", []) - if any( - status.get("region") == "US" and status.get("status") == "in_stock" - for status in variant.get("availability_status", []) - ) - ] - labels = { - int(variant["id"]): ( - f"{variant.get('color')} · {variant.get('size')} · " - f"${variant.get('price', '?')}" - ) - for variant in variants - } - default_variants = item["preferred_variant_ids"] - if product_id == 206 and 12736 in labels: - default_variants = [12736] - variant_ids_by_product[str(product_id)] = st.multiselect( - f"Variants · {item['title']}", - options=list(labels), - default=[value for value in default_variants if value in labels], - format_func=labels.__getitem__, - key=f"draft-variants-{product_id}", - ) - placements = [ - str(file["type"]) - for file in detail.get("product", {}).get("files", []) - if file.get("type") != "mockup" - ] - default_placement = item["preferred_placement"] - if product_id == 206 and "embroidery_front_large" in placements: - default_placement = "embroidery_front_large" - placement_by_product[str(product_id)] = st.selectbox( - f"Placement · {item['title']}", - options=placements, - index=placements.index(default_placement) - if default_placement in placements - else 0, - key=f"draft-placement-{product_id}", - ) - techniques = detail.get("product", {}).get("techniques", []) - technique_keys = [str(item["key"]) for item in techniques] - default_technique = next( - (str(item["key"]) for item in techniques if item.get("is_default")), - technique_keys[0] if technique_keys else "EMBROIDERY", - ) - technique_by_product[str(product_id)] = st.selectbox( - f"Technique · {item['title']}", - options=technique_keys or ["EMBROIDERY"], - index=(technique_keys or ["EMBROIDERY"]).index(default_technique), - key=f"draft-technique-{product_id}", - ) - if st.button( - "Create drafts", - type="primary", - disabled=not selected_designs or not selected_products, - ): - created = post( - "/printful/drafts", - { - "design_ids": selected_designs, - "catalog_product_ids": selected_products, - "variant_ids_by_product": variant_ids_by_product, - "placement_by_product": placement_by_product, - "technique_by_product": technique_by_product, - "retail_price": str(Decimal(str(retail_price))), - }, - timeout=120, - ) - st.success(f"Created {len(created)} draft(s).") - st.rerun() - - st.subheader("Prepared drafts") - drafts = get("/printful/drafts") - products = get("/concepts") - for draft in drafts: - with st.expander(draft_label(draft), expanded=draft["status"] != "succeeded"): - validation = draft["validation"] - metrics = st.columns(4) - metrics[0].metric("Base cost", validation.get("base_cost") or "unknown") - metrics[1].metric("Decoration", validation.get("decoration_surcharge") or "0") - metrics[2].metric("Margin", validation.get("estimated_margin") or "unknown") - metrics[3].metric("Effective DPI", validation.get("effective_dpi")) - for warning in validation.get("warnings", []): - st.warning(warning) - for error in validation.get("errors", []): - st.error(error) - if draft.get("mockup_url"): - st.image(draft["mockup_url"], width=420) - actions = st.columns(4) - with actions[0]: - if st.button( - "Generate mockup", - key=f"mockup-{draft['id']}", - disabled=not validation.get("valid") or not connection["submission_ready"], - ): - with st.spinner("Generating Printful mockup..."): - post(f"/printful/drafts/{draft['id']}/mockup", timeout=90) - st.rerun() - with actions[1]: - if st.button( - "Submit", - type="primary", - key=f"submit-{draft['id']}", - disabled=not validation.get("valid") - or not connection["submission_ready"] - or draft["status"] in {"queued", "submitting", "succeeded"}, - ): - post("/printful/drafts/submit", {"draft_ids": [draft["id"]]}) - st.rerun() - with actions[2]: - if st.button( - "Retry", - key=f"retry-{draft['id']}", - disabled=draft["status"] != "failed" or not connection["submission_ready"], - ): - post(f"/printful/drafts/{draft['id']}/retry") - st.rerun() - with actions[3]: - attachable = [ - item - for item in products - if item["status"] in {"concept", "copy_review", "awaiting_printful_product"} - ] - if attachable: - attach_labels = {str(item["id"]): str(item["title"]) for item in attachable} - product_id = st.selectbox( - "Attach to workflow", - options=list(attach_labels), - format_func=attach_labels.__getitem__, - key=f"attach-select-{draft['id']}", - label_visibility="collapsed", - ) - if st.button("Attach", key=f"attach-{draft['id']}"): - post( - f"/printful/drafts/{draft['id']}/attach", - {"product_id": product_id}, - ) - st.rerun() - if draft.get("error_message"): - st.error(draft["error_message"]) - - with templates_tab: - templates = get("/printful/templates") - designs = get("/printful/designs") - if not templates: - st.info("No Printful Product Templates are available.") - for template in templates: - with st.expander(template["title"]): - if template.get("mockup_file_url"): - st.image(template["mockup_file_url"], width=320) - st.caption( - f"Catalog product {template['product_id']} · " - f"{len(template.get('available_variant_ids', []))} variants" - ) - selected = st.multiselect( - "Designs to apply", - options=[item["id"] for item in designs], - format_func=lambda value: next( - item["title"] for item in designs if item["id"] == value - ), - key=f"template-designs-{template['id']}", - ) - price = st.number_input( - "Retail price", - value=34.99, - min_value=0.01, - key=f"template-price-{template['id']}", - ) - if st.button( - "Use as starting point", - key=f"use-template-{template['id']}", - disabled=not selected, - ): - post( - f"/printful/templates/{template['id']}/drafts", - {"design_ids": selected, "retail_price": str(price)}, - timeout=120, - ) - st.success("Drafts created from the template.") - st.rerun() - - with sync_tab: - sync_products = get("/printful/sync-products") - if sync_products: - st.dataframe(sync_products, hide_index=True, width="stretch") - else: - st.info("No sync products exist in the configured Printful store.") -except Exception as error: - show_error(error) diff --git a/src/ecommerce_agent/dashboard/pages/8_Agent_Runs_Logs.py b/src/ecommerce_agent/dashboard/pages/8_Agent_Runs_Logs.py deleted file mode 100644 index 8f9dbfb..0000000 --- a/src/ecommerce_agent/dashboard/pages/8_Agent_Runs_Logs.py +++ /dev/null @@ -1,15 +0,0 @@ -import streamlit as st - -from ecommerce_agent.dashboard.client import get -from ecommerce_agent.dashboard.ui import page_header, show_error, table - -page_header("Agent Runs / Logs", "Workflow status, checkpoints, and audit events.") -try: - runs = get("/runs") - table(runs) - if runs: - run_id = st.selectbox("Run details", [run["id"] for run in runs]) - st.subheader("Events") - table(get(f"/runs/{run_id}/events")) -except Exception as error: - show_error(error) diff --git a/src/ecommerce_agent/dashboard/ui.py b/src/ecommerce_agent/dashboard/ui.py deleted file mode 100644 index 79458ec..0000000 --- a/src/ecommerce_agent/dashboard/ui.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Any - -import streamlit as st - -from ecommerce_agent.dashboard.client import ApiError - - -def page_header(title: str, description: str) -> None: - st.set_page_config(page_title=title, layout="wide") - st.markdown( - """ - - """, - unsafe_allow_html=True, - ) - st.title(title) - st.caption(description) - - -def show_error(error: Exception) -> None: - if isinstance(error, ApiError): - st.error(str(error)) - else: - st.exception(error) - - -def table(rows: list[dict[str, Any]], *, empty: str = "Nothing here yet.") -> None: - if rows: - st.dataframe(rows, use_container_width=True, hide_index=True) - else: - st.info(empty) diff --git a/src/ecommerce_agent/web/__init__.py b/src/ecommerce_agent/web/__init__.py new file mode 100644 index 0000000..1275202 --- /dev/null +++ b/src/ecommerce_agent/web/__init__.py @@ -0,0 +1,11 @@ +# Import screen modules for their side-effecting route registrations. +from ecommerce_agent.web import ( # noqa: E402,F401 + design_studio, + etsy_posting, + group_b, + research_reports, + trend_inbox, +) +from ecommerce_agent.web.routes import router, templates + +__all__ = ["router", "templates"] diff --git a/src/ecommerce_agent/dashboard/design_lineage.py b/src/ecommerce_agent/web/design_lineage.py similarity index 100% rename from src/ecommerce_agent/dashboard/design_lineage.py rename to src/ecommerce_agent/web/design_lineage.py diff --git a/src/ecommerce_agent/web/design_studio.py b/src/ecommerce_agent/web/design_studio.py new file mode 100644 index 0000000..f874757 --- /dev/null +++ b/src/ecommerce_agent/web/design_studio.py @@ -0,0 +1,166 @@ +"""Design Studio screen — composer + version-tracked library + focus detail. + +Library "lines" are lineage paths over image generations (reusing the lineage +helpers). Per the reconciliation inventory, WORKSHOP SCORE and niche have no +backing data, so they render as explicit placeholders rather than fabricated +values. Everything else (versions, prompt, model/size/cost meta, delete) is real. +""" + +import uuid +from typing import Annotated, Any + +from fastapi import Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ecommerce_agent.api.serialization import serialize_model +from ecommerce_agent.db.models import ImageGeneration +from ecommerce_agent.db.session import get_session +from ecommerce_agent.web.design_lineage import branch_label, lineage_paths +from ecommerce_agent.web.routes import asset_url, router, shell_context, templates + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +SUBTITLE = "Create standalone designs, edit versions, and track each design line." + + +def _fmt_date(value: Any) -> str: + text = str(value or "") + return text[:10] if text else "—" + + +def _thumbs(path: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for index, gen in enumerate(path[:4]): + out.append( + { + "label": f"v{index + 1}", + "url": asset_url(gen.get("asset_uri")), + "status": gen.get("status"), + } + ) + return out + + +def _line_view(path: list[dict[str, Any]]) -> dict[str, Any]: + leaf = path[-1] + return { + "line_id": leaf["id"], + "title": leaf.get("title") or "Untitled design", + "eyebrow": (leaf.get("model") or "design").upper(), + "versions": len(path), + "latest": f"v{len(path)}", + "updated": _fmt_date(leaf.get("created_at")), + "lineage": branch_label(path).replace("->", "→"), + "thumbs": _thumbs(path), + } + + +def _focus_view(path: list[dict[str, Any]], version_index: int) -> dict[str, Any]: + version_index = max(0, min(version_index, len(path) - 1)) + gen = path[version_index] + return { + "line_id": path[-1]["id"], + "line_title": path[-1].get("title") or "Untitled design", + "version": f"v{version_index + 1}", + "version_count": len(path), + "versions": [{"label": f"v{i + 1}", "index": i} for i in range(len(path))], + "active_index": version_index, + "thumb": asset_url(gen.get("asset_uri")), + "prompt": gen.get("prompt") or "", + "status": gen.get("status"), + "meta": [ + str(gen.get("id", ""))[:12], + gen.get("model") or "—", + gen.get("quality") or "—", + f"{gen.get('width', '?')} × {gen.get('height', '?')}", + (gen.get("output_format") or "").upper(), + f"${gen.get('estimated_cost_usd', '0')}", + ], + "bg_friendly": gen.get("background_removal_friendly"), + "gen_id": str(gen.get("id")), + "download_url": asset_url(gen.get("asset_uri")), + } + + +async def _all_paths(session: AsyncSession) -> list[list[dict[str, Any]]]: + rows = ( + await session.scalars( + select(ImageGeneration).order_by(ImageGeneration.created_at.desc()).limit(500) + ) + ).all() + gens = [serialize_model(r) for r in rows] + return lineage_paths(gens) + + +@router.get("/design-studio", response_class=HTMLResponse) +async def design_studio( + request: Request, + session: SessionDep, + sel: str | None = None, + prompt: str = "", + title: str = "", +) -> HTMLResponse: + paths = await _all_paths(session) + lines = [_line_view(p) for p in paths] + selected_path = next((p for p in paths if p[-1]["id"] == sel), paths[0] if paths else None) + focus = _focus_view(selected_path, len(selected_path) - 1) if selected_path else None + total_versions = sum(len(p) for p in paths) + ctx = shell_context( + request, + active="Design Studio", + title="Design Studio", + subtitle=SUBTITLE, + primary={"label": "New design", "icon": "plus"}, + lines=lines, + focus=focus, + selected_line=focus["line_id"] if focus else None, + line_count=len(paths), + version_count=total_versions, + prefill_prompt=prompt, + prefill_title=title, + ) + return templates.TemplateResponse(request=request, name="design_studio.html", context=ctx) + + +@router.get("/design-studio/focus/{leaf_id}", response_class=HTMLResponse) +async def studio_focus( + request: Request, session: SessionDep, leaf_id: str, v: int = -1 +) -> HTMLResponse: + paths = await _all_paths(session) + path = next((p for p in paths if p[-1]["id"] == leaf_id), None) + focus = None + if path is not None: + focus = _focus_view(path, v if v >= 0 else len(path) - 1) + return templates.TemplateResponse( + request=request, + name="partials/_studio_focus.html", + context={"request": request, "focus": focus}, + ) + + +@router.post("/design-studio/generate", response_class=HTMLResponse) +async def generate(request: Request, session: SessionDep) -> RedirectResponse: + services = getattr(request.app.state, "services", None) + if services is not None: + from ecommerce_agent.api.routes import create_image_generation + + try: + await create_image_generation(request=request, session=session, services=services) + except Exception: + pass + return RedirectResponse(url="/design-studio", status_code=303) + + +@router.post("/design-studio/lines/{leaf_id}/delete", response_class=HTMLResponse) +async def delete_line(request: Request, session: SessionDep, leaf_id: str) -> RedirectResponse: + paths = await _all_paths(session) + path = next((p for p in paths if p[-1]["id"] == leaf_id), None) + if path is not None: + for gen in reversed(path): # leaf → root, respecting the self-FK + obj = await session.get(ImageGeneration, uuid.UUID(gen["id"])) + if obj is not None: + await session.delete(obj) + await session.commit() + return RedirectResponse(url="/design-studio", status_code=303) diff --git a/src/ecommerce_agent/web/etsy_posting.py b/src/ecommerce_agent/web/etsy_posting.py new file mode 100644 index 0000000..53ad8e1 --- /dev/null +++ b/src/ecommerce_agent/web/etsy_posting.py @@ -0,0 +1,253 @@ +"""Etsy Posting screen — pipeline + listing editor over real posting sessions. + +The real workflow is a multi-step wizard (select → copy → images → review → +submit); the B1 design collapses it into a pipeline + single editor. We keep the +underlying data/actions: editing item copy, validating, and submitting. Pipeline +stages map real item statuses onto the design's Draft / In review / Scheduled / Live. +""" + +import uuid +from decimal import Decimal, InvalidOperation +from typing import Annotated, Any + +from fastapi import Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ecommerce_agent.api.posting_routes import _validate_item +from ecommerce_agent.db.models import PostingItem, PostingSession +from ecommerce_agent.db.session import get_session +from ecommerce_agent.web.routes import asset_url, router, shell_context, templates + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +SUBTITLE = "Move approved designs through draft, review, and scheduling onto Etsy listings." +TAG_CAP = 13 +IMAGE_CAP = 10 + +# Design pipeline stages ← real PostingItemStatus. +STAGES = [("Draft", "sub"), ("In review", "warn"), ("Scheduled", "accent"), ("Live", "ok")] +STAGE_FOR = { + "selected": "Draft", + "copy_ready": "Draft", + "images_ready": "Draft", + "ready": "In review", + "failed": "In review", + "unknown": "In review", + "submitting": "Scheduled", + "submitted": "Live", +} + + +def _item_title(item: PostingItem) -> str: + return item.title or item.template_snapshot.get("name") or "Untitled listing" + + +def _first_image(item: PostingItem) -> str: + for image in sorted(item.images, key=lambda i: i.position): + url = image.source_url or image.asset_uri + if url: + return asset_url(url) + return "" + + +def _card_view(item: PostingItem) -> dict[str, Any]: + return { + "id": str(item.id), + "title": _item_title(item), + "price": f"{item.retail_price:.2f}", + "stage": STAGE_FOR.get(item.status.value, "Draft"), + "image": _first_image(item), + } + + +def _readiness(item: PostingItem) -> list[dict[str, Any]]: + approved = [i for i in item.images if i.approved] + title = item.title or "" + title_ok = bool(title) and len(title) <= 140 + tags_ok = 1 <= len(item.tags) <= TAG_CAP + img_ok = len(approved) >= 1 + cat_ok = item.taxonomy_id is not None + desc_ok = bool(item.description) + desc_words = len((item.description or "").split()) + return [ + {"name": "Title", "ok": title_ok, "value": f"{len(item.title or '')} chars"}, + {"name": "Tags", "ok": tags_ok, "value": f"{len(item.tags)}/{TAG_CAP}"}, + {"name": "Images", "ok": img_ok, "value": f"{len(item.images)} of {IMAGE_CAP}"}, + {"name": "Category", "ok": cat_ok, "value": str(item.taxonomy_id or "—")}, + {"name": "Description", "ok": desc_ok, "value": f"{desc_words} words"}, + ] + + +def _editor_view(item: PostingItem) -> dict[str, Any]: + images = sorted(item.images, key=lambda i: i.position) + return { + "id": str(item.id), + "session_id": str(item.session_id), + "title": _item_title(item), + "raw_title": item.title or "", + "stage": STAGE_FOR.get(item.status.value, "Draft"), + "stage_tone": dict(STAGES).get(STAGE_FOR.get(item.status.value, "Draft"), "sub"), + "status": item.status.value, + "price": f"{item.retail_price:.2f}", + "quantity": item.quantity, + "taxonomy_id": item.taxonomy_id or "", + "category": item.template_snapshot.get("category") or "Clothing › Tops & Tees › T-shirts", + "tags": item.tags, + "tag_cap": TAG_CAP, + "description": item.description or "", + "materials": item.materials, + "images": [ + { + "url": asset_url(i.source_url or i.asset_uri), + "approved": i.approved, + "source": i.source.value, + } + for i in images + ], + "image_count": len(images), + "image_cap": IMAGE_CAP, + "readiness": _readiness(item), + "errors": item.validation_errors, + } + + +async def _load_items(session: AsyncSession) -> list[PostingItem]: + sessions = ( + await session.scalars( + select(PostingSession) + .options(selectinload(PostingSession.items).selectinload(PostingItem.images)) + .order_by(PostingSession.updated_at.desc()) + ) + ).all() + items: list[PostingItem] = [] + for s in sessions: + items.extend(s.items) + return items + + +async def _get_item(session: AsyncSession, item_id: str) -> PostingItem | None: + try: + pk = uuid.UUID(item_id) + except ValueError: + return None + stmt = select(PostingItem).options(selectinload(PostingItem.images)).where(PostingItem.id == pk) + return (await session.scalars(stmt)).first() + + +def _pipeline(items: list[PostingItem]) -> list[dict[str, Any]]: + cards = [_card_view(i) for i in items] + groups = [] + for name, tone in STAGES: + members = [c for c in cards if c["stage"] == name] + groups.append({"name": name, "tone": tone, "cards": members}) + return groups + + +@router.get("/etsy-posting", response_class=HTMLResponse) +async def etsy_posting( + request: Request, session: SessionDep, sel: str | None = None +) -> HTMLResponse: + items = await _load_items(session) + selected_item = next((i for i in items if str(i.id) == sel), items[0] if items else None) + selected = _editor_view(selected_item) if selected_item else None + ctx = shell_context( + request, + active="Etsy Posting", + title="Etsy Posting", + subtitle=SUBTITLE, + primary={"label": "New listing", "icon": "plus"}, + pipeline=_pipeline(items), + selected=selected, + has_items=bool(items), + ) + return templates.TemplateResponse(request=request, name="etsy_posting.html", context=ctx) + + +@router.get("/etsy-posting/detail/{item_id}", response_class=HTMLResponse) +async def etsy_detail(request: Request, session: SessionDep, item_id: str) -> HTMLResponse: + item = await _get_item(session, item_id) + return templates.TemplateResponse( + request=request, + name="partials/_listing_editor.html", + context={"request": request, "selected": _editor_view(item) if item else None}, + ) + + +@router.post("/etsy-posting/items/{item_id}/save", response_class=HTMLResponse) +async def save_item( + request: Request, + session: SessionDep, + item_id: str, + title: Annotated[str, Form()] = "", + description: Annotated[str, Form()] = "", + retail_price: Annotated[str, Form()] = "", + quantity: Annotated[int, Form()] = 999, + taxonomy_id: Annotated[str, Form()] = "", + tags: Annotated[list[str], Form()] = [], # noqa: B006 + materials: Annotated[list[str], Form()] = [], # noqa: B006 +) -> HTMLResponse: + item = await _get_item(session, item_id) + if item is not None: + item.title = title.strip() or None + item.description = description.strip() or None + try: + item.retail_price = Decimal(retail_price) if retail_price else item.retail_price + except InvalidOperation: + pass + item.quantity = quantity + item.taxonomy_id = int(taxonomy_id) if taxonomy_id.strip().isdigit() else None + item.tags = [t.strip() for t in tags if t.strip()][:TAG_CAP] + item.materials = [m.strip() for m in materials if m.strip()] + await session.commit() + item = await _get_item(session, item_id) + return templates.TemplateResponse( + request=request, + name="partials/_listing_editor.html", + context={ + "request": request, + "selected": _editor_view(item) if item else None, + "saved": True, + }, + ) + + +@router.post("/etsy-posting/sessions/{session_id}/validate", response_class=HTMLResponse) +async def validate_session( + request: Request, session: SessionDep, session_id: str +) -> RedirectResponse: + stmt = ( + select(PostingSession) + .options(selectinload(PostingSession.items).selectinload(PostingItem.images)) + .where(PostingSession.id == uuid.UUID(session_id)) + ) + posting = (await session.scalars(stmt)).first() + if posting is not None: + for item in posting.items: + item.validation_errors = _validate_item(item, posting.defaults) + await session.commit() + return RedirectResponse(url="/etsy-posting", status_code=303) + + +@router.post("/etsy-posting/sessions/{session_id}/submit", response_class=HTMLResponse) +async def submit_session( + request: Request, session: SessionDep, session_id: str +) -> RedirectResponse: + # Delegate to the real submit pipeline (needs the runtime service container). + from ecommerce_agent.api.posting_routes import submit_posting_session + from ecommerce_agent.api.schemas import PostingSubmitRequest + + services = getattr(request.app.state, "services", None) + if services is not None: + try: + await submit_posting_session( + session_id=uuid.UUID(session_id), + body=PostingSubmitRequest(item_ids=None), + session=session, + services=services, + ) + except Exception: # surfaced on the page via validation_errors / status + pass + return RedirectResponse(url="/etsy-posting", status_code=303) diff --git a/src/ecommerce_agent/web/group_b.py b/src/ecommerce_agent/web/group_b.py new file mode 100644 index 0000000..9648334 --- /dev/null +++ b/src/ecommerce_agent/web/group_b.py @@ -0,0 +1,158 @@ +"""Group B screens (no design reference) — restyled in the B1 language. + +Product Drafts, Analytics, Agent Runs · Logs, Printful Workshop. These reuse the +JSON layer's queries and render B1 cards/tables. +""" + +from typing import Annotated, Any + +from fastapi import Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ecommerce_agent.db.models import AnalyticsSnapshot, MerchDesign, Product +from ecommerce_agent.db.session import get_session +from ecommerce_agent.web.routes import router, shell_context, templates + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +_STATUS_TONE = { + "published": "ok", + "ready_for_manual_publish": "accent", + "rejected": "warn", + "failed": "warn", +} + + +def _tone(status: str) -> str: + return _STATUS_TONE.get(status, "sub") + + +@router.get("/product-drafts", response_class=HTMLResponse) +async def product_drafts(request: Request, session: SessionDep) -> HTMLResponse: + stmt = select(Product).order_by(desc(Product.created_at)).limit(200) + rows = (await session.scalars(stmt)).all() + drafts = [ + { + "id": str(p.id), + "title": (p.listing_copy or {}).get("title") or p.title, + "audience": p.audience, + "product_type": p.product_type, + "status": p.status.value, + "tone": _tone(p.status.value), + "price": f"{p.retail_price:.2f}", + "currency": p.currency, + } + for p in rows + if p.listing_copy is not None + ] + ctx = shell_context( + request, + active="Product Drafts", + title="Product Drafts", + subtitle="Concepts with generated listing copy, in progress toward Etsy.", + drafts=drafts, + ) + return templates.TemplateResponse(request=request, name="product_drafts.html", context=ctx) + + +@router.get("/analytics", response_class=HTMLResponse) +async def analytics(request: Request, session: SessionDep) -> HTMLResponse: + rows = ( + await session.scalars( + select(AnalyticsSnapshot).order_by(desc(AnalyticsSnapshot.captured_at)).limit(200) + ) + ).all() + snapshots = [] + for s in rows: + product = await session.get(Product, s.product_id) + snapshots.append( + { + "title": ((product.listing_copy or {}).get("title") if product else None) + or (product.title if product else "—"), + "captured_at": s.captured_at.strftime("%b %d") if s.captured_at else "—", + "views": s.views, + "favorites": s.favorites, + "orders": s.orders, + "revenue": f"{s.revenue:.2f}", + "conversion": f"{float(s.conversion_rate) * 100:.1f}%", + "fulfillment": s.fulfillment_status, + } + ) + ctx = shell_context( + request, + active="Analytics", + title="Analytics", + subtitle="Listing performance snapshots captured from live listings.", + snapshots=snapshots, + ) + return templates.TemplateResponse(request=request, name="analytics.html", context=ctx) + + +async def _runs(session: AsyncSession) -> list[dict[str, Any]]: + from ecommerce_agent.api.routes import list_runs + + items = await list_runs(session) + for item in items: + last = str(item.get("last_event", "")) + item["tone"] = ( + "warn" + if last in {"failed", "error"} + else ( + "ok" + if last in {"completed", "succeeded"} + else "accent" + if last == "running" + else "sub" + ) + ) + item["updated"] = str(item.get("updated_at", ""))[:19].replace("T", " ") + return items + + +@router.get("/agent-runs", response_class=HTMLResponse) +async def agent_runs(request: Request, session: SessionDep) -> HTMLResponse: + ctx = shell_context( + request, + active="Agent Runs · Logs", + title="Agent Runs · Logs", + subtitle="Active jobs and the recent pipeline event timeline.", + runs=await _runs(session), + ) + return templates.TemplateResponse(request=request, name="agent_runs.html", context=ctx) + + +@router.get("/agent-runs/table", response_class=HTMLResponse) +async def agent_runs_table(request: Request, session: SessionDep) -> HTMLResponse: + return templates.TemplateResponse( + request=request, + name="partials/_runs_table.html", + context={"request": request, "runs": await _runs(session)}, + ) + + +@router.get("/printful-workshop", response_class=HTMLResponse) +async def printful_workshop(request: Request, session: SessionDep) -> HTMLResponse: + stmt = select(MerchDesign).order_by(desc(MerchDesign.created_at)).limit(200) + rows = (await session.scalars(stmt)).all() + designs = [ + { + "title": d.title, + "asset": d.asset_uri, + "dims": f"{d.width} × {d.height}", + "source": d.source_type, + "print_ready": bool(d.print_asset_uri), + } + for d in rows + ] + settings = request.app.state.settings + ctx = shell_context( + request, + active="Printful Workshop", + title="Printful Workshop", + subtitle="Curate the merch design library and prepare Printful drafts.", + designs=designs, + printful_mode=getattr(settings, "printful_mode", "mock"), + ) + return templates.TemplateResponse(request=request, name="printful_workshop.html", context=ctx) diff --git a/src/ecommerce_agent/web/research_reports.py b/src/ecommerce_agent/web/research_reports.py new file mode 100644 index 0000000..3e7e808 --- /dev/null +++ b/src/ecommerce_agent/web/research_reports.py @@ -0,0 +1,229 @@ +"""Research Reports screen — list + reading pane over real research runs. + +The agent writes freeform markdown; we derive the TOC and section heads from its +headings and render the body with a minimal markdown subset (no new dependency). +Opportunity score is derived from the linked trend's apparel score where present. +""" + +import html as _html +import re +import uuid +from typing import Annotated, Any + +from fastapi import Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ecommerce_agent.db.models import ResearchRun, Trend +from ecommerce_agent.db.session import get_session +from ecommerce_agent.domain.enums import ResearchRunStatus +from ecommerce_agent.jobs.queue import enqueue +from ecommerce_agent.web.routes import router, shell_context, templates + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +SUBTITLE = "Agent-written market reports — read, score, and turn opportunities into designs." +_NEW_REPORT_ATTRS = "onclick=\"document.getElementById('new-report').open=true\"" + +_STATUS = { + "completed": ("FINAL", "ok"), + "queued": ("QUEUED", "warn"), + "running": ("RUNNING", "warn"), + "failed": ("FAILED", "warn"), +} + + +def _inline(text: str) -> str: + out = _html.escape(text) + out = re.sub(r"\*\*(.+?)\*\*", r"\1", out) + out = re.sub(r"`(.+?)`", r"\1", out) + return out + + +def _render_body(lines: list[str]) -> str: + html: list[str] = [] + para: list[str] = [] + bullets: list[str] = [] + + def flush_para() -> None: + if para: + html.append("

" + " ".join(_inline(p) for p in para) + "

") + para.clear() + + def flush_list() -> None: + if bullets: + html.append("
    " + "".join(f"
  • {_inline(b)}
  • " for b in bullets) + "
") + bullets.clear() + + for raw in lines: + line = raw.rstrip() + if not line.strip(): + flush_para() + flush_list() + elif re.match(r"^\s*[-*]\s+", line): + flush_para() + bullets.append(re.sub(r"^\s*[-*]\s+", "", line)) + else: + flush_list() + para.append(line.strip()) + flush_para() + flush_list() + return "".join(html) + + +def parse_report(md: str | None) -> dict[str, Any]: + """Split markdown into (toc, sections, word count).""" + if not md: + return {"toc": [], "sections": [], "words": 0} + sections: list[dict[str, Any]] = [] + current: dict[str, Any] = {"title": "", "lines": []} + for line in md.splitlines(): + heading = re.match(r"^#{1,6}\s+(.*)$", line) + if heading: + if current["title"] or current["lines"]: + sections.append(current) + current = {"title": heading.group(1).strip(), "lines": []} + else: + current["lines"].append(line) + if current["title"] or current["lines"]: + sections.append(current) + rendered = [ + {"title": s["title"] or "Overview", "html": _render_body(s["lines"])} for s in sections + ] + toc = [s["title"] for s in rendered] + return {"toc": toc, "sections": rendered, "words": len(md.split())} + + +def _score_from_trend(trend: Trend | None) -> int | None: + if trend is None or trend.apparel_score is None: + return None + return round(float(trend.apparel_score) / 5 * 100) + + +def _list_view(run: ResearchRun) -> dict[str, Any]: + label, tone = _STATUS.get(run.status.value, (run.status.value.upper(), "sub")) + md = run.markdown_report or "" + return { + "id": str(run.id), + "title": run.topic_title or "Untitled report", + "niche": run.trend.niche if run.trend else "—", + "status_label": label, + "status_tone": tone, + "score": _score_from_trend(run.trend), + "date": run.created_at.strftime("%b %d") if run.created_at else "—", + "sections": len(re.findall(r"^#{1,6}\s+", md, flags=re.MULTILINE)), + } + + +def _read_view(run: ResearchRun) -> dict[str, Any]: + parsed = parse_report(run.markdown_report) + base = _list_view(run) + base.update( + { + "words": parsed["words"], + "toc": parsed["toc"], + "report_sections": parsed["sections"], + "error": run.error_message, + "can_download": run.status == ResearchRunStatus.COMPLETED and bool(run.markdown_report), + "can_retry": run.status == ResearchRunStatus.FAILED, + "context": run.additional_context or "", + } + ) + return base + + +async def _load(session: AsyncSession) -> list[ResearchRun]: + return list( + ( + await session.scalars( + select(ResearchRun) + .options(selectinload(ResearchRun.trend)) + .order_by(desc(ResearchRun.created_at)) + .limit(200) + ) + ).all() + ) + + +@router.get("/research-reports", response_class=HTMLResponse) +async def research_reports( + request: Request, session: SessionDep, sel: str | None = None, trend_id: str | None = None +) -> HTMLResponse: + runs = await _load(session) + views = [_list_view(r) for r in runs] + selected_run = next((r for r in runs if str(r.id) == sel), runs[0] if runs else None) + selected = _read_view(selected_run) if selected_run else None + + prefill = "" + if trend_id: + try: + trend = await session.get(Trend, uuid.UUID(trend_id)) + prefill = trend.title if trend else "" + except (ValueError, TypeError): + prefill = "" + + ctx = shell_context( + request, + active="Research Reports", + title="Research Reports", + subtitle=SUBTITLE, + primary={"label": "New report", "icon": "plus", "attrs": _NEW_REPORT_ATTRS}, + reports=views, + selected=selected, + prefill=prefill, + ) + return templates.TemplateResponse(request=request, name="research_reports.html", context=ctx) + + +@router.get("/research-reports/detail/{run_id}", response_class=HTMLResponse) +async def research_detail(request: Request, session: SessionDep, run_id: str) -> HTMLResponse: + try: + pk = uuid.UUID(run_id) + except ValueError: + pk = None + run = None + if pk: + stmt = select(ResearchRun).options(selectinload(ResearchRun.trend)) + run = (await session.scalars(stmt.where(ResearchRun.id == pk))).first() + return templates.TemplateResponse( + request=request, + name="partials/_report_reader.html", + context={"request": request, "selected": _read_view(run) if run else None}, + ) + + +@router.post("/research-reports/new", response_class=HTMLResponse) +async def new_report( + request: Request, + session: SessionDep, + topic_title: Annotated[str, Form()], + additional_context: Annotated[str, Form()] = "", + trend_id: Annotated[str, Form()] = "", +) -> RedirectResponse: + title = topic_title.strip() + if title: + run = ResearchRun( + topic_title=title, + additional_context=additional_context.strip(), + status=ResearchRunStatus.QUEUED, + trend_id=uuid.UUID(trend_id) if trend_id else None, + ) + session.add(run) + await session.flush() + await enqueue(session, "generate_research_report", run.id) + await session.commit() + return RedirectResponse(url="/research-reports", status_code=303) + + +@router.post("/research-reports/{run_id}/retry", response_class=HTMLResponse) +async def retry_report(request: Request, session: SessionDep, run_id: str) -> RedirectResponse: + run = await session.get(ResearchRun, uuid.UUID(run_id)) + if run and run.status == ResearchRunStatus.FAILED: + run.status = ResearchRunStatus.QUEUED + run.error_message = None + run.finished_at = None + await enqueue(session, "generate_research_report", run.id) + await session.commit() + return RedirectResponse(url=f"/research-reports?sel={run_id}", status_code=303) diff --git a/src/ecommerce_agent/web/routes.py b/src/ecommerce_agent/web/routes.py new file mode 100644 index 0000000..245ccd7 --- /dev/null +++ b/src/ecommerce_agent/web/routes.py @@ -0,0 +1,150 @@ +"""B1 Workshop web UI — server-rendered Jinja2 templates + HTMX. + +This router renders HTML for the human operator. It shares the FastAPI app's +service layer (via `get_services`) rather than calling the JSON API over HTTP. +""" + +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from markupsafe import Markup + +_TEMPLATES_DIR = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + +router = APIRouter(include_in_schema=False) + +# ── Icons ──────────────────────────────────────────────────────────────────── +# Inline 20×20 stroke SVG paths, lifted verbatim from the B1 prototype +# (wschrome.jsx / studio.jsx) so the icon set matches pixel-for-pixel. +ICON_PATHS: dict[str, str] = { + "inbox": "M3 13h4l1 2h4l1-2h4M4 13l2-8h8l2 8", + "doc": "M5 2h7l3 3v11H5zM12 2v3h3", + "check": "M4 9l3 3 6-7", + "image": "M3 4h14v12H3zM3 13l4-4 3 3 3-4 4 5", + "box": "M3 6l7-3 7 3-7 3zM3 6v8l7 3 7-3V6M10 9v8", + "shirt": "M6 3l4 2 4-2 3 3-2 2v8H5V8L3 6z", + "tag": "M3 3h6l8 8-6 6-8-8zM6.5 6.5h.01", + "chart": "M3 16V4M3 16h13M7 13V9M11 13V6M15 13v-3", + "pulse": "M3 10h3l2-5 3 10 2-5h3", + "plus": "M10 4v12M4 10h12", + "search": "M9 3a6 6 0 100 12A6 6 0 009 3zM14 14l3 3", + "ref": "M4 14l4-5 3 3 2-3 3 5zM4 4h12v10H4z", + "bolt": "M11 2L4 11h5l-1 7 7-9h-5z", + "eye": "M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z M10 8a2 2 0 100 4 2 2 0 000-4z", + "down": "M10 3v10M5 9l5 5 5-5M4 17h12", + "branch": "M6 4v8a3 3 0 003 3h2M6 4a2 2 0 100-.1zM14 4a2 2 0 100-.1zM12 15a2 2 0 100 .1z", + "trash": "M4 5h12M8 5V3h4v2M6 5l1 12h6l1-12", + "arrow": "M4 10h12M11 5l5 5-5 5", + "clock": "M10 5v5l3 2M10 3a7 7 0 100 14 7 7 0 000-14z", + "flame": "M10 2c1 3 4 4 4 8a4 4 0 11-8 0c0-2 1-3 2-4 0 2 1 2 2 1 0-2-1-4-0-6z", + "cal": "M3 5h14v12H3zM3 8h14M7 3v3M13 3v3", + "star": "M10 2l2.4 5 5.6.6-4 3.8 1 5.6L10 14.8 4 17l1-5.6-4-3.8 5.6-.6z", + "sliders": "M4 6h8M14 6h2M4 14h2M8 14h8M11 4v4M6 12v4", + "ext": "M8 4H4v12h12v-4M12 4h4v4M16 4l-7 7", + "edit": "M4 13l9-9 3 3-9 9H4z", + "x": "M5 5l10 10M15 5L5 15", + "layers": "M10 3l7 4-7 4-7-4zM3 11l7 4 7-4", + "dollar": "M10 3v14M13 6c0-1.5-1.5-2-3-2s-3 .5-3 2 1.5 2 3 2 3 .5 3 2-1.5 2-3 2-3-.5-3-2", +} + + +def render_icon(name: str, size: int = 16, sw: float = 1.6) -> Markup: + path = ICON_PATHS.get(name, "") + return Markup( + f'' + ) + + +templates.env.globals["icon"] = render_icon + + +def asset_url(uri: str | None) -> str: + """Resolve a stored asset reference to a browser URL (mirrors the old client).""" + if not uri: + return "" + if uri.startswith(("http://", "https://", "/")): + return uri + return f"/api/assets/{uri}" + + +templates.env.globals["asset_url"] = asset_url + +# ── Navigation ─────────────────────────────────────────────────────────────── +# Design's 9-item nav minus Approval Queue (dropped per product decision). +NAV: list[dict[str, str]] = [ + {"label": "Trend Inbox", "slug": "trend-inbox", "icon": "inbox"}, + {"label": "Research Reports", "slug": "research-reports", "icon": "doc"}, + {"label": "Design Studio", "slug": "design-studio", "icon": "image"}, + {"label": "Product Drafts", "slug": "product-drafts", "icon": "box"}, + {"label": "Printful Workshop", "slug": "printful-workshop", "icon": "shirt"}, + {"label": "Etsy Posting", "slug": "etsy-posting", "icon": "tag"}, + {"label": "Analytics", "slug": "analytics", "icon": "chart"}, + {"label": "Agent Runs · Logs", "slug": "agent-runs", "icon": "pulse"}, +] + +# Topbar model dropdowns are cosmetic selectors (the app uses a fixed +# server-side model). Options mirror the prototype's WS_DATA.models. +TEXT_MODELS = [ + "gpt-5", + "gpt-5-mini", + "claude-opus-4.5", + "claude-sonnet-4.5", + "gemini-2.5-pro", + "llama-4-70b", +] +IMAGE_MODELS = [ + "gpt-image-2", + "gpt-image-2-mini", + "dall-e-3", + "flux-1.1-pro", + "imagen-4", + "sd-3.5-large", +] + + +def shell_context( + request: Request, + *, + active: str, + title: str, + subtitle: str = "", + primary: dict[str, str] | None = None, + badges: dict[str, str] | None = None, + **extra: Any, +) -> dict[str, Any]: + """Build the template context shared by every screen (sidebar + topbar).""" + settings = request.app.state.settings + cap = getattr(settings, "image_generation_cap_usd", None) or getattr(settings, "cap_usd", None) + ctx: dict[str, Any] = { + "request": request, + "app_name": "WORKSHOP", + "nav": NAV, + "active": active, + "title": title, + "subtitle": subtitle, + "primary": primary, + "badges": badges or {}, + "text_models": TEXT_MODELS, + "image_models": IMAGE_MODELS, + "text_model": TEXT_MODELS[0], + "image_model": IMAGE_MODELS[0], + "integrations": "LIVE", + "cap_usd": f"{float(cap):.2f}" if cap is not None else "0.50", + } + ctx.update(extra) + return ctx + + +# ── Routes ─────────────────────────────────────────────────────────────────-- +# Screen routes live in their own modules (imported by web/__init__). + + +@router.get("/", response_class=HTMLResponse) +async def home() -> RedirectResponse: + return RedirectResponse(url="/trend-inbox") diff --git a/src/ecommerce_agent/web/static/app.js b/src/ecommerce_agent/web/static/app.js new file mode 100644 index 0000000..8934125 --- /dev/null +++ b/src/ecommerce_agent/web/static/app.js @@ -0,0 +1,152 @@ +// B1 Workshop — minimal client behavior: theme toggle + topbar dropdowns. + +(function () { + // ── Theme toggle ────────────────────────────────────────────────────────── + function currentTheme() { + return document.documentElement.getAttribute("data-theme") || "light"; + } + function syncToggleLabels() { + var mode = currentTheme(); + document.querySelectorAll(".ws-toggle .ws-toggle-label").forEach(function (el) { + el.textContent = mode; + }); + } + document.addEventListener("click", function (e) { + var toggle = e.target.closest(".ws-toggle"); + if (!toggle) return; + var next = currentTheme() === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + try { localStorage.setItem("ws-theme", next); } catch (err) {} + syncToggleLabels(); + }); + + // ── Dropdowns (one open at a time; close on outside click) ───────────────── + function closeAllMenus(except) { + document.querySelectorAll(".ws-dd-wrap").forEach(function (wrap) { + if (wrap === except) return; + var menu = wrap.querySelector(".ws-dd-menu"); + var btn = wrap.querySelector(".ws-dd"); + if (menu) menu.hidden = true; + if (btn) btn.classList.remove("is-open"); + }); + } + document.addEventListener("click", function (e) { + var btn = e.target.closest(".ws-dd-wrap > .ws-dd"); + if (btn) { + var wrap = btn.parentElement; + var menu = wrap.querySelector(".ws-dd-menu"); + var open = menu && menu.hidden; + closeAllMenus(wrap); + if (menu) { menu.hidden = !open; btn.classList.toggle("is-open", open); } + return; + } + var opt = e.target.closest(".ws-opt"); + if (opt) { + var wrap2 = opt.closest(".ws-dd-wrap"); + var value = opt.getAttribute("data-value"); + wrap2.querySelectorAll(".ws-opt").forEach(function (o) { + o.classList.toggle("is-on", o === opt); + var active = o.querySelector(".ws-opt-active"); + if (o === opt && !active) { + var span = document.createElement("span"); + span.className = "ws-opt-active"; + span.textContent = "ACTIVE"; + o.appendChild(span); + } else if (o !== opt && active) { + active.remove(); + } + }); + var valueEl = wrap2.querySelector(".ws-dd-value"); + if (valueEl) valueEl.textContent = value; + var key = wrap2.getAttribute("data-dd"); + try { if (key) localStorage.setItem("ws-model-" + key, value); } catch (err) {} + closeAllMenus(null); + return; + } + // click elsewhere closes any open menu + if (!e.target.closest(".ws-dd-menu")) closeAllMenus(null); + }); + + // ── Master/detail selection highlight ───────────────────────────────────── + document.addEventListener("click", function (e) { + var card = e.target.closest("[data-detail]"); + if (!card) return; + var master = card.closest("[data-master]"); + if (!master) return; + master.querySelectorAll("[data-detail].is-selected").forEach(function (el) { + el.classList.remove("is-selected"); + }); + card.classList.add("is-selected"); + }); + + // ── Composer aspect → width/height ──────────────────────────────────────── + document.addEventListener("change", function (e) { + var radio = e.target.closest("[data-aspect-form] input[name='aspect']"); + if (!radio) return; + var form = radio.closest("[data-aspect-form]"); + form.querySelectorAll(".ws-aspect .ws-seg-cell").forEach(function (cell) { + cell.classList.toggle("is-on", cell.contains(radio)); + }); + var w = form.querySelector("[data-ds-width]"); + var h = form.querySelector("[data-ds-height]"); + if (w) w.value = radio.getAttribute("data-w"); + if (h) h.value = radio.getAttribute("data-h"); + }); + + // ── Removable tags editor ───────────────────────────────────────────────── + function updateTagCount(form) { + var count = form.querySelectorAll("[data-tag-list] .ws-tag:not(.ws-tag-add)").length; + var el = form.querySelector("[data-tag-count]"); + if (el) el.textContent = count; + } + document.addEventListener("click", function (e) { + var rm = e.target.closest("[data-remove-tag]"); + if (!rm) return; + var form = rm.closest("[data-tags]"); + rm.closest(".ws-tag").remove(); + if (form) updateTagCount(form); + }); + document.addEventListener("keydown", function (e) { + var input = e.target.closest("[data-tag-input]"); + if (!input || e.key !== "Enter") return; + e.preventDefault(); + var value = input.value.trim(); + if (!value) return; + var form = input.closest("[data-tags]"); + var addCell = input.closest(".ws-tag-add"); + var tag = document.createElement("span"); + tag.className = "ws-tag"; + var hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.name = "tags"; + hidden.value = value; + tag.appendChild(hidden); + tag.appendChild(document.createTextNode(value)); + var x = document.createElement("button"); + x.type = "button"; + x.className = "ws-tag-x"; + x.setAttribute("data-remove-tag", ""); + x.textContent = "×"; + tag.appendChild(x); + addCell.parentNode.insertBefore(tag, addCell); + input.value = ""; + if (form) updateTagCount(form); + }); + + // restore persisted model selections + theme label on load + function restore() { + document.querySelectorAll(".ws-dd-wrap[data-dd]").forEach(function (wrap) { + var key = wrap.getAttribute("data-dd"); + var saved; + try { saved = localStorage.getItem("ws-model-" + key); } catch (err) {} + if (!saved) return; + var match = wrap.querySelector('.ws-opt[data-value="' + saved + '"]'); + if (match) match.click(); + var menu = wrap.querySelector(".ws-dd-menu"); + if (menu) menu.hidden = true; + }); + syncToggleLabels(); + } + if (document.readyState !== "loading") restore(); + else document.addEventListener("DOMContentLoaded", restore); +})(); diff --git a/src/ecommerce_agent/web/static/b1.css b/src/ecommerce_agent/web/static/b1.css new file mode 100644 index 0000000..024f0eb --- /dev/null +++ b/src/ecommerce_agent/web/static/b1.css @@ -0,0 +1,569 @@ +/* B1 "Concrete · Amber Dark" design system. + Tokens per docs/01; light is default, dark via [data-theme="dark"]. + `--line` is the structural border (full ink in light, faint in dark — matching + the prototype's `c.line`); `--ink` stays the text color. */ + +:root, +:root[data-theme="light"] { + --bg: #e9e6dd; + --panel: #f7f5ef; + --panel2: #efece3; + --ink: #16130d; + --sub: #736d60; + --faint: #a39c8c; + --line: #16130d; + --hairline: rgba(22, 19, 13, 0.14); + --accent: #e23c0c; + --ok: #3f7a44; + --warn: #b8761f; + --on-accent: #f7f5ef; + --hover: rgba(22, 19, 13, 0.06); + --thumb-bg: #dedace; +} + +:root[data-theme="dark"] { + --bg: #1c1108; + --panel: #271810; + --panel2: #312115; + --ink: #f4ece1; + --sub: #a89a84; + --faint: #6e624d; + --line: rgba(244, 236, 225, 0.03); + --hairline: rgba(244, 236, 225, 0.13); + --accent: #fb5a28; + --ok: #6cae66; + --warn: #e0a24a; + --on-accent: #1c1108; + --hover: rgba(244, 236, 225, 0.06); + --thumb-bg: #382616; +} + +:root { + --radius: 0; + --border-w: 1.5px; + --border: var(--border-w) solid var(--line); + --hair: 1px solid var(--hairline); + --hardshadow: 5px 5px 0 var(--accent); + --display: "Archivo", system-ui, sans-serif; + --body: "Archivo", system-ui, sans-serif; + --mono: "JetBrains Mono", ui-monospace, monospace; +} + +* { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--body); + -webkit-font-smoothing: antialiased; + font-size: 14px; +} + +.ws-display { + font-family: var(--display); + font-weight: 800; + text-transform: uppercase; + letter-spacing: -0.02em; + line-height: 0.98; + color: var(--ink); + margin: 0; +} + +.ws-mono-label { + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--faint); +} + +.ws-icon { display: block; } + +/* ── App shell ───────────────────────────────────────────────────────────── */ +.ws-app { display: flex; height: 100vh; overflow: hidden; } + +.ws-sidebar { + width: 250px; + flex-shrink: 0; + background: var(--panel); + border-right: var(--border); + display: flex; + flex-direction: column; +} + +.ws-brand { padding: 22px 20px; border-bottom: var(--border); } +.ws-brand-row { display: flex; align-items: center; gap: 9px; } +.ws-brand-tile { + width: 26px; height: 26px; flex-shrink: 0; + background: var(--accent); color: var(--on-accent); + display: flex; align-items: center; justify-content: center; +} +.ws-brand-mark { + font-family: var(--display); font-weight: 800; text-transform: uppercase; + letter-spacing: 0.02em; font-size: 19px; line-height: 0.98; +} +.ws-brand-sub { + font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.08em; + color: var(--faint); margin-top: 8px; text-transform: uppercase; +} + +.ws-nav { padding: 12px; flex: 1; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; } +.ws-nav-item { + display: flex; align-items: center; gap: 11px; padding: 9px 11px; + color: var(--sub); text-decoration: none; cursor: pointer; + font-family: var(--body); font-size: 13.5px; font-weight: 500; + transition: background .12s, color .12s, transform .1s; +} +.ws-nav-item .ws-nav-icon { display: flex; opacity: 0.85; } +.ws-nav-label { flex: 1; } +.ws-nav-item:hover { background: var(--hover); color: var(--ink); } +.ws-nav-item:not(.is-active):hover { transform: translateX(2px); } +.ws-nav-item:active { transform: translateX(2px) scale(.99); } +.ws-nav-item.is-active { + background: var(--accent); color: var(--on-accent); font-weight: 600; +} +.ws-nav-item.is-active .ws-nav-icon { opacity: 1; } +.ws-nav-badge { + font-family: var(--mono); font-size: 10.5px; font-weight: 700; line-height: 1; + padding: 2px 5px; background: var(--accent); color: var(--on-accent); +} +.ws-nav-item.is-active .ws-nav-badge { background: var(--on-accent); color: var(--accent); } + +.ws-side-footer { padding: 14px 16px; border-top: var(--border); display: flex; flex-direction: column; gap: 9px; } +.ws-budget-row { display: flex; justify-content: space-between; align-items: center; } +.ws-budget-val { font-family: var(--mono); font-size: 11px; color: var(--sub); } +.ws-meter { height: 5px; background: var(--panel2); border: var(--hair); } +.ws-meter-fill { height: 100%; background: var(--accent); } +.ws-operator { display: flex; align-items: center; gap: 9px; margin-top: 4px; } +.ws-operator-tile { + width: 28px; height: 28px; border: var(--border); background: var(--panel2); + display: flex; align-items: center; justify-content: center; + font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--ink); +} +.ws-operator-text { line-height: 1.2; } +.ws-operator-name { font-size: 12.5px; font-weight: 600; color: var(--ink); } +.ws-operator-meta { font-family: var(--mono); font-size: 10.5px; color: var(--faint); } + +.ws-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } + +.ws-topbar { + height: 74px; flex-shrink: 0; border-bottom: var(--border); background: var(--panel); + display: flex; align-items: center; padding: 0 24px; gap: 20px; +} +.ws-topbar-title { min-width: 0; } +.ws-h1 { font-size: 23px; } +.ws-subtitle { font-family: var(--body); font-size: 12.5px; color: var(--sub); margin-top: 3px; } +.ws-topbar-spacer { flex: 1; } +.ws-topbar-cluster { display: flex; gap: 7px; align-items: center; } + +.ws-content { flex: 1; display: flex; overflow: hidden; min-height: 0; } + +/* ── Chip ────────────────────────────────────────────────────────────────── */ +.ws-chip { + display: inline-flex; align-items: center; gap: 5px; + font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; + text-transform: uppercase; color: var(--sub); + padding: 3px 7px; border: 1px solid var(--hairline); line-height: 1; white-space: nowrap; +} +.ws-chip-dot { width: 6px; height: 6px; border-radius: 6px; background: currentColor; } +.ws-chip.tone-live, .ws-chip.tone-accent { color: var(--accent); border-color: var(--accent); } +.ws-chip.tone-ok { color: var(--ok); border-color: var(--ok); } +.ws-chip.tone-warn { color: var(--warn); border-color: var(--warn); } +.ws-chip.tone-sub { color: var(--sub); border-color: var(--sub); } + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +.ws-btn { + font-family: var(--mono); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; + text-transform: uppercase; padding: 11px 16px; line-height: 1; + display: inline-flex; align-items: center; gap: 7px; + border: var(--border); cursor: pointer; user-select: none; background: transparent; color: var(--ink); + transition: background .14s, color .14s, border-color .14s, transform .08s, filter .14s, box-shadow .12s; +} +.ws-btn:active { transform: translateY(1px) scale(.985); } +.ws-primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } +.ws-primary:hover { filter: saturate(1.1) brightness(.97); transform: translateY(-1px); box-shadow: var(--hardshadow); } +.ws-primary:active { transform: translateY(0) scale(.985); filter: saturate(1.12) brightness(.9); box-shadow: none; } +.ws-ghost:hover { background: var(--hover); border-color: var(--accent); color: var(--ink); } +.ws-ghost:active { background: var(--hover); } +.ws-destructive { color: var(--warn); border-color: var(--warn); } +.ws-destructive:hover { background: var(--hover); border-color: var(--warn); color: var(--warn); } +/* compact icon-only button */ +.ws-icon-btn { + display: inline-flex; align-items: center; justify-content: center; padding: 6px; + border: 1px solid var(--hairline); background: transparent; cursor: pointer; line-height: 1; + color: var(--ink); +} +.ws-icon-btn.is-warn { color: var(--warn); } + +/* ── Field label + input ─────────────────────────────────────────────────── */ +.ws-field-label { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 7px; gap: 10px; } +.ws-field-name { + font-family: var(--mono); font-size: 11px; letter-spacing: 0.07em; text-transform: uppercase; + color: var(--sub); font-weight: 600; white-space: nowrap; +} +.ws-field-hint { font-family: var(--mono); font-size: 11px; color: var(--faint); white-space: nowrap; } +.ws-input { + width: 100%; background: var(--panel); border: var(--border); color: var(--ink); + font-family: var(--body); font-size: 13.5px; padding: 10px 12px; outline: none; +} +textarea.ws-input { line-height: 1.5; font-size: 13px; resize: vertical; } +.ws-input::placeholder { color: var(--faint); } + +/* ── Score bar ───────────────────────────────────────────────────────────── */ +.ws-score { display: flex; align-items: center; gap: 8px; } +.ws-score-track { flex: 1; min-width: 70px; height: 6px; background: var(--panel2); border: var(--hair); overflow: hidden; } +.ws-score-fill { height: 100%; background: var(--accent); } +.ws-score-num { font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; } + +/* ── Thumb placeholder ───────────────────────────────────────────────────── */ +.ws-thumb { + position: relative; width: 100%; background: var(--thumb-bg); + border: var(--border); overflow: hidden; cursor: pointer; + transition: transform .18s, filter .18s, box-shadow .18s; +} +.ws-thumb-hatch { + position: absolute; inset: 0; + background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--ink) 8%, transparent) 0 2px, transparent 2px 9px); +} +.ws-thumb:hover { transform: scale(1.04); z-index: 3; box-shadow: var(--hardshadow); } +.ws-thumb:active { transform: scale(.99); } +.ws-thumb-sub { + position: absolute; left: 7px; top: 6px; font-family: var(--mono); font-size: 9px; + letter-spacing: 0.06em; color: var(--sub); background: var(--thumb-bg); padding: 1px 4px; border: var(--hair); +} +.ws-thumb-label { + position: absolute; right: 7px; bottom: 6px; font-family: var(--mono); font-weight: 700; + font-size: 11px; color: var(--accent); +} +.ws-thumb.is-big .ws-thumb-sub { font-size: 11px; } +.ws-thumb.is-big .ws-thumb-label { font-size: 13px; } +.ws-thumb-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } + +/* ── Checkbox ────────────────────────────────────────────────────────────── */ +.ws-check-row { display: flex; align-items: center; gap: 10px; cursor: pointer; } +.ws-check { + width: 18px; height: 18px; border: var(--border); background: transparent; color: var(--on-accent); + display: flex; align-items: center; justify-content: center; flex-shrink: 0; + transition: transform .08s, filter .12s; +} +.ws-check.is-on { background: var(--accent); } +.ws-check-row:hover .ws-check { filter: brightness(1.06); } +.ws-check-row:active .ws-check { transform: scale(.92); } +.ws-check-label { font-size: 13px; color: var(--ink); } + +/* ── Segmented control ───────────────────────────────────────────────────── */ +.ws-seg { display: flex; } +.ws-seg.is-grid { display: grid; } +.ws-seg-cell { + flex: 1; text-align: center; font-family: var(--mono); font-size: 10.5px; font-weight: 600; + letter-spacing: 0.02em; padding: 9px 4px; cursor: pointer; user-select: none; + border: var(--border); border-left: none; background: var(--panel); color: var(--sub); + transition: background .12s, color .12s, box-shadow .1s; +} +.ws-seg-cell:first-child { border-left: var(--border); } +.ws-seg-cell:not(.is-on):hover { background: var(--hover); color: var(--ink); } +.ws-seg-cell:active { box-shadow: inset 0 2px 5px rgba(0,0,0,.18); } +.ws-seg-cell.is-on { background: var(--accent); color: var(--on-accent); } + +/* ── Dropdown + mode toggle ──────────────────────────────────────────────── */ +.ws-dd-wrap { position: relative; } +.ws-dd { + display: inline-flex; align-items: center; gap: 7px; line-height: 1; font-family: var(--mono); + font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; padding: 6px 9px; + border: 1px solid var(--hairline); background: transparent; color: var(--sub); cursor: pointer; user-select: none; + transition: background .12s, border-color .12s, color .12s, transform .08s; +} +.ws-dd:hover { border-color: var(--accent); color: var(--ink); } +.ws-dd:active { transform: translateY(1px); } +.ws-dd.is-open { border-color: var(--accent); background: var(--hover); } +.ws-dd-label { color: var(--faint); } +.ws-dd-value { color: var(--ink); font-weight: 700; } +.ws-dd-chev { opacity: 0.7; transition: transform .15s; } +.ws-dd.is-open .ws-dd-chev { transform: rotate(180deg); } +.ws-dd-menu { + position: absolute; top: calc(100% + 6px); right: 0; min-width: 210px; z-index: 60; + background: var(--panel); border: var(--border-w) solid var(--accent); padding: 4px; box-shadow: 6px 6px 0 var(--accent); +} +.ws-dd-head { + font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.1em; color: var(--faint); + text-transform: uppercase; padding: 4px 8px 6px; +} +.ws-opt { + display: flex; align-items: center; gap: 9px; width: 100%; text-align: left; padding: 8px 9px; + border: none; background: transparent; color: var(--ink); cursor: pointer; + font-family: var(--mono); font-size: 12px; line-height: 1; transition: background .1s, color .1s; +} +.ws-opt:hover { background: var(--hover); } +.ws-opt-mark { width: 6px; height: 6px; flex-shrink: 0; background: var(--accent); } +.ws-opt-name { flex: 1; } +.ws-opt-active { font-size: 9.5px; letter-spacing: 0.08em; opacity: 0.8; } +.ws-opt.is-on { background: var(--accent); color: var(--on-accent); } +.ws-opt.is-on .ws-opt-mark { background: var(--on-accent); } +.ws-toggle-glyph { color: var(--sub); } +.ws-toggle-label { color: var(--ink); font-weight: 700; } + +/* ── Selectable card ─────────────────────────────────────────────────────── */ +.ws-card { + background: var(--panel); border: var(--border); cursor: pointer; + transition: transform .16s cubic-bezier(.2,.7,.3,1), box-shadow .16s, border-color .16s; +} +.ws-card:hover { transform: translate(-2px,-2px); box-shadow: var(--hardshadow); border-color: var(--accent); } +.ws-card:active { transform: translate(0,0); box-shadow: none; } +.ws-card.is-selected { border: var(--border-w) solid var(--accent); box-shadow: 4px 4px 0 var(--accent); } + +/* ── Dashed drop zone ────────────────────────────────────────────────────── */ +.ws-drop { + border: 1.5px dashed var(--hairline); display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 4px; color: var(--faint); cursor: pointer; width: 100%; + transition: border-color .15s, background .15s; +} +.ws-drop:hover { border-color: var(--accent); background: var(--hover); } +.ws-drop-label { font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.04em; } + +/* ── Link ────────────────────────────────────────────────────────────────── */ +.ws-link { color: var(--accent); font-weight: 600; cursor: pointer; transition: opacity .12s; text-decoration: none; } +.ws-link:hover { opacity: .55; } + +/* ── Placeholder screen ──────────────────────────────────────────────────── */ +.ws-placeholder { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px; } +.ws-placeholder-card { border: var(--border); background: var(--panel); padding: 30px 36px; max-width: 420px; } +.ws-placeholder-card h2 { font-size: 26px; margin: 8px 0 12px; } +.ws-placeholder-note { color: var(--sub); font-size: 13.5px; line-height: 1.5; margin: 0; } + +/* ── Master / detail layout ──────────────────────────────────────────────── */ +.ws-col-master { + width: var(--master-w, 420px); flex-shrink: 0; background: var(--panel2); + border-right: var(--border); display: flex; flex-direction: column; overflow: hidden; +} +.ws-col-detail { flex: 1; overflow-y: auto; min-width: 0; } +.ws-list { display: flex; flex-direction: column; height: 100%; overflow: hidden; } +.ws-list-head { padding: 16px 24px; border-bottom: var(--border); flex-shrink: 0; } +.ws-list-head-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 12px; } +.ws-list-title { font-size: 17px; } +.ws-list-meta { font-family: var(--mono); font-size: 11px; color: var(--faint); white-space: nowrap; } +.ws-filter .ws-seg-cell { display: flex; align-items: center; justify-content: center; text-decoration: none; } +.ws-list-body { flex: 1; overflow-y: auto; padding: 14px 24px; display: flex; flex-direction: column; gap: 10px; } +.ws-empty, .ws-empty-detail { color: var(--faint); font-size: 13px; padding: 30px 6px; line-height: 1.5; } +.ws-empty-detail { display: flex; align-items: center; justify-content: center; height: 100%; } + +/* ── Signal card ─────────────────────────────────────────────────────────── */ +.ws-signal { display: flex; align-items: center; gap: 12px; padding: 12px 14px; } +.ws-signal-mom { width: 46px; flex-shrink: 0; text-align: center; } +.ws-signal-mom-num { font-family: var(--display); font-weight: 800; font-size: 30px; line-height: 0.9; color: var(--ink); } +.ws-signal-mom-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.04em; color: var(--faint); text-transform: uppercase; margin-top: 2px; } +.ws-signal-main { flex: 1; min-width: 0; } +.ws-signal-title { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.2; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-signal-meta { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 11px; min-width: 0; } +.ws-signal-niche { color: var(--faint); text-transform: uppercase; letter-spacing: 0.03em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-state { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; white-space: nowrap; } +.ws-state.tone-live, .ws-state.tone-accent { color: var(--accent); } +.ws-state.tone-ok { color: var(--ok); } +.ws-state.tone-warn { color: var(--warn); } +.ws-state.tone-sub { color: var(--sub); } + +/* ── Sparkline ───────────────────────────────────────────────────────────── */ +.ws-spark { display: flex; align-items: flex-end; gap: 2px; height: 28px; flex-shrink: 0; } +.ws-spark span { width: 4px; background: var(--accent); opacity: 0.85; } +.ws-spark-lg { height: 60px; gap: 3px; } +.ws-spark-lg span { width: 7px; } +.ws-spark-axis { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 9px; color: var(--faint); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.06em; } + +/* ── Trend detail ────────────────────────────────────────────────────────── */ +.ws-detail-inner { padding: 24px 30px; display: flex; flex-direction: column; gap: 20px; } +.ws-detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; } +.ws-eyebrow { display: flex; align-items: center; gap: 9px; margin-bottom: 10px; } +.ws-eyebrow-niche { font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--faint); } +.ws-detail-title { font-size: 30px; } +.ws-mom-readout { text-align: right; flex-shrink: 0; } +.ws-mom-big { font-family: var(--display); font-weight: 800; font-size: 48px; line-height: 0.9; color: var(--accent); } +.ws-metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); border: var(--border); } +.ws-metric { padding: 14px 16px; border-left: var(--border); } +.ws-metric:first-child { border-left: none; } +.ws-metric-val { font-family: var(--mono); font-size: 15px; color: var(--ink); margin-top: 6px; font-variant-numeric: tabular-nums; } +.ws-section { display: flex; flex-direction: column; gap: 8px; } +.ws-prose { font-size: 13.5px; line-height: 1.55; color: var(--sub); margin: 0; max-width: 640px; } +.ws-prose.ws-faint { color: var(--faint); } +.ws-detail-cols { display: grid; grid-template-columns: 1fr 200px; gap: 16px; align-items: start; } +.ws-section-grow { min-width: 0; } +.ws-momentum-card { border: var(--border); padding: 14px; } +.ws-evidence { margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.5; color: var(--sub); } +.ws-evidence li { margin-bottom: 4px; } +.ws-source-links { display: flex; flex-wrap: wrap; gap: 12px; } +.ws-source-links .ws-link { font-family: var(--mono); font-size: 11px; display: inline-flex; align-items: center; gap: 4px; } +.ws-risks .ws-prose { color: var(--warn); } +.ws-thumb-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; max-width: 420px; } +.ws-detail-actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; padding-top: 4px; } +.ws-spacer { flex: 1; } + +/* ── List footer + disclosure form ───────────────────────────────────────── */ +.ws-list-footer { padding: 14px 24px; border-top: var(--border); flex-shrink: 0; } +.ws-full { width: 100%; justify-content: center; } +.ws-disclosure summary { list-style: none; cursor: pointer; } +.ws-disclosure summary::-webkit-details-marker { display: none; } +.ws-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } +.ws-faint-mono { font-family: var(--mono); font-size: 11px; color: var(--faint); } + +/* ── Report list card ────────────────────────────────────────────────────── */ +.ws-report { padding: 13px 14px; display: flex; flex-direction: column; gap: 8px; } +.ws-report-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } +.ws-report-title { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.25; } +.ws-report-sub { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.03em; text-transform: uppercase; color: var(--faint); } +.ws-report-row { display: flex; align-items: center; gap: 12px; } +.ws-report-date { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-left: auto; } + +/* ── Reading pane ────────────────────────────────────────────────────────── */ +.ws-display-normal { font-family: var(--display); font-weight: 800; text-transform: none; letter-spacing: -0.01em; line-height: 1.0; margin: 0; color: var(--ink); } +.ws-reader { display: flex; flex-direction: column; } +.ws-reader-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; padding: 22px 30px; border-bottom: var(--border); } +.ws-reader-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.ws-reader-title { font-size: 26px; } +.ws-score-big { font-family: var(--display); font-weight: 800; font-size: 42px; line-height: 0.9; color: var(--accent); } +.ws-alert { margin: 16px 30px 0; padding: 10px 14px; border: 1px solid var(--warn); color: var(--warn); font-size: 13px; } +.ws-reader-body { display: grid; grid-template-columns: 188px 1fr; gap: 0; align-items: start; } +.ws-toc { padding: 22px 18px 22px 30px; position: sticky; top: 0; display: flex; flex-direction: column; gap: 4px; } +.ws-toc .ws-mono-label { margin-bottom: 8px; } +.ws-toc-link { display: block; padding: 5px 0 5px 10px; color: var(--sub); text-decoration: none; font-size: 13px; line-height: 1.3; border-left: 2px solid transparent; transition: color .12s, border-color .12s; } +.ws-toc-link:hover { color: var(--ink); } +.ws-toc-link.is-active { color: var(--ink); border-left-color: var(--accent); font-weight: 600; } +.ws-article { padding: 22px 30px; border-left: var(--border); min-width: 0; } +.ws-article-sec { margin-bottom: 24px; } +.ws-section-head { font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +.ws-section-head::before { content: ""; width: 16px; height: 2px; background: var(--accent); display: inline-block; flex-shrink: 0; } +.ws-article-body { font-size: 13.5px; line-height: 1.6; color: var(--sub); max-width: 680px; } +.ws-article-body p { margin: 0 0 12px; } +.ws-article-body ul { margin: 0 0 12px; padding-left: 18px; } +.ws-article-body li { margin-bottom: 4px; } +.ws-article-body strong { color: var(--ink); font-weight: 700; } +.ws-article-body code { font-family: var(--mono); font-size: 12px; background: var(--panel2); padding: 1px 4px; } +.ws-reader .ws-detail-actions { padding: 16px 30px; border-top: var(--border); } + +/* ── Etsy pipeline ───────────────────────────────────────────────────────── */ +.ws-stage { margin-bottom: 18px; } +.ws-stage-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +.ws-state-dot { width: 8px; height: 8px; border-radius: 8px; background: var(--sub); flex-shrink: 0; } +.ws-state-dot.tone-warn { background: var(--warn); } +.ws-state-dot.tone-accent { background: var(--accent); } +.ws-state-dot.tone-ok { background: var(--ok); } +.ws-state-dot.tone-sub { background: var(--sub); } +.ws-stage-name { font-family: var(--mono); font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink); } +.ws-stage-count { font-family: var(--mono); font-size: 11px; color: var(--faint); } +.ws-stage-rule { flex: 1; height: 0; border-top: var(--hair); } +.ws-pcard { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin-bottom: 8px; } +.ws-pcard-thumb { width: 38px; height: 38px; flex-shrink: 0; border: var(--border); background: var(--thumb-bg); position: relative; overflow: hidden; } +.ws-pcard-main { flex: 1; min-width: 0; } +.ws-pcard-title { font-size: 13.5px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-pcard-price { font-family: var(--mono); font-size: 12px; color: var(--sub); } +.ws-pcard-arrow { color: var(--faint); display: flex; } + +/* ── Listing editor ──────────────────────────────────────────────────────── */ +.ws-editor { display: flex; flex-direction: column; } +.ws-editor-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 18px 30px; border-bottom: var(--border); } +.ws-editor-title { font-size: 22px; } +.ws-editor-body { display: grid; grid-template-columns: 210px 1fr; gap: 24px; padding: 22px 30px; } +.ws-editor-images { display: flex; flex-direction: column; gap: 10px; } +.ws-listing-thumb { aspect-ratio: 1; } +.ws-img-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } +.ws-img-cell { aspect-ratio: 1; } +.ws-img-count { text-align: center; } +.ws-editor-fields { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.ws-field-row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.ws-field-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.ws-mono-input { font-family: var(--mono); font-size: 12px; } +.ws-tags-hint { float: right; margin-top: -26px; } +.ws-seg-tags { display: flex; flex-wrap: wrap; gap: 6px; } +.ws-tag { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; padding: 5px 8px; border: var(--border); color: var(--ink); text-transform: lowercase; } +.ws-tag-x { display: inline-flex; border: none; background: transparent; color: var(--warn); cursor: pointer; padding: 0; line-height: 1; } +.ws-tag-add { border-style: dashed; border-color: var(--accent); color: var(--accent); } +.ws-tag-add input { border: none; background: transparent; outline: none; font-family: var(--mono); font-size: 11px; color: var(--ink); width: 70px; } +.ws-chip-list { display: flex; flex-wrap: wrap; gap: 6px; } +.ws-readiness { display: grid; grid-template-columns: repeat(5, 1fr); border: var(--border); } +.ws-ready-cell { padding: 12px; border-left: var(--border); } +.ws-ready-cell:first-child { border-left: none; } +.ws-ready-mark { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; background: var(--ok); color: var(--on-accent); margin-bottom: 8px; } +.ws-ready-cell.is-warn .ws-ready-mark { background: transparent; border: 1px solid var(--warn); color: var(--warn); } +.ws-ready-name { font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--sub); } +.ws-ready-val { font-family: var(--mono); font-size: 12px; color: var(--ink); margin-top: 3px; } +.ws-ready-cell.is-warn .ws-ready-val { color: var(--warn); } +.ws-editor-foot { display: flex; align-items: center; gap: 9px; padding: 16px 30px; border-top: var(--border); } + +/* ── Design Studio: composer ─────────────────────────────────────────────── */ +.ws-col-composer { width: 396px; flex-shrink: 0; background: var(--panel2); border-right: var(--border); display: flex; flex-direction: column; overflow: hidden; } +.ws-composer-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: var(--border); } +.ws-composer-body { padding: 18px 20px; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; flex: 1; } +.ws-composer-prompt { min-height: 110px; } +.ws-cost { display: flex; justify-content: space-between; align-items: center; font-family: var(--mono); } +.ws-ok-note { color: var(--ok); font-size: 11px; } +.ws-drop { aspect-ratio: 3.4; } +.ws-aspect .ws-seg-cell { cursor: pointer; } +.ws-composer-foot { margin-top: auto; padding-top: 14px; border-top: var(--border); display: flex; gap: 10px; } +.ws-grow { flex: 1; justify-content: center; } + +/* ── Design Studio: library ──────────────────────────────────────────────── */ +.ws-col-library { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } +.ws-lib-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; border-bottom: var(--border); } +.ws-lib-head-l { display: flex; align-items: baseline; gap: 12px; } +.ws-lib-head-r { display: flex; align-items: center; gap: 8px; } +.ws-pseudo-input { display: flex; align-items: center; gap: 7px; border: var(--border); padding: 7px 11px; color: var(--faint); font-family: var(--mono); font-size: 11.5px; } +.ws-lib-scroll { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 16px; } +.ws-lib-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.ws-libcard { padding: 14px; display: flex; flex-direction: column; gap: 11px; } +.ws-libcard-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } +.ws-libcard-titles { min-width: 0; } +.ws-libcard-title { font-size: 14.5px; font-weight: 600; color: var(--ink); line-height: 1.2; margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-libcard-eyebrow { font-family: var(--mono); font-size: 10.5px; color: var(--faint); letter-spacing: 0.03em; text-transform: uppercase; } +.ws-libcard-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } +.ws-libcard-thumbs { display: flex; gap: 6px; } +.ws-libcard-thumbs .ws-thumb { aspect-ratio: 1; flex: 1; } +.ws-libscore-row { display: flex; justify-content: space-between; margin-bottom: 5px; } +.ws-lineage { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; color: var(--faint); } +.ws-lineage-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── Design Studio: focus detail ─────────────────────────────────────────── */ +.ws-focus { border: var(--border); background: var(--panel); display: flex; overflow: hidden; } +.ws-focus-rail { width: 220px; flex-shrink: 0; padding: 16px; border-right: var(--border); background: var(--panel2); display: flex; flex-direction: column; gap: 12px; } +.ws-focus-thumb { aspect-ratio: 1; } +.ws-focus-versions .ws-seg-cell { padding: 5px 0; font-weight: 700; cursor: pointer; text-decoration: none; display: flex; align-items: center; justify-content: center; } +.ws-focus-main { flex: 1; padding: 18px; display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.ws-focus-top { display: flex; justify-content: space-between; align-items: flex-start; } +.ws-focus-title { font-family: var(--display); font-weight: 800; font-size: 18px; line-height: 1; } +.ws-accent { color: var(--accent); } +.ws-clamp2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.ws-meta-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.ws-meta-chip { font-family: var(--mono); font-size: 10.5px; color: var(--sub); padding: 3px 7px; background: var(--panel2); border: var(--hair); } +.ws-meta-chip.is-ok { color: var(--ok); border: 1px solid var(--ok); background: transparent; } +.ws-focus-actions { margin-top: auto; display: flex; gap: 9px; flex-wrap: wrap; align-items: center; } +.ws-ml-auto { margin-left: auto; } +.ws-faint { color: var(--faint); } + +/* ── Group B: single-column screens ──────────────────────────────────────── */ +.ws-screen { flex: 1; overflow-y: auto; padding: 24px 30px; min-width: 0; } +.ws-status-row { display: flex; gap: 8px; margin-bottom: 20px; } +.ws-grid3 { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; } +.ws-grid4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; } +.ws-info-card { padding: 16px; display: flex; flex-direction: column; gap: 8px; cursor: default; } +.ws-info-card:hover { transform: none; box-shadow: none; border-color: var(--line); } +.ws-info-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } +.ws-info-title { font-size: 15px; font-weight: 600; color: var(--ink); line-height: 1.25; } +.ws-info-meta { font-family: var(--mono); font-size: 11px; color: var(--faint); text-transform: uppercase; letter-spacing: 0.03em; } +.ws-info-price { font-family: var(--mono); font-size: 14px; color: var(--ink); font-variant-numeric: tabular-nums; } +.ws-design-card { padding: 12px; display: flex; flex-direction: column; gap: 10px; cursor: default; } +.ws-design-card:hover { transform: none; box-shadow: none; border-color: var(--line); } +.ws-design-thumb { aspect-ratio: 1; } +.ws-design-title { font-size: 13.5px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-design-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + +/* ── Table ───────────────────────────────────────────────────────────────── */ +.ws-table { width: 100%; border-collapse: collapse; border: var(--border); } +.ws-table th { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--sub); text-align: left; padding: 11px 14px; background: var(--panel2); border-bottom: var(--border); } +.ws-table td { font-size: 13px; color: var(--ink); padding: 11px 14px; border-bottom: var(--hair); } +.ws-table tr:last-child td { border-bottom: none; } +.ws-num { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.ws-mono-cell { font-family: var(--mono); font-size: 12px; } + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } + .ws-card:hover, .ws-thumb:hover, .ws-primary:hover { transform: none; } +} diff --git a/src/ecommerce_agent/web/static/htmx.min.js b/src/ecommerce_agent/web/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/src/ecommerce_agent/web/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/ecommerce_agent/web/templates/agent_runs.html b/src/ecommerce_agent/web/templates/agent_runs.html new file mode 100644 index 0000000..a023da3 --- /dev/null +++ b/src/ecommerce_agent/web/templates/agent_runs.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +
+ {% include "partials/_runs_table.html" %} +
+{% endblock %} diff --git a/src/ecommerce_agent/web/templates/analytics.html b/src/ecommerce_agent/web/templates/analytics.html new file mode 100644 index 0000000..69131dd --- /dev/null +++ b/src/ecommerce_agent/web/templates/analytics.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +
+ {% if snapshots %} + + + + + + {% for s in snapshots %} + + + + + + + + + + + {% endfor %} + +
ListingCapturedViewsFavoritesOrdersRevenueConv.Fulfillment
{{ s.title }}{{ s.captured_at }}{{ s.views }}{{ s.favorites }}{{ s.orders }}${{ s.revenue }}{{ s.conversion }}{{ s.fulfillment|replace("_", " ") }}
+ {% else %} +
No analytics snapshots yet.
+ {% endif %} +
+{% endblock %} diff --git a/src/ecommerce_agent/web/templates/base.html b/src/ecommerce_agent/web/templates/base.html new file mode 100644 index 0000000..1510b63 --- /dev/null +++ b/src/ecommerce_agent/web/templates/base.html @@ -0,0 +1,34 @@ + + + + + + {{ title }} · Workshop + + + + + + + + + +
+ {% include "partials/_sidebar.html" %} +
+ {% include "partials/_topbar.html" %} +
+ {% block content %}{% endblock %} +
+
+
+ + diff --git a/src/ecommerce_agent/web/templates/design_studio.html b/src/ecommerce_agent/web/templates/design_studio.html new file mode 100644 index 0000000..78028b5 --- /dev/null +++ b/src/ecommerce_agent/web/templates/design_studio.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{% from "partials/_macros.html" import chip, button, field_label %} +{% block content %} +
+
+ New design + {{ chip("v1 → generate", "live") }} +
+
+
{{ field_label("Title") }}
+
{{ field_label("Prompt", "preview available") }}
+ +
+ {{ field_label("Reference images", "0 / 4") }} + +
+ +
+ {{ field_label("Aspect ratio", "1024 × 1280") }} +
+ + + + +
+ + +
+ +
+
{{ field_label("Format") }} + +
+
{{ field_label("Est. cost") }}
—under cap
+
+ + + +
+ + +
+
+
+ +
+
+
+ Design Library + {{ line_count }} lines · {{ version_count }} versions +
+
+
{{ icon("search", 13) }} filter lines
+ {{ chip("all succeeded", "ok") }} +
+
+
+
+ {% for line in lines %} +
+
+
+
{{ line.title }}
+
{{ line.eyebrow }}
+
+
+ {{ chip(line.latest) }} + +
+
+
+ {% for t in line.thumbs %} +
+ {% if t.url %}{% else %}
{% endif %} +
{{ t.label }}
+
+ {% endfor %} +
+
+
+ Workshop score + {{ line.versions }} ver · {{ line.updated }} +
+
n/a
+
+
+ {{ icon("branch", 13) }} + {{ line.lineage }} + LINEAGE → +
+
+ {% else %} +
No designs yet. Use the composer to generate v1.
+ {% endfor %} +
+
+ {% include "partials/_studio_focus.html" %} +
+
+
+{% endblock %} diff --git a/src/ecommerce_agent/web/templates/etsy_posting.html b/src/ecommerce_agent/web/templates/etsy_posting.html new file mode 100644 index 0000000..02167fe --- /dev/null +++ b/src/ecommerce_agent/web/templates/etsy_posting.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% from "partials/_macros.html" import chip %} +{% block content %} +
+
+
+
+ Pipeline + {{ chip("etsy · mock", "warn") }} +
+
+
+ {% for stage in pipeline %} +
+
+ + {{ stage.name }} + {{ stage.cards|length }} + +
+ {% for it in stage.cards %} +
+
+ {% if it.image %}{% else %}
{% endif %} +
+
+
{{ it.title }}
+
${{ it.price }}
+
+ {{ icon("arrow", 16) }} +
+ {% endfor %} +
+ {% endfor %} + {% if not has_items %}
No listings in the pipeline yet.
{% endif %} +
+
+
+
+ {% include "partials/_listing_editor.html" %} +
+{% endblock %} diff --git a/src/ecommerce_agent/web/templates/partials/_listing_editor.html b/src/ecommerce_agent/web/templates/partials/_listing_editor.html new file mode 100644 index 0000000..f48fab9 --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_listing_editor.html @@ -0,0 +1,102 @@ +{% from "partials/_macros.html" import chip, button, field_label %} +{% if not selected %} +
Select a listing to edit.
+{% else %} +
+
+

{{ selected.title }}

+ {{ chip(selected.stage, selected.stage_tone) }} +
+ +
+
+
+ {% if selected.images and selected.images[0].url %}{% else %}
{% endif %} +
LISTING
+
+
+ {% for n in range(4) %} +
+ {% if selected.images|length > n and selected.images[n].url %}{% else %}
{% endif %} +
{{ n + 1 }}
+
+ {% endfor %} +
+
{{ selected.image_count }} of {{ selected.image_cap }} images
+
+ +
+
+
{{ field_label("Price") }}
+
{{ field_label("Quantity") }}
+
{{ field_label("Taxonomy") }}
+
+ +
{{ field_label("Category") }}
{{ selected.category }}
+ +
+ {{ field_label("Title", "≤ 140 chars") }} + +
+ +
+ {{ field_label("Tags", "") }}{{ selected.tags|length }} / {{ selected.tag_cap }} +
+ {% for t in selected.tags %} + {{ t }} + {% endfor %} + {{ icon("plus", 11, 2) }} +
+
+ +
{{ field_label("Description") }}
+ +
+
+ {{ field_label("Materials") }} +
+ {% for m in selected.materials %}{{ m }}{% endfor %} + {% if not selected.materials %}none{% endif %} +
+
+
+ {{ field_label("Auto-renew", "cosmetic") }} +
+ AutomaticManual +
+
+
+ +
+ {{ field_label("Listing readiness") }} +
+ {% for cell in selected.readiness %} +
+ {% if cell.ok %}{{ icon("check", 12, 2.4) }}{% else %}{{ icon("plus", 12, 2.4) }}{% endif %} +
{{ cell.name }}
+
{{ cell.value }}
+
+ {% endfor %} +
+
+ + {% if selected.errors %} +
{% for e in selected.errors %}
{{ e }}
{% endfor %}
+ {% endif %} +
+
+ +
+ {{ button("Save", "check", "ghost", 'type="submit"') }} +
+ {% if saved %}changes saved · {{ selected.stage|lower }}{% endif %} + + +
+
+{% endif %} diff --git a/src/ecommerce_agent/web/templates/partials/_macros.html b/src/ecommerce_agent/web/templates/partials/_macros.html new file mode 100644 index 0000000..da15196 --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_macros.html @@ -0,0 +1,74 @@ +{# Reusable B1 primitives. Colors come from CSS tokens; geometry is fixed. #} + +{% macro chip(text, tone="") -%} + + {%- if tone and tone != "plain" %}{% endif -%} + {{ text }} + +{%- endmacro %} + +{% macro button(label, icon_name="", variant="ghost", attrs="") -%} + +{%- endmacro %} + +{% macro field_label(text, hint="") -%} +
+ {{ text }} + {% if hint %}{{ hint }}{% endif %} +
+{%- endmacro %} + +{% macro score_bar(score) -%} +
+
+ {{ score }} +
+{%- endmacro %} + +{% macro thumb(label="", sub="", ratio=1, big=false) -%} +
+
+ {% if sub %}
{{ sub }}
{% endif %} + {% if label %}
{{ label }}
{% endif %} +
+{%- endmacro %} + +{% macro checkbox(checked=false, label="") -%} + +{%- endmacro %} + +{% macro model_dropdown(label, value, options, key) -%} +
+ + +
+{%- endmacro %} + +{% macro mode_toggle() -%} + +{%- endmacro %} diff --git a/src/ecommerce_agent/web/templates/partials/_report_list.html b/src/ecommerce_agent/web/templates/partials/_report_list.html new file mode 100644 index 0000000..e44b3c0 --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_report_list.html @@ -0,0 +1,38 @@ +{% from "partials/_macros.html" import chip, score_bar %} +
+
+
+ Reports + {{ reports|length }} total +
+
+
+ {% for r in reports %} +
+
+
{{ r.title }}
+ {{ chip(r.status_label, r.status_tone) }} +
+
{{ r.niche }} · {{ r.sections }} sections
+
+ {% if r.score is not none %}{{ score_bar(r.score) }}{% else %}no score{% endif %} + {{ r.date }} +
+
+ {% else %} +
No research reports yet.
+ {% endfor %} +
+ +
diff --git a/src/ecommerce_agent/web/templates/partials/_report_reader.html b/src/ecommerce_agent/web/templates/partials/_report_reader.html new file mode 100644 index 0000000..ce9a0ae --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_report_reader.html @@ -0,0 +1,56 @@ +{% from "partials/_macros.html" import chip, button %} +{% if not selected %} +
Select a report to read.
+{% else %} +
+
+
+
+ {{ chip(selected.status_label, selected.status_tone) }} + {{ selected.words }} words · {{ selected.date }} +
+

{{ selected.title }}

+
+ {% if selected.score is not none %} +
+
{{ selected.score }}
+
opportunity
+
+ {% endif %} +
+ + {% if selected.error %}
{{ selected.error }}
{% endif %} + +
+ +
+ {% for s in selected.report_sections %} +
+
{{ s.title }}
+
{{ s.html|safe }}
+
+ {% else %} +

No report content yet{% if selected.status_label != "FINAL" %} — generation {{ selected.status_label|lower }}{% endif %}.

+ {% endfor %} +
+
+ +
+ {{ button("Generate designs from report", "image", "primary", "onclick=\"location.href='/design-studio'\"") }} + {% if selected.can_download %} + {{ icon("down", 14) }} Export + {% endif %} +
+ {% if selected.can_retry %} +
+ +
+ {% endif %} +
+
+{% endif %} diff --git a/src/ecommerce_agent/web/templates/partials/_runs_table.html b/src/ecommerce_agent/web/templates/partials/_runs_table.html new file mode 100644 index 0000000..8bf9c3a --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_runs_table.html @@ -0,0 +1,18 @@ +{% from "partials/_macros.html" import chip %} +{% if runs %} + + + + {% for r in runs %} + + + + + + + {% endfor %} + +
SubjectStatusMessageUpdated
{{ r.subject_type }}{{ chip(r.last_event, r.tone) }}{{ r.last_message }}{{ r.updated }}
+{% else %} +
No active jobs or recent events.
+{% endif %} diff --git a/src/ecommerce_agent/web/templates/partials/_sidebar.html b/src/ecommerce_agent/web/templates/partials/_sidebar.html new file mode 100644 index 0000000..0ec4572 --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_sidebar.html @@ -0,0 +1,34 @@ + diff --git a/src/ecommerce_agent/web/templates/partials/_signals_meta.html b/src/ecommerce_agent/web/templates/partials/_signals_meta.html new file mode 100644 index 0000000..cd5985c --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_signals_meta.html @@ -0,0 +1,3 @@ + + {%- if queued -%}discovery queued · just now{%- else -%}{{ trends|length }} signals · agent{%- endif -%} + diff --git a/src/ecommerce_agent/web/templates/partials/_studio_focus.html b/src/ecommerce_agent/web/templates/partials/_studio_focus.html new file mode 100644 index 0000000..e632bef --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_studio_focus.html @@ -0,0 +1,45 @@ +{% from "partials/_macros.html" import button %} +{% if not focus %} +
Select a design line to inspect its versions.
+{% else %} +
+
+
+ {% if focus.thumb %}{% else %}
{% endif %} +
1024²
+
{{ focus.version }}
+
+
+ {% for v in focus.versions %} + {{ v.label }} + {% endfor %} +
+
+
+
+
+
Selected version
+
{{ focus.line_title }} · {{ focus.version }}
+
+
+
—
+
/ 100 score
+
+
+

{{ focus.prompt }}

+
+ {% for m in focus.meta %}{{ m }}{% endfor %} + {% if focus.bg_friendly %}bg-removal friendly{% endif %} +
+ +
+
+{% endif %} diff --git a/src/ecommerce_agent/web/templates/partials/_topbar.html b/src/ecommerce_agent/web/templates/partials/_topbar.html new file mode 100644 index 0000000..80f5bd2 --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_topbar.html @@ -0,0 +1,18 @@ +{% from "partials/_macros.html" import chip, model_dropdown, mode_toggle, button %} +
+
+

{{ title }}

+ {% if subtitle %}
{{ subtitle }}
{% endif %} +
+
+
+ {{ chip(integrations, "live") }} + {{ model_dropdown("TXT", text_model, text_models, "text") }} + {{ model_dropdown("IMG", image_model, image_models, "image") }} + {{ mode_toggle() }} + {{ chip("cap $" ~ cap_usd) }} +
+ {% if primary %} + {{ button(primary.label, primary.get("icon", "plus"), "primary", primary.get("attrs", "")) }} + {% endif %} +
diff --git a/src/ecommerce_agent/web/templates/partials/_trend_detail.html b/src/ecommerce_agent/web/templates/partials/_trend_detail.html new file mode 100644 index 0000000..70bed17 --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_trend_detail.html @@ -0,0 +1,79 @@ +{% from "partials/_macros.html" import chip, button, thumb %} +{% if not selected %} +
Select a signal to inspect.
+{% else %} +
+
+
+
+ {{ chip(selected.state_label, "live" if selected.is_rising else "warn") }} + {{ selected.niche }} +
+

{{ selected.title }}

+
+
+
{{ selected.momentum if selected.momentum is not none else "—" }}
+
momentum
+
+
+ +
+
Search volume
{{ selected.volume }}
+
First seen
{{ selected.first_seen }}
+
Sources
{{ selected.sources|length }}
+
+ + {% if selected.blurb %} +
+
Why it matters
+

{{ selected.blurb }}

+
+ {% endif %} + +
+
+
Evidence
+ {% if selected.evidence %} +
    + {% for e in selected.evidence %}
  • {{ e }}
  • {% endfor %} +
+ {% else %}

No evidence captured.

{% endif %} +
+
+
Momentum · 7 wk
+
{% for v in selected.spark %}{% endfor %}
+
7w agonow
+
+
+ + {% if selected.sources %} +
+
Verified sources
+ +
+ {% endif %} + + {% if selected.risks %} +
+
Risks & cautions
+ {% for r in selected.risks %}

{{ r }}

{% endfor %} +
+ {% endif %} + +
+
Suggested design directions
+
+ {{ thumb("LINEWORK", "dir·1") }}{{ thumb("DUOTONE", "dir·2") }}{{ thumb("SPECIMEN", "dir·3") }} +
+
+ +
+ {{ button("Spin up research report", "doc", "primary", 'onclick="location.href=\'/research-reports?trend_id=' ~ selected.id ~ '\'"') }} + {{ button("Send to Design Studio", "image", "ghost", 'onclick="location.href=\'/design-studio\'"') }} +
+ {{ button("Dismiss", "x", "destructive", 'disabled title="Dismiss is not wired yet"') }} +
+
+{% endif %} diff --git a/src/ecommerce_agent/web/templates/partials/_trend_list.html b/src/ecommerce_agent/web/templates/partials/_trend_list.html new file mode 100644 index 0000000..15da4b4 --- /dev/null +++ b/src/ecommerce_agent/web/templates/partials/_trend_list.html @@ -0,0 +1,38 @@ +
+
+
+ Signals + {% include "partials/_signals_meta.html" %} +
+
+ {% for key, label, _status in filters %} + {{ label }} + {% endfor %} +
+
+
+ {% for t in trends %} +
+
+
{{ t.momentum if t.momentum is not none else "—" }}
+
mom.
+
+
+
{{ t.title }}
+
+ {{ t.state_label }} + {{ t.niche }} +
+
+
{% for v in t.spark %}{% endfor %}
+
+ {% else %} +
No signals yet. Use Refresh trends to discover.
+ {% endfor %} +
+
diff --git a/src/ecommerce_agent/web/templates/printful_workshop.html b/src/ecommerce_agent/web/templates/printful_workshop.html new file mode 100644 index 0000000..53f4ae5 --- /dev/null +++ b/src/ecommerce_agent/web/templates/printful_workshop.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% from "partials/_macros.html" import chip %} +{% block content %} +
+
+ {{ chip("printful · " ~ printful_mode, "warn" if printful_mode == "mock" else "ok") }} + {{ chip(designs|length ~ " designs") }} +
+ {% if designs %} +
+ {% for d in designs %} +
+
+ {% if d.asset %}{% else %}
{% endif %} +
+
{{ d.title }}
+
+ {{ d.dims }} + {% if d.print_ready %}{{ chip("print-ready", "ok") }}{% else %}{{ chip("display", "sub") }}{% endif %} +
+
+ {% endfor %} +
+ {% else %} +
No merch designs promoted yet. Promote a generation from Design Studio.
+ {% endif %} +
+{% endblock %} diff --git a/src/ecommerce_agent/web/templates/product_drafts.html b/src/ecommerce_agent/web/templates/product_drafts.html new file mode 100644 index 0000000..6601bbd --- /dev/null +++ b/src/ecommerce_agent/web/templates/product_drafts.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% from "partials/_macros.html" import chip %} +{% block content %} +
+ {% if drafts %} +
+ {% for d in drafts %} +
+
+
{{ d.title }}
+ {{ chip(d.status|replace("_", " "), d.tone) }} +
+
{{ d.product_type }} · {{ d.audience }}
+
{{ d.currency }} {{ d.price }}
+
+ {% endfor %} +
+ {% else %} +
No product drafts yet. Approve trends and designs to populate this list.
+ {% endif %} +
+{% endblock %} diff --git a/src/ecommerce_agent/web/templates/research_reports.html b/src/ecommerce_agent/web/templates/research_reports.html new file mode 100644 index 0000000..06f5b35 --- /dev/null +++ b/src/ecommerce_agent/web/templates/research_reports.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content %} +
+ {% include "partials/_report_list.html" %} +
+
+ {% include "partials/_report_reader.html" %} +
+{% endblock %} diff --git a/src/ecommerce_agent/web/templates/trend_inbox.html b/src/ecommerce_agent/web/templates/trend_inbox.html new file mode 100644 index 0000000..d2a5d92 --- /dev/null +++ b/src/ecommerce_agent/web/templates/trend_inbox.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content %} +
+ {% include "partials/_trend_list.html" %} +
+
+ {% include "partials/_trend_detail.html" %} +
+{% endblock %} diff --git a/src/ecommerce_agent/web/trend_inbox.py b/src/ecommerce_agent/web/trend_inbox.py new file mode 100644 index 0000000..a9573b7 --- /dev/null +++ b/src/ecommerce_agent/web/trend_inbox.py @@ -0,0 +1,151 @@ +"""Trend Inbox screen — master/detail over real trends.""" + +import uuid +from typing import Annotated, Any + +from fastapi import Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ecommerce_agent.db.models import Trend, TrendDiscoveryRun +from ecommerce_agent.db.session import get_session +from ecommerce_agent.domain.enums import ResearchRunStatus +from ecommerce_agent.jobs.queue import enqueue +from ecommerce_agent.web.routes import router, shell_context, templates + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +SUBTITLE = "Signals surfaced by the research agent — triage, then route to a report or a design." + +# Real trend lifecycle status → display state chip (no momentum-state data exists). +_STATE = { + "discovered": ("NEW", "live"), + "pending_review": ("IN REVIEW", "warn"), + "approved": ("APPROVED", "ok"), + "rejected": ("REJECTED", "sub"), +} +# Filter tabs map to real statuses (design's Rising/Peaking have no backing). +FILTERS = [ + ("all", "All signals", None), + ("new", "New", "discovered"), + ("review", "In review", "pending_review"), + ("approved", "Approved", "approved"), +] + + +def _spark(seed: str) -> list[int]: + """Deterministic placeholder sparkline (no time-series data yet).""" + h = abs(hash(seed)) + return [30 + (h >> (i * 3)) % 70 for i in range(7)] + + +def _view(t: Trend) -> dict[str, Any]: + score = float(t.apparel_score) if t.apparel_score is not None else None + momentum = round(score / 5 * 100) if score is not None else None + label, tone = _STATE.get(t.status.value, (t.status.value.upper(), "sub")) + return { + "id": str(t.id), + "title": t.title, + "niche": t.niche, + "momentum": momentum, + "state_label": label, + "state_tone": tone, + "blurb": t.blurb or "", + "evidence": t.evidence or [], + "risks": t.risk_warnings or [], + "sources": t.verified_sources or [], + "confidence": round(float(t.confidence) * 100) if t.confidence is not None else None, + "first_seen": t.created_at.strftime("%b %d") if t.created_at else "—", + "volume": "—", # placeholder: no search-volume data + "spark": _spark(str(t.id)), + "is_rising": t.status.value == "discovered", + } + + +async def _load(session: AsyncSession, status: str | None) -> list[dict[str, Any]]: + stmt = select(Trend).order_by(Trend.created_at.desc(), Trend.position.asc()) + if status: + stmt = stmt.where(Trend.status == status) + rows = (await session.scalars(stmt.limit(200))).all() + return [_view(t) for t in rows] + + +async def _new_count(session: AsyncSession) -> int: + rows = (await session.scalars(select(Trend).where(Trend.status == "discovered"))).all() + return len(rows) + + +@router.get("/trend-inbox", response_class=HTMLResponse) +async def trend_inbox( + request: Request, session: SessionDep, filter: str = "all", sel: str | None = None +) -> HTMLResponse: + status = dict((f[0], f[2]) for f in FILTERS).get(filter) + trends = await _load(session, status) + selected = next((t for t in trends if t["id"] == sel), trends[0] if trends else None) + ctx = shell_context( + request, + active="Trend Inbox", + title="Trend Inbox", + subtitle=SUBTITLE, + primary={ + "label": "Refresh trends", + "icon": "bolt", + "attrs": 'hx-post="/trend-inbox/refresh" hx-target="#signals-meta" hx-swap="outerHTML"', + }, + badges={"Trend Inbox": str(await _new_count(session))} if trends else {}, + trends=trends, + selected=selected, + filters=FILTERS, + active_filter=filter, + ) + return templates.TemplateResponse(request=request, name="trend_inbox.html", context=ctx) + + +@router.get("/trend-inbox/list", response_class=HTMLResponse) +async def trend_list(request: Request, session: SessionDep, filter: str = "all") -> HTMLResponse: + status = dict((f[0], f[2]) for f in FILTERS).get(filter) + trends = await _load(session, status) + return templates.TemplateResponse( + request=request, + name="partials/_trend_list.html", + context={ + "request": request, + "trends": trends, + "selected": trends[0] if trends else None, + "filters": FILTERS, + "active_filter": filter, + }, + ) + + +@router.get("/trend-inbox/detail/{trend_id}", response_class=HTMLResponse) +async def trend_detail(request: Request, session: SessionDep, trend_id: str) -> HTMLResponse: + try: + pk = uuid.UUID(trend_id) + except ValueError: + pk = None + trend = await session.get(Trend, pk) if pk else None + selected = _view(trend) if trend else None + return templates.TemplateResponse( + request=request, + name="partials/_trend_detail.html", + context={"request": request, "selected": selected}, + ) + + +@router.post("/trend-inbox/refresh", response_class=HTMLResponse) +async def refresh_trends(request: Request, session: SessionDep) -> HTMLResponse: + limit = request.app.state.settings.research_trend_limit + run = TrendDiscoveryRun( + status=ResearchRunStatus.QUEUED, input_snapshot={"requested_limit": limit} + ) + session.add(run) + await session.flush() + await enqueue(session, "discover_trends", run.id) + await session.commit() + return templates.TemplateResponse( + request=request, + name="partials/_signals_meta.html", + context={"request": request, "queued": True}, + ) diff --git a/tests/smoke/test_api_and_dashboard.py b/tests/smoke/test_api_and_dashboard.py index 69ec095..538a390 100644 --- a/tests/smoke/test_api_and_dashboard.py +++ b/tests/smoke/test_api_and_dashboard.py @@ -1,4 +1,3 @@ -import importlib import io import uuid from dataclasses import replace @@ -12,13 +11,13 @@ from ecommerce_agent.api.main import create_app from ecommerce_agent.config import Settings -from ecommerce_agent.dashboard.design_lineage import lineage_paths from ecommerce_agent.db.models import Job, ResearchRun from ecommerce_agent.db.session import get_session from ecommerce_agent.domain.enums import JobStatus, ResearchRunStatus from ecommerce_agent.jobs.worker import process_one from ecommerce_agent.services.assets import SignedLocalAssetStore from ecommerce_agent.services.research import MockResearchProvider +from ecommerce_agent.web.design_lineage import lineage_paths def make_settings(tmp_path) -> Settings: @@ -943,8 +942,5 @@ async def _get_session(): assert response.headers["cache-control"] == "private, no-store" -def test_dashboard_modules_import() -> None: - importlib.import_module("ecommerce_agent.dashboard.app") - importlib.import_module("ecommerce_agent.dashboard.client") - importlib.import_module("ecommerce_agent.dashboard.pages.4_Design_Studio") - importlib.import_module("ecommerce_agent.dashboard.pages.6_Printful_Workshop") +def test_web_modules_import() -> None: + import ecommerce_agent.web # noqa: F401 (registers all screen routes) diff --git a/tests/unit/test_dashboard_client.py b/tests/unit/test_dashboard_client.py deleted file mode 100644 index b2a198b..0000000 --- a/tests/unit/test_dashboard_client.py +++ /dev/null @@ -1,28 +0,0 @@ -import httpx -import pytest - -from ecommerce_agent.dashboard.client import ApiError, request - - -def test_api_client_handles_non_json_server_errors(monkeypatch) -> None: - response = httpx.Response( - 500, - text="Internal Server Error", - request=httpx.Request("POST", "http://api.test/api/printful/drafts"), - ) - monkeypatch.setattr(httpx, "request", lambda *args, **kwargs: response) - - with pytest.raises(ApiError, match="status 500"): - request("POST", "/printful/drafts", json={}) - - -def test_api_client_preserves_json_error_details(monkeypatch) -> None: - response = httpx.Response( - 409, - json={"detail": "Draft validation failed."}, - request=httpx.Request("POST", "http://api.test/api/printful/drafts"), - ) - monkeypatch.setattr(httpx, "request", lambda *args, **kwargs: response) - - with pytest.raises(ApiError, match="Draft validation failed"): - request("POST", "/printful/drafts", json={}) diff --git a/tests/unit/test_design_lineage.py b/tests/unit/test_design_lineage.py index 5d2c732..fafb723 100644 --- a/tests/unit/test_design_lineage.py +++ b/tests/unit/test_design_lineage.py @@ -1,4 +1,4 @@ -from ecommerce_agent.dashboard.design_lineage import branch_label, lineage_paths, version_label +from ecommerce_agent.web.design_lineage import branch_label, lineage_paths, version_label def generation(id_: str, parent: str | None = None, created: int = 0) -> dict[str, object]: diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..685319e --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,43 @@ +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from ecommerce_agent.api.main import create_app +from ecommerce_agent.config import Settings +from ecommerce_agent.db.base import Base +from ecommerce_agent.db.session import get_session + + +@dataclass +class WebHarness: + client: AsyncClient + sessions: async_sessionmaker + + +@pytest.fixture +async def web(tmp_path) -> AsyncIterator[WebHarness]: + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + sessions = async_sessionmaker(engine, expire_on_commit=False) + + app = create_app( + Settings(_env_file=None, app_env="test", printful_mode="mock", asset_root=tmp_path) + ) + + async def _session() -> AsyncIterator: + async with sessions() as session: + yield session + + app.dependency_overrides[get_session] = _session + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client: + yield WebHarness(client=client, sessions=sessions) + await engine.dispose() diff --git a/tests/web/test_design_studio.py b/tests/web/test_design_studio.py new file mode 100644 index 0000000..1d4d0f2 --- /dev/null +++ b/tests/web/test_design_studio.py @@ -0,0 +1,70 @@ +from decimal import Decimal + +from ecommerce_agent.db.models import ImageGeneration +from ecommerce_agent.domain.enums import ImageGenerationStatus + + +async def _seed_line(web) -> str: + async with web.sessions() as session: + root = ImageGeneration( + title="Cottagecore Mushroom Forager", + prompt="A fox foraging amanita mushrooms among ferns.", + status=ImageGenerationStatus.SUCCEEDED, + asset_uri="gen/root.png", + width=1024, + height=1024, + provider="mock", + model="gpt-image-2", + quality="medium", + output_format="png", + estimated_cost_usd=Decimal("0.04"), + ) + session.add(root) + await session.flush() + child = ImageGeneration( + parent_generation_id=root.id, + title="Cottagecore Mushroom Forager", + prompt="Revised palette, sage and ochre.", + status=ImageGenerationStatus.SUCCEEDED, + asset_uri="gen/child.png", + width=1024, + height=1024, + provider="mock", + model="gpt-image-2", + quality="medium", + output_format="png", + estimated_cost_usd=Decimal("0.04"), + ) + session.add(child) + await session.commit() + return str(child.id) + + +async def test_library_and_focus_render(web) -> None: + leaf_id = await _seed_line(web) + resp = await web.client.get("/design-studio") + assert resp.status_code == 200 + body = resp.text + assert "Design Library" in body + assert "Cottagecore Mushroom Forager" in body + assert "1 lines · 2 versions" in body + assert "Workshop score" in body and ">n/a<" in body # placeholder, not a fake number + assert "Revised palette" in body # focus shows latest version prompt + + focus = await web.client.get(f"/design-studio/focus/{leaf_id}?v=0") + assert focus.status_code == 200 + assert "A fox foraging" in focus.text # v0 prompt + + +async def test_delete_line(web) -> None: + leaf_id = await _seed_line(web) + resp = await web.client.post(f"/design-studio/lines/{leaf_id}/delete", follow_redirects=False) + assert resp.status_code == 303 + page = await web.client.get("/design-studio") + assert "No designs yet" in page.text + + +async def test_empty_state(web) -> None: + resp = await web.client.get("/design-studio") + assert resp.status_code == 200 + assert "No designs yet" in resp.text diff --git a/tests/web/test_etsy_posting.py b/tests/web/test_etsy_posting.py new file mode 100644 index 0000000..ebbb889 --- /dev/null +++ b/tests/web/test_etsy_posting.py @@ -0,0 +1,75 @@ +from decimal import Decimal + +from ecommerce_agent.db.models import ListingImage, PostingItem, PostingSession +from ecommerce_agent.domain.enums import ListingImageSource, PostingItemStatus + + +async def _seed(web) -> str: + async with web.sessions() as session: + posting = PostingSession(name="Fall drop") + session.add(posting) + await session.flush() + item = PostingItem( + session_id=posting.id, + printful_template_id=101, + template_snapshot={"name": "Amanita Forager Tee"}, + status=PostingItemStatus.SELECTED, + title="Amanita Forager Heavyweight Tee", + description="Hand-drawn amanita tee.", + tags=["amanita tee", "cottagecore"], + materials=["Ringspun cotton"], + retail_price=Decimal("28.00"), + quantity=25, + taxonomy_id=1059, + ) + session.add(item) + await session.flush() + session.add( + ListingImage( + posting_item_id=item.id, + source=ListingImageSource.PRINTFUL, + source_url="https://example.com/mockup.png", + position=0, + approved=True, + ) + ) + await session.commit() + return str(item.id) + + +async def test_pipeline_and_editor_render(web) -> None: + await _seed(web) + resp = await web.client.get("/etsy-posting") + assert resp.status_code == 200 + body = resp.text + assert "Pipeline" in body and "Draft" in body + assert "Amanita Forager Heavyweight Tee" in body + assert "Listing readiness" in body + assert "Publish to Etsy" in body + assert "data-tag-count>2<" in body and "/ 13" in body # tag count + + +async def test_save_updates_item(web) -> None: + item_id = await _seed(web) + resp = await web.client.post( + f"/etsy-posting/items/{item_id}/save", + data={ + "title": "Renamed Tee", + "description": "Updated copy.", + "retail_price": "30.00", + "quantity": "10", + "taxonomy_id": "1059", + "tags": ["one", "two", "three"], + "materials": ["Cotton"], + }, + ) + assert resp.status_code == 200 + assert "Renamed Tee" in resp.text + assert "changes saved" in resp.text + assert "data-tag-count>3<" in resp.text + + +async def test_empty_pipeline(web) -> None: + resp = await web.client.get("/etsy-posting") + assert resp.status_code == 200 + assert "No listings in the pipeline yet." in resp.text diff --git a/tests/web/test_group_b.py b/tests/web/test_group_b.py new file mode 100644 index 0000000..0a3885d --- /dev/null +++ b/tests/web/test_group_b.py @@ -0,0 +1,78 @@ +import uuid +from datetime import UTC, datetime +from decimal import Decimal + +from ecommerce_agent.db.models import AnalyticsSnapshot, MerchDesign, Product +from ecommerce_agent.domain.enums import ProductStatus + + +async def test_product_drafts(web) -> None: + async with web.sessions() as session: + session.add( + Product( + title="Amanita Tee", + audience="cottagecore fans", + product_type="t-shirt", + status=ProductStatus.READY_FOR_MANUAL_PUBLISH, + listing_copy={"title": "Amanita Forager Tee"}, + retail_price=Decimal("28.00"), + currency="USD", + ) + ) + await session.commit() + resp = await web.client.get("/product-drafts") + assert resp.status_code == 200 + assert "Amanita Forager Tee" in resp.text + assert "USD 28.00" in resp.text + + +async def test_analytics(web) -> None: + async with web.sessions() as session: + product = Product(title="Tee", audience="a", product_type="t-shirt") + session.add(product) + await session.flush() + session.add( + AnalyticsSnapshot( + product_id=product.id, + captured_at=datetime.now(UTC), + views=120, + favorites=8, + orders=3, + revenue=Decimal("84.00"), + conversion_rate=Decimal("0.025"), + ) + ) + await session.commit() + resp = await web.client.get("/analytics") + assert resp.status_code == 200 + assert "120" in resp.text and "$84.00" in resp.text and "2.5%" in resp.text + + +async def test_printful_workshop(web) -> None: + async with web.sessions() as session: + session.add( + MerchDesign( + source_type="generation", + source_id=str(uuid.uuid4()), + title="Mushroom Forager", + asset_uri="gen/x.png", + print_asset_uri="gen/x-print.png", + checksum="abc", + width=1024, + height=1024, + ) + ) + await session.commit() + resp = await web.client.get("/printful-workshop") + assert resp.status_code == 200 + assert "Mushroom Forager" in resp.text + assert "print-ready" in resp.text + assert "printful · mock" in resp.text + + +async def test_agent_runs_empty(web) -> None: + resp = await web.client.get("/agent-runs") + assert resp.status_code == 200 + assert "No active jobs or recent events." in resp.text + table = await web.client.get("/agent-runs/table") + assert table.status_code == 200 diff --git a/tests/web/test_research_reports.py b/tests/web/test_research_reports.py new file mode 100644 index 0000000..f31ee65 --- /dev/null +++ b/tests/web/test_research_reports.py @@ -0,0 +1,67 @@ +import uuid +from decimal import Decimal + +from ecommerce_agent.db.models import ResearchRun, Trend +from ecommerce_agent.domain.enums import ResearchRunStatus, TrendStatus + +REPORT_MD = """# Market summary + +Demand for **mushroom** motifs is rising sharply. + +# Competitive landscape + +- 37 established shops +- mostly stock photography +""" + + +async def _seed(web) -> None: + async with web.sessions() as session: + trend = Trend( + research_batch_id=uuid.uuid4(), + title="Cottagecore mushroom apparel", + niche="cottagecore", + source="agent", + apparel_score=Decimal("4.3"), + status=TrendStatus.APPROVED, + ) + session.add(trend) + await session.flush() + session.add( + ResearchRun( + trend_id=trend.id, + topic_title="Cottagecore mushroom apparel", + status=ResearchRunStatus.COMPLETED, + markdown_report=REPORT_MD, + ) + ) + await session.commit() + + +async def test_reading_pane_renders_markdown_and_toc(web) -> None: + await _seed(web) + resp = await web.client.get("/research-reports") + assert resp.status_code == 200 + body = resp.text + assert "Cottagecore mushroom apparel" in body + assert "Market summary" in body and "Competitive landscape" in body # TOC + section heads + assert "mushroom" in body # inline markdown rendered + assert "
  • 37 established shops
  • " in body # list rendered + assert "86" in body # 4.3/5 → 86 opportunity score (derived from trend) + + +async def test_new_report_form_creates_run(web) -> None: + resp = await web.client.post( + "/research-reports/new", + data={"topic_title": "Retro parks", "additional_context": ""}, + follow_redirects=False, + ) + assert resp.status_code == 303 + listing = await web.client.get("/research-reports") + assert "Retro parks" in listing.text + + +async def test_empty_state(web) -> None: + resp = await web.client.get("/research-reports") + assert resp.status_code == 200 + assert "No research reports yet." in resp.text diff --git a/tests/web/test_trend_inbox.py b/tests/web/test_trend_inbox.py new file mode 100644 index 0000000..636fe43 --- /dev/null +++ b/tests/web/test_trend_inbox.py @@ -0,0 +1,85 @@ +import uuid +from collections.abc import AsyncIterator +from decimal import Decimal + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from ecommerce_agent.api.main import create_app +from ecommerce_agent.config import Settings +from ecommerce_agent.db.base import Base +from ecommerce_agent.db.models import Trend +from ecommerce_agent.db.session import get_session +from ecommerce_agent.domain.enums import TrendStatus + + +@pytest.fixture +async def client(tmp_path) -> AsyncIterator[AsyncClient]: + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + sessionmaker = async_sessionmaker(engine, expire_on_commit=False) + + async with sessionmaker() as session: + session.add( + Trend( + research_batch_id=uuid.uuid4(), + title="Mushroom foraging core", + niche="cottagecore · apparel", + source="agent", + blurb="Forager aesthetic is spiking.", + apparel_score=Decimal("4.6"), + confidence=Decimal("0.78"), + status=TrendStatus.DISCOVERED, + evidence=["Etsy searches up 34%"], + verified_sources=[{"title": "Etsy Trends", "url": "https://example.com"}], + risk_warnings=["Avoid edibility claims."], + ) + ) + await session.commit() + + app = create_app( + Settings(_env_file=None, app_env="test", printful_mode="mock", asset_root=tmp_path) + ) + + async def _session() -> AsyncIterator: + async with sessionmaker() as session: + yield session + + app.dependency_overrides[get_session] = _session + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + yield c + await engine.dispose() + + +async def test_trend_inbox_renders_shell_and_signal(client: AsyncClient) -> None: + resp = await client.get("/trend-inbox") + assert resp.status_code == 200 + body = resp.text + assert 'data-theme="light"' in body + assert "ws-sidebar" in body and "ws-topbar" in body + assert "Mushroom foraging core" in body + assert "92" in body # 4.6/5 → 92 momentum + assert "Avoid edibility claims." in body + + +async def test_trend_filter_and_detail_fragments(client: AsyncClient) -> None: + listing = await client.get("/trend-inbox/list?filter=new") + assert listing.status_code == 200 + assert "Mushroom foraging core" in listing.text + + empty = await client.get("/trend-inbox/list?filter=approved") + assert empty.status_code == 200 + assert "No signals yet" in empty.text + + +async def test_home_redirects_to_trend_inbox(client: AsyncClient) -> None: + resp = await client.get("/", follow_redirects=False) + assert resp.status_code in (302, 307) + assert resp.headers["location"] == "/trend-inbox" diff --git a/uv.lock b/uv.lock index e45905d..81e0400 100644 --- a/uv.lock +++ b/uv.lock @@ -30,22 +30,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, ] -[[package]] -name = "altair" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "narwhals" }, - { name = "packaging" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/97/9a0dc61efd4f2dee29cb6d8edbbacdb789ce48cbffd98efa2b3ab145b297/altair-6.2.1.tar.gz", hash = "sha256:ca0298fa20b1a4fae22eff8847b95f74912bd90544013ad36af192119883ea64", size = 766468, upload-time = "2026-06-05T16:23:36.57Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/78/b556548d92b9e29ae68a86e7b416888820900809e189e39caf308c7d44a3/altair-6.2.1-py3-none-any.whl", hash = "sha256:bf2fee3733c3a31a588e45b857a2495a88d506970deb87f74e1613f0247446b1", size = 797498, upload-time = "2026-06-05T16:23:34.799Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -101,24 +85,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, ] -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - [[package]] name = "boto3" version = "1.43.29" @@ -147,15 +113,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/b1/aa410c22355f8f6c4ac2433db6a1c557dd959acf2953ccae4bfc37488119/botocore-1.43.29-py3-none-any.whl", hash = "sha256:5d62f2a03ed279a50207ca2824e009313df15f082b6bb591a095a4f04c7faef3", size = 15194135, upload-time = "2026-06-12T19:32:09.463Z" }, ] -[[package]] -name = "cachetools" -version = "7.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, -] - [[package]] name = "certifi" version = "2026.5.20" @@ -200,47 +157,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - [[package]] name = "click" version = "8.4.1" @@ -362,11 +278,11 @@ dependencies = [ { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, + { name = "jinja2" }, { name = "pillow" }, { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "sqlalchemy", extra = ["asyncio"] }, - { name = "streamlit" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -389,6 +305,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=44,<46" }, { name = "fastapi", specifier = ">=0.115,<1" }, { name = "httpx", specifier = ">=0.28,<1" }, + { name = "jinja2", specifier = ">=3,<4" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14,<2" }, { name = "pillow", specifier = ">=11,<13" }, { name = "pydantic-settings", specifier = ">=2.7,<3" }, @@ -398,7 +315,6 @@ requires-dist = [ { name = "python-multipart", specifier = ">=0.0.20,<1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9,<1" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.36,<3" }, - { name = "streamlit", specifier = ">=1.41,<2" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, ] provides-extras = ["dev"] @@ -419,30 +335,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.50" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, -] - [[package]] name = "greenlet" version = "3.5.1" @@ -548,15 +440,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -578,33 +461,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - [[package]] name = "librt" version = "0.11.0" @@ -730,55 +586,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "narwhals" -version = "2.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, -] - -[[package]] -name = "numpy" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, - { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -788,42 +595,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "pandas" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, - { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -886,50 +657,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "protobuf" -version = "7.35.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, - { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, - { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, - { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, -] - -[[package]] -name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1013,19 +740,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] -[[package]] -name = "pydeck" -version = "0.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/df/4e9e7f20f8034a37c6571c93809f6d22388c39978c98d174d656c1a18fd2/pydeck-0.9.2.tar.gz", hash = "sha256:c10d9035e81ead6385264cac8d19402471f6866a15ca1f7df1400f52142bcf87", size = 5849672, upload-time = "2026-04-16T18:30:30.089Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/24/b30ee7d723100fd822de1bb4c0adea62f3419884a75a536f35f355d1e7c0/pydeck-0.9.2-py2.py3-none-any.whl", hash = "sha256:8213dfeacc5f6bfe6825f61c8ee34e3850e8a31fc43924379ec98edb34a75b25", size = 11305615, upload-time = "2026-04-16T18:30:28.133Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1135,87 +849,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - -[[package]] -name = "rpds-py" -version = "2026.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, - { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, - { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, - { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, - { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, - { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, - { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, - { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, - { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, - { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, - { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, - { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, - { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, - { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, - { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, - { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, - { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, - { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, - { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, - { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, - { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, - { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, - { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, - { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, - { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, -] - [[package]] name = "ruff" version = "0.15.16" @@ -1262,15 +895,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.50" @@ -1316,59 +940,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] -[[package]] -name = "streamlit" -version = "1.58.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altair" }, - { name = "anyio" }, - { name = "blinker" }, - { name = "cachetools" }, - { name = "click" }, - { name = "gitpython" }, - { name = "httptools" }, - { name = "itsdangerous" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "protobuf" }, - { name = "pyarrow" }, - { name = "pydeck" }, - { name = "python-multipart" }, - { name = "requests" }, - { name = "starlette" }, - { name = "tenacity" }, - { name = "toml" }, - { name = "typing-extensions" }, - { name = "uvicorn" }, - { name = "watchdog", marker = "sys_platform != 'darwin'" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/74/20dac6d6200d6ec0e1c230fb8eeb6a1a423645eacb76e8d802adfc246456/streamlit-1.58.0.tar.gz", hash = "sha256:78a22e7085b053af7ce544442bf4b670771e68c509ba1bdaa056ba0708f49c3d", size = 8721149, upload-time = "2026-05-28T18:02:44.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/84/14c36a92fb24f8e1cea452f53b0744b5da69d52cdd2fe22e71e6fbf765d5/streamlit-1.58.0-py3-none-any.whl", hash = "sha256:4ca8a7afc5bd16a5f176ccf4be1e34e8121cad0240becd127fb58a103ea3178d", size = 9219185, upload-time = "2026-05-28T18:02:41.993Z" }, -] - -[[package]] -name = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -1390,15 +961,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "tzdata" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" @@ -1452,24 +1014,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - [[package]] name = "watchfiles" version = "1.2.0"