Skip to main content

max / makenotwork

server: describe the project Overview panel Fourth of the tier-1 batch. A fill on build_overview, not a mounted screen: project_tab_overview answers a conditional GET through resolve_project_etag, which is the rule the batch before it found. ProjectOverviewTabTemplate stops deriving Template and stays as the shape of the gather, following ProjectContentTabTemplate. The first screen to say a disclosure, and the shape is exact. A disclosure is a region showing at most one frame whose single frame is a LABELLED sub-region; the label is the summary line. Putting the label on the outer slot instead leaves the renderer with no labels, so it falls through to the frame-stepper branch and draws Prev/Next buttons, a "0 / 1" counter and forty lines of hyperscript. That is what the first attempt did, it compiled and rendered, and only the test caught it. The test now asserts aria-expanded and the absence of data-shows rather than just looking for the summary text. So buyer_contacts can take its <details open> back whenever somebody is in there, and the 51-site gap it filed has an answer with a shape worth writing down. The setup checklist was three steps written twice each, once done and once not, differing in a tick, a label class and whether a CTA existed. Said once it is three Steps built from three booleans. Copy change, deliberate and noted in the module header: taking the shared export_act renames the button from "Export Data" to "Export CSV". Five other sites already said "Export CSV" and this was the only one that did not. Also deletes 1986 bytes of orphaned project-overview CSS and the .quick-actions block, both unreferenced once the template went. 187 lib tests green, fmt and clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 22:06 UTC
Signed with PGP, not checked
Commit: 86624def0d36da17297ef9c2dfa78c1239205b21
Parent: 16d8cac
9 files changed, +439 insertions, -192 deletions
@@ -4982,18 +4982,6 @@
4982 4982 border-bottom: none;
4983 4983 }
4984 4984
4985 - /* Quick actions */
4986 - .quick-actions {
4987 - background: var(--surface-sunken);
4988 - padding: var(--gap-pane);
4989 - }
4990 -
4991 - .quick-actions h2 {
4992 - font-size: var(--text-subhead);
4993 - font-weight: normal;
4994 - margin-bottom: var(--gap-section);
4995 - }
4996 -
4997 4985 /* Filter controls */
4998 4986 .filter-controls {
4999 4987 display: flex;
@@ -9349,85 +9337,6 @@
9349 9337 margin-top: var(--gap-peer);
9350 9338 }
9351 9339
9352 - /* ===========================================
9353 - PROJECT OVERVIEW TAB (replaces inline styles in partials/tabs/project_overview.html)
9354 - =========================================== */
9355 -
9356 - .proj-overview-setup {
9357 - margin-bottom: var(--gap-section);
9358 - padding: var(--gap-section);
9359 - background: var(--surface-overlay);
9360 - border: 1px solid var(--border);
9361 - }
9362 - .proj-overview-setup-title {
9363 - margin: 0 0 var(--gap-section) 0;
9364 - font-family: var(--font-display);
9365 - font-weight: bold;
9366 - }
9367 - .proj-overview-setup-row {
9368 - display: flex;
9369 - align-items: center;
9370 - gap: var(--gap-section);
9371 - padding: var(--gap-peer) 0;
9372 - border-bottom: 1px solid var(--border);
9373 - }
9374 - .proj-overview-setup-row:last-child { border-bottom: none; }
9375 - .proj-overview-setup-check {
9376 - color: var(--success);
9377 - font-family: var(--font-mono);
9378 - font-size: var(--text-fine);
9379 - text-transform: uppercase;
9380 - letter-spacing: 0.04em;
9381 - flex-shrink: 0;
9382 - }
9383 - .proj-overview-setup-bullet {
9384 - width: 1.1rem;
9385 - height: 1.1rem;
9386 - border: 2px solid var(--border);
9387 - border-radius: var(--radius-round);
9388 - flex-shrink: 0;
9389 - }
9390 - .proj-overview-setup-label {
9391 - flex: 1;
9392 - font-size: var(--text-note);
9393 - }
9394 - .proj-overview-setup-label--done {
9395 - opacity: 0.5;
9396 - text-decoration: line-through;
9397 - }
9398 - .proj-overview-setup-btn {
9399 - font-size: var(--text-fine);
9400 - padding: var(--gap-bound) var(--gap-group);
9401 - }
9402 -
9403 - .proj-overview-tools { margin-top: var(--gap-page); }
9404 - .proj-overview-tools-summary {
9405 - cursor: pointer;
9406 - font-size: var(--text-body);
9407 - font-family: var(--font-display);
9408 - font-weight: bold;
9409 - }
9410 - .proj-overview-tools-grid {
9411 - display: grid;
9412 - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
9413 - gap: var(--gap-section);
9414 - margin-top: var(--gap-section);
9415 - }
9416 - .proj-overview-tool {
9417 - background: var(--surface-sunken);
9418 - padding: var(--gap-section);
9419 - }
9420 - .proj-overview-tool-name {
9421 - font-weight: bold;
9422 - font-size: var(--text-body);
9423 - margin-bottom: var(--gap-bound);
9424 - }
9425 - .proj-overview-tool-desc {
9426 - font-size: var(--text-note);
9427 - opacity: 0.7;
9428 - margin: 0;
9429 - }
9430 -
9431 9340 /* ===========================================
9432 9341 PROJECT SYNCKIT TAB (replaces inline styles in partials/tabs/project_synckit.html)
9433 9342 =========================================== */
@@ -49,6 +49,7 @@
49 49 pub mod media_picker;
50 50 pub mod payout_summary;
51 51 pub mod project_content;
52 + pub mod project_overview;
52 53 pub mod project_tabs;
53 54 pub mod rich_field;
54 55 pub mod settings_tabs;
@@ -86,6 +87,19 @@
86 87 /// get-or-create, so a described page and a templated one in the same
87 88 /// session agree, and validation is one comparison either way.
88 89 pub csrf: String,
90 + /// This request's session-tracking id, when it has one.
91 + ///
92 + /// The same class of fact as [`csrf`](Self::csrf) and resolved the same
93 + /// way: reading it is an async session lookup, so the factory does it and
94 + /// the sync handler reads the answer off `&S`. It is what lets a screen
95 + /// tell the reader's own row apart from the rest, which
96 + /// `user_sessions` needs twice over: the `Current` badge, and the one row
97 + /// that offers no `Sign out`.
98 + ///
99 + /// `None` is a real state rather than a failure. A session predating
100 + /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a
101 + /// screen answering for one marks no row as current.
102 + pub session_id: Option<crate::db::UserSessionId>,
89 103 /// Markup for the bespoke regions this request's screen describes.
90 104 ///
91 105 /// The seam between a handler and its renderer, and the reason it has to be
@@ -182,11 +196,20 @@
182 196 let csrf = crate::csrf::get_or_create_token(&session)
183 197 .await
184 198 .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
199 + // Same reason as `csrf`: an async read a sync handler
200 + // cannot do. Absent on a legacy session, which is a state
201 + // the screens describe rather than an error.
202 + let session_id = session
203 + .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
204 + .await
205 + .ok()
206 + .flatten();
185 207 Ok(Viewer {
186 208 app,
187 209 user,
188 210 runtime,
189 211 csrf,
212 + session_id,
190 213 fills: std::sync::Mutex::default(),
191 214 })
192 215 }
@@ -93,7 +93,7 @@
93 93 const TABS: &[Tab] = &[
94 94 Tab {
95 95 label: "Overview",
96 - panel: "project-overview",
96 + panel: super::project_overview::REGION,
97 97 route: "overview",
98 98 gate: Gate::Always,
99 99 },
@@ -226,7 +226,6 @@
226 226 UserAccountTabTemplate,
227 227 UserPaymentsTabTemplate,
228 228 UserCreatorTabTemplate,
229 - ProjectOverviewTabTemplate,
230 229 ProjectAnalyticsTabTemplate,
231 230 ProjectSettingsTabTemplate,
232 231 ProjectCodeTabTemplate,
@@ -407,8 +407,12 @@
407 407 }
408 408
409 409 /// Dashboard tab: project overview with stat cards.
410 - #[derive(Template)]
411 - #[template(path = "partials/tabs/project_overview.html")]
410 + ///
411 + /// Not a `Template` any more, following `ProjectContentTabTemplate` above: the
412 + /// described panel (`crate::quasi::project_overview`) is the only rendering, and
413 + /// both callers of `build_overview` take these five fields off this struct and
414 + /// hand them to it. Kept as the shape of that gather rather than folded into the
415 + /// callers, because two call sites want the same three queries.
412 416 pub struct ProjectOverviewTabTemplate {
413 417 pub stats: Vec<StatCard>,
414 418 pub project_slug: String,
@@ -271,9 +271,19 @@
271 271 "synckit" => project_tabs::build_synckit(&db, &session_user, &db_project)
272 272 .await?
273 273 .render(),
274 - _ => project_tabs::build_overview(&db, &session_user, &db_project)
275 - .await?
276 - .render(),
274 + // The fill rather than the fragment, as for Content above: the strip
275 + // draws the region and two elements carrying one id is a target nothing
276 + // can aim at.
277 + _ => {
278 + let overview = project_tabs::build_overview(&db, &session_user, &db_project).await?;
279 + Ok(quasi::project_overview::fill(
280 + &overview.project_slug,
281 + &overview.stats,
282 + overview.stripe_connected,
283 + overview.has_items,
284 + overview.has_published_item,
285 + ))
286 + }
277 287 }
278 288 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
279 289
@@ -120,9 +120,17 @@
120 120 Err(not_modified) => return Ok(not_modified),
121 121 };
122 122
123 + let overview = build_overview(&db, &session_user, &db_project).await?;
124 +
123 125 Ok(helpers::with_etag(
124 126 generation,
125 - build_overview(&db, &session_user, &db_project).await?,
127 + axum::response::Html(crate::quasi::project_overview::fragment(
128 + &overview.project_slug,
129 + &overview.stats,
130 + overview.stripe_connected,
131 + overview.has_items,
132 + overview.has_published_item,
133 + )),
126 134 ))
127 135 }
128 136
@@ -1,0 +1,387 @@
1 + //! The project dashboard's Overview panel, described.
2 + //!
3 + //! Fourth of the tier-1 batch (wiki `mnw-server-conversion-plan`, "The S4 tab
4 + //! inventory"): 93 lines, three `hx-` attributes, no `data-action`, and nothing
5 + //! in `static/` or `frontend/src` reaches for any id it writes.
6 + //!
7 + //! A fill on `project_tabs::build_overview` rather than a mounted screen, for
8 + //! the reason the batch before it established: `project_tab_overview` answers a
9 + //! conditional GET through `resolve_project_etag`, and `super::mount` has no
10 + //! way to say "304 if the project's cache generation has not moved". See
11 + //! [`super::user_projects`] for the rule and why it is the ETag that decides.
12 + //!
13 + //! # The disclosure is sayable now, and this is the first screen to say it
14 + //!
15 + //! `super::buyer_contacts` gave up its `<details open>` in August and filed the
16 + //! gap: 51 sites, and nothing named a disclosure. It is named now, as a
17 + //! selective region -- `Slot::widget(id, "disclosure").showing_at_most_one(..)`
18 + //! -- where `None` is the closed state and is a legal resting place. So the
19 + //! tools panel keeps its collapse instead of becoming a heading and six
20 + //! paragraphs, and `buyer_contacts` can take its own back whenever somebody is
21 + //! in there.
22 + //!
23 + //! # The setup checklist is a list of three steps, not three copies of one row
24 + //!
25 + //! The template writes each step twice, once done and once not, and the two
26 + //! branches differ in a tick, a label class, and whether a CTA is there at all.
27 + //! Said once, a step is a row that carries a token when it is finished and an
28 + //! act when there is something to do about it, and the six branches collapse to
29 + //! three [`Step`]s built from three booleans.
30 + //!
31 + //! The whole block is conditional on at least one step being unfinished, which
32 + //! is [`Step::all_done`] here rather than a three-way `||` in the caller.
33 + //!
34 + //! # Quick Actions loses its third spelling of Export
35 + //!
36 + //! `hx-post="/api/export/projects"` with `hx-target="body" hx-swap="beforeend"`
37 + //! was a sixth hand-written export control. [`super::export_act`] is the one
38 + //! that already exists, and it says what the answer *is* -- a file the reader
39 + //! keeps -- rather than where to staple the response.
40 + //!
41 + //! **It renames the button, from "Export Data" to "Export CSV".** That is the
42 + //! cost of taking the shared control rather than spelling a sixth one, and it
43 + //! is the right way round: five other sites already say "Export CSV" and this
44 + //! was the only one that did not. Noted rather than hidden, since a conversion
45 + //! changing user-visible copy should say so.
46 +
47 + use makeover_layout as layout;
48 + use quasi_router::screen::{Act, Figure, Row, Tag};
49 + use quasi_router::{Action, Node, RegionKind, Slot};
50 + use quasi_webview::Webview;
51 +
52 + use crate::types::StatCard;
53 +
54 + /// The region the answer replaces, keeping the id the page already used.
55 + pub const REGION: &str = "project-overview";
56 +
57 + /// The disclosure holding the tour of the other tabs.
58 + const TOOLS: &str = "project-overview-tools";
59 +
60 + /// The one frame inside it, which is what carries the summary line.
61 + const TOOLS_BODY: &str = "project-overview-tools-body";
62 +
63 + /// One line of the setup checklist.
64 + struct Step {
65 + /// What the reader is being asked to do.
66 + label: &'static str,
67 + /// Whether they have done it.
68 + done: bool,
69 + /// Where to go and do it, when there is somewhere and it is not done.
70 + act: Option<(&'static str, String)>,
71 + }
72 +
73 + impl Step {
74 + /// The three steps, in the order the template drew them.
75 + fn all(slug: &str, stripe_connected: bool, has_items: bool, has_published: bool) -> Vec<Self> {
76 + vec![
77 + Self {
78 + label: "Add your first item: upload files, set a price",
79 + done: has_items,
80 + act: Some(("New Item", format!("/dashboard/project/{slug}/new-item"))),
81 + },
82 + Self {
83 + label: "Connect Stripe: required to receive payments (3% processing only)",
84 + done: stripe_connected,
85 + act: Some(("Go to Payments", "/dashboard?tab=payments".to_owned())),
86 + },
87 + Self {
88 + label: "Publish an item: make it visible on your public page",
89 + done: has_published,
90 + // The template offers this only once there is something to
91 + // publish, which is a real condition and not an oversight:
92 + // Content is an empty screen before the first item exists.
93 + act: has_items.then(|| {
94 + (
95 + "Go to Content",
96 + format!("/dashboard/project/{slug}?tab=content"),
97 + )
98 + }),
99 + },
100 + ]
101 + }
102 +
103 + /// Whether the checklist has anything left to say.
104 + fn all_done(steps: &[Self]) -> bool {
105 + steps.iter().all(|step| step.done)
106 + }
107 +
108 + /// One step as a row.
109 + fn row(&self) -> Row {
110 + let mut row = Row::new(self.label);
111 +
112 + if self.done {
113 + let mut tick = Tag::badge("Done");
114 + tick.tone = layout::Tone::Success;
115 + return row.token(tick);
116 + }
117 +
118 + if let Some((label, href)) = &self.act {
119 + row = row.act(Act::new(*label, Action::external(href.clone())));
120 + }
121 + row
122 + }
123 + }
124 +
125 + /// The panel as the route answers it: the region, carrying its own id.
126 + #[must_use]
127 + pub fn fragment(
128 + slug: &str,
129 + stats: &[StatCard],
130 + stripe_connected: bool,
131 + has_items: bool,
132 + has_published: bool,
133 + ) -> String {
134 + use quasi_axum::Serves as _;
135 +
136 + let mut slot = Slot::new(REGION, RegionKind::Pane);
137 + for node in body(slug, stats, stripe_connected, has_items, has_published) {
138 + slot = slot.with(node);
139 + }
140 + Webview::new().fragment(&Node::Region(slot))
141 + }
142 +
143 + /// The panel's contents as the page embeds them, without a region wrapper.
144 + #[must_use]
145 + pub fn fill(
146 + slug: &str,
147 + stats: &[StatCard],
148 + stripe_connected: bool,
149 + has_items: bool,
150 + has_published: bool,
151 + ) -> String {
152 + use quasi_axum::Serves as _;
153 +
154 + let mut out = String::new();
155 + for node in body(slug, stats, stripe_connected, has_items, has_published) {
156 + out.push_str(&Webview::new().fragment(&node));
157 + }
158 + out
159 + }
160 +
161 + /// The panel's contents, in order.
162 + fn body(
163 + slug: &str,
164 + stats: &[StatCard],
165 + stripe_connected: bool,
166 + has_items: bool,
167 + has_published: bool,
168 + ) -> Vec<Node> {
169 + let mut out = Vec::new();
170 +
171 + let steps = Step::all(slug, stripe_connected, has_items, has_published);
172 + if !Step::all_done(&steps) {
173 + out.push(setup(&steps));
174 + }
175 +
176 + out.push(Node::Link {
177 + text: "Docs: Projects".into(),
178 + action: Action::get("/docs/projects").navigating(),
179 + });
180 + out.push(figures(stats));
181 + out.push(Node::section("Quick Actions"));
182 + out.extend(quick_actions(slug));
183 + out.push(tools());
184 + out
185 + }
186 +
187 + /// What is left to do before the project can sell anything.
188 + fn setup(steps: &[Step]) -> Node {
189 + Node::Region(
190 + Slot::new("project-overview-setup", RegionKind::Pane)
191 + .with(Node::Heading {
192 + level: layout::Heading::Subsection,
193 + text: "Project Setup".into(),
194 + })
195 + .with(Node::list(steps.iter().map(Step::row))),
196 + )
197 + }
198 +
199 + /// The figures across the top.
200 + ///
201 + /// The same shape as `super::user_analytics::stats`, and toned the same way:
202 + /// the tone rides on the delta, so a card with nothing to report stays neutral
203 + /// rather than going green for having no news.
204 + fn figures(stats: &[StatCard]) -> Node {
205 + Node::Stats {
206 + figures: stats
207 + .iter()
208 + .map(|stat| {
209 + let mut figure = Figure::new(stat.value.clone(), stat.label.clone());
210 + if let Some(change) = &stat.change {
211 + figure = figure.change(change.clone()).tone(if stat.is_positive {
212 + layout::Tone::Success
213 + } else {
214 + layout::Tone::Danger
215 + });
216 + }
217 + (figure, None)
218 + })
219 + .collect(),
220 + }
221 + }
222 +
223 + /// The three controls under Quick Actions.
224 + fn quick_actions(slug: &str) -> Vec<Node> {
225 + vec![
226 + // Whole pages rather than fragments, so both leave. An internal
227 + // `Action::get` would fetch them into this panel.
228 + Node::act(
229 + "New Item",
230 + Action::external(format!("/dashboard/project/{slug}/new-item")),
231 + ),
232 + Node::act("View Public Page", Action::external(format!("/p/{slug}"))),
233 + // Through `export_act`, which is the described control five other sites
234 + // already use. See the module header.
235 + super::export_act::act("/api/export/projects", "projects.csv"),
236 + ]
237 + }
238 +
239 + /// The tour of the other tabs, behind a disclosure.
240 + fn tools() -> Node {
241 + const TOOLS_LIST: &[(&str, &str)] = &[
242 + ("Content", "Upload items, manage versions, set prices."),
243 + (
244 + "Blog",
245 + "Write posts that appear on your project page and RSS feed.",
246 + ),
247 + (
248 + "Promo Codes",
249 + "Create discounts, free access codes, or trial periods.",
250 + ),
251 + (
252 + "Membership Tiers",
253 + "Recurring subscriptions with gated content access.",
254 + ),
255 + ("Team", "Add collaborators and split revenue automatically."),
256 + ("Analytics", "Track sales, revenue, and views over time."),
257 + ];
258 +
259 + // The shape is what makes this a disclosure, and it is exact: a region
260 + // showing at most one frame, whose single frame is a LABELLED sub-region.
261 + // The label is the summary line. Put it on the outer slot instead and the
262 + // renderer finds no labels, falls through to the frame-stepper branch, and
263 + // draws Prev/Next buttons and a "0 / 1" counter. Measured 2026-08-26 by
264 + // doing exactly that.
265 + Node::Region(
266 + Slot::new(TOOLS, RegionKind::Group)
267 + .with(Node::Region(
268 + Slot::new(TOOLS_BODY, RegionKind::Pane)
269 + .label("Explore Your Project Tools")
270 + .with(Node::list(TOOLS_LIST.iter().map(|(name, description)| {
271 + Row::new(*name).secondary(*description)
272 + }))),
273 + ))
274 + // `None` is closed, which is where the template's `<details>` rests:
275 + // it carries no `open`.
276 + .showing_at_most_one(None),
277 + )
278 + }
279 +
280 + #[cfg(test)]
281 + mod tests {
282 + use super::*;
283 + use quasi_axum::Serves;
284 +
285 + fn stat(label: &str, change: Option<&str>, positive: bool) -> StatCard {
286 + StatCard {
287 + label: label.into(),
288 + value: "12".into(),
289 + change: change.map(Into::into),
290 + is_positive: positive,
291 + }
292 + }
293 +
294 + fn render(slug: &str, stripe: bool, items: bool, published: bool) -> String {
295 + let mut out = String::new();
296 + for node in &body(slug, &[stat("Items", None, true)], stripe, items, published) {
297 + out.push_str(&Webview::new().fragment(node));
298 + }
299 + out
300 + }
301 +
302 + #[test]
303 + fn a_finished_project_is_not_shown_the_setup_checklist() {
304 + let done = render("an-album", true, true, true);
305 + assert!(!done.contains("Project Setup"), "{done}");
306 +
307 + let unfinished = render("an-album", false, true, true);
308 + assert!(unfinished.contains("Project Setup"), "{unfinished}");
309 + }
310 +
311 + #[test]
312 + fn a_finished_step_says_done_and_offers_nothing() {
313 + let html = render("an-album", false, true, false);
314 +
315 + // Items is done, so its CTA is gone and the tick is there.
316 + assert!(html.contains("Done"), "{html}");
317 + assert!(
318 + !html.contains("/dashboard/project/an-album/new-item\">New Item"),
319 + "a finished step still offers its CTA: {html}"
320 + );
321 + // Stripe is not, so its CTA is there.
322 + assert!(html.contains("/dashboard?tab=payments"), "{html}");
323 + }
324 +
325 + #[test]
326 + fn publish_offers_content_only_once_there_is_something_to_publish() {
327 + let empty = render("an-album", false, false, false);
328 + let stocked = render("an-album", false, true, false);
329 +
330 + assert!(!empty.contains("Go to Content"), "{empty}");
331 + assert!(stocked.contains("Go to Content"), "{stocked}");
332 + }
333 +
334 + #[test]
335 + fn the_tools_disclosure_is_closed_and_holds_all_six() {
336 + let html = render("an-album", true, true, true);
337 +
338 + assert!(html.contains("Explore Your Project Tools"), "{html}");
339 + // It is a disclosure and not a frame-stepper. Both are legal renderings
340 + // of a selective region and only one of them is this screen; getting
341 + // the shape wrong draws Prev/Next and a counter, which is what happened
342 + // on the first attempt.
343 + assert!(html.contains("aria-expanded=\"false\""), "{html}");
344 + assert!(!html.contains("data-shows=\"next\""), "{html}");
345 + assert!(!html.contains("data-shows=\"previous\""), "{html}");
346 + for tool in [
347 + "Content",
348 + "Blog",
349 + "Promo Codes",
350 + "Membership Tiers",
351 + "Team",
352 + "Analytics",
353 + ] {
354 + assert!(html.contains(tool), "missing {tool}: {html}");
355 + }
356 + }
357 +
358 + #[test]
359 + fn the_export_is_the_described_one_and_not_a_sixth_spelling() {
360 + let html = render("an-album", true, true, true);
361 +
362 + assert!(html.contains("data-saves=\"projects.csv\""), "{html}");
363 + assert!(html.contains("hx-post=\"/api/export/projects\""), "{html}");
364 + // What the template did instead: staple the answer onto the document.
365 + assert!(!html.contains("hx-swap=\"beforeend\""), "{html}");
366 + assert!(!html.contains("data-action"), "{html}");
367 + }
368 +
369 + #[test]
370 + fn a_figure_without_a_delta_stays_neutral() {
371 + let out = Webview::new().fragment(&figures(&[stat("Items", None, true)]));
372 + assert!(!out.contains("data-tone=\"success\""), "{out}");
373 + }
374 +
375 + #[test]
376 + fn the_slug_cannot_smuggle_markup() {
377 + let html = render("<script>x()</script>", false, false, false);
378 + assert!(!html.contains("<script>x()"), "{html}");
379 + }
380 +
381 + #[test]
382 + fn the_inline_fill_carries_no_region_because_the_strip_draws_one() {
383 + let inline = fill("an-album", &[], true, true, true);
384 + assert!(!inline.contains(&format!("id=\"{REGION}\"")), "{inline}");
385 + assert!(inline.contains("Quick Actions"), "{inline}");
386 + }
387 + }
@@ -1,93 +1,0 @@
1 - {% if !stripe_connected || !has_items || !has_published_item %}
2 - <div class="proj-overview-setup">
3 - <h3 class="proj-overview-setup-title">Project Setup</h3>
4 - <div class="proj-overview-setup-row">
5 - {% if has_items %}
6 - <span class="proj-overview-setup-check">Done</span>
7 - <span class="proj-overview-setup-label proj-overview-setup-label--done">Add your first item: upload files, set a price</span>
8 - {% else %}
9 - <span class="proj-overview-setup-bullet"></span>
10 - <span class="proj-overview-setup-label">Add your first item: upload files, set a price</span>
11 - <a href="/dashboard/project/{{ project_slug }}/new-item" class="btn btn-small proj-overview-setup-btn">New Item</a>
12 - {% endif %}
13 - </div>
14 - <div class="proj-overview-setup-row">
15 - {% if stripe_connected %}
16 - <span class="proj-overview-setup-check">Done</span>
17 - <span class="proj-overview-setup-label proj-overview-setup-label--done">Connect Stripe: required to receive payments (3% processing only)</span>
18 - {% else %}
19 - <span class="proj-overview-setup-bullet"></span>
20 - <span class="proj-overview-setup-label">Connect Stripe: required to receive payments (3% processing only)</span>
21 - <a href="/dashboard?tab=payments" class="btn btn-small proj-overview-setup-btn">Go to Payments</a>
22 - {% endif %}
23 - </div>
24 - <div class="proj-overview-setup-row">
25 - {% if has_published_item %}
26 - <span class="proj-overview-setup-check">Done</span>
27 - <span class="proj-overview-setup-label proj-overview-setup-label--done">Publish an item: make it visible on your public page</span>
28 - {% else %}
29 - <span class="proj-overview-setup-bullet"></span>
30 - <span class="proj-overview-setup-label">Publish an item: make it visible on your public page</span>
31 - {% if has_items %}
32 - <a class="btn btn-small proj-overview-setup-btn" href="/dashboard/project/{{ project_slug }}?tab=content">Go to Content</a>
33 - {% endif %}
34 - {% endif %}
35 - </div>
36 - </div>
37 - {% endif %}
38 -
39 - <div class="tab-docs"><a href="/docs/projects">Docs: Projects &rarr;</a></div>
40 -
41 - <div class="stats-grid">
42 - {% for stat in stats %}
43 - <div class="card-muted">
44 - <div class="stat-label">{{ stat.label }}</div>
45 - <div class="stat-value">{{ stat.value }}</div>
46 - {% if let Some(change) = stat.change %}
47 - <div class="stat-change{% if stat.is_positive %} positive{% endif %}">{{ change }}</div>
48 - {% endif %}
49 - </div>
50 - {% endfor %}
51 - </div>
52 -
53 - <div class="quick-actions">
54 - <h2 class="subsection-title">Quick Actions</h2>
55 - <div class="action-buttons">
56 - <a href="/dashboard/project/{{ project_slug }}/new-item" class="btn-primary">New Item</a>
57 - <a href="/p/{{ project_slug }}" target="_blank" rel="noopener" class="btn-secondary">View Public Page</a>
58 - <button class="btn-secondary"
59 - hx-post="/api/export/projects"
60 - hx-target="body"
61 - hx-swap="beforeend">Export Data</button>
62 - </div>
63 - </div>
64 -
65 - <details class="proj-overview-tools">
66 - <summary class="proj-overview-tools-summary">Explore Your Project Tools</summary>
67 - <div class="proj-overview-tools-grid">
68 - <div class="proj-overview-tool">
69 - <div class="proj-overview-tool-name">Content</div>
70 - <p class="proj-overview-tool-desc">Upload items, manage versions, set prices.</p>
71 - </div>
72 - <div class="proj-overview-tool">
73 - <div class="proj-overview-tool-name">Blog</div>
74 - <p class="proj-overview-tool-desc">Write posts that appear on your project page and RSS feed.</p>
75 - </div>
76 - <div class="proj-overview-tool">
77 - <div class="proj-overview-tool-name">Promo Codes</div>
78 - <p class="proj-overview-tool-desc">Create discounts, free access codes, or trial periods.</p>
79 - </div>
80 - <div class="proj-overview-tool">
81 - <div class="proj-overview-tool-name">Membership Tiers</div>
82 - <p class="proj-overview-tool-desc">Recurring subscriptions with gated content access.</p>
83 - </div>
84 - <div class="proj-overview-tool">
85 - <div class="proj-overview-tool-name">Team</div>
86 - <p class="proj-overview-tool-desc">Add collaborators and split revenue automatically.</p>
87 - </div>
88 - <div class="proj-overview-tool">
89 - <div class="proj-overview-tool-name">Analytics</div>
90 - <p class="proj-overview-tool-desc">Track sales, revenue, and views over time.</p>
91 - </div>
92 - </div>
93 - </details>