canvas-data
Holding and shaping data inside a standalone HTML page: localStorage done safely as a per-viewer convenience, a light/dark toggle that persists, importing and exporting JSON or CSV with no server, rendering a list or table from an array, and drawing a chart to scale. Read when a canvas page remembers, loads, saves or visualises data.
- License 许可
- Apache-2.0
- 所属仓库
- enowdev/enowxcli
- 最近更新
- 2026-10-06
- Stars
- ★ 67
- Forks
- 21
01 前言 Front Matter
- name
- canvas-data
- description
- Holding and shaping data inside a standalone HTML page: localStorage done safely as a per-viewer convenience, a light/dark toggle that persists, importing and exporting JSON or CSV with no server, rendering a list or table from an array, and drawing a chart to scale. Read when a canvas page remembers, loads, saves or visualises data.
03 SKILL.md 正文
Data in a standalone page, with no server
A page that opens from disk has no database and no backend. Everything it remembers lives in the one browser it was opened in; everything it loads, the person hands it as a file; everything it saves, it hands back as a file. This skill is those four jobs: persist, toggle, move data in and out, and render it.
1. localStorage, as a convenience only
localStorage is per-origin, per-browser, and for a file:// page the origin
is fragile. Treat it as a nicety that may be absent, never as the source of
truth the page depends on.
const store = {
get(key, fallback) {
try { const v = localStorage.getItem(key); return v == null ? fallback : JSON.parse(v); }
catch { return fallback; }
},
set(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* private window, full quota */ }
},
};
- Every read and write is wrapped in try/catch: a private window throws, a full quota throws, a blocked origin returns nothing. The page renders correctly when it comes back empty.
- Store per-viewer conveniences only: a remembered tab, a theme choice, a draft, the rows the person entered on this machine. Not anything that must be shared, must survive a move to another machine, or must be trusted.
- Namespace the key so two pages opened from the same folder do not collide:
tipsplitter.rows, notrows.
2. A theme toggle that persists
Combine the OS default (the media query in the canvas skill) with an
explicit choice stored per viewer. Set the attribute before the first paint
so there is no flash.
<script>
// in <head>, before the body paints:
try {
const t = localStorage.getItem("app.theme");
if (t === "dark" || t === "light") document.documentElement.dataset.theme = t;
} catch {}
</script>
function setTheme(t) { // "dark" | "light"
document.documentElement.dataset.theme = t;
store.set("app.theme", t);
}
toggle.addEventListener("click", () => {
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
const current = document.documentElement.dataset.theme || (dark ? "dark" : "light");
setTheme(current === "dark" ? "light" : "dark");
});
The toggle itself is an icon button, a sun or a moon and no text beside
it, named by aria-label (and title) for what it does, updated on each
switch:
<button id="theme" class="icon-button" type="button" aria-label="Switch to dark theme"
title="Switch to dark theme">
<svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20"><!-- moon --></svg>
</button>
The CSS has the three blocks from the canvas skill: bare :root (light),
@media (prefers-color-scheme: dark) :root (OS dark), and
:root[data-theme="dark"] / :root[data-theme="light"] so the toggle wins
both ways.
3. Loading a file the person gives you
No fetch; the person chooses a file and the page reads it with FileReader
or the File API. This works from a file:// page.
fileInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const rows = file.name.endsWith(".csv") ? parseCsv(text) : JSON.parse(text);
set({ rows });
} catch (err) {
showError("That file could not be read as " + (file.name.endsWith(".csv") ? "CSV" : "JSON") + ".");
}
});
- Support drag and drop too when it suits:
dragover(preventDefault) anddrop(reade.dataTransfer.files[0]). - Parsing can fail; wrap it and show a message on the page, never a thrown error or a silent nothing.
- A tiny CSV parser is fine for simple data; for quoted fields and embedded commas, load a small parser from a CDN rather than getting it subtly wrong.
4. Saving without a server
The page builds the file in memory and offers it as a download through a
blob: URL. This works from disk in a browser.
function download(filename, text, type = "application/json") {
const blob = new Blob([text], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
saveBtn.addEventListener("click", () => {
download("data.json", JSON.stringify(state.rows, null, 2));
});
- Export JSON for round-tripping (the page can load its own export), CSV for a spreadsheet. Offer whichever the person actually needs.
- Revoke the object URL after the click so it is not held forever.
- Note: a download started this way works in a normal browser opening the file; some embedded or locked-down viewers block it. For a page that is only ever opened as a local file in a browser, this is the right tool.
5. Rendering a list or table from an array
Render from the state array; rebuild the rows on change rather than mutating
them in place for a small list. Build nodes, do not concatenate HTML from
user data (an imported name with < must not become markup).
function renderRows() {
tbody.replaceChildren(...state.rows.map((r) => {
const tr = document.createElement("tr");
const name = document.createElement("td"); name.textContent = r.name;
const amt = document.createElement("td");
amt.textContent = r.amount.toFixed(2);
amt.style.fontVariantNumeric = "tabular-nums"; // digits line up
tr.append(name, amt);
return tr;
}));
}
textContent, neverinnerHTML, for any value that came from a file or an input: it escapes automatically and closes the injection door.font-variant-numeric: tabular-numson any column of figures so they align.- An empty array renders a designed empty state ("No rows yet. Add one above, or load a file."), not a blank table.
- For a long list (thousands of rows), render the visible window and page or
virtualise; a
file://page still has one main thread.
6. Drawing a chart to scale
For a sparkline or one bar row, hand-drawn SVG is enough and stays inside the
file; for anything richer, load a charting library (canvas-interactive,
section 5). Whichever you use, the marks must be true.
- One scale maps data to pixels; every tick and label names a value the chart
actually reaches. Compute the domain from the data
(
Math.min/Math.max), do not assume 0 to 100. - Leave room in the SVG
viewBoxfor the outermost labels, so they are not clipped. - Colour chart text and lines from the theme tokens, so the chart is legible in both themes; give every drawn shape an explicit fill.
- Label the axes with the subject's real units (the detail the
canvasskill asks for), not bare numbers.
const xs = state.points, max = Math.max(...xs, 1), W = 300, H = 60;
const d = xs.map((v, i) => `${(i / (xs.length - 1)) * W},${H - (v / max) * H}`).join(" ");
spark.innerHTML = `<polyline fill="none" stroke="var(--accent)" stroke-width="2" points="${d}"/>`;
(innerHTML here is safe: the string is built from numbers the page
computed, never from user text.)
7. Check it
- Every
localStorageread and write is wrapped; the page works with it empty. - The theme choice persists and is set before first paint (no flash).
- Files are read with the File API and parsed inside a try/catch with a visible error on failure.
- Saving builds a
blob:and revokes the URL; export can be re-imported. - Rows render with
textContent; figures use tabular numerals; an empty array shows a designed empty state. - Charts are drawn to a scale computed from the data, with labels in real units, legible in both themes.
04 同仓库更多 Skills
demo-skill
Project-scope demo skill for tests.
writing
Writing words people read: the specific thing instead of claims, words to drop, sentences, interface copy (buttons, labels, errors, empty states, confirmations), page copy, notifications and emails, alt text and link text, numbers and dates, words that translate well, documentation and voice. Read before writing a page's copy, an interface's text or a document.
ui
Designing or restyling an interface: direction, layout, type, colour, icons, components, states, responsive behaviour. Read before building a page, a screen or a set of components.
ui-themes
Light and dark themes, and colour that holds in both: tokens with a value per theme, and what each kind of content needs to stay visible (icons, SVG drawings and line art, logos, photos, screenshots, charts, code, maps, borders, shadows, focus, form controls), switching without a flash, and checking both. Read before building a second theme or a theme toggle, and before any drawing, logo or chart on a page that has two themes.
ui-structure
Structure before styling: the blueprint written before any interface code (the screen's job, the regions its kind of page needs, their order and why, the layout skeleton in numbers, the components and their states), the anatomy and order of a homepage, a dashboard, a list, a detail page, settings, a form, sign-in, pricing, docs, an article and a checkout, and how to turn the blueprint into code (tokens, layout primitives, components, sections, page). Read first, before building or rebuilding any page or screen.
ui-stack-vue
Building interfaces in Vue 3 and Nuxt: project layout, script setup with typed defineProps, defineEmits and defineModel, attribute fallthrough, slots and scoped slots, template refs, ref, reactive and computed, watch versus watchEffect, composables and VueUse, provide and inject, Pinia, keyed v-for, transitions, Nuxt 4 (app directory, pages, layouts, useFetch and useAsyncData keys, $fetch in handlers, server routes, runtime config, useSeoMeta, NuxtImg, middleware), VeeValidate forms, Reka UI and Nuxt UI, testing, vue-tsc, and the traps. Read when the project uses Vue or Nuxt.