create-sinter 0.0.0-stage → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # create-sinter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scaffolds a small task board built with [sinter](https://github.com/tiaforge/sinter) (your JSON API
4
+ bound to plain HTML) and [temper](https://github.com/tiaforge/temper) (CSS for semantic HTML).
5
+
6
+ ```sh
7
+ npm create sinter my-app
8
+ ```
9
+
10
+ It runs against a demo API inside Vite, so there is nothing else to start. The app shows:
11
+
12
+ - server data with live updates across tabs
13
+ - optimistic changes that roll back when the server refuses
14
+ - a dialog without JavaScript
15
+ - a layout both pages share, and components (one with a script that draws a canvas chart)
16
+ - next-page data and page transitions
17
+ - build-time checks that turn typos into build errors
18
+
19
+ The template is in [`template/`](template). Its README lists what to try first.
package/index.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // `npm create sinter [directory]`: copies the template (a sinter + temper app with a demo
3
+ // API) into a new directory. No dependencies; Node 20 or later.
4
+ import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { basename, join, relative, resolve } from 'node:path';
6
+ import { createInterface } from 'node:readline/promises';
7
+
8
+ const DEFAULT_DIR = 'tiaforge-app';
9
+ const args = process.argv.slice(2);
10
+
11
+ if (args.includes('-h') || args.includes('--help')) {
12
+ console.log(`Usage: npm create sinter [directory]
13
+
14
+ Creates a task board built with sinter (data) and temper (styles), with a demo API inside Vite.`);
15
+ process.exit(0);
16
+ }
17
+
18
+ let dir = args.find((a) => !a.startsWith('-'));
19
+ if (!dir) {
20
+ if (process.stdin.isTTY) {
21
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22
+ dir = (await rl.question(`Directory (${DEFAULT_DIR}): `)).trim() || DEFAULT_DIR;
23
+ rl.close();
24
+ } else {
25
+ dir = DEFAULT_DIR;
26
+ }
27
+ }
28
+
29
+ const target = resolve(dir);
30
+ if (existsSync(target) && readdirSync(target).length) {
31
+ console.error(`${dir} is not empty. Choose a new directory.`);
32
+ process.exit(1);
33
+ }
34
+
35
+ cpSync(join(import.meta.dirname, 'template'), target, { recursive: true });
36
+ // npm leaves .gitignore out of published packages, so the template carries it as _gitignore
37
+ renameSync(join(target, '_gitignore'), join(target, '.gitignore'));
38
+
39
+ const pkgPath = join(target, 'package.json');
40
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
41
+ pkg.name = basename(target).toLowerCase().replace(/[^a-z0-9._~-]+/g, '-').replace(/^[._-]+|-+$/g, '') || DEFAULT_DIR;
42
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
43
+
44
+ const pm = (process.env.npm_config_user_agent ?? 'npm').split('/')[0];
45
+ const run = pm === 'npm' ? 'npm run' : pm;
46
+ const cd = relative(process.cwd(), target) || '.';
47
+ console.log(`
48
+ Created ${cd}. Next:
49
+
50
+ ${cd === '.' ? '' : `cd ${cd.includes(' ') ? JSON.stringify(cd) : cd}\n `}${pm} install
51
+ ${run} dev
52
+
53
+ The README lists what to try first.`);
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "create-sinter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.15.0",
4
+ "description": "npm create sinter: a new app with sinter (JSON API bound to HTML) and temper (CSS for semantic HTML).",
5
+ "license": "MIT",
6
+ "author": "Tia Heinrich",
7
+ "homepage": "https://github.com/tiaforge/sinter/tree/main/packages/create#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/tiaforge/sinter.git",
11
+ "directory": "packages/create"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/tiaforge/sinter/issues"
15
+ },
16
+ "keywords": [
17
+ "create",
18
+ "scaffold",
19
+ "template",
20
+ "sinter",
21
+ "temper",
22
+ "vite"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "create-sinter": "index.js"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "template"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20.11"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,16 @@
1
+ # Notes for coding agents
2
+
3
+ - Data and behavior: sinter (`data-sn-*` attributes, `<sn-*>` elements). Read
4
+ `node_modules/@tiaforge-ui/sinter/llms.txt` before editing pages.
5
+ - Styling: temper (`data-variant`, `data-tone`, `data-layout`, … with fixed values; no classes for
6
+ looks, no margins). Read `node_modules/@tiaforge-ui/temper/llms.txt` before editing markup, and
7
+ `llms-full.txt` before building whole pages.
8
+ - Shared page markup (head, header, frame, toasts) is a layout: `src/layouts/app-layout.html`. Each page is only the
9
+ layout's tag, `<app-layout data-sn-prop="{ title: '…' }">…</app-layout>`, and its children fill the layout's slots
10
+ (`slot="head"` for head elements). Change the shared parts there, not in each page.
11
+ - Browser APIs (storage, timers, canvas, `fetch`) are not allowed in expressions: put them in a
12
+ component's script (`src/components/<name>.ts` beside `<name>.html`, extending the class imported
13
+ from `./<name>.html`; it writes state, never nodes sinter renders).
14
+ - Types for `type="…"` go in `src/sinter.types.ts`.
15
+ - Run `npm run check` after editing; `npm run build` runs the same checks.
16
+ - `server/api.js` is a demo backend. Do not grow it into the real one.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,81 @@
1
+ # Tasks: sinter + temper
2
+
3
+ A small task board: [sinter](https://github.com/tiaforge/sinter) binds the JSON API to plain HTML,
4
+ [temper](https://github.com/tiaforge/temper) styles the HTML. There is no router, no store and no
5
+ hydration: the pages share a layout and two components, all expanded at build time, and the only CSS
6
+ the app writes is the chart's.
7
+
8
+ ```sh
9
+ npm install
10
+ npm run dev
11
+ ```
12
+
13
+ ## Try this
14
+
15
+ - **Open the page in two tabs.** Tick, add or delete a task in one; the other follows. The server
16
+ sends events (`<sn-sync>` in `index.html`), and sinter applies them to the list.
17
+ - **Press "Fail next change", then tick a task.** The tick shows at once (`data-sn-optimistic`),
18
+ the server refuses it, and the tick is taken back. The toast comes from `onError` in
19
+ `src/sinter.config.ts`.
20
+ - **Press N.** A key filter on `<body>` opens the dialog. The dialog itself needs no JavaScript:
21
+ `command="show-modal"` is the browser's.
22
+ - **Click the ring.** It is a component with a script (`src/components/task-chart.html` and
23
+ `.ts`): sinter renders its markup, the script draws on the canvas; it gets the tasks through
24
+ `data-sn-prop` and sends the filter back as an event.
25
+ - **Rest the pointer on a task, then click it.** Its data was requested while you pointed, so
26
+ `task.html` shows it at once, and the title morphs into the page's heading.
27
+ - **Reload.** The list appears before the server answers (`cache`), and the filter is kept
28
+ (`data-sn-persist`).
29
+ - **Change `--tm-hue` in `src/style.css`.** Every color follows, in light and dark mode.
30
+
31
+ ## Break it on purpose
32
+
33
+ Both libraries check the markup at build time. Make these typos in `index.html` and run
34
+ `npm run build`:
35
+
36
+ | Change | Build error |
37
+ | --- | --- |
38
+ | `t.title` → `t.titel` | `SN208 Property 'titel' does not exist on type 'Task'. Did you mean 'title'?` |
39
+ | `visible(tasks, q, show)` → `visible(tasks, qq, show)` | ``SN201 `qq` is not defined here. Did you mean `q`?`` |
40
+ | `data-variant="ghost"` → `data-variant="danger"` on the delete button | `TM101 data-variant="danger" is not valid. Did you mean data-tone="danger"?` |
41
+
42
+ `npm run check` runs both checkers without building. Your editor can show the same errors as you
43
+ type: see sinter's `docs/editors.md`.
44
+
45
+ ## Files
46
+
47
+ | File | What it is |
48
+ | --- | --- |
49
+ | `index.html`, `task.html` | The pages: `data-sn-*` for data, `data-*` from temper for looks. Each is one `<app-layout>` tag. |
50
+ | `src/layouts/app-layout.html` | The layout: the head, the header and the frame both pages share; the page's content fills its slot. |
51
+ | `src/components/app-header.html` | A component: shared markup, expanded at build time where `<app-header>` is used. |
52
+ | `src/components/task-chart.html` | A component with a script: its markup, props and event. |
53
+ | `src/sinter.types.ts` | The API's types. `type="Task[]"` on a resource checks every expression that reads it. |
54
+ | `src/sinter.helpers.ts` | Functions expressions can call (`visible(…)`). |
55
+ | `src/sinter.config.ts` | Error handling. |
56
+ | `src/components/*.ts` | Scripts of components, for browser work: canvas, editors, storage, timers. |
57
+ | `src/sinter.components.d.ts` | Generated: the types of the components' scripts and the layout's props, from their HTML. |
58
+ | `src/style.css` | temper's modules and the brand color. |
59
+ | `server/api.js` | The demo backend, running inside Vite. |
60
+
61
+ ## Your own API
62
+
63
+ Remove `api()` from `vite.config.js`, delete `server/`, and proxy `/api` to your server in
64
+ development:
65
+
66
+ ```js
67
+ server: { proxy: { '/api': 'http://localhost:3000' } },
68
+ ```
69
+
70
+ In production, serve `dist/` and your API from the same origin. For an API on another origin, list
71
+ it in `sinter({ origins: ['https://api.example.com'] })`. With an OpenAPI spec,
72
+ `sinter({ openapi: 'openapi.yml' })` types resources without `type="…"`, checks request bodies, and
73
+ applies more responses to the lists by itself.
74
+
75
+ Serve the event stream over HTTP/2: over HTTP/1.1 each open tab holds one of the browser's six
76
+ connections per origin. The dev server is HTTP/1.1, so it warns in the console.
77
+
78
+ ## Learn more
79
+
80
+ - sinter: `node_modules/@tiaforge-ui/sinter/llms.txt` (attributes, recipes and their tests)
81
+ - temper: `node_modules/@tiaforge-ui/temper/llms-full.txt` (attributes, tokens, recipes)
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ dist/
3
+ .sinter/
@@ -0,0 +1,78 @@
1
+ <!-- The page is the app layout (src/layouts/app-layout.html): its head, header and frame. Page state: the
2
+ search, the filter (kept across page loads by data-sn-persist) and the new task's draft.
3
+ N opens the new-task dialog. -->
4
+ <app-layout data-sn-state="{ q: '', show: 'all', draft: { title: '' } }"
5
+ data-sn-on-keydown-n="showModal('new-task')">
6
+
7
+ <!-- The data: loaded once, kept sorted, shown at once on the next page load (`cache`), then revalidated. -->
8
+ <sn-resource name="tasks" src="/api/tasks" key="id" order="-createdAt" type="Task[]" cache></sn-resource>
9
+
10
+ <!-- Live updates: what another tab (or user) changes arrives as server events. -->
11
+ <sn-sync src="/api/events">
12
+ <sn-event name="task_updated" resource="tasks" op="upsert" item="task" type="TaskEvent"></sn-event>
13
+ <sn-event name="task_removed" resource="tasks" op="remove" type="TaskRemoved"></sn-event>
14
+ </sn-sync>
15
+
16
+ <div data-layout="row" data-gap="sm">
17
+ <h1 data-grow>Tasks</h1>
18
+ <button commandfor="new-task" command="show-modal" data-icon="plus">New task <kbd>N</kbd></button>
19
+ </div>
20
+
21
+ <!-- split: the chart beside the list, above it on a phone -->
22
+ <div data-layout="split" data-gap="lg" style="--tm-split: 9rem">
23
+ <!-- A component with a script: the tasks in through props, the filter back out through its event. -->
24
+ <aside>
25
+ <task-chart data-sn-prop="{ tasks, show }" data-sn-on-pick="show = $event.detail"></task-chart>
26
+ </aside>
27
+
28
+ <section data-layout="stack" data-gap="md" data-sn-let="{ shown: visible(tasks, q, show) }">
29
+ <div data-layout="row" data-gap="sm">
30
+ <input type="search" data-grow placeholder="Search" aria-label="Search tasks" data-sn-model="q">
31
+ <select aria-label="Show" data-sn-model="show" data-sn-persist="task-filter">
32
+ <option value="all">All</option>
33
+ <option value="open">Open</option>
34
+ <option value="done">Done</option>
35
+ </select>
36
+ </div>
37
+
38
+ <p data-sn-show="$meta.tasks.loading && !tasks.length" aria-busy="true">Loading…</p>
39
+ <p data-muted data-sn-show="$meta.tasks.loadedAt && !shown.length">No tasks here.</p>
40
+
41
+ <ul data-list data-sn-show="shown.length">
42
+ <template data-sn-for="t in shown">
43
+ <li data-layout="row" data-gap="sm" data-wrap="none">
44
+ <!-- Optimistic: ticks at once, rolls back if the server refuses. -->
45
+ <input type="checkbox" aria-label="Done"
46
+ data-sn-patch="/api/tasks/{t.id}" data-sn-body="{ done: !t.done }"
47
+ data-sn-optimistic="patch(t, { done: !t.done })"
48
+ data-sn-attr="{ checked: t.done }">
49
+ <!-- The title morphs into the heading of the task's page (view transition). -->
50
+ <div data-grow data-layout="stack" data-gap="none">
51
+ <a data-truncate data-sn-attr="{ href: '/task/?id=' + t.id }"
52
+ data-sn-transition="'task-' + t.id" data-sn-text="t.title"></a>
53
+ <small data-muted data-sn-text="relative(t.createdAt)"></small>
54
+ </div>
55
+ <button data-icon="trash-2" data-variant="ghost" data-size="sm" data-tone="danger" aria-label="Delete task"
56
+ data-sn-delete="/api/tasks/{t.id}" data-sn-optimistic="remove(t)"></button>
57
+ </li>
58
+ </template>
59
+ </ul>
60
+ </section>
61
+ </div>
62
+
63
+ <dialog id="new-task" closedby="any">
64
+ <form data-layout="stack" data-gap="md" data-sn-post="/api/tasks" data-sn-body="draft"
65
+ data-sn-then="upsert('tasks', $result); draft = { title: '' }; close()">
66
+ <header>
67
+ <h2>New task</h2>
68
+ <button type="button" commandfor="new-task" command="close" aria-label="Close"></button>
69
+ </header>
70
+ <!-- `name`: a 422 with { errors: { title: … } } marks this field and shows the message after it. -->
71
+ <label>Title <input name="title" data-sn-model="draft.title" required autofocus></label>
72
+ <footer>
73
+ <button type="button" commandfor="new-task" command="close" data-variant="ghost">Cancel</button>
74
+ <button>Add task</button>
75
+ </footer>
76
+ </form>
77
+ </dialog>
78
+ </app-layout>
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "tiaforge-app",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "preview": "vite preview",
9
+ "check": "sinter check && temper check ."
10
+ },
11
+ "dependencies": {
12
+ "@tiaforge-ui/sinter": "^0.15.0",
13
+ "@tiaforge-ui/temper": "^0.1.9"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^7.0.2",
17
+ "vite": "^8.3.5"
18
+ }
19
+ }
@@ -0,0 +1,125 @@
1
+ // A stand-in for your backend: an in-memory task list with a JSON API and a server-sent event
2
+ // stream, served by Vite in `npm run dev` and `npm run preview`. When your real API is up, remove
3
+ // `api()` from vite.config.js (and proxy /api to your server there, `server.proxy`).
4
+ //
5
+ // GET /api/tasks all tasks
6
+ // POST /api/tasks { title } → the new task
7
+ // GET /api/tasks/:id one task
8
+ // PATCH /api/tasks/:id { title?, notes?, done? } → the task
9
+ // DELETE /api/tasks/:id
10
+ // GET /api/events task_updated { task }, task_removed { id }
11
+ // POST /api/fail-next the next change (POST, PATCH, DELETE) fails with a 503
12
+
13
+ /** Every request waits this long, so optimistic updates are visible. */
14
+ const LATENCY_MS = 300;
15
+
16
+ /** @returns {import('vite').Plugin} */
17
+ export default function api() {
18
+ /** @type {Map<string, { id: string, title: string, notes: string, done: boolean, createdAt: string }>} */
19
+ const tasks = new Map();
20
+ /** @type {Set<import('node:http').ServerResponse>} */
21
+ const streams = new Set();
22
+ let failNext = false;
23
+ let lastId = 0;
24
+
25
+ const create = (title, done = false, minutesAgo = 0) => {
26
+ const id = String(++lastId);
27
+ const task = { id, title, notes: '', done, createdAt: new Date(Date.now() - minutesAgo * 60_000).toISOString() };
28
+ tasks.set(id, task);
29
+ return task;
30
+ };
31
+ create('Read the README', true, 90);
32
+ create('Open this page in a second tab, then tick a task in one of them', false, 60);
33
+ create('Press "Fail next change", then tick a task', false, 30);
34
+ create('Open a task and give it some notes', false, 10);
35
+
36
+ /** Sends one event to every open stream. */
37
+ const broadcast = (event, data) => {
38
+ for (const res of streams) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
39
+ };
40
+
41
+ /** @type {import('vite').Connect.NextHandleFunction} */
42
+ const handle = async (req, res, next) => {
43
+ const url = new URL(req.url ?? '/', 'http://localhost');
44
+ if (!url.pathname.startsWith('/api/')) return next();
45
+
46
+ const send = (status, body) => {
47
+ res.statusCode = status;
48
+ if (body === undefined) return res.end();
49
+ res.setHeader('content-type', 'application/json');
50
+ res.end(JSON.stringify(body));
51
+ };
52
+
53
+ if (url.pathname === '/api/events') {
54
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store' });
55
+ res.write(': connected\n\n');
56
+ streams.add(res);
57
+ req.on('close', () => streams.delete(res));
58
+ return;
59
+ }
60
+
61
+ await new Promise((r) => setTimeout(r, LATENCY_MS));
62
+ const body = req.method === 'GET' || req.method === 'DELETE' ? {} : await readJson(req);
63
+ if (body === null) return send(400, { message: 'The request body is not JSON.' });
64
+
65
+ if (req.method === 'POST' && url.pathname === '/api/fail-next') {
66
+ failNext = true;
67
+ return send(204);
68
+ }
69
+ if (req.method !== 'GET' && failNext) {
70
+ failNext = false;
71
+ return send(503, { message: 'The server refused this change, as you asked it to.' });
72
+ }
73
+
74
+ if (url.pathname === '/api/tasks') {
75
+ if (req.method === 'GET') return send(200, [...tasks.values()]);
76
+ if (req.method === 'POST') {
77
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
78
+ if (!title) return send(422, { message: 'A task needs a title.', errors: { title: 'A task needs a title.' } });
79
+ const task = create(title);
80
+ broadcast('task_updated', { task });
81
+ return send(201, task);
82
+ }
83
+ }
84
+
85
+ const id = /^\/api\/tasks\/([^/]+)$/.exec(url.pathname)?.[1];
86
+ const task = id ? tasks.get(id) : undefined;
87
+ if (id && !task) return send(404, { message: 'There is no such task. Maybe someone deleted it.' });
88
+ if (task) {
89
+ if (req.method === 'GET') return send(200, task);
90
+ if (req.method === 'PATCH') {
91
+ if ('title' in body && (typeof body.title !== 'string' || !body.title.trim())) return send(422, { message: 'A task needs a title.', errors: { title: 'A task needs a title.' } });
92
+ const changed = { ...task };
93
+ if (typeof body.title === 'string') changed.title = body.title.trim();
94
+ if (typeof body.notes === 'string') changed.notes = body.notes;
95
+ if (typeof body.done === 'boolean') changed.done = body.done;
96
+ tasks.set(task.id, changed);
97
+ broadcast('task_updated', { task: changed });
98
+ return send(200, changed);
99
+ }
100
+ if (req.method === 'DELETE') {
101
+ tasks.delete(task.id);
102
+ broadcast('task_removed', { id: task.id });
103
+ return send(204);
104
+ }
105
+ }
106
+ send(404, { message: `No route for ${req.method} ${url.pathname}.` });
107
+ };
108
+
109
+ /** @param {import('vite').ViteDevServer | import('vite').PreviewServer} server */
110
+ const use = (server) => { server.middlewares.use(handle); };
111
+ return { name: 'demo-api', configureServer: use, configurePreviewServer: use };
112
+ }
113
+
114
+ /** The parsed JSON body ({} when empty), or null when it is not JSON. */
115
+ async function readJson(req) {
116
+ let text = '';
117
+ for await (const chunk of req) text += chunk;
118
+ if (!text) return {};
119
+ try {
120
+ const value = JSON.parse(text);
121
+ return value && typeof value === 'object' ? value : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
@@ -0,0 +1,16 @@
1
+ <!-- The header of the app layout: a component, expanded at build time. It shows the layout's `armed`
2
+ state and emits `arm` when the next change is set to fail; the layout writes its state.
3
+ data-theme="dark": a dark band on a light page is one attribute. -->
4
+ <template data-sn-component="{ armed: boolean }" data-sn-emits="{ arm: null }">
5
+ <header data-theme="dark" data-pad="sm">
6
+ <div data-layout="center" style="--tm-measure: 48rem">
7
+ <div data-layout="row" data-gap="sm" data-justify="between">
8
+ <a href="/" data-icon="circle-check"><strong>Tasks</strong></a>
9
+ <!-- a plain POST action; the next failed change resets `armed` (data-sn-on-sn-error on the layout's <body>) -->
10
+ <button data-variant="outline" data-size="sm" data-icon="triangle-alert"
11
+ data-sn-post="/api/fail-next" data-sn-then="emit('arm', null)"
12
+ data-sn-attr="{'aria-pressed': armed ? 'true' : 'false'}">Fail next change</button>
13
+ </div>
14
+ </div>
15
+ </header>
16
+ </template>
@@ -0,0 +1,14 @@
1
+ <!-- A component with a script (task-chart.ts beside it): sinter renders the markup, the script draws
2
+ the ring on the canvas. The label is a binding, so it is in the first frame. -->
3
+ <template data-sn-component="{ tasks: Task[]; show: string }" data-sn-emits="{ pick: 'done' | 'open' | 'all' }">
4
+ <figure class="chart" role="img" data-sn-attr="{ 'aria-label': tasks.filter((t) => t.done).length + ' of ' + tasks.length + ' tasks done' }">
5
+ <canvas data-sn-ref="canvas"></canvas>
6
+ <!-- resolves temper's color tokens for the canvas -->
7
+ <span data-sn-ref="probe" hidden></span>
8
+ </figure>
9
+ </template>
10
+
11
+ <style>
12
+ .chart { display: block; inline-size: 9rem; aspect-ratio: 1; margin: 0; cursor: pointer; }
13
+ canvas { display: block; }
14
+ </style>
@@ -0,0 +1,80 @@
1
+ // The script of <task-chart>: the browser work markup cannot do (drawing on a canvas). Its props,
2
+ // event and refs are declared in task-chart.html, and typed through the base class generated from it
3
+ // (src/sinter.components.d.ts). sinter runs it once per instance; it writes no node sinter renders.
4
+ import { TaskChartComponent } from './task-chart.html';
5
+
6
+ const TAU = 2 * Math.PI;
7
+
8
+ export class TaskChart extends TaskChartComponent {
9
+ #resize = new ResizeObserver(() => this.draw());
10
+ #scheme = matchMedia('(prefers-color-scheme: dark)');
11
+ #redraw = () => this.draw();
12
+
13
+ connected() {
14
+ this.refs.canvas.addEventListener('click', (e) => this.click(e));
15
+ this.#resize.observe(this.root);
16
+ this.#scheme.addEventListener('change', this.#redraw);
17
+ }
18
+
19
+ propsChanged() { this.draw(); }
20
+
21
+ disconnected() {
22
+ this.#resize.disconnect();
23
+ this.#scheme.removeEventListener('change', this.#redraw);
24
+ }
25
+
26
+ counts() {
27
+ const done = this.props.tasks.filter((t) => t.done).length;
28
+ return { done, total: this.props.tasks.length };
29
+ }
30
+
31
+ color(token: string) {
32
+ this.refs.probe.style.color = `var(${token})`;
33
+ return getComputedStyle(this.refs.probe).color;
34
+ }
35
+
36
+ draw() {
37
+ const size = this.root.clientWidth;
38
+ if (!size) return;
39
+ const { done, total } = this.counts();
40
+ const dpr = devicePixelRatio || 1;
41
+ const c = this.refs.canvas;
42
+ c.width = c.height = Math.round(size * dpr);
43
+ c.style.width = c.style.height = `${size}px`;
44
+ const g = c.getContext('2d')!;
45
+ g.scale(dpr, dpr);
46
+
47
+ const r = size / 2, width = size * 0.14;
48
+ const arc = (from: number, to: number, token: string, dim: boolean) => {
49
+ g.globalAlpha = dim ? 0.3 : 1;
50
+ g.strokeStyle = this.color(token);
51
+ g.lineWidth = width;
52
+ g.beginPath();
53
+ g.arc(r, r, r - width / 2, from - TAU / 4, to - TAU / 4);
54
+ g.stroke();
55
+ };
56
+ if (!total) arc(0, TAU, '--tm-fill', false);
57
+ const split = total ? (done / total) * TAU : 0;
58
+ if (done) arc(0, split, '--tm-success', this.props.show === 'open');
59
+ if (done < total) arc(split, TAU, '--tm-accent', this.props.show === 'done');
60
+
61
+ g.globalAlpha = 1;
62
+ g.fillStyle = this.color('--tm-text');
63
+ g.font = `600 ${Math.round(size / 6)}px ${getComputedStyle(this.root).fontFamily}`;
64
+ g.textAlign = 'center';
65
+ g.textBaseline = 'middle';
66
+ g.fillText(`${done}/${total}`, r, r);
67
+ }
68
+
69
+ /** a click on a part picks its filter, or all when that part is already shown */
70
+ click(e: MouseEvent) {
71
+ const { done, total } = this.counts();
72
+ const r = this.root.clientWidth / 2, width = this.root.clientWidth * 0.14;
73
+ const x = e.offsetX - r, y = e.offsetY - r;
74
+ const distance = Math.hypot(x, y);
75
+ if (!total || distance < r - width || distance > r) return;
76
+ const angle = (Math.atan2(y, x) + TAU / 4 + TAU) % TAU;
77
+ const part = angle < (done / total) * TAU ? 'done' : 'open';
78
+ this.emit('pick', part === this.props.show ? 'all' : part);
79
+ }
80
+ }
@@ -0,0 +1,24 @@
1
+ <!-- The document both pages share: the head, the header, the frame around the page's content and the
2
+ toast region. A page is this tag, with its title as a prop and its content inside.
3
+ `armed`: whether the next change is set to fail. The header sets it; the next failed action, on
4
+ any page, resets it (sn-error bubbles to <body>). -->
5
+ <!doctype html>
6
+ <html lang="en" data-sn-layout="{ title?: string }">
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <meta name="color-scheme" content="light dark">
11
+ <title data-sn-text="title ? title + ' · Tasks' : 'Tasks'">Tasks</title>
12
+ <link rel="icon" href="data:,">
13
+ <link rel="stylesheet" href="../style.css">
14
+ </head>
15
+ <body data-sn-state="{ armed: false }" data-sn-on-sn-error="armed = false">
16
+ <app-header data-sn-prop="{ armed }" data-sn-on-arm="armed = true"></app-header>
17
+
18
+ <main data-layout="center" data-gap="lg" data-pad="lg" style="--tm-measure: 48rem">
19
+ <slot></slot>
20
+ </main>
21
+
22
+ <div data-toasts role="status"></div>
23
+ </body>
24
+ </html>
@@ -0,0 +1,15 @@
1
+ // Generated by sinter from src/components/*.html and src/layouts/*.html. Do not edit.
2
+ /** every component of the app, by its tag */
3
+ type ComponentName = "app-header" | "task-chart";
4
+ /** every layout of the app, by its tag */
5
+ type LayoutName = "app-layout";
6
+ /** the props each layout declares (`data-sn-layout`) */
7
+ interface LayoutProps {
8
+ "app-layout": { title?: string };
9
+ }
10
+ declare module '*/task-chart.html' {
11
+ type Task = import('./sinter.types').Task;
12
+ const Base: typeof import('@tiaforge-ui/sinter/components').Component;
13
+ /** the base of `src/components/task-chart.ts`: props, events, refs and state as `task-chart.html` declares them */
14
+ export class TaskChartComponent extends Base<{ tasks: Task[]; show: string }, { pick: 'done' | 'open' | 'all' }, { canvas: HTMLElementTagNameMap["canvas"]; probe: HTMLElementTagNameMap["span"] }, {}> {}
15
+ }
@@ -0,0 +1,9 @@
1
+ // App configuration, loaded by every page before it mounts.
2
+ import { configure } from '@tiaforge-ui/sinter';
3
+ import { toast } from './toast';
4
+
5
+ configure({
6
+ // A failed load or action that no data-sn-catch handled: an optimistic change has already
7
+ // been rolled back, so say why.
8
+ onError: (error) => toast(error.message, 'danger'),
9
+ });
@@ -0,0 +1,10 @@
1
+ // Functions the pages' expressions can call. Keep them pure (the same arguments give the same
2
+ // result), so they can be unit-tested without a page.
3
+ import type { Task } from './sinter.types';
4
+
5
+ /** The tasks the list shows: `show` is 'all', 'open' or 'done'; `q` matches the title. */
6
+ export function visible(tasks: readonly Task[], q: string, show: string): Task[] {
7
+ const query = q.trim().toLowerCase();
8
+ return tasks.filter((t) =>
9
+ (show === 'all' || t.done === (show === 'done')) && t.title.toLowerCase().includes(query));
10
+ }
@@ -0,0 +1,22 @@
1
+ // Types the pages name in `type="…"` on <sn-resource> and <sn-event>. `sinter check` (and every
2
+ // `vite build`) checks each expression against them: `t.titel` fails the build.
3
+ import type {} from '@tiaforge-ui/temper/sinter'; // temper's attribute values, for bound data-tone etc.
4
+
5
+ export interface Task {
6
+ id: string;
7
+ title: string;
8
+ notes: string;
9
+ done: boolean;
10
+ /** ISO 8601 */
11
+ createdAt: string;
12
+ }
13
+
14
+ /** `task_updated` on /api/events */
15
+ export interface TaskEvent {
16
+ task: Task;
17
+ }
18
+
19
+ /** `task_removed` on /api/events */
20
+ export interface TaskRemoved {
21
+ id: string;
22
+ }
@@ -0,0 +1,11 @@
1
+ /* temper styles the HTML itself; this file only picks the modules and the brand. */
2
+ @import '@tiaforge-ui/temper/core.css';
3
+ @import '@tiaforge-ui/temper/feedback.css'; /* toasts */
4
+ @import '@tiaforge-ui/temper/icons.css';
5
+
6
+ /* Rebrand with two numbers: every color derives from them, in light and dark mode. Try 20 or 200. */
7
+ :root {
8
+ --tm-hue: 290;
9
+ --tm-chroma: 0.16;
10
+ }
11
+
@@ -0,0 +1,15 @@
1
+ // A temper toast: appended to the page's [data-toasts] region, faded out after a few seconds.
2
+ export function toast(text: string, tone?: 'danger' | 'success') {
3
+ const region = document.querySelector('[data-toasts]');
4
+ if (!region) return;
5
+ const t = document.createElement('div');
6
+ t.dataset.toast = '';
7
+ if (tone) t.dataset.tone = tone;
8
+ if (tone === 'danger') t.role = 'alert';
9
+ t.textContent = text;
10
+ region.append(t);
11
+ setTimeout(() => {
12
+ t.hidden = true;
13
+ setTimeout(() => t.remove(), 300);
14
+ }, 5000);
15
+ }
@@ -0,0 +1,47 @@
1
+ <!-- /task/?id=…: one task. Its data was requested while the pointer rested on the link,
2
+ so it shows at once. The tab's title is the task's, once it is there. -->
3
+ <app-layout data-sn-prop="{ title: task ? task.title : 'Task' }">
4
+
5
+ <sn-resource name="task" src="/api/tasks/{$url.id}" type="Task"></sn-resource>
6
+
7
+ <!-- Only events about this task: an edit in another tab shows here, a delete empties the page. -->
8
+ <sn-sync src="/api/events">
9
+ <sn-event name="task_updated" resource="task" op="upsert" item="task" when="task.id === $url.id" type="TaskEvent"></sn-event>
10
+ <sn-event name="task_removed" resource="task" op="remove" when="id === $url.id" type="TaskRemoved"></sn-event>
11
+ </sn-sync>
12
+
13
+ <a href="/" data-icon="arrow-left" data-self="start">All tasks</a>
14
+ <!-- The same transition name as the link on the list: the title morphs into this heading. -->
15
+ <h1 data-sn-transition="'task-' + $url.id" data-sn-text="task ? task.title : ''"></h1>
16
+
17
+ <template data-sn-if="task">
18
+ <article data-layout="stack" data-gap="md">
19
+ <div data-layout="row" data-gap="sm" data-justify="between">
20
+ <span data-badge data-sn-attr="{ 'data-tone': task.done ? 'success' : 'accent' }"
21
+ data-sn-text="task.done ? 'Done' : 'Open'"></span>
22
+ <small data-muted data-sn-text="'Created ' + datetime(task.createdAt)"></small>
23
+ </div>
24
+
25
+ <!-- Edit a copy; saving sends it, and the response updates `task`. -->
26
+ <form data-layout="stack" data-gap="md" data-sn-state="{ draft: { title: task.title, notes: task.notes } }"
27
+ data-sn-patch="/api/tasks/{task.id}" data-sn-body="draft">
28
+ <label>Title <input name="title" data-sn-model="draft.title" required></label>
29
+ <label>Notes <textarea rows="5" data-sn-model="draft.notes"></textarea></label>
30
+ <footer>
31
+ <button type="button" data-variant="secondary"
32
+ data-sn-patch="/api/tasks/{task.id}" data-sn-body="{ done: !task.done }"
33
+ data-sn-optimistic="patch(task, { done: !task.done })"
34
+ data-sn-text="task.done ? 'Reopen' : 'Mark done'"></button>
35
+ <button>Save</button>
36
+ </footer>
37
+ </form>
38
+ </article>
39
+ </template>
40
+
41
+ <template data-sn-if="!task && ($meta.task.error || $meta.task.loadedAt)">
42
+ <div data-callout data-tone="warning" role="status">
43
+ <strong>This task is gone</strong>
44
+ It was deleted, or the link is wrong. <a href="/">Back to all tasks</a>
45
+ </div>
46
+ </template>
47
+ </app-layout>
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "isolatedModules": true,
10
+ "verbatimModuleSyntax": true,
11
+ "noEmit": true
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,26 @@
1
+ import { resolve } from 'node:path';
2
+ import { defineConfig } from 'vite';
3
+ import sinter from '@tiaforge-ui/sinter/vite';
4
+ import temper from '@tiaforge-ui/temper/vite';
5
+ import api from './server/api.js';
6
+
7
+ export default defineConfig({
8
+ appType: 'mpa',
9
+ plugins: [
10
+ // Compiles the data-sn-* pages; typecheck: every expression is checked against src/sinter.types.ts in `vite build`.
11
+ sinter({ typecheck: true }),
12
+ // temper check on the markup: a wrong data-* value fails the build, the dev server warns.
13
+ temper(),
14
+ // The demo backend (server/api.js). Replace it with your API, e.g. server: { proxy: { '/api': 'http://localhost:3000' } }.
15
+ api(),
16
+ ],
17
+ build: {
18
+ // One entry per page: a sinter app is a multi-page app.
19
+ rollupOptions: {
20
+ input: {
21
+ index: resolve(import.meta.dirname, 'index.html'),
22
+ task: resolve(import.meta.dirname, 'task.html'),
23
+ },
24
+ },
25
+ },
26
+ });