From 9f9b13aa01492ace3a8ec37bc6d0aa64251bd4dd Mon Sep 17 00:00:00 2001 From: Andraxion Date: Fri, 26 Jun 2026 23:35:24 -0400 Subject: [PATCH] Process launcher requests into active items --- server.js | 284 ++++++++++++++++++++++++++++++++++-- src/WorldshaperLauncher.tsx | 170 +++++++++++++++------ src/index.css | 173 +++++++++++++++++++--- 3 files changed, 546 insertions(+), 81 deletions(-) diff --git a/server.js b/server.js index 25d5240..5dfc791 100644 --- a/server.js +++ b/server.js @@ -45,6 +45,156 @@ const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json"); const recentSaveEvents = []; const DEFAULT_WORLDSHAPER_THEME_PRESET = "azure"; const WORLDSHAPER_THEME_PRESET_IDS = new Set(["azure", "verdant", "ember", "amethyst"]); +const LEGACY_LAUNCHER_REQUEST_MIGRATIONS = { + request_mqvrray7v2kg9y: [ + { + id: "active_layers_step_reordering", + title: "Safer layer reordering", + category: "Layers", + tags: ["Layers", "UI / Workflow"], + summary: "Layer reordering is too coarse right now, and moving the currently selected layer can produce confusing results when 'Move toward Base' jumps it more than expected.", + implementationNotes: "Replace the current coarse move action with explicit 'Move Up' and 'Move Down' commands that swap adjacent layer records only one step at a time. Keep the moved layer selected through the swap, clamp movement at the background/top boundaries, and refresh cached layer metadata after the final order change so the active layer never appears to jump unpredictably.", + }, + ], + request_mqvrykx1rtgl9l: [ + { + id: "active_layers_day_night_lighting", + title: "Day-night lighting layer", + category: "Layers", + tags: ["Layers", "Rendering", "World Systems"], + summary: "Worldshaper should support a top-level lighting layer driven by time of day so the scene brightens and darkens based on a controllable sun exposure model.", + implementationNotes: "Add a dedicated lighting pass above the world layers, then store time-of-day settings on the world definition such as sun angle, daylight curve, ambient minimum, and transition length. The renderer can evaluate those settings per frame and multiply the final scene colors by the resulting exposure value without rewriting individual chunk art.", + }, + { + id: "active_layers_weather_overlay", + title: "Weather overlay layer", + category: "Layers", + tags: ["Layers", "Rendering", "World Systems"], + summary: "Weather effects like clouds and rain should exist as their own highest-order layer so they can tint or darken the world without editing the base map.", + implementationNotes: "Treat weather as a dedicated overlay layer that stores brush masks, particle regions, or placed weather sprites. During rendering, composite the weather layer after the main chunk layers and let each effect contribute opacity, tint, and optional darkness so cloud cover can filter light independently from the terrain below.", + }, + ], + request_mqvs0joxmxvnl6: [ + { + id: "active_chunks_background_color_fills", + title: "Per-chunk background colors", + category: "Chunks", + tags: ["Chunks", "Rendering"], + summary: "Chunks should be able to define a flat fallback background color instead of relying only on a background tile asset.", + implementationNotes: "Extend chunk payloads with an optional background color override, expose it in the chunk editing controls, and have the renderer fill the chunk with that color before drawing background tiles or empty cells. If the color is unset, keep falling back to the world-level default background behavior.", + }, + ], + request_mqvs2pc8tr2wyw: [ + { + id: "active_chunks_terrain_chunk_painter", + title: "Terrain chunk painter", + category: "Chunks", + tags: ["Chunks", "Tiling", "Tools"], + summary: "Large terrain features such as rivers, mountains, ravines, and woods should be paintable as semantic terrain operations instead of hand-placing every supporting tile.", + implementationNotes: "Introduce a terrain painting mode that writes high-level masks or terrain types into a chunk-first data layer, then resolve those masks into tile selections with rules for edges, walls, floors, peaks, and deep water. That gives you fast broad painting while keeping the final result grounded in actual tile output.", + }, + ], + request_mqvs3w4r1n2552: [ + { + id: "active_performance_chunk_indexing_audit", + title: "Chunk indexing audit", + category: "Performance", + tags: ["Performance", "Chunks"], + summary: "Evaluate whether spatial indexing structures would materially improve chunk lookup and neighborhood loading, rather than assuming they will.", + implementationNotes: "Profile the current chunk grid and cache path first, because exact chunk lookups are already grid-addressable and usually favor a sparse map plus cached bounds over a quadtree or kd-tree. If profiling shows true range-query pressure later, start with a lightweight in-memory chunk manifest before introducing a more complex spatial tree.", + }, + ], + request_mqvsarw8jmlzph: [ + { + id: "active_tiling_autotile_structures", + title: "Autotile structures", + category: "Tiling", + tags: ["Tiling", "Tools"], + summary: "Users should be able to sketch a structure footprint and have Worldshaper infer the matching walls, doors, windows, flooring, and other tiles automatically.", + implementationNotes: "Build an autotile ruleset system that reads either a painted mask or placed anchor corners, classifies interior, edge, and corner cells, and then emits tile IDs from a structure-specific palette. The first version can target one pattern family such as houses before generalizing to broader autotile presets.", + }, + { + id: "active_tiling_prefab_stamps", + title: "Prefab tile stamps", + category: "Tiling", + tags: ["Tiling", "Chunks", "Tools"], + summary: "Reusable stamps should let creators place premade or lightly procedural tile assemblies like houses or forests in one action.", + implementationNotes: "Represent a prefab as a chunk-relative bundle of tiles, optional instances, and parameter slots, then add a stamp placement tool with preview, rotation, and flip support. Once the stamp is accepted, resolve the prefab into normal chunk edits so it works with the rest of the editing pipeline.", + }, + { + id: "active_graphics_painter_move_tool", + title: "Graphic painter move tool", + category: "Graphics Painter", + tags: ["Graphics Painter", "Tools"], + summary: "The graphic painter needs a select-and-move workflow so artists can reposition existing pixels or regions without redrawing them by hand.", + implementationNotes: "Add marquee selection in the painter, store the selected pixels as a temporary overlay, and let drag operations translate that overlay before committing it back into the current frame. Once selection movement exists, copy, paste, and flip workflows become much easier to layer on top.", + }, + { + id: "active_ui_dockable_tool_windows", + title: "Dockable editor windows", + category: "UI / Workflow", + tags: ["UI / Workflow", "Windows"], + summary: "Floating tool windows should be able to clamp into fixed UI panels when dragged to the sides of the editor.", + implementationNotes: "Extend the existing popout window controller with edge snap targets and a docked layout mode. Persist whether each tool is floating or docked alongside its saved rect so tools can move fluidly between free windows and locked side panels.", + }, + ], + request_mqvsax8rdgcj1o: [ + { + id: "active_tiling_unsnapped_tile_placement", + title: "Unsnapped tile placement", + category: "Tiling", + tags: ["Tiling", "Chunks", "Layers"], + summary: "Tile placement should support a free-placement or sublayer mode that is not forced into the saved chunk row grid.", + implementationNotes: "Add a chunk patch or overlay sublayer format that stores per-placement offsets or patch cells separately from the canonical chunk rows. A hotkey can switch between snapped grid placement and patch placement so freeform details remain possible without destabilizing the base tile grid.", + }, + ], + request_mqvsb6r8u29ewv: [ + { + id: "active_ui_custom_prompts_and_confirms", + title: "Custom prompts and confirmations", + category: "UI / Workflow", + tags: ["UI / Workflow", "Windows"], + summary: "Browser-native prompts and confirmation dialogs should be replaced with editor-native UI so they match the rest of Worldshaper.", + implementationNotes: "Create a reusable modal or popout dialog controller that supports message-only confirms, text input prompts, validation, and custom button labels. Once that exists, swap the current `prompt` and `confirm` call sites over to it so every critical interaction uses the same styled flow.", + }, + ], + request_mqvsdze7taujmp: [ + { + id: "active_layers_elevation_tile_variants", + title: "Elevation-driven tile variants", + category: "Layers", + tags: ["Layers", "Rendering", "World Systems"], + summary: "Tiles with elevation should be able to reveal different visual subframes as the camera or player viewpoint rises through stacked floors.", + implementationNotes: "Add elevation metadata plus optional frame variants to tile definitions, then track current view elevation in runtime state. During rendering, only tiles in the viewport whose elevation thresholds are crossed need to swap to their alternate subframe, which keeps the effect local and efficient.", + }, + ], + request_mqvsg4r5eaq210: [ + { + id: "active_tiling_animated_tilestrip_instances", + title: "Animated tilestrip instances", + category: "Tiling", + tags: ["Tiling", "Animation", "Rendering"], + summary: "An image with multiple animation frames should be usable like a tilestrip, where placement can target a specific subimage or frame without duplicating separate assets.", + implementationNotes: "Let tile definitions reference an image frame set plus a selected default subframe, then have placement store the chosen frame index alongside the tile ID when needed. That keeps one asset source while still allowing different tile instances to resolve to different strip positions or animation states.", + }, + { + id: "active_rendering_animation_playback_controls", + title: "Renderer animation playback controls", + category: "Rendering", + tags: ["Rendering", "Animation", "Graphics Painter"], + summary: "Animated graphics should play in the live renderer and be controllable through a hotkey and engine override toggle.", + implementationNotes: "Promote frame timing and playback metadata from the graphic painter into runtime render state, then add a global animation-enabled flag that can be toggled from both input and engine overrides. The renderer can respect that flag before advancing animation clocks for visible animated assets.", + }, + { + id: "active_graphics_frame_duplication_effects", + title: "Frame duplication effects", + category: "Graphics Painter", + tags: ["Graphics Painter", "Animation", "Tools"], + summary: "Animation work would move faster with one-click helpers that duplicate a frame and apply a predictable transform such as shifting pixels in a direction.", + implementationNotes: "Add scripted timeline actions inside the graphic painter that clone the selected frame, transform the copied pixels by a configured offset or effect, and append the new result as another frame. Start with duplicate-and-shift, then add room later for fade, opacity, and color-shift helpers.", + }, + ], +}; const contentMap = { npcs: { file: "npcs.json", root: "npcs" }, @@ -223,6 +373,26 @@ function createLauncherRequestId() { return `request_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; } +function normalizeLauncherRequestStatus(value) { + return String(value || "").trim().toLowerCase() === "active" ? "active" : "pending"; +} + +function normalizeLauncherRequestTags(value) { + return normalizeUniqueStringList(value, { + normalizeValue: (entry) => String(entry || "").replace(/\s+/g, " ").trim(), + dedupeKey: (entry) => String(entry || "").replace(/\s+/g, " ").trim().toLowerCase(), + }); +} + +function buildPendingLauncherRequestTitle(text, fallback = "Pending request") { + const normalized = String(text || "").replace(/\s+/g, " ").trim(); + if (!normalized) { + return fallback; + } + const firstSentence = normalized.split(/[\r\n.!?]+/).map((entry) => entry.trim()).find(Boolean) || normalized; + return firstSentence.length > 72 ? `${firstSentence.slice(0, 69).trim()}...` : firstSentence; +} + function normalizeLauncherRequestEntry(entry, index = 0) { const source = entry && typeof entry === "object" && !Array.isArray(entry) ? entry @@ -230,8 +400,12 @@ function normalizeLauncherRequestEntry(entry, index = 0) { if (!source) { return null; } - const text = String(source.text || "").trim(); - if (!text) { + const sourceText = String(source.sourceText || source.text || "").trim(); + const title = String(source.title || "").trim() || buildPendingLauncherRequestTitle(sourceText); + const status = normalizeLauncherRequestStatus(source.status); + const summary = String(source.summary || "").trim() + || (sourceText ? (status === "pending" ? "Awaiting parsing and categorization." : sourceText) : ""); + if (!title || !summary) { return null; } const createdAt = String(source.createdAt || "").trim() || new Date().toISOString(); @@ -239,31 +413,98 @@ function normalizeLauncherRequestEntry(entry, index = 0) { const fallbackId = `request_${index + 1}`; return { id: String(source.id || fallbackId).trim() || fallbackId, - text, - done: source.done === true, + sourceSubmissionId: String(source.sourceSubmissionId || "").trim() || undefined, + title, + status, + category: String(source.category || "").trim() || (status === "active" ? "General" : "Unsorted"), + tags: normalizeLauncherRequestTags(source.tags), + sourceText, + summary, + implementationNotes: String(source.implementationNotes || "").trim(), createdAt, updatedAt, }; } +function expandLegacyLauncherRequestEntry(entry, index = 0) { + const source = entry && typeof entry === "object" && !Array.isArray(entry) + ? entry + : null; + if (!source) { + return []; + } + const sourceId = String(source.id || `legacy_request_${index + 1}`).trim() || `legacy_request_${index + 1}`; + const sourceText = String(source.text || "").trim(); + const createdAt = String(source.createdAt || "").trim() || new Date().toISOString(); + const updatedAt = String(source.updatedAt || "").trim() || createdAt; + const migrations = LEGACY_LAUNCHER_REQUEST_MIGRATIONS[sourceId]; + if (Array.isArray(migrations) && migrations.length > 0) { + return migrations + .map((migration, migrationIndex) => normalizeLauncherRequestEntry({ + id: migration.id || `${sourceId}_active_${migrationIndex + 1}`, + sourceSubmissionId: sourceId, + title: migration.title, + status: "active", + category: migration.category, + tags: migration.tags, + sourceText, + summary: migration.summary, + implementationNotes: migration.implementationNotes, + createdAt, + updatedAt, + }, migrationIndex)) + .filter(Boolean); + } + const pendingEntry = normalizeLauncherRequestEntry({ + id: sourceId, + title: buildPendingLauncherRequestTitle(sourceText), + status: "pending", + category: "Unsorted", + tags: [], + sourceText, + summary: "Awaiting parsing and categorization.", + implementationNotes: "", + createdAt, + updatedAt, + }, index); + return pendingEntry ? [pendingEntry] : []; +} + function readLauncherRequestsPayload() { - const fallback = { schemaVersion: 1, requests: [] }; + const fallback = { schemaVersion: 2, requests: [] }; const payload = readJsonSafe(launcherRequestsPath, fallback); + let migrated = false; const requests = Array.isArray(payload?.requests) - ? payload.requests - .map((entry, index) => normalizeLauncherRequestEntry(entry, index)) - .filter(Boolean) + ? payload.requests.flatMap((entry, index) => { + const isStructured = entry + && typeof entry === "object" + && !Array.isArray(entry) + && (Object.prototype.hasOwnProperty.call(entry, "status") + || Object.prototype.hasOwnProperty.call(entry, "title") + || Object.prototype.hasOwnProperty.call(entry, "summary") + || Object.prototype.hasOwnProperty.call(entry, "sourceText")); + if (isStructured) { + const normalized = normalizeLauncherRequestEntry(entry, index); + return normalized ? [normalized] : []; + } + migrated = true; + return expandLegacyLauncherRequestEntry(entry, index); + }) : []; - return { - schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1, + const normalizedPayload = { + schemaVersion: typeof payload?.schemaVersion === "number" ? Math.max(2, payload.schemaVersion) : 2, requests, }; + if (migrated || normalizedPayload.schemaVersion !== payload?.schemaVersion) { + writeLauncherRequestsPayload(normalizedPayload); + } + return normalizedPayload; } function writeLauncherRequestsPayload(payload) { const requests = Array.isArray(payload?.requests) ? payload.requests : []; writeJsonAtomic(launcherRequestsPath, { - schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1, + schemaVersion: typeof payload?.schemaVersion === "number" ? Math.max(2, payload.schemaVersion) : 2, requests: requests .map((entry, index) => normalizeLauncherRequestEntry(entry, index)) .filter(Boolean), @@ -2118,8 +2359,13 @@ app.post("/api/launcher-requests", (req, res) => { const now = new Date().toISOString(); const requestEntry = normalizeLauncherRequestEntry({ id: createLauncherRequestId(), - text, - done: false, + title: buildPendingLauncherRequestTitle(text), + status: "pending", + category: "Unsorted", + tags: [], + sourceText: text, + summary: "Awaiting parsing and categorization.", + implementationNotes: "", createdAt: now, updatedAt: now, }, payload.requests.length); @@ -2153,10 +2399,15 @@ app.patch("/api/launcher-requests/:requestId", (req, res) => { return; } const existing = payload.requests[index]; - const nextDone = req.body?.done === true; const updated = normalizeLauncherRequestEntry({ ...existing, - done: nextDone, + title: req.body?.title ?? existing.title, + status: req.body?.status ?? existing.status, + category: req.body?.category ?? existing.category, + tags: Array.isArray(req.body?.tags) ? req.body.tags : existing.tags, + sourceText: req.body?.sourceText ?? existing.sourceText, + summary: req.body?.summary ?? existing.summary, + implementationNotes: req.body?.implementationNotes ?? existing.implementationNotes, updatedAt: new Date().toISOString(), }, index); const nextRequests = [...payload.requests]; @@ -2168,7 +2419,8 @@ app.patch("/api/launcher-requests/:requestId", (req, res) => { recordSaveEvent({ type: "launcher-request-update", requestId, - done: updated.done, + status: updated.status, + category: updated.category, }); res.json({ ok: true, diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index f99c961..da5c96e 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -23,8 +23,14 @@ type BoardTab = "news" | "requests"; type LauncherRequest = { id: string; - text: string; - done: boolean; + sourceSubmissionId?: string; + title: string; + status: "pending" | "active"; + category: string; + tags: string[]; + sourceText: string; + summary: string; + implementationNotes: string; createdAt: string; updatedAt: string; }; @@ -73,6 +79,10 @@ function formatRequestTimestamp(value: string): string { }).format(parsed); } +function formatRequestStatusLabel(status: "pending" | "active"): string { + return status === "active" ? "Active" : "Pending"; +} + function openStudioPopup(worldId: string): boolean { const popup = openWorldshaperStudioWindow(worldId, window, { worldId }); return Boolean(popup); @@ -95,6 +105,8 @@ function WorldshaperLauncher() { const [requestDraft, setRequestDraft] = useState(""); const [requestSubmitting, setRequestSubmitting] = useState(false); const [requestMutatingId, setRequestMutatingId] = useState(""); + const [requestFilter, setRequestFilter] = useState("all"); + const [expandedRequestIds, setExpandedRequestIds] = useState([]); useEffect(() => { let cancelled = false; @@ -198,27 +210,16 @@ function WorldshaperLauncher() { } } - async function handleToggleRequest(requestEntry: LauncherRequest): Promise { - setRequestMutatingId(requestEntry.id); - try { - const payload = await fetchJsonOrThrow(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ done: !requestEntry.done }), - }); - setRequests(Array.isArray(payload.requests) ? payload.requests : []); - setRequestsError(""); - } catch (nextError: unknown) { - setRequestsError(String(nextError || "Failed to update request.")); - } finally { - setRequestMutatingId(""); - } + function handleToggleExpandedRequest(requestId: string): void { + setExpandedRequestIds((current) => ( + current.includes(requestId) + ? current.filter((entry) => entry !== requestId) + : [...current, requestId] + )); } async function handleDeleteRequest(requestEntry: LauncherRequest): Promise { - const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.text}`); + const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.title}`); if (!confirmed) { return; } @@ -229,6 +230,7 @@ function WorldshaperLauncher() { }); setRequests(Array.isArray(payload.requests) ? payload.requests : []); setRequestsError(""); + setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id)); } catch (nextError: unknown) { setRequestsError(String(nextError || "Failed to delete request.")); } finally { @@ -238,10 +240,30 @@ function WorldshaperLauncher() { const isBusy = launchState === "opening"; const requestCount = requests.length; - const pendingRequestCount = requests.filter((entry) => entry.done !== true).length; + const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length; + const activeRequestCount = requests.filter((entry) => entry.status === "active").length; + const requestTags = Array.from(new Set( + requests + .flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : []) + .map((entry) => String(entry || "").trim()) + .filter(Boolean), + )).sort((a, b) => a.localeCompare(b)); + const filteredRequests = requests.filter((entry) => { + if (requestFilter === "status:pending") { + return entry.status === "pending"; + } + if (requestFilter === "status:active") { + return entry.status === "active"; + } + if (requestFilter.startsWith("tag:")) { + const tag = requestFilter.slice(4); + return entry.status === "active" && entry.tags.includes(tag); + } + return true; + }); const boardHint = activeBoardTab === "news" ? "Latest announcements" - : `${pendingRequestCount} open request${pendingRequestCount === 1 ? "" : "s"}`; + : `${pendingRequestCount} pending, ${activeRequestCount} active`; return (
Shared Request Board
Requests
- {requestCount} saved request{requestCount === 1 ? "" : "s"} with {pendingRequestCount} still open. + {requestCount} saved request{requestCount === 1 ? "" : "s"}: {pendingRequestCount} pending and {activeRequestCount} active.
-
- +
+
+ +
+
{requestDraftOpen ? (
@@ -407,40 +446,77 @@ function WorldshaperLauncher() {
Loading saved requests...
) : null} - {!requestsLoading && requests.length === 0 ? ( + {!requestsLoading && filteredRequests.length === 0 ? (
-
No requests yet. Add the first one to start the board.
+
+ {requests.length === 0 + ? "No requests yet. Add the first one to start the board." + : "No requests match the current filter."} +
) : null} - {!requestsLoading ? requests.map((requestEntry) => { + {!requestsLoading ? filteredRequests.map((requestEntry) => { const isMutating = requestMutatingId === requestEntry.id; + const isExpanded = expandedRequestIds.includes(requestEntry.id); + const isActiveRequest = requestEntry.status === "active"; return (
handleToggleExpandedRequest(requestEntry.id) : undefined} >
- +
+
+ {formatRequestStatusLabel(requestEntry.status)} +
+
+

{requestEntry.title}

+
{requestEntry.category}
+
+
-
{requestEntry.text}
+ {requestEntry.tags.length > 0 ? ( +
+ {requestEntry.tags.map((tag) => ( + {tag} + ))} +
+ ) : null} +
+ {requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText} +
{formatRequestTimestamp(requestEntry.createdAt)}
+ {isActiveRequest && isExpanded ? ( +
+
+
Parsed interpretation
+

{requestEntry.summary}

+
+
+
How we could do that
+

{requestEntry.implementationNotes}

+
+ {requestEntry.sourceText ? ( +
+
Original submission
+

{requestEntry.sourceText}

+
+ ) : null} +
+ ) : null}
); }) : null} diff --git a/src/index.css b/src/index.css index 64bdfdb..ffff6eb 100644 --- a/src/index.css +++ b/src/index.css @@ -255,11 +255,50 @@ body { padding-right: 4px; } +.launcher-request-controls { + position: sticky; + top: 0; + z-index: 3; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 12px; + border: 1px solid #365782; + border-radius: 12px; + background: + linear-gradient(180deg, rgba(16, 29, 56, 0.96) 0%, rgba(12, 22, 43, 0.96) 100%); + box-shadow: + 0 10px 24px rgba(3, 8, 18, 0.26), + inset 0 0 0 1px rgba(10, 16, 32, 0.14); +} + .launcher-request-toolbar { display: flex; justify-content: flex-start; } +.launcher-request-filter { + display: grid; + gap: 4px; + min-width: 190px; +} + +.launcher-request-filter-label { + color: #9fb8e5; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.launcher-request-filter-select { + min-height: 40px; + border-color: #365782; + background: rgba(8, 16, 31, 0.88); + color: #eef6ff; +} + .launcher-request-composer { display: grid; gap: 10px; @@ -313,9 +352,23 @@ body { box-shadow: inset 0 0 0 1px rgba(10, 16, 32, 0.14); } -.launcher-request-entry.is-done { - border-color: #2f7e60; - background: rgba(15, 40, 33, 0.84); +.launcher-request-entry.is-clickable { + cursor: pointer; +} + +.launcher-request-entry.is-active { + border-color: #4f79af; +} + +.launcher-request-entry.is-pending { + border-color: #6d5f36; + background: rgba(45, 34, 15, 0.64); +} + +.launcher-request-entry.is-expanded { + box-shadow: + 0 14px 28px rgba(3, 8, 18, 0.28), + inset 0 0 0 1px rgba(10, 16, 32, 0.14); } .launcher-request-entry-head { @@ -325,19 +378,53 @@ body { gap: 12px; } -.launcher-request-check { - display: inline-flex; - align-items: center; - gap: 8px; - color: #eef6ff; - font-size: 12px; - font-weight: 700; +.launcher-request-entry-head-main { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; } -.launcher-request-check input { - width: 16px; - height: 16px; +.launcher-request-status-pill { + padding: 4px 9px; + border: 1px solid #365782; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +.launcher-request-status-pill.is-active { + border-color: #2f7e60; + background: rgba(19, 73, 50, 0.88); + color: #b7f0d5; +} + +.launcher-request-status-pill.is-pending { + border-color: #9c8140; + background: rgba(93, 70, 19, 0.78); + color: #ffe7a9; +} + +.launcher-request-entry-title-block { + min-width: 0; + display: grid; + gap: 3px; +} + +.launcher-request-entry-title { margin: 0; + color: #eef6ff; + font-size: 14px; + line-height: 1.25; +} + +.launcher-request-entry-category { + color: #9fb8e5; + font-size: 11px; + line-height: 1.35; } .launcher-request-delete-btn { @@ -354,6 +441,22 @@ body { background: #672536; } +.launcher-request-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.launcher-request-tag { + padding: 4px 8px; + border: 1px solid #365782; + border-radius: 999px; + background: rgba(25, 48, 87, 0.72); + color: #d7e7ff; + font-size: 11px; + line-height: 1; +} + .launcher-request-entry-text { color: #d7e7ff; font-size: 13px; @@ -361,11 +464,6 @@ body { white-space: pre-wrap; } -.launcher-request-entry.is-done .launcher-request-entry-text { - color: #9ec7b4; - text-decoration: line-through; -} - .launcher-request-entry-meta, .launcher-request-empty { color: #9fb8e5; @@ -373,6 +471,37 @@ body { line-height: 1.4; } +.launcher-request-expanded { + display: grid; + gap: 10px; + padding-top: 2px; +} + +.launcher-request-expanded-block { + display: grid; + gap: 5px; + padding: 10px 12px; + border: 1px solid #365782; + border-radius: 10px; + background: rgba(8, 16, 31, 0.74); +} + +.launcher-request-expanded-label { + color: #9fd8ff; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.launcher-request-expanded-copy { + margin: 0; + color: #d7e7ff; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} + .launcher-request-error { margin: 0; color: #ff9aa4; @@ -1567,6 +1696,14 @@ button.danger:not(:disabled):hover { flex-direction: column; } + .launcher-request-controls { + grid-template-columns: 1fr; + } + + .launcher-request-filter { + min-width: 0; + } + .launcher-primary-btn, .launcher-secondary-btn { width: 100%;