Removable reference curves, audit fixes across pages, shaded Academy art
Test and deploy / test-and-deploy (push) Successful in 1m8s
Test and deploy / test-and-deploy (push) Successful in 1m8s
The planner's Artisan .alog drawer now shows what's attached with a "Remove reference curve" control (re-rendered per drawer open), so adding a reference curve is no longer a one-way door; /api/alog shares the 8 MB body cap so real-sized logs parse instead of failing with "bad_request". Deep-evaluation fixes: editing a brew of an archived bean no longer silently detaches the bean; the roasts pending-review poll no longer wipes in-progress after-roast edits; roasters gain an Edit (rename/model) action; the gear page refuses to autosave over a failed load; duplicating a plan carries its custom name; cupping sessions can attach a plan after creation (ownership-checked PUT + selector); admin user deletion also refreshes plans/audit; cupping cup-count subtitle stays live; roasts error-row colspan corrected. Regression tests cover the new cupping PUT and the /api/alog body cap. Academy scenes drop the flat paper-cutout look: shared defs provide radial-gradient shading on every bean/half-bean/particle, flame gradients with radiant halos, soft ground shadows, and a warm-lit stage background; fill-shift animations now ride a partial-opacity tint overlay so shading survives the color change. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+13
-4
@@ -3141,10 +3141,16 @@ select.f {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.academy-stage {
|
||||
background: var(--surface);
|
||||
/* Warm-lit paper: a top light and a faint ember glow rising from the floor, so the
|
||||
* scenes sit in an environment instead of on a flat panel. */
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 253, 248, 0.65), rgba(255, 253, 248, 0) 45%),
|
||||
radial-gradient(90% 60% at 50% 115%, rgba(168, 72, 26, 0.07), transparent 60%),
|
||||
var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 1px 3px rgba(27, 22, 20, 0.05);
|
||||
}
|
||||
.academy-stage svg {
|
||||
width: 100%;
|
||||
@@ -3229,7 +3235,10 @@ select.f {
|
||||
@keyframes ac-pour-kf { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
.ac-pourline { animation: ac-pour-kf 1.6s ease-in-out infinite; }
|
||||
@keyframes ac-maillard-kf { 0% { fill: #d9ba5a; } 100% { fill: #8a5a30; } }
|
||||
.ac-maillard-shift ellipse:first-child { animation: ac-maillard-kf 5s ease-in-out infinite alternate; }
|
||||
/* Fill-shift animations ride a flat .ac-tint overlay at partial opacity so the
|
||||
* gradient shading underneath survives the color change. */
|
||||
.ac-tint, .ac-jb-color { opacity: 0.82; }
|
||||
.ac-maillard-shift .ac-tint { animation: ac-maillard-kf 5s ease-in-out infinite alternate; }
|
||||
@keyframes ac-tumble-kf { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-8px) rotate(6deg); } }
|
||||
.ac-tumble { animation: ac-tumble-kf 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-flame-kf { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(1.12); } }
|
||||
@@ -3271,7 +3280,7 @@ select.f {
|
||||
@keyframes ac-fill-up-kf { from { transform: scaleY(0.12); } to { transform: scaleY(0.86); } }
|
||||
.ac-fill-up { transform-box: fill-box; transform-origin: bottom; animation: ac-fill-up-kf 5s ease-out forwards; }
|
||||
@keyframes ac-dry-shift-kf { 0% { fill: #7a8c5e; } 100% { fill: #d9ba5a; } }
|
||||
.ac-dry-shift > ellipse:first-child { animation: ac-dry-shift-kf 6s ease-in-out infinite alternate; }
|
||||
.ac-dry-shift .ac-tint { animation: ac-dry-shift-kf 6s ease-in-out infinite alternate; }
|
||||
@keyframes ac-needle-kf { 0%, 100% { transform: rotate(-52deg); } 50% { transform: rotate(52deg); } }
|
||||
.ac-needle { animation: ac-needle-kf 7s ease-in-out infinite; }
|
||||
@keyframes ac-pulse-soft-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.75; } }
|
||||
@@ -3342,7 +3351,7 @@ select.f {
|
||||
}
|
||||
.ac-burst { animation: ac-burst-kf 1.1s ease-out infinite; }
|
||||
@keyframes ac-grass-fade-kf { 0% { fill: #8a9575; } 40% { fill: #7a8c5e; } 100% { fill: #cdbd8f; } }
|
||||
.ac-grass-fade > ellipse:first-child { animation: ac-grass-fade-kf 6s ease-in-out infinite alternate; }
|
||||
.ac-grass-fade .ac-tint { animation: ac-grass-fade-kf 6s ease-in-out infinite alternate; }
|
||||
@keyframes ac-cross-a-kf { 0%, 45% { opacity: 1; } 55%, 100% { opacity: 0; } }
|
||||
.ac-cross-a { animation: ac-cross-a-kf 4s ease-in-out infinite; }
|
||||
@keyframes ac-cross-b-kf { 0%, 45% { opacity: 0; } 55%, 100% { opacity: 1; } }
|
||||
|
||||
+115
-17
@@ -31,6 +31,66 @@ function el(tag, attrs = {}, ...children) {
|
||||
node.append(...children);
|
||||
return node;
|
||||
}
|
||||
|
||||
// ── Shading helpers ────────────────────────────────────────────────────────
|
||||
// Flat fills read as paper cutouts; every solid body (beans, flames, liquids)
|
||||
// instead gets a radial gradient built from its base color — warm-white toward
|
||||
// the light, warm-black away from it — so the shapes model light like objects.
|
||||
const hexToRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||||
const mixHex = (hex, target, f) =>
|
||||
`#${hexToRgb(hex)
|
||||
.map((c, i) => Math.round(c + (target[i] - c) * f).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
const tint = (hex, f) => mixHex(hex, [255, 250, 240], f);
|
||||
const shade = (hex, f) => mixHex(hex, [30, 18, 10], f);
|
||||
|
||||
let gradSeq = 0;
|
||||
/** Per-instance radial gradient for a body color; returns [defNode, fillUrl].
|
||||
* gradientUnits stay objectBoundingBox, so one def can shade many siblings. */
|
||||
function radialFill(fill, opts = {}) {
|
||||
const id = `ac-rg-${gradSeq++}`;
|
||||
const node = el(
|
||||
"radialGradient",
|
||||
{ id, cx: opts.cx ?? "36%", cy: opts.cy ?? "30%", r: opts.r ?? "85%" },
|
||||
el("stop", { offset: "0%", "stop-color": tint(fill, opts.tint ?? 0.3) }),
|
||||
el("stop", { offset: "55%", "stop-color": fill }),
|
||||
el("stop", { offset: "100%", "stop-color": shade(fill, opts.shade ?? 0.3) }),
|
||||
);
|
||||
return [node, `url(#${id})`];
|
||||
}
|
||||
|
||||
/** Shared gradient/glow definitions every scene can reference by id.
|
||||
* Appended once per slide render (the SVG is rebuilt each slide). */
|
||||
export function sceneDefs() {
|
||||
return el(
|
||||
"defs",
|
||||
{},
|
||||
el(
|
||||
"radialGradient",
|
||||
{ id: "ac-ground", cx: "50%", cy: "50%", r: "50%" },
|
||||
el("stop", { offset: "0%", "stop-color": "#1b1614", "stop-opacity": 0.16 }),
|
||||
el("stop", { offset: "100%", "stop-color": "#1b1614", "stop-opacity": 0 }),
|
||||
),
|
||||
el(
|
||||
"radialGradient",
|
||||
{ id: "ac-halo", cx: "50%", cy: "50%", r: "50%" },
|
||||
el("stop", { offset: "0%", "stop-color": "#e8892c", "stop-opacity": 0.38 }),
|
||||
el("stop", { offset: "100%", "stop-color": "#e8892c", "stop-opacity": 0 }),
|
||||
),
|
||||
el(
|
||||
"linearGradient",
|
||||
{ id: "ac-flame-grad", x1: "0", y1: "0", x2: "0", y2: "1" },
|
||||
el("stop", { offset: "0%", "stop-color": "#e8952f" }),
|
||||
el("stop", { offset: "100%", "stop-color": "#9c3a10" }),
|
||||
),
|
||||
el(
|
||||
"linearGradient",
|
||||
{ id: "ac-flame-core", x1: "0", y1: "0", x2: "0", y2: "1" },
|
||||
el("stop", { offset: "0%", "stop-color": "#f7e3a0" }),
|
||||
el("stop", { offset: "100%", "stop-color": "#e0a83c" }),
|
||||
),
|
||||
);
|
||||
}
|
||||
const text = (x, y, str, size = 12.5, fill = C.ink2, anchor = "middle", weight = "400") =>
|
||||
el("text", { x, y, fill, "font-size": size, "text-anchor": anchor, "font-weight": weight }, str);
|
||||
const caption = (str, fill = C.ink2) => text(280, 286, str, 12.5, fill);
|
||||
@@ -42,10 +102,16 @@ export const sceneFallbackText = (title) => text(280, 150, title, 16, C.ink2);
|
||||
* INNER group — a CSS transform animation on the same element would silently override the
|
||||
* SVG transform attribute and throw the bean to the canvas origin. */
|
||||
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
||||
const [grad, gradUrl] = radialFill(fill);
|
||||
const inner = el("g", { class: opts.class ?? "" });
|
||||
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: gradUrl }));
|
||||
// Fill-shift animations (drying, Maillard, …) animate a flat overlay riding at partial
|
||||
// opacity ABOVE the gradient, so the color changes while the 3-D shading survives.
|
||||
if (/ac-(dry-shift|maillard-shift|grass-fade)/.test(opts.class ?? ""))
|
||||
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill, class: "ac-tint" }));
|
||||
inner.append(
|
||||
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill }),
|
||||
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.22)" }),
|
||||
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.26)" }),
|
||||
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
|
||||
);
|
||||
if (opts.crack)
|
||||
inner.append(
|
||||
@@ -57,7 +123,11 @@ function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
||||
class: "ac-crackline",
|
||||
}),
|
||||
);
|
||||
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` }, inner);
|
||||
const outer = el("g", { transform: `translate(${x} ${y}) scale(${scale})` });
|
||||
if (opts.shadow !== false)
|
||||
outer.append(el("ellipse", { cx: 0, cy: 45, rx: 24, ry: 6, fill: "url(#ac-ground)" }));
|
||||
outer.append(grad, inner);
|
||||
return outer;
|
||||
}
|
||||
|
||||
function steamWisps(x, y, n = 3) {
|
||||
@@ -79,10 +149,12 @@ function steamWisps(x, y, n = 3) {
|
||||
|
||||
function flame(x, y, scale = 1) {
|
||||
// Same outer/inner split as bean(): position on the outer, animation class on the inner.
|
||||
// A soft radial halo behind the flame reads as radiant heat.
|
||||
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` },
|
||||
el("ellipse", { cx: 0, cy: -14, rx: 22, ry: 26, fill: "url(#ac-halo)" }),
|
||||
el("g", { class: "ac-flame" },
|
||||
el("path", { d: "M0 0 C 11 -12, 4 -22, 0 -32 C -4 -22, -11 -12, 0 0 Z", fill: C.ember }),
|
||||
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: C.yellow }),
|
||||
el("path", { d: "M0 0 C 11 -12, 4 -22, 0 -32 C -4 -22, -11 -12, 0 0 Z", fill: "url(#ac-flame-grad)" }),
|
||||
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: "url(#ac-flame-core)" }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -95,16 +167,24 @@ function halfBean(x, y, scale = 1, opts = {}) {
|
||||
const shapeD =
|
||||
"M0,-58 C 28,-58 44,-32 44,0 C 44,32 28,58 0,58 C -28,58 -44,32 -44,0 C -44,-32 -28,-58 0,-58 Z";
|
||||
const id = `hb-clip-${halfBeanClipSeq++}`;
|
||||
const [grad, gradUrl] = radialFill(opts.shellFill ?? C.tan);
|
||||
const shell = el("path", {
|
||||
d: shapeD,
|
||||
fill: opts.shellFill ?? C.tan,
|
||||
fill: gradUrl,
|
||||
stroke: opts.stroke ?? C.dark,
|
||||
"stroke-width": opts.strokeWidth ?? 3,
|
||||
});
|
||||
const clip = el("clipPath", { id }, el("path", { d: shapeD }));
|
||||
const content = el("g", { "clip-path": `url(#${id})` }, ...(opts.children ?? []));
|
||||
const inner = el("g", { class: opts.class ?? "" }, shell, content);
|
||||
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` }, clip, inner);
|
||||
return el(
|
||||
"g",
|
||||
{ transform: `translate(${x} ${y}) scale(${scale})` },
|
||||
el("ellipse", { cx: 0, cy: 66, rx: 44, ry: 8, fill: "url(#ac-ground)" }),
|
||||
clip,
|
||||
grad,
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
/** Small semi-circle dial gauge with a needle that sweeps toward the reading on a loop.
|
||||
@@ -141,6 +221,8 @@ const arcCurve = (points, color, cls = "") =>
|
||||
fill: "none",
|
||||
stroke: color,
|
||||
"stroke-width": 2.5,
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
class: cls,
|
||||
});
|
||||
|
||||
@@ -194,6 +276,7 @@ export const SCENES = {
|
||||
* crack — synced to a stage timeline underneath. All keyframes share one 14s loop. */
|
||||
beanJourney() {
|
||||
const cx = 190, cy = 120;
|
||||
const [jbGrad, jbUrl] = radialFill(C.green);
|
||||
const seg = (x, w, color, label) => [
|
||||
el("rect", { x, y: 236, width: w, height: 12, rx: 3, fill: color, opacity: 0.55 }),
|
||||
text(x + w / 2, 264, label, 11.5, C.ink2),
|
||||
@@ -205,11 +288,15 @@ export const SCENES = {
|
||||
el("path", { d: `M${cx - 26 + i * 26} 214 v -12`, stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }),
|
||||
),
|
||||
text(cx, 228, "", 1),
|
||||
// The bean itself: color, swell, crack all on the 14s journey clock
|
||||
// The bean itself: color, swell, crack all on the 14s journey clock. The gradient
|
||||
// base carries the shading; the animated flat overlay above it carries the color.
|
||||
el("g", { transform: `translate(${cx} ${cy}) scale(2.15)` },
|
||||
jbGrad,
|
||||
el("g", { class: "ac-jb-grow" },
|
||||
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: jbUrl }),
|
||||
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: C.green, class: "ac-jb-color" }),
|
||||
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.22)" }),
|
||||
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.26)" }),
|
||||
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
|
||||
el("path", { d: "M-2 -30 L 5 -12 L -4 4 L 4 20 L -1 32", fill: "none", stroke: C.paper, "stroke-width": 2.4, class: "ac-jb-crack" }),
|
||||
),
|
||||
),
|
||||
@@ -232,9 +319,13 @@ export const SCENES = {
|
||||
},
|
||||
beanAnatomy() {
|
||||
const cx = 180, cy = 145;
|
||||
const [outerGrad, outerUrl] = radialFill(C.green);
|
||||
const [innerGrad, innerUrl] = radialFill("#8ea06f", { tint: 0.2, shade: 0.18 });
|
||||
return [
|
||||
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: C.green, class: "ac-breathe" }),
|
||||
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: "#8ea06f" }),
|
||||
outerGrad,
|
||||
innerGrad,
|
||||
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: outerUrl, class: "ac-breathe" }),
|
||||
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: innerUrl }),
|
||||
el("path", { d: `M${cx} ${cy - 86} C ${cx + 27} ${cy - 36}, ${cx + 27} ${cy + 36}, ${cx} ${cy + 86} C ${cx - 18} ${cy + 36}, ${cx - 18} ${cy - 36}, ${cx} ${cy - 86} Z`, fill: "#6a7a50" }),
|
||||
...[[cx - 55, cy - 45, 0], [cx + 48, cy - 30, 1], [cx - 30, cy + 50, 2], [cx + 40, cy + 42, 0], [cx + 4, cy - 4, 1]].map(([x, y, d]) =>
|
||||
el("circle", { cx: x, cy: y, r: 5, fill: C.water, class: `ac-pulse ac-delay-${d}` }),
|
||||
@@ -278,8 +369,8 @@ export const SCENES = {
|
||||
// Drum with tumbling beans
|
||||
el("circle", { cx: 150, cy: 138, r: 76, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
|
||||
el("g", { class: "ac-tumble" },
|
||||
bean(126, 118, 0.5, C.green), bean(172, 132, 0.5, C.green),
|
||||
bean(140, 162, 0.5, "#8ea06f"), bean(182, 168, 0.5, C.green)),
|
||||
bean(126, 118, 0.5, C.green, { shadow: false }), bean(172, 132, 0.5, C.green, { shadow: false }),
|
||||
bean(140, 162, 0.5, "#8ea06f", { shadow: false }), bean(182, 168, 0.5, C.green, { shadow: false })),
|
||||
flame(150, 250, 1),
|
||||
el("line", { x1: 254, y1: 120, x2: 205, y2: 132, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round" }),
|
||||
el("circle", { cx: 205, cy: 132, r: 4, fill: C.ink }),
|
||||
@@ -440,8 +531,11 @@ export const SCENES = {
|
||||
|
||||
// ── Brewing scenes ──
|
||||
dissolve() {
|
||||
const [grad, gradUrl] = radialFill(C.brown);
|
||||
return [
|
||||
el("circle", { cx: 150, cy: 140, r: 52, fill: C.brown }),
|
||||
grad,
|
||||
el("circle", { cx: 150, cy: 140, r: 52, fill: gradUrl }),
|
||||
el("ellipse", { cx: 150, cy: 206, rx: 48, ry: 9, fill: "url(#ac-ground)" }),
|
||||
text(150, 218, "ground coffee", 12, C.ink2),
|
||||
...[0, 1, 2, 3, 4, 5].map((i) =>
|
||||
el("circle", {
|
||||
@@ -481,13 +575,17 @@ export const SCENES = {
|
||||
];
|
||||
},
|
||||
grind() {
|
||||
// One gradient def serves every particle (objectBoundingBox units shade each circle).
|
||||
const [grindGrad, grindUrl] = radialFill(C.brown);
|
||||
const pile = (x, label, sub, circles) => {
|
||||
const g = el("g", {});
|
||||
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: C.brown }));
|
||||
g.append(el("ellipse", { cx: x, cy: 172, rx: 52, ry: 8, fill: "url(#ac-ground)" }));
|
||||
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: grindUrl }));
|
||||
g.append(text(x, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
|
||||
return g;
|
||||
};
|
||||
return [
|
||||
grindGrad,
|
||||
text(280, 62, "the same 20 grams of coffee", 12.5),
|
||||
pile(130, "coarse", "less surface · fast flow", [[-16, -10, 19], [16, -8, 19], [0, 18, 19]]),
|
||||
pile(280, "medium", "the middle path", [[-24, -14, 10], [0, -18, 10], [24, -14, 10], [-14, 4, 10], [12, 4, 10], [-2, 22, 10], [22, 20, 10]]),
|
||||
@@ -508,7 +606,7 @@ export const SCENES = {
|
||||
el("circle", { cx: 280, cy: 190, r: 8, fill: C.ink }),
|
||||
text(280, 240, "time = how far down the list", 12.5, C.ink, "middle", "700"),
|
||||
// Ratio: one bean to many drops
|
||||
bean(420, 110, 0.62, C.brown),
|
||||
bean(420, 110, 0.62, C.brown, { shadow: false }),
|
||||
text(446, 116, "1 :", 15, C.ink, "start", "700"),
|
||||
...[0, 1, 2].map((i) =>
|
||||
el("path", { d: `M${480 + i * 18} 104 c 5 8 5 14 0 18 c -5 -4 -5 -10 0 -18 Z`, fill: C.water, class: `ac-pulse ac-delay-${i}` }),
|
||||
@@ -1165,7 +1263,7 @@ export const SCENES = {
|
||||
el("circle", { cx: 200, cy: 130, r: 74, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
|
||||
...[0, 1, 2].map((i) => el("path", { d: `M${140 + i * 60} 210 v 30`, stroke: C.water, "stroke-width": 3, "stroke-linecap": "round", class: `ac-rise ac-delay-${i}` })),
|
||||
text(200, 258, "airflow", 11, C.water, "middle"),
|
||||
el("g", { class: "ac-tumble" }, bean(180, 116, 0.5, C.tan), bean(220, 132, 0.5, C.brown), bean(196, 150, 0.5, C.tan)),
|
||||
el("g", { class: "ac-tumble" }, bean(180, 116, 0.5, C.tan, { shadow: false }), bean(220, 132, 0.5, C.brown, { shadow: false }), bean(196, 150, 0.5, C.tan, { shadow: false })),
|
||||
text(400, 90, "too little →", 11.5, C.fail, "start", "700"),
|
||||
text(400, 106, "smoke settles, ashy,", 10.5, C.ink2, "start"),
|
||||
text(400, 120, "chaff fire risk", 10.5, C.ink2, "start"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||
import { ACADEMY_TRACKS } from "./academy-content.js?v=__ASSET_VERSION__";
|
||||
import { SCENES, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
|
||||
import { SCENES, sceneDefs, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
|
||||
|
||||
// ── Progress ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -119,7 +119,7 @@ function renderSlide() {
|
||||
|
||||
// Rebuilt per slide so CSS animations restart with it.
|
||||
const svg = $("player-scene");
|
||||
svg.replaceChildren();
|
||||
svg.replaceChildren(sceneDefs());
|
||||
try {
|
||||
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
|
||||
} catch {
|
||||
|
||||
@@ -131,7 +131,12 @@ async function loadPlans() {
|
||||
try {
|
||||
await api("/api/plans", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ plan: plan.plan }),
|
||||
body: JSON.stringify({
|
||||
plan: plan.plan,
|
||||
// Carry the custom name across — a rename-only plan would otherwise
|
||||
// duplicate as an indistinguishable "Untitled plan".
|
||||
name: plan.name ? `${plan.name} (copy)` : "",
|
||||
}),
|
||||
});
|
||||
showToast("Plan duplicated.");
|
||||
loadPlans();
|
||||
|
||||
@@ -330,6 +330,8 @@ function renderUsers() {
|
||||
showToast("User deleted.");
|
||||
loadUsers();
|
||||
loadMetrics();
|
||||
loadPlans(); // their plans/audit rows are gone too
|
||||
loadAudit();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
|
||||
+33
-2
@@ -21,6 +21,38 @@ export function initAlogPanel({
|
||||
resultEl = document.getElementById("alog-result"),
|
||||
actualInput = document.getElementById("actual-alog-file"),
|
||||
actualResultEl = document.getElementById("actual-alog-result");
|
||||
// A plan keeps its attached reference curve until explicitly removed — show what's
|
||||
// attached (including on page load) with a Remove control, so adding a curve is
|
||||
// never a one-way door.
|
||||
function renderReference(summary) {
|
||||
const ref = state.plan.reference;
|
||||
if (!ref?.roast) {
|
||||
resultEl.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const node = document.createElement("p");
|
||||
node.textContent =
|
||||
summary ??
|
||||
`Attached: ${ref.roast.title || "reference curve"} — ${ref.roast.roastDate || "no date"}.`;
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "ghost-btn small";
|
||||
removeBtn.style.marginLeft = "8px";
|
||||
removeBtn.textContent = "Remove reference curve";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
state.plan.reference = null;
|
||||
recompute();
|
||||
setText(resultEl, "Reference curve removed.");
|
||||
});
|
||||
node.append(removeBtn);
|
||||
resultEl.replaceChildren(node);
|
||||
}
|
||||
renderReference();
|
||||
// Re-render on every drawer open: the user may have switched plans since init, and the
|
||||
// summary must always describe the plan currently loaded.
|
||||
document
|
||||
.getElementById("nav-alog")
|
||||
?.addEventListener("click", () => renderReference());
|
||||
fileInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -135,8 +167,7 @@ export function initAlogPanel({
|
||||
}
|
||||
state.plan.reference = body;
|
||||
recompute();
|
||||
setText(
|
||||
resultEl,
|
||||
renderReference(
|
||||
`${body.roast.title} — ${body.roast.roastDate || "no date"}; first crack ${fmt(body.derived?.firstCrackS)}; development ${fmt(body.derived?.developmentS)}; drop ${fmt(body.derived?.dropS)}; DTR ${body.derived?.dtrPct ?? "—"}%.`,
|
||||
);
|
||||
}
|
||||
|
||||
+6
-2
@@ -147,9 +147,12 @@ function updateRatingPill() {
|
||||
pill.classList.toggle("poor", Number(value) <= 4);
|
||||
}
|
||||
|
||||
function renderBeanSelect() {
|
||||
function renderBeanSelect(keepId) {
|
||||
// keepId: a bean that must stay listed even if archived — editing an old brew of a
|
||||
// finished bag must not silently detach the bean (that would inflate its derived
|
||||
// remaining weight when saved).
|
||||
const select = document.getElementById("brew-bean-select");
|
||||
const current = select.value;
|
||||
const current = keepId ?? select.value;
|
||||
select.replaceChildren(
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: "",
|
||||
@@ -307,6 +310,7 @@ async function loadBrews() {
|
||||
function startEdit(brew) {
|
||||
editingId = brew.id;
|
||||
const form = document.getElementById("brew-form");
|
||||
renderBeanSelect(brew.beanId ?? ""); // the edited brew's bean may be archived
|
||||
form.beanId.value = brew.beanId ?? "";
|
||||
selectedMethod = brew.method;
|
||||
renderBrewerPicker(); // the edited brew's method may be outside the owned-gear filter
|
||||
|
||||
+50
-1
@@ -355,10 +355,15 @@ function wireCupCount() {
|
||||
renderTickRows();
|
||||
renderDefectRows();
|
||||
renderTotal();
|
||||
renderSubtitle();
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
function renderSubtitle() {
|
||||
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
|
||||
}
|
||||
|
||||
// ── Flavor checklist ──
|
||||
function renderFlavorChips() {
|
||||
const chips = document.getElementById("flavor-chips");
|
||||
@@ -578,7 +583,51 @@ async function initSessionView(user) {
|
||||
data = body.session.data;
|
||||
document.getElementById("cupping-title").textContent =
|
||||
body.session.planTitle || "Cupping session";
|
||||
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
|
||||
renderSubtitle();
|
||||
// Sessions created without a plan can attach one later (same PUT the autosave uses) —
|
||||
// otherwise they're stuck titled "Untitled" forever.
|
||||
if (!body.session.roastPlanId) {
|
||||
const subtitle = document.getElementById("cupping-subtitle");
|
||||
try {
|
||||
const { plans } = await api("/api/plans");
|
||||
if (plans.length) {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.append(" · Plan: ");
|
||||
const select = document.createElement("select");
|
||||
select.className = "field-input sm";
|
||||
select.style.display = "inline-block";
|
||||
select.style.width = "auto";
|
||||
select.append(
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: "",
|
||||
textContent: "— none —",
|
||||
}),
|
||||
...plans.map((p) =>
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: p.id,
|
||||
textContent: p.name || p.plan?.fields?.["0.1"] || "Untitled plan",
|
||||
}),
|
||||
),
|
||||
);
|
||||
select.addEventListener("change", async () => {
|
||||
try {
|
||||
await api(`/api/cupping/${sessionId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data, roastPlanId: select.value || null }),
|
||||
});
|
||||
showToast("Plan attached.");
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
wrap.append(select);
|
||||
subtitle.after(wrap);
|
||||
}
|
||||
} catch {
|
||||
/* attaching later is a convenience — the session still works without it */
|
||||
}
|
||||
}
|
||||
|
||||
wireCupCount();
|
||||
renderScoreRows();
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
let gear = { brewers: [], grinders: [] };
|
||||
let saveTimer = null;
|
||||
// Never save over the server until we've successfully read its state: the PUT replaces both
|
||||
// arrays wholesale, so saving after a failed load would wipe the user's stored equipment.
|
||||
let loaded = false;
|
||||
|
||||
function silhouetteSvg(methodKey) {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
@@ -29,6 +32,10 @@ function silhouetteSvg(methodKey) {
|
||||
// Saves shortly after the last toggle rather than needing a Save button — an equipment page
|
||||
// is set-and-forget, so every change should just stick.
|
||||
function scheduleSave() {
|
||||
if (!loaded) {
|
||||
showToast("Equipment didn't load — reload the page before making changes.", "fail");
|
||||
return;
|
||||
}
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
@@ -144,6 +151,7 @@ async function init() {
|
||||
if (!user) return;
|
||||
try {
|
||||
gear = (await api("/api/gear")).gear;
|
||||
loaded = true;
|
||||
} catch {
|
||||
showToast("Could not load equipment.", "fail");
|
||||
}
|
||||
|
||||
@@ -76,6 +76,35 @@ function renderRoasters() {
|
||||
});
|
||||
actions.append(makeDefault);
|
||||
}
|
||||
// Rename/model edits reuse the existing PUT — without this, a typo'd machine
|
||||
// could only be deleted, which detaches every roast logged against it.
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "ghost-btn small";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", async (event) => {
|
||||
event.stopPropagation();
|
||||
const name = prompt("Machine name:", roaster.name);
|
||||
if (name == null) return;
|
||||
if (!name.trim()) {
|
||||
showToast("Name cannot be empty.", "fail");
|
||||
return;
|
||||
}
|
||||
const model = prompt("Model (optional):", roaster.model ?? "");
|
||||
if (model == null) return;
|
||||
try {
|
||||
await api(`/api/roasters/${roaster.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name: name.trim(), model: model.trim() }),
|
||||
});
|
||||
showToast("Roaster updated.");
|
||||
await loadRoasters();
|
||||
if (selectedId === roaster.id) renderReport();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(editBtn);
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "ghost-btn small";
|
||||
deleteBtn.type = "button";
|
||||
|
||||
+14
-2
@@ -86,7 +86,7 @@ async function loadRoasts() {
|
||||
schedulePoll();
|
||||
} catch {
|
||||
document.getElementById("roasts-body").innerHTML =
|
||||
`<tr><td colspan="7" class="empty-state">Could not load roasts.</td></tr>`;
|
||||
`<tr><td colspan="6" class="empty-state">Could not load roasts.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +406,12 @@ async function openDetail(id, { keepScroll = false } = {}) {
|
||||
`/api/roasts/${encodeURIComponent(id)}/download`;
|
||||
document.getElementById("detail-download-updated").href =
|
||||
`/api/roasts/${encodeURIComponent(id)}/download?variant=updated`;
|
||||
renderAfterForm(detail);
|
||||
// The 4s pending-evaluation poll re-runs openDetail; never let that background refresh
|
||||
// overwrite after-roast fields the user is currently editing.
|
||||
const afterForm = document.getElementById("after-form");
|
||||
if (!keepScroll || !(afterFormDirty || afterForm.contains(document.activeElement)))
|
||||
renderAfterForm(detail);
|
||||
if (!keepScroll) afterFormDirty = false;
|
||||
renderGraph(detail);
|
||||
renderStats(detail);
|
||||
renderEvaluation(detail);
|
||||
@@ -421,6 +426,12 @@ const AFTER_FIELDS = [
|
||||
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
|
||||
];
|
||||
|
||||
// True while the after-roast form holds unsaved user edits (cleared on save/open).
|
||||
let afterFormDirty = false;
|
||||
document.getElementById("after-form").addEventListener("input", () => {
|
||||
afterFormDirty = true;
|
||||
});
|
||||
|
||||
function renderAfterForm(detail) {
|
||||
const form = document.getElementById("after-form");
|
||||
const after = detail.after ?? {};
|
||||
@@ -449,6 +460,7 @@ document.getElementById("after-form").addEventListener("submit", async (event) =
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ after }),
|
||||
});
|
||||
afterFormDirty = false;
|
||||
showToast("After-roast saved — included in the updated .alog.");
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
|
||||
+24
-3
@@ -173,7 +173,8 @@ export function createApp({
|
||||
const parser =
|
||||
req.method === "POST" && req.path === "/api/admin/backup/import"
|
||||
? jsonBodyBackup
|
||||
: req.method === "POST" && req.path === "/api/roasts"
|
||||
: req.method === "POST" &&
|
||||
(req.path === "/api/roasts" || req.path === "/api/alog")
|
||||
? jsonBodyLarge
|
||||
: jsonBody;
|
||||
return parser(req, res, next);
|
||||
@@ -2483,10 +2484,30 @@ export function createApp({
|
||||
coerced.fault_cups,
|
||||
coerced.cup_count,
|
||||
);
|
||||
// Optional plan reattachment (mirrors PUT /api/roasts): a session created
|
||||
// without a plan would otherwise be stuck "Untitled" forever. Ownership of
|
||||
// the target plan is enforced; absent field leaves the link unchanged.
|
||||
let planClause = "";
|
||||
const params = [coerced, total, req.params.id, req.user.id];
|
||||
if (req.body.roastPlanId !== undefined) {
|
||||
const planId = req.body.roastPlanId;
|
||||
if (planId !== null) {
|
||||
if (!UUID_RE.test(String(planId)))
|
||||
return res.status(400).json({ ok: false, code: "bad_plan" });
|
||||
const owned = await db.query(
|
||||
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
|
||||
[planId, req.user.id],
|
||||
);
|
||||
if (!owned.rowCount)
|
||||
return res.status(404).json({ ok: false, code: "plan_not_found" });
|
||||
}
|
||||
params.push(planId);
|
||||
planClause = ", roast_plan_id=$5";
|
||||
}
|
||||
const row = (
|
||||
await db.query(
|
||||
"UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING *",
|
||||
[coerced, total, req.params.id, req.user.id],
|
||||
`UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now()${planClause} WHERE id=$3 AND user_id=$4 RETURNING *`,
|
||||
params,
|
||||
)
|
||||
).rows[0];
|
||||
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
|
||||
|
||||
@@ -210,3 +210,53 @@ test("cupping: delete removes the session, and deleting the linked plan nulls ro
|
||||
);
|
||||
assert.equal((await agent.get(`/api/cupping/${session.id}`)).status, 404);
|
||||
});
|
||||
|
||||
test("cupping: a plan can be attached after creation, with ownership enforced", async () => {
|
||||
const { app } = await setup();
|
||||
const owner = request.agent(app);
|
||||
const attacker = request.agent(app);
|
||||
const { csrf } = await signup(owner, "[email protected]");
|
||||
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
|
||||
const planId = await bootstrapPlan(owner, csrf);
|
||||
const foreignPlanId = await bootstrapPlan(attacker, attackerCsrf);
|
||||
|
||||
const session = (
|
||||
await owner.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 3 })
|
||||
).body.session;
|
||||
assert.equal(session.roastPlanId, null);
|
||||
|
||||
// A PUT without roastPlanId leaves the link untouched
|
||||
const noTouch = await owner
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: session.data });
|
||||
assert.equal(noTouch.status, 200);
|
||||
assert.equal(noTouch.body.session.roastPlanId, null);
|
||||
|
||||
// Attaching my own plan works
|
||||
const linked = await owner
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: session.data, roastPlanId: planId });
|
||||
assert.equal(linked.status, 200);
|
||||
assert.equal(linked.body.session.roastPlanId, planId);
|
||||
|
||||
// Someone else's plan is refused and the link stays intact
|
||||
const cross = await owner
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: session.data, roastPlanId: foreignPlanId });
|
||||
assert.equal(cross.status, 404);
|
||||
assert.equal(
|
||||
(await owner.get(`/api/cupping/${session.id}`)).body.session.roastPlanId,
|
||||
planId,
|
||||
);
|
||||
|
||||
// Explicit null detaches
|
||||
const detached = await owner
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: session.data, roastPlanId: null });
|
||||
assert.equal(detached.status, 200);
|
||||
assert.equal(detached.body.session.roastPlanId, null);
|
||||
});
|
||||
|
||||
@@ -326,3 +326,19 @@ test("roasts are private to their owner", async () => {
|
||||
.send({ roastPlanId: plan.body.plan.id, filename: "x.alog", content: makeAlog() });
|
||||
assert.equal(crossPlan.status, 404);
|
||||
});
|
||||
|
||||
test("reference-curve parse endpoint accepts a real-sized (multi-MB) .alog", async () => {
|
||||
const { agent } = await setup({}, { evaluateRoast: evaluationStub() });
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
// Real Artisan logs carry full telemetry arrays and legitimately exceed 1 MB —
|
||||
// /api/alog must share the enlarged body cap that /api/roasts already has.
|
||||
const doc = JSON.parse(makeAlog("Big log"));
|
||||
doc.padding = "x".repeat(2 * 1024 * 1024);
|
||||
const res = await agent
|
||||
.post("/api/alog")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ filename: "big.alog", content: JSON.stringify(doc) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(res.body.roast.title, "Big log");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user