make static
This commit is contained in:
@@ -19,9 +19,12 @@ estimated **remaining** balance, for both meal blocks and FLEX.
|
|||||||
- Checkboxes for configured breaks ("missing periods") you spent off campus.
|
- Checkboxes for configured breaks ("missing periods") you spent off campus.
|
||||||
- A numeric input for any additional days you don't want counted.
|
- A numeric input for any additional days you don't want counted.
|
||||||
- Live-updating results — every input recomputes the estimate instantly.
|
- Live-updating results — every input recomputes the estimate instantly.
|
||||||
- Everything is config-driven: plans, dates, and breaks live in one JSON file. No code changes
|
- Everything is config-driven: plans, dates, and breaks live in one JSON file that is fetched at
|
||||||
needed to adjust them.
|
runtime. Edit it on the server and changes appear on the next page load — no rebuild needed.
|
||||||
|
- Your plan, checked breaks, and extra-days value are remembered between visits (via
|
||||||
|
`localStorage`). The date always resets to today.
|
||||||
- Dark theme with restrained CMU Tartan-red accents.
|
- Dark theme with restrained CMU Tartan-red accents.
|
||||||
|
- Ships as plain static files — no server-side code or backend.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -42,8 +45,11 @@ npm test # run the estimation-engine tests
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All tunable values live in [`src/config.json`](src/config.json). Edit that file, then reload the
|
All tunable values live in [`public/config.json`](public/config.json). This file is **fetched at
|
||||||
page — the UI updates reactively.
|
runtime**, not compiled into the bundle — so after deployment you can edit the deployed
|
||||||
|
`config.json` directly and the changes take effect on the next page load, with no rebuild.
|
||||||
|
|
||||||
|
During development, edit `public/config.json` and reload the page.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -96,17 +102,62 @@ Notes:
|
|||||||
Checking a break or adding excluded days shifts your expected consumption down, which is what you
|
Checking a break or adding excluded days shifts your expected consumption down, which is what you
|
||||||
want when you were away and not eating on campus.
|
want when you were away and not eating on campus.
|
||||||
|
|
||||||
|
## Deployment (static hosting)
|
||||||
|
|
||||||
|
The app is a purely client-side static site — no backend, no server-side rendering. To deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # outputs everything to dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then copy the **contents of `dist/`** to wherever you're serving from. The build produces:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
index.html
|
||||||
|
config.json # the live, editable config
|
||||||
|
assets/ # hashed JS + CSS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subpath hosting
|
||||||
|
|
||||||
|
Asset paths in the build are **relative**, and `config.json` is loaded relative to the page's
|
||||||
|
own location. That means the app works unchanged whether it's served from a domain root or a
|
||||||
|
subpath — e.g. `https://www.cmu.edu/~you/meal-estimator/`. No base-path configuration or rebuild
|
||||||
|
is needed to move it between locations.
|
||||||
|
|
||||||
|
The one requirement is that the app is served as a directory with a trailing slash (e.g.
|
||||||
|
`.../meal-estimator/`). Virtually all static servers (Apache, nginx, and the like) redirect
|
||||||
|
`.../meal-estimator` to `.../meal-estimator/` automatically, so this is handled for you.
|
||||||
|
|
||||||
|
### Editing config after deployment
|
||||||
|
|
||||||
|
Because `config.json` sits alongside `index.html` in the deployed files and is fetched at
|
||||||
|
runtime, you can update plan numbers, dates, or breaks by editing the deployed
|
||||||
|
`config.json` directly. No rebuild or redeploy of the JS is required — just reload the page.
|
||||||
|
|
||||||
|
## Data persistence
|
||||||
|
|
||||||
|
The selected plan, checked breaks, and additional-days value are saved to the browser's
|
||||||
|
`localStorage` and restored on the next visit. The "as of" date is intentionally **not**
|
||||||
|
persisted — it always defaults to today. Persistence fails silently if `localStorage` is
|
||||||
|
unavailable (e.g. private browsing), and stale entries (a plan or break no longer in the config)
|
||||||
|
are ignored on load.
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html # app shell
|
index.html # app shell
|
||||||
|
public/
|
||||||
|
config.json # all tunable values (plans, dates, breaks); served + fetched at runtime
|
||||||
src/
|
src/
|
||||||
config.json # all tunable values (plans, dates, breaks)
|
|
||||||
types.ts # shared types
|
types.ts # shared types
|
||||||
dates.ts # whole-day date helpers
|
dates.ts # whole-day date helpers
|
||||||
configLoader.ts # validates + normalizes config
|
configLoader.ts # validates + normalizes config
|
||||||
|
configFetch.ts # fetches config.json at runtime (subpath-aware)
|
||||||
engine.ts # pure estimation math (no DOM)
|
engine.ts # pure estimation math (no DOM)
|
||||||
store.ts # tiny reactive state store
|
store.ts # tiny reactive state store
|
||||||
|
persistence.ts # localStorage load/save of selections
|
||||||
main.ts # builds the UI, re-renders on change
|
main.ts # builds the UI, re-renders on change
|
||||||
style.css # dark CMU-accented theme
|
style.css # dark CMU-accented theme
|
||||||
engine.test.ts # engine unit tests
|
engine.test.ts # engine unit tests
|
||||||
|
|||||||
Generated
+18
@@ -8,6 +8,7 @@
|
|||||||
"name": "cmu-meal-estimator",
|
"name": "cmu-meal-estimator",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vitest": "^2.1.4"
|
"vitest": "^2.1.4"
|
||||||
@@ -785,6 +786,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||||
@@ -1285,6 +1296,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vitest": "^2.1.4"
|
"vitest": "^2.1.4"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Edit this file to configure the estimator. Dates are ISO YYYY-MM-DD. totalMealBlocks and totalFlexDollars are the amounts for the whole meal period. Add/remove plans and missingPeriods freely; the UI updates reactively. Set exact figures from your CMU dining agreement.",
|
"$comment": "Edit this file to configure the estimator. It is fetched at runtime, so changes take effect on reload without rebuilding. Dates are ISO YYYY-MM-DD. totalMealBlocks and totalFlexDollars are the amounts for the whole meal period. Add/remove plans and missingPeriods freely; the UI updates reactively. Set exact figures from your CMU dining agreement.",
|
||||||
|
|
||||||
"mealStartDate": "2026-08-24",
|
"mealStartDate": "2026-08-24",
|
||||||
"mealEndDate": "2026-12-13",
|
"mealEndDate": "2026-12-13",
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { loadConfig } from "./configLoader";
|
||||||
|
import type { ConfigResult } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and validate config.json at runtime. The URL is resolved against the
|
||||||
|
* app's configured base (import.meta.env.BASE_URL), so it works whether the app
|
||||||
|
* is served from a domain root or a subpath.
|
||||||
|
*/
|
||||||
|
export async function fetchConfig(): Promise<ConfigResult> {
|
||||||
|
const url = configUrl();
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url.toString(), { cache: "no-cache" });
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errors: [`Could not fetch config.json (${describe(err)}).`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errors: [
|
||||||
|
`Could not load config.json (HTTP ${response.status} ${response.statusText}).`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await response.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errors: ["config.json is not valid JSON."],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadConfig(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the config.json URL for the deployed location. Uses Vite's configured
|
||||||
|
* base when it is an absolute path (e.g. "/meal-estimator/"); otherwise falls
|
||||||
|
* back to the directory of the current document so a relative "./" base works
|
||||||
|
* whether or not the URL has a trailing slash.
|
||||||
|
*/
|
||||||
|
function configUrl(): string {
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
|
||||||
|
if (base.startsWith("/")) {
|
||||||
|
// Absolute base path: config sits directly under it.
|
||||||
|
return new URL(`${base}config.json`, window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative base: resolve against the current document's directory.
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const dir = path.endsWith("/") ? path : path.slice(0, path.lastIndexOf("/") + 1);
|
||||||
|
return new URL(`${dir}config.json`, window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : "network error";
|
||||||
|
}
|
||||||
+58
-13
@@ -1,29 +1,57 @@
|
|||||||
import "./style.css";
|
import "./style.css";
|
||||||
import rawConfig from "./config.json";
|
import { fetchConfig } from "./configFetch";
|
||||||
import { loadConfig } from "./configLoader";
|
|
||||||
import { estimate } from "./engine";
|
import { estimate } from "./engine";
|
||||||
import { Store } from "./store";
|
import { Store } from "./store";
|
||||||
import { toISODate, parseISODate, atMidnight } from "./dates";
|
import { toISODate, parseISODate, atMidnight } from "./dates";
|
||||||
import type { Config, EstimationResult } from "./types";
|
import { loadPersisted, savePersisted } from "./persistence";
|
||||||
|
import type { Config, EstimationResult, Selections } from "./types";
|
||||||
|
|
||||||
const appEl = document.getElementById("app")!;
|
const appEl = document.getElementById("app")!;
|
||||||
|
|
||||||
const result = loadConfig(rawConfig);
|
void main();
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
renderLoading();
|
||||||
|
const result = await fetchConfig();
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
renderConfigError(result.errors);
|
renderConfigError(result.errors);
|
||||||
} else {
|
} else {
|
||||||
bootstrap(result.config);
|
bootstrap(result.config);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bootstrap(config: Config): void {
|
/**
|
||||||
const today = atMidnight(new Date());
|
* Build the initial selections: defaults, overlaid with any persisted values
|
||||||
|
* that are still valid for this config. The target date is never persisted —
|
||||||
const store = new Store(config, {
|
* it always starts at today.
|
||||||
|
*/
|
||||||
|
function initialSelections(config: Config): Selections {
|
||||||
|
const defaults: Selections = {
|
||||||
selectedPlanId: config.plans[0].id,
|
selectedPlanId: config.plans[0].id,
|
||||||
targetDate: today,
|
targetDate: atMidnight(new Date()),
|
||||||
activeMissingPeriodIds: new Set(),
|
activeMissingPeriodIds: new Set(),
|
||||||
extraExcludedDays: 0,
|
extraExcludedDays: 0,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const persisted = loadPersisted();
|
||||||
|
if (!persisted) return defaults;
|
||||||
|
|
||||||
|
// Only honor a persisted plan / periods that still exist in the config.
|
||||||
|
const planExists = config.plans.some((p) => p.id === persisted.selectedPlanId);
|
||||||
|
const validPeriodIds = new Set(config.missingPeriods.map((m) => m.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
selectedPlanId: planExists ? persisted.selectedPlanId : defaults.selectedPlanId,
|
||||||
|
activeMissingPeriodIds: new Set(
|
||||||
|
persisted.activeMissingPeriodIds.filter((id) => validPeriodIds.has(id))
|
||||||
|
),
|
||||||
|
extraExcludedDays: persisted.extraExcludedDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrap(config: Config): void {
|
||||||
|
const store = new Store(config, initialSelections(config));
|
||||||
|
|
||||||
appEl.innerHTML = "";
|
appEl.innerHTML = "";
|
||||||
appEl.appendChild(buildLayout(store, config));
|
appEl.appendChild(buildLayout(store, config));
|
||||||
@@ -32,7 +60,16 @@ function bootstrap(config: Config): void {
|
|||||||
const daysNote = appEl.querySelector<HTMLElement>("#days-note")!;
|
const daysNote = appEl.querySelector<HTMLElement>("#days-note")!;
|
||||||
|
|
||||||
const rerender = () => {
|
const rerender = () => {
|
||||||
const r = estimate(config, store.getSelections());
|
const sel = store.getSelections();
|
||||||
|
|
||||||
|
// Persist everything except the date.
|
||||||
|
savePersisted({
|
||||||
|
selectedPlanId: sel.selectedPlanId,
|
||||||
|
activeMissingPeriodIds: [...sel.activeMissingPeriodIds],
|
||||||
|
extraExcludedDays: sel.extraExcludedDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = estimate(config, sel);
|
||||||
resultsMount.innerHTML = "";
|
resultsMount.innerHTML = "";
|
||||||
resultsMount.appendChild(renderResults(r));
|
resultsMount.appendChild(renderResults(r));
|
||||||
daysNote.textContent = `${fmtDays(r.activeDaysConsumed)} of ${fmtDays(
|
daysNote.textContent = `${fmtDays(r.activeDaysConsumed)} of ${fmtDays(
|
||||||
@@ -124,6 +161,7 @@ function buildControls(store: Store, config: Config): HTMLElement {
|
|||||||
const cb = document.createElement("input");
|
const cb = document.createElement("input");
|
||||||
cb.type = "checkbox";
|
cb.type = "checkbox";
|
||||||
cb.value = period.id;
|
cb.value = period.id;
|
||||||
|
cb.checked = store.getSelections().activeMissingPeriodIds.has(period.id);
|
||||||
cb.addEventListener("change", () =>
|
cb.addEventListener("change", () =>
|
||||||
store.setMissingPeriodActive(period.id, cb.checked)
|
store.setMissingPeriodActive(period.id, cb.checked)
|
||||||
);
|
);
|
||||||
@@ -150,7 +188,7 @@ function buildControls(store: Store, config: Config): HTMLElement {
|
|||||||
extra.type = "number";
|
extra.type = "number";
|
||||||
extra.min = "0";
|
extra.min = "0";
|
||||||
extra.step = "1";
|
extra.step = "1";
|
||||||
extra.value = "0";
|
extra.value = String(store.getSelections().extraExcludedDays);
|
||||||
extra.className = "input";
|
extra.className = "input";
|
||||||
extra.id = "extra-days";
|
extra.id = "extra-days";
|
||||||
extra.addEventListener("input", () =>
|
extra.addEventListener("input", () =>
|
||||||
@@ -214,7 +252,14 @@ function metric(value: string, unit: string): HTMLElement {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- config error ---------- */
|
/* ---------- loading + config error ---------- */
|
||||||
|
|
||||||
|
function renderLoading(): void {
|
||||||
|
const box = el("div", "loading");
|
||||||
|
box.textContent = "Loading…";
|
||||||
|
appEl.innerHTML = "";
|
||||||
|
appEl.appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
function renderConfigError(errors: string[]): void {
|
function renderConfigError(errors: string[]): void {
|
||||||
const box = el("div", "config-error");
|
const box = el("div", "config-error");
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Persists the user's selections across visits via localStorage.
|
||||||
|
* Deliberately excludes the target date — that always defaults to today unless
|
||||||
|
* the user changes it during a session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "cmu-meal-estimator:selections:v1";
|
||||||
|
|
||||||
|
export interface PersistedSelections {
|
||||||
|
selectedPlanId: string;
|
||||||
|
activeMissingPeriodIds: string[];
|
||||||
|
extraExcludedDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load persisted selections, or null if none/invalid/unavailable. */
|
||||||
|
export function loadPersisted(): PersistedSelections | null {
|
||||||
|
let raw: string | null;
|
||||||
|
try {
|
||||||
|
raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// localStorage can throw (private mode, disabled). Fail soft.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (typeof parsed !== "object" || parsed === null) return null;
|
||||||
|
const o = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
const selectedPlanId = typeof o.selectedPlanId === "string" ? o.selectedPlanId : null;
|
||||||
|
const idsRaw = Array.isArray(o.activeMissingPeriodIds) ? o.activeMissingPeriodIds : [];
|
||||||
|
const activeMissingPeriodIds = idsRaw.filter(
|
||||||
|
(x): x is string => typeof x === "string"
|
||||||
|
);
|
||||||
|
const extraExcludedDays =
|
||||||
|
typeof o.extraExcludedDays === "number" && Number.isFinite(o.extraExcludedDays)
|
||||||
|
? Math.max(0, Math.floor(o.extraExcludedDays))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (selectedPlanId === null) return null;
|
||||||
|
|
||||||
|
return { selectedPlanId, activeMissingPeriodIds, extraExcludedDays };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the given selections. Fails soft if storage is unavailable. */
|
||||||
|
export function savePersisted(sel: PersistedSelections): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(sel));
|
||||||
|
} catch {
|
||||||
|
// Ignore — persistence is a convenience, not a requirement.
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -1,9 +1,14 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import rawConfig from "./config.json";
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadConfig } from "./configLoader";
|
import { loadConfig } from "./configLoader";
|
||||||
import { estimate } from "./engine";
|
import { estimate } from "./engine";
|
||||||
import { parseISODate } from "./dates";
|
import { parseISODate } from "./dates";
|
||||||
|
|
||||||
|
// Read the actual shipped config that gets served at runtime.
|
||||||
|
const configPath = fileURLToPath(new URL("../public/config.json", import.meta.url));
|
||||||
|
const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||||
|
|
||||||
describe("real config sanity", () => {
|
describe("real config sanity", () => {
|
||||||
const result = loadConfig(rawConfig);
|
const result = loadConfig(rawConfig);
|
||||||
|
|
||||||
|
|||||||
@@ -299,3 +299,13 @@ body {
|
|||||||
.config-error li {
|
.config-error li {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 96px auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user