chore: initial deploy — SLA Shift Ledger Foundry VTT module

This commit is contained in:
2026-05-24 22:02:45 +01:00
commit 4c8da646da
6 changed files with 1018 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
{
"SLAShiftLedger.Title": "SLA Shift Ledger"
}
+29
View File
@@ -0,0 +1,29 @@
{
"id": "sla-shift-ledger",
"title": "SLA Shift Ledger",
"description": "Tracks SLA campaign day shifts, actor duty schedules, housing tiers, and weekly living-cost ledgers.",
"version": "0.1.0",
"compatibility": {
"minimum": "13",
"verified": "14",
"maximum": "14"
},
"authors": [
{
"name": "Tim Evans"
}
],
"esmodules": [
"module/sla-shift-ledger.mjs"
],
"styles": [
"styles/sla-shift-ledger.css"
],
"languages": [
{
"lang": "en",
"name": "English",
"path": "lang/en.json"
}
]
}
+584
View File
@@ -0,0 +1,584 @@
const MODULE_ID = "sla-shift-ledger";
const STATE_KEY = "campaignState";
const CONFIG_KEY = "billingConfig";
const ACTOR_FLAG = "actorState";
const SHIFTS = [
{ key: "morning", label: "Morning Shift", short: "Morning" },
{ key: "afternoon", label: "Afternoon Shift", short: "Afternoon" },
{ key: "evening", label: "Evening Shift", short: "Evening" },
{ key: "night", label: "Night Shift", short: "Night" }
];
const DEFAULT_ACTIVITIES = [
"Investigation",
"Resting",
"Normal Social Time",
"Transit",
"Fieldwork",
"Surveillance",
"Paperwork",
"Medical Recovery",
"Training",
"Downtime"
];
function defaultCampaignState() {
return {
day: 1000,
shift: 0
};
}
function defaultConfig() {
return {
activityOptions: [...DEFAULT_ACTIVITIES],
housingLabels: [
"Squalid",
"Bare Minimum",
"Cheap Cubicle",
"Basic",
"Steady",
"Comfortable",
"Professional",
"High Grade",
"Luxury",
"Executive"
],
housingCosts: Array.from({ length: 10 }, (_value, index) => (index + 1) * 50),
livingMultiplier: 0.5,
autoChargeWeekly: true
};
}
function defaultActorState() {
return {
housingTier: 1,
schedule: {},
ledger: [],
lastBilledWeek: 0
};
}
function getState() {
return foundry.utils.mergeObject(defaultCampaignState(), game.settings.get(MODULE_ID, STATE_KEY) ?? {});
}
function getConfig() {
const cfg = foundry.utils.mergeObject(defaultConfig(), game.settings.get(MODULE_ID, CONFIG_KEY) ?? {});
cfg.activityOptions = normalizeActivityOptions(cfg.activityOptions);
cfg.housingLabels = Array.from({ length: 10 }, (_value, index) => String(cfg.housingLabels?.[index] ?? defaultConfig().housingLabels[index]).trim() || defaultConfig().housingLabels[index]);
cfg.housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(cfg.housingCosts?.[index] ?? ((index + 1) * 50)) || 0));
cfg.livingMultiplier = Math.max(0, Number(cfg.livingMultiplier ?? 0.5) || 0);
cfg.autoChargeWeekly = cfg.autoChargeWeekly !== false;
return cfg;
}
async function setState(nextState) {
await game.settings.set(MODULE_ID, STATE_KEY, foundry.utils.mergeObject(defaultCampaignState(), nextState ?? {}));
}
async function setConfig(nextConfig) {
await game.settings.set(MODULE_ID, CONFIG_KEY, foundry.utils.mergeObject(defaultConfig(), nextConfig ?? {}));
}
function normalizeActivityOptions(value) {
if (Array.isArray(value)) {
const cleaned = value.map((entry) => String(entry ?? "").trim()).filter(Boolean);
return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES];
}
const cleaned = String(value ?? "")
.split(/\n+/)
.map((entry) => entry.trim())
.filter(Boolean);
return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES];
}
function getWeekFromDay(day) {
const numericDay = Math.max(0, Number(day ?? 0) || 0);
return Math.floor(numericDay / 7) + 1;
}
function getShift(index) {
return SHIFTS[Math.max(0, Math.min(SHIFTS.length - 1, Number(index ?? 0) || 0))] ?? SHIFTS[0];
}
function buildCampaignStamp(state = getState()) {
return `Day ${state.day} / ${getShift(state.shift).label}`;
}
function getActorState(actor) {
return foundry.utils.mergeObject(defaultActorState(), actor.getFlag(MODULE_ID, ACTOR_FLAG) ?? {});
}
async function setActorState(actor, nextState) {
await actor.setFlag(MODULE_ID, ACTOR_FLAG, foundry.utils.mergeObject(defaultActorState(), nextState ?? {}));
}
function normalizeDaySchedule(scheduleMap, day) {
const next = foundry.utils.deepClone(scheduleMap ?? {});
const key = String(day);
const existing = Array.isArray(next[key]) ? next[key] : [];
next[key] = Array.from({ length: SHIFTS.length }, (_value, index) => String(existing[index] ?? DEFAULT_ACTIVITIES[0] ?? "Investigation"));
return next;
}
function calculateWeeklyCosts(housingTier, config = getConfig()) {
const index = Math.max(0, Math.min(9, Number(housingTier ?? 1) - 1));
const housing = Number(config.housingCosts?.[index] ?? 0) || 0;
const living = Math.round(housing * (Number(config.livingMultiplier ?? 0.5) || 0) * 100) / 100;
return {
label: String(config.housingLabels?.[index] ?? `Tier ${index + 1}`),
housing,
living,
total: Math.round((housing + living) * 100) / 100
};
}
function getViewedDay(app) {
const currentDay = Number(getState().day ?? 1000) || 1000;
const stored = Number(app?._slaShiftLedgerViewedDay ?? currentDay) || currentDay;
return Math.max(0, stored);
}
function getHtmlRoot(html) {
return html instanceof HTMLElement ? html : html?.[0] ?? null;
}
function getCredits(actor) {
return Number(actor.system?.credits?.value ?? 0) || 0;
}
async function applyWeeklyBilling(targetWeek = getWeekFromDay(getState().day), { force = false } = {}) {
const config = getConfig();
const state = getState();
const actors = game.actors.filter((actor) => actor.type === "character");
for (const actor of actors) {
const actorState = getActorState(actor);
if (!force && Number(actorState.lastBilledWeek ?? 0) >= Number(targetWeek)) continue;
const costs = calculateWeeklyCosts(actorState.housingTier, config);
const currentCredits = getCredits(actor);
const newCredits = Math.round((currentCredits - costs.total) * 100) / 100;
const entry = {
week: targetWeek,
day: state.day,
stamp: buildCampaignStamp(state),
housingTier: actorState.housingTier,
housingLabel: costs.label,
housing: costs.housing,
living: costs.living,
total: costs.total,
balance: newCredits
};
const ledger = [entry, ...(Array.isArray(actorState.ledger) ? actorState.ledger : [])].slice(0, 24);
await setActorState(actor, {
...actorState,
ledger,
lastBilledWeek: Number(targetWeek)
});
await actor.update({ "system.credits.value": newCredits });
}
}
async function advanceShift(delta = 1) {
const state = getState();
const previousDay = Number(state.day ?? 1000) || 1000;
const previousWeek = getWeekFromDay(previousDay);
let nextShift = Number(state.shift ?? 0) + Number(delta ?? 0);
let nextDay = previousDay;
while (nextShift < 0) {
nextShift += SHIFTS.length;
nextDay = Math.max(0, nextDay - 1);
}
while (nextShift >= SHIFTS.length) {
nextShift -= SHIFTS.length;
nextDay += 1;
}
const nextState = { day: nextDay, shift: nextShift };
await setState(nextState);
const nextWeek = getWeekFromDay(nextDay);
if (delta > 0 && nextWeek > previousWeek && getConfig().autoChargeWeekly) {
await applyWeeklyBilling(nextWeek);
}
renderCampaignBanner();
ui.notifications.info(`Campaign time advanced to ${buildCampaignStamp(nextState)}.`);
}
function renderCampaignBanner() {
let host = document.querySelector("#sla-shift-ledger-banner");
if (!host) {
host = document.createElement("section");
host.id = "sla-shift-ledger-banner";
host.className = "sla-shift-ledger-banner";
document.body.appendChild(host);
}
const state = getState();
const weekNumber = getWeekFromDay(state.day);
const currentShift = getShift(state.shift);
host.innerHTML = `
<div class="sla-shift-ledger-banner-inner">
<div class="sla-shift-ledger-banner-meta">
<div class="sla-shift-ledger-banner-kicker">SLA Campaign Clock</div>
<div class="sla-shift-ledger-banner-title">Day ${state.day} · Week ${weekNumber}</div>
<div class="sla-shift-ledger-banner-subtitle">${currentShift.label}</div>
</div>
<div class="sla-shift-ledger-banner-main">
<div class="sla-shift-ledger-shifts">
${SHIFTS.map((shift, index) => `
<div class="sla-shift-ledger-shift ${index === Number(state.shift) ? "is-active" : ""}">
${shift.short}
</div>
`).join("")}
</div>
<div class="sla-shift-ledger-banner-controls">
${game.user?.isGM ? `
<button type="button" class="sla-shift-ledger-button" data-action="prev">Prev</button>
<button type="button" class="sla-shift-ledger-button" data-action="next">Next</button>
<button type="button" class="sla-shift-ledger-button" data-action="open">Open</button>
` : ""}
</div>
</div>
</div>
`;
if (game.user?.isGM) {
host.querySelector("[data-action='prev']")?.addEventListener("click", () => advanceShift(-1));
host.querySelector("[data-action='next']")?.addEventListener("click", () => advanceShift(1));
host.querySelector("[data-action='open']")?.addEventListener("click", () => SlaShiftLedgerApp.open());
}
}
class SlaShiftLedgerApp extends FormApplication {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "sla-shift-ledger-app",
title: "SLA Shift Ledger",
template: "modules/sla-shift-ledger/templates/timekeeper.hbs",
classes: ["sla-shift-ledger", "mosh", "sheet"],
width: 760,
height: "auto",
submitOnChange: false,
closeOnSubmit: false,
resizable: true
});
}
async getData() {
const state = getState();
const config = getConfig();
return {
state,
config,
weekNumber: getWeekFromDay(state.day),
currentShiftLabel: getShift(state.shift).label,
activityOptionsText: config.activityOptions.join("\n"),
housingLevels: config.housingCosts.map((cost, index) => ({ tier: index + 1, cost, label: config.housingLabels[index] })),
shifts: SHIFTS.map((shift, index) => ({
...shift,
index,
selected: index === Number(state.shift),
active: index === Number(state.shift)
}))
};
}
activateListeners(html) {
super.activateListeners(html);
html.find("[data-action='prev']").on("click", () => advanceShift(-1));
html.find("[data-action='next']").on("click", () => advanceShift(1));
html.find("[data-action='bill']").on("click", async () => {
await applyWeeklyBilling(getWeekFromDay(getState().day), { force: true });
ui.notifications.info("Weekly billing applied to all character actors.");
});
}
async _updateObject(_event, formData) {
const housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(formData[`housingCost${index + 1}`] ?? 0) || 0));
const housingLabels = Array.from({ length: 10 }, (_value, index) => String(formData[`housingLabel${index + 1}`] ?? "").trim() || defaultConfig().housingLabels[index]);
await setState({
day: Math.max(0, Number(formData.day ?? 1000) || 1000),
shift: Math.max(0, Math.min(3, Number(formData.shift ?? 0) || 0))
});
await setConfig({
activityOptions: normalizeActivityOptions(formData.activityOptions),
housingLabels,
housingCosts,
livingMultiplier: Math.max(0, Number(formData.livingMultiplier ?? 0.5) || 0.5),
autoChargeWeekly: String(formData.autoChargeWeekly) !== "false"
});
renderCampaignBanner();
ui.notifications.info("SLA Shift Ledger saved.");
}
static open() {
return new SlaShiftLedgerApp().render(true);
}
}
function installSceneButton(_app, html) {
if (!game.user?.isGM) return;
const root = getHtmlRoot(html);
const directoryHeader = root?.querySelector(".directory-header");
if (!directoryHeader) return;
let header = directoryHeader.querySelector(".header-actions, .action-buttons");
if (!header) {
header = document.createElement("div");
header.className = "header-actions sla-shift-ledger-fallback-actions";
directoryHeader.appendChild(header);
}
if (!header || header.querySelector(".sla-shift-ledger-open")) return;
const button = document.createElement("button");
button.type = "button";
button.className = "sla-shift-ledger-open";
button.innerHTML = `<i class="fas fa-calendar-days"></i> Shift Ledger`;
button.addEventListener("click", () => SlaShiftLedgerApp.open());
header.appendChild(button);
}
function installSceneButtonFromSidebarTab(app, html) {
const root = getHtmlRoot(html);
const tabName = app?.tabName ?? root?.dataset?.tab ?? "";
if (tabName !== "scenes") return;
installSceneButton(app, html);
}
function buildLedgerHtml(actorState) {
const entries = Array.isArray(actorState.ledger) ? actorState.ledger : [];
if (!entries.length) {
return `<div class="sla-shift-ledger-ledger-entry">No weekly charges have been logged yet.</div>`;
}
return entries.map((entry) => `
<div class="sla-shift-ledger-ledger-entry">
<strong>Week ${entry.week}</strong> · ${entry.stamp}<br>
Housing Tier ${entry.housingTier}${entry.housingLabel ? ` · ${entry.housingLabel}` : ""}: -${entry.housing} cR · Living: -${entry.living} cR · <strong>Total -${entry.total} cR</strong><br>
Balance after charge: ${entry.balance} cR
</div>
`).join("");
}
function renderActorTimePanel(app, html) {
if (app.actor?.type !== "character") return;
const root = getHtmlRoot(html);
const notesTab = root?.querySelector(".tab[data-tab='notes']");
if (!notesTab || notesTab.querySelector(".sla-shift-ledger-actor-panel")) return;
const actor = app.actor;
const actorState = getActorState(actor);
const state = getState();
const config = getConfig();
const viewedDay = getViewedDay(app);
const schedule = normalizeDaySchedule(actorState.schedule, viewedDay);
const currentDaySchedule = schedule[String(viewedDay)];
const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config);
const currentShiftIndex = Number(state.shift ?? 0) || 0;
const panel = document.createElement("section");
panel.className = "sla-shift-ledger-actor-panel sla-card";
panel.innerHTML = `
<div class="sla-card-label">Shift Ledger</div>
<div class="sla-card-copy">Track what this operative is doing across the current day and let weekly housing and living costs feed a running ledger.</div>
<div class="sla-shift-ledger-actor-summary">
<span class="sla-shift-ledger-tag">Day ${viewedDay}</span>
<span class="sla-shift-ledger-tag">Week ${getWeekFromDay(viewedDay)}</span>
<span class="sla-shift-ledger-tag">${getShift(state.shift).label}</span>
<span class="sla-shift-ledger-tag">Current Duty: ${currentDaySchedule[currentShiftIndex] ?? config.activityOptions[0]}</span>
</div>
<div class="sla-shift-ledger-actor-card">
<div class="sla-shift-ledger-kicker">Daily Schedule</div>
<div class="sla-shift-ledger-day-nav">
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="-1">Earlier Day</button>
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="today">Current Day</button>
<button type="button" class="sla-shift-ledger-button" data-sla-day-nav="1">Later Day</button>
<span class="sla-shift-ledger-day-readout">Viewing Day ${viewedDay}</span>
</div>
<div class="sla-shift-ledger-actor-grid">
${SHIFTS.map((shift, index) => `
<div class="sla-shift-ledger-field">
<label>${shift.label}</label>
<select data-sla-shift-index="${index}" ${app.isEditable ? "" : "disabled"}>
${config.activityOptions.map((option) => `
<option value="${foundry.utils.escapeHTML(option)}" ${option === currentDaySchedule[index] ? "selected" : ""}>${foundry.utils.escapeHTML(option)}</option>
`).join("")}
</select>
</div>
`).join("")}
</div>
</div>
<div class="sla-shift-ledger-actor-card">
<div class="sla-shift-ledger-kicker">Housing & Cost of Living</div>
<div class="sla-shift-ledger-grid">
<div class="sla-shift-ledger-field">
<label>Housing Tier</label>
<select data-sla-housing-tier ${app.isEditable ? "" : "disabled"}>
${Array.from({ length: 10 }, (_value, index) => {
const tier = index + 1;
return `<option value="${tier}" ${tier === Number(actorState.housingTier ?? 1) ? "selected" : ""}>Tier ${tier} · ${config.housingLabels[index]} · ${config.housingCosts[index]} cR/week</option>`;
}).join("")}
</select>
</div>
<div class="sla-shift-ledger-field">
<label>Weekly Total</label>
<div class="sla-shift-ledger-tag">${weeklyCosts.label} · Housing ${weeklyCosts.housing} cR · Living ${weeklyCosts.living} cR · Total ${weeklyCosts.total} cR</div>
</div>
</div>
<div class="sla-shift-ledger-inline-actions">
${game.user?.isGM ? `<button type="button" class="sla-shift-ledger-button" data-sla-bill-now>Charge Now</button>` : ""}
</div>
</div>
<div class="sla-shift-ledger-actor-card">
<div class="sla-shift-ledger-kicker">Living Cost Ledger</div>
<div class="sla-shift-ledger-ledger">
${buildLedgerHtml(actorState)}
</div>
</div>
`;
notesTab.prepend(panel);
if (!app.isEditable) return;
panel.querySelectorAll("[data-sla-day-nav]").forEach((button) => {
button.addEventListener("click", () => {
const dir = button.dataset.slaDayNav;
if (dir === "today") {
app._slaShiftLedgerViewedDay = Number(state.day ?? viewedDay) || viewedDay;
} else {
app._slaShiftLedgerViewedDay = Math.max(0, viewedDay + (Number(dir) || 0));
}
app.render(false);
});
});
panel.querySelectorAll("[data-sla-shift-index]").forEach((select) => {
select.addEventListener("change", async (event) => {
const shiftIndex = Number(event.currentTarget.dataset.slaShiftIndex ?? 0) || 0;
const nextActorState = getActorState(actor);
const nextSchedule = normalizeDaySchedule(nextActorState.schedule, viewedDay);
nextSchedule[String(viewedDay)][shiftIndex] = String(event.currentTarget.value ?? config.activityOptions[0]);
await setActorState(actor, { ...nextActorState, schedule: nextSchedule });
app.render(false);
});
});
panel.querySelector("[data-sla-housing-tier]")?.addEventListener("change", async (event) => {
const nextActorState = getActorState(actor);
await setActorState(actor, {
...nextActorState,
housingTier: Math.max(1, Math.min(10, Number(event.currentTarget.value ?? 1) || 1))
});
app.render(false);
});
panel.querySelector("[data-sla-bill-now]")?.addEventListener("click", async () => {
const nextActorState = getActorState(actor);
const costs = calculateWeeklyCosts(nextActorState.housingTier, getConfig());
const stateNow = getState();
const newCredits = Math.round((getCredits(actor) - costs.total) * 100) / 100;
const entry = {
week: getWeekFromDay(stateNow.day),
day: stateNow.day,
stamp: buildCampaignStamp(stateNow),
housingTier: nextActorState.housingTier,
housingLabel: costs.label,
housing: costs.housing,
living: costs.living,
total: costs.total,
balance: newCredits
};
await setActorState(actor, {
...nextActorState,
ledger: [entry, ...(Array.isArray(nextActorState.ledger) ? nextActorState.ledger : [])].slice(0, 24),
lastBilledWeek: Math.max(Number(nextActorState.lastBilledWeek ?? 0), getWeekFromDay(stateNow.day))
});
await actor.update({ "system.credits.value": newCredits });
app.render(false);
});
}
function renderActorFrontSummary(app, html) {
if (app.actor?.type !== "character") return;
const root = getHtmlRoot(html);
const strip = root?.querySelector(".sla-front-strip");
if (!strip || strip.querySelector(".sla-shift-ledger-front-panel")) return;
const actorState = getActorState(app.actor);
const state = getState();
const config = getConfig();
const schedule = normalizeDaySchedule(actorState.schedule, state.day);
const currentSchedule = schedule[String(state.day)];
const currentDuty = currentSchedule[Number(state.shift ?? 0) || 0] ?? config.activityOptions[0];
const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config);
const panel = document.createElement("div");
panel.className = "sla-shift-ledger-front-panel";
panel.innerHTML = `
<div class="sla-shift-ledger-front-summary">
<span class="sla-shift-ledger-tag">Day ${state.day}</span>
<span class="sla-shift-ledger-tag">${getShift(state.shift).label}</span>
<span class="sla-shift-ledger-tag">Duty: ${currentDuty}</span>
<span class="sla-shift-ledger-tag">Housing: Tier ${actorState.housingTier} · ${weeklyCosts.label}</span>
<span class="sla-shift-ledger-tag">Weekly: ${weeklyCosts.total} cR</span>
</div>
`;
strip.appendChild(panel);
}
Hooks.once("init", () => {
game.settings.register(MODULE_ID, STATE_KEY, {
scope: "world",
config: false,
type: Object,
default: defaultCampaignState()
});
game.settings.register(MODULE_ID, CONFIG_KEY, {
scope: "world",
config: false,
type: Object,
default: defaultConfig()
});
game.settings.registerMenu(MODULE_ID, "shiftLedgerMenu", {
name: "SLA Shift Ledger",
label: "Open Shift Ledger",
hint: "Track campaign day, shift cadence, schedule options, and living costs.",
icon: "fas fa-calendar-days",
type: SlaShiftLedgerApp,
restricted: true
});
});
Hooks.once("ready", () => {
game.slaShiftLedger = {
open: () => SlaShiftLedgerApp.open(),
getState,
getConfig,
advanceShift,
applyWeeklyBilling
};
renderCampaignBanner();
});
Hooks.on("renderSceneDirectory", installSceneButton);
Hooks.on("renderSidebarTab", installSceneButtonFromSidebarTab);
Hooks.on("renderActorSheet", renderActorTimePanel);
Hooks.on("renderActorSheet", renderActorFrontSummary);
Hooks.on("renderMothershipActorSheet", renderActorTimePanel);
Hooks.on("renderMothershipActorSheet", renderActorFrontSummary);
Hooks.on("renderMothershipActorSheetDossier", renderActorTimePanel);
Hooks.on("renderMothershipActorSheetDossier", renderActorFrontSummary);
Hooks.on("updateSetting", (setting) => {
if (setting?.namespace === MODULE_ID && [STATE_KEY, CONFIG_KEY].includes(setting?.key)) {
renderCampaignBanner();
}
});
+334
View File
@@ -0,0 +1,334 @@
.sla-shift-ledger-banner {
position: fixed;
top: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 75;
width: min(1100px, calc(100vw - 120px));
pointer-events: auto;
font-family: "Josefin Sans", "Signika", sans-serif;
}
.sla-shift-ledger-banner-inner {
display: grid;
grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.75fr);
gap: 16px;
align-items: center;
padding: 14px 18px;
border-radius: 16px;
border: 1px solid rgba(124, 196, 255, 0.24);
background:
linear-gradient(180deg, rgba(17, 22, 31, 0.96), rgba(12, 16, 23, 0.94));
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: #d9e7f7;
}
.sla-shift-ledger-banner-meta {
min-width: 0;
}
.sla-shift-ledger-banner-main {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
align-items: stretch;
}
.sla-shift-ledger-banner-kicker,
.sla-shift-ledger-kicker {
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #7cc4ff;
margin-bottom: 4px;
}
.sla-shift-ledger-banner-title,
.sla-shift-ledger-state-line {
font-size: 1.1rem;
font-weight: 700;
color: #f4f8fd;
}
.sla-shift-ledger-banner-subtitle {
font-size: 0.78rem;
color: #9eb5cf;
}
.sla-shift-ledger-banner-controls {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
}
.sla-shift-ledger-button {
border: 1px solid rgba(124, 196, 255, 0.4);
border-radius: 999px;
background: rgba(31, 93, 156, 0.24);
color: #e9f4ff;
min-width: 72px;
padding: 8px 12px;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.sla-shift-ledger-button:hover {
background: rgba(46, 114, 184, 0.34);
}
.sla-shift-ledger-shifts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
min-width: 0;
width: 100%;
}
.sla-shift-ledger-shift,
.sla-shift-ledger-shift-chip {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
min-height: 56px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #90a8c2;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
}
.sla-shift-ledger-shift.is-active,
.sla-shift-ledger-shift-chip.is-active {
background: linear-gradient(180deg, rgba(77, 149, 221, 0.38), rgba(31, 93, 156, 0.42));
color: #ffffff;
border-color: rgba(146, 207, 255, 0.7);
box-shadow: 0 0 0 1px rgba(146, 207, 255, 0.12), 0 0 20px rgba(62, 145, 218, 0.22);
}
.sla-shift-ledger-open {
margin-left: 6px;
}
.sla-shift-ledger-form,
.sla-shift-ledger-actor-panel {
color: #d9e7f7;
}
.sla-shift-ledger-form input,
.sla-shift-ledger-form select,
.sla-shift-ledger-form textarea,
.sla-shift-ledger-actor-panel input,
.sla-shift-ledger-actor-panel select,
.sla-shift-ledger-actor-panel textarea {
background: linear-gradient(180deg, rgba(242, 247, 252, 0.98), rgba(229, 236, 245, 0.98)) !important;
color: #112030 !important;
border: 1px solid rgba(89, 118, 152, 0.48) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.78) !important;
}
.sla-shift-ledger-form input::placeholder,
.sla-shift-ledger-form textarea::placeholder,
.sla-shift-ledger-actor-panel input::placeholder,
.sla-shift-ledger-actor-panel textarea::placeholder {
color: #61758c !important;
}
.sla-shift-ledger-form option,
.sla-shift-ledger-actor-panel option {
color: #112030;
background: #e7edf5;
}
.sla-shift-ledger-grid,
.sla-shift-ledger-actor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.sla-shift-ledger-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.sla-shift-ledger-field label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8eb4da;
}
.sla-shift-ledger-field .notes {
margin: 0;
font-size: 0.74rem;
color: #98aac0;
}
.sla-shift-ledger-banner-card,
.sla-shift-ledger-cost-panel,
.sla-shift-ledger-actor-card {
margin: 12px 0;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(124, 196, 255, 0.15);
background: linear-gradient(180deg, rgba(17, 22, 31, 0.82), rgba(10, 14, 20, 0.9));
}
.sla-shift-ledger-shift-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.sla-shift-ledger-cost-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin-top: 8px;
}
.sla-shift-ledger-actions,
.sla-shift-ledger-inline-actions {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.sla-shift-ledger-form .sla-shift-ledger-actions button,
.sla-shift-ledger-form .sla-shift-ledger-inline-actions button,
.sla-shift-ledger-actor-panel .sla-shift-ledger-inline-actions button,
.sla-shift-ledger-actor-panel .sla-shift-ledger-day-nav button {
background: linear-gradient(180deg, rgba(68, 138, 212, 0.96), rgba(29, 95, 162, 0.98)) !important;
color: #f5fbff !important;
border: 1px solid rgba(145, 210, 255, 0.56) !important;
box-shadow: 0 6px 16px rgba(16, 50, 89, 0.22), inset 0 1px 0 rgba(255,255,255,0.18) !important;
}
.sla-shift-ledger-form .sla-shift-ledger-actions button:hover,
.sla-shift-ledger-form .sla-shift-ledger-inline-actions button:hover,
.sla-shift-ledger-actor-panel .sla-shift-ledger-inline-actions button:hover,
.sla-shift-ledger-actor-panel .sla-shift-ledger-day-nav button:hover {
background: linear-gradient(180deg, rgba(87, 157, 230, 0.98), rgba(42, 111, 181, 0.99)) !important;
}
.sla-shift-ledger-actor-panel {
margin-bottom: 12px;
}
.sla-shift-ledger-actor-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.sla-shift-ledger-actor-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.sla-shift-ledger-day-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 8px 0 10px;
}
.sla-shift-ledger-day-readout {
font-size: 0.8rem;
color: #dcecff;
font-weight: 700;
}
.sla-shift-ledger-tag {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.72rem;
background: rgba(124, 196, 255, 0.12);
border: 1px solid rgba(124, 196, 255, 0.18);
color: #d7ecff;
}
.sla-shift-ledger-front-panel {
margin-top: 10px;
}
.sla-shift-ledger-front-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.sla-shift-ledger-front-summary .sla-shift-ledger-tag {
background: rgba(90, 168, 255, 0.14);
}
.sla-shift-ledger-ledger {
margin-top: 10px;
max-height: 180px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.sla-shift-ledger-ledger-entry {
padding: 8px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 0.78rem;
}
.sla-shift-ledger-ledger-entry strong {
color: #ffffff;
}
@media (max-width: 1100px) {
.sla-shift-ledger-banner {
width: min(900px, calc(100vw - 80px));
}
.sla-shift-ledger-banner-inner {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.sla-shift-ledger-banner {
position: static;
transform: none;
width: auto;
margin: 8px 8px 0;
}
.sla-shift-ledger-banner-inner {
grid-template-columns: 1fr;
align-items: stretch;
}
.sla-shift-ledger-shifts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sla-shift-ledger-cost-grid,
.sla-shift-ledger-actor-grid,
.sla-shift-ledger-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+67
View File
@@ -0,0 +1,67 @@
<form class="sla-shift-ledger-form">
<div class="sla-shift-ledger-grid">
<div class="sla-shift-ledger-field">
<label>Current Day</label>
<input type="number" name="day" min="0" step="1" value="{{state.day}}">
</div>
<div class="sla-shift-ledger-field">
<label>Current Shift</label>
<select name="shift">
{{#each shifts}}
<option value="{{this.index}}" {{#if this.selected}}selected{{/if}}>{{this.label}}</option>
{{/each}}
</select>
</div>
</div>
<div class="sla-shift-ledger-banner-card">
<div class="sla-shift-ledger-kicker">Campaign Cadence</div>
<div class="sla-shift-ledger-state-line">Day {{state.day}} · Week {{weekNumber}} · {{currentShiftLabel}}</div>
<div class="sla-shift-ledger-shift-row">
{{#each shifts}}
<div class="sla-shift-ledger-shift-chip {{#if this.active}}is-active{{/if}}">{{this.short}}</div>
{{/each}}
</div>
</div>
<div class="sla-shift-ledger-field">
<label>Activity Options</label>
<textarea name="activityOptions" rows="4">{{activityOptionsText}}</textarea>
<p class="notes">One option per line. These become the schedule choices on each character sheet.</p>
</div>
<div class="sla-shift-ledger-cost-panel">
<div class="sla-shift-ledger-kicker">Weekly Housing Costs</div>
<div class="sla-shift-ledger-cost-grid">
{{#each housingLevels}}
<div class="sla-shift-ledger-field">
<label>Tier {{this.tier}}</label>
<input type="text" name="housingLabel{{this.tier}}" value="{{this.label}}">
<input type="number" name="housingCost{{this.tier}}" min="0" step="1" value="{{this.cost}}">
</div>
{{/each}}
</div>
</div>
<div class="sla-shift-ledger-grid">
<div class="sla-shift-ledger-field">
<label>Living Cost Multiplier</label>
<input type="number" name="livingMultiplier" min="0" step="0.1" value="{{config.livingMultiplier}}">
<p class="notes">`0.5` means living costs are 50% of housing.</p>
</div>
<div class="sla-shift-ledger-field">
<label>Auto Charge Weekly</label>
<select name="autoChargeWeekly">
<option value="true" {{#if config.autoChargeWeekly}}selected{{/if}}>Enabled</option>
<option value="false" {{#unless config.autoChargeWeekly}}selected{{/unless}}>Disabled</option>
</select>
</div>
</div>
<div class="sla-shift-ledger-actions">
<button type="button" data-action="prev">Previous Shift</button>
<button type="button" data-action="next">Advance Shift</button>
<button type="button" data-action="bill">Run Weekly Billing Now</button>
<button type="submit">Save</button>
</div>
</form>