Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ jobs:
- run: bun run format
- run: bun run test
- run: bun run build
- run: bunx playwright install --with-deps chromium firefox
- run: bun run test:browser
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ node_modules/
dist/
build/
coverage/
test-results/
playwright-report/
*.tsbuildinfo

.DS_Store
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
dist/
build/
coverage/
test-results/
playwright-report/
node_modules/
bun.lock
apps/*/public/
17 changes: 14 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,34 @@ bun install
bun run verify
```

`bun run verify` is lint, typecheck, format check, tests, and a full site build. CI runs those same steps. The [README](./README.md) covers `bun run dev` and the Windows note about `TOOLS_BASE`.
`bun run verify` is lint, typecheck, format check, tests, and a full site build. CI runs those steps followed by the browser suite. The [README](./README.md) covers `bun run dev` and the Windows note about `TOOLS_BASE`.

## Where to change things

- A new tool is `bun run new-app <slug> "Display Name"`. That creates `apps/<slug>` and an unlisted catalog entry. [`packages/catalog`](./packages/catalog/src/index.ts) is the only list of apps — the index, the build, and each page's title read it.
- Shared browser behaviour goes in `packages/ui`. Code only one app uses stays in that app until a second one needs it. The catalog holds data, not app code.
- In the SVG app, start with [`model.ts`](./apps/svg/src/lib/model.ts) and [`io.ts`](./apps/svg/src/lib/io.ts). Those are the document. [`interaction.ts`](./apps/svg/src/lib/interaction.ts) and [`render.ts`](./apps/svg/src/lib/render.ts) draw that document; change them when the gesture or the picture is what is wrong.
- In the SVG app, [`model.ts`](./apps/svg/src/lib/model.ts) owns construction, transforms and editing. [`element-geometry.ts`](./apps/svg/src/lib/element-geometry.ts) owns bounds and rendering geometry; [`element-style.ts`](./apps/svg/src/lib/element-style.ts) owns paint and style attributes. [`project-file.ts`](./apps/svg/src/lib/project-file.ts) validates saved documents. Canvas gestures live in [`interaction.ts`](./apps/svg/src/lib/interaction.ts), ruler gestures in [`ruler-guides.ts`](./apps/svg/src/lib/ruler-guides.ts).

## Tests

Put a test next to the pure logic it covers, the way `model.test.ts` sits beside `model.ts`. Prefer plain data over a rendered page.

[`io.test.ts`](./apps/svg/src/lib/io.test.ts) checks that export, then import, then export is byte-for-byte the same. The live SVG panel re-imports its own output, so an unstable serializer makes shapes drift while someone types. Leave that test as it is.

After a build, run real-browser checks with:

```bash
bunx playwright install chromium firefox
bun run test:browser
```

The suite uses isolated profiles and a temporary production-file server on port 5171, shut down by Playwright. It does not use or stop the development server on 5170. Chromium and Firefox cover editing, drawing, source-panel loading, keyboard menus, responsive headers, and automated WCAG A/AA checks in both themes. Chromium also covers native touch drawing/pinch and offline service-worker replacement. Automated accessibility checks complement manual keyboard and screen-reader testing; they do not certify accessibility.

Builds enforce gzip JavaScript budgets from the catalog's `jsBudget`: the initial entry and static module preloads, plus all chunks including lazy ones. The SVG source editor loads when visible; all chunks are still precached for offline use. Change a budget only after measuring and explaining the additional cost.

## Saved documents

The SVG app is released. A drawing lives in the browser's IndexedDB (`ProjectFile`, currently version 1) and in exported SVG. A change to either keeps documents people already have opening: bump `PROJECT_VERSION` in `types.ts` and add the step up from the previous version in `readProject` (`io.ts`). Exported SVG carries no version marker, so an importer change must still read files written before it.
The SVG app is released. A drawing lives in the browser's IndexedDB (`ProjectFile`, currently version 1) and in exported SVG. A change to either keeps documents people already have opening: bump `PROJECT_VERSION` in `types.ts` and add the step up from the previous version in `readProject` (`project-file.ts`). Exported SVG carries no version marker, so an importer change must still read files written before it.

An app that is not released yet has no saved documents to carry forward. When its stored format changes, fail and tell the person to clear that app's storage.

Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,15 @@ editing code, reached only through `packages/editor`

## Commands

| Command | What it does |
| -------------------- | ------------------------------------ |
| `bun run dev [slug]` | Serve the whole site with hot reload |
| `bun run check` | Lint, typecheck and format check |
| `bun run test` | Run the test suites |
| `bun run build` | Build the whole site into `dist/` |
| `bun run verify` | `check` + `test` + `build` |
| `bun run new-app` | Scaffold a new app |
| Command | What it does |
| ---------------------- | -------------------------------------------------- |
| `bun run dev [slug]` | Serve the whole site with hot reload |
| `bun run check` | Lint, typecheck and format check |
| `bun run test` | Run the test suites |
| `bun run test:browser` | Check the production build in Chromium and Firefox |
| `bun run build` | Build the whole site into `dist/` |
| `bun run verify` | `check` + `test` + `build` |
| `bun run new-app` | Scaffold a new app |

## Contributing

Expand Down
36 changes: 36 additions & 0 deletions apps/json/src/lib/header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { bindDock, bindMenu, bindThemeToggle, byId } from "@tools/ui";

/** Header menus and docks own their layout behavior; document actions stay in the controller. */
export function bindHeader(): void {
const more = byId("more");
const moreBtn = byId<HTMLButtonElement>("more-btn");
const fileButtons = ["open", "download", "copy", "clear"].map((id) =>
byId<HTMLButtonElement>(id)
);
const findButton = byId<HTMLButtonElement>("find-open");
const menu = bindMenu(moreBtn, more, {
onOpen: () =>
more.replaceChildren(
...[
...fileButtons,
...(getComputedStyle(findButton).display === "none" ? [findButton] : []),
].map((button) => {
const item = document.createElement("button");
item.type = "button";
item.className = "ui-menu-item";
item.disabled = button.disabled;
item.append(
button.querySelector("svg")!.cloneNode(true),
button.title.replace(/ —.*/, "")
);
item.addEventListener("click", () => {
menu.close();
button.click();
});
return item;
})
),
});
bindDock(byId("help"), byId("help-btn"), { key: "tools.json.help" }).restore();
bindThemeToggle(byId("theme-toggle"));
}
19 changes: 19 additions & 0 deletions apps/json/src/lib/preferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { parsePrefs } from "./preferences.js";

test("older settings keep recognized fields and default anything missing or unknown", () => {
expect(parsePrefs({ indent: "tab", sortKeys: true, view: "csv" })).toEqual({
indent: "tab",
sortKeys: true,
view: "csv",
pathStyle: "js",
colours: true,
});
expect(
parsePrefs({ indent: 4, sortKeys: "true", view: "future", pathStyle: "unknown", colours: 0 })
).toEqual(parsePrefs(null));
expect(parsePrefs({ colours: false, pathStyle: "pointer" })).toMatchObject({
colours: false,
pathStyle: "pointer",
});
});
43 changes: 43 additions & 0 deletions apps/json/src/lib/preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readStored, writeStored } from "@tools/ui";
import type { ExportKind } from "./exports.js";

export type ViewMode = "json" | ExportKind;
export type PathStyle = "js" | "pointer";
export type Indent = "2" | "4" | "tab";
export interface Prefs {
indent: Indent;
sortKeys: boolean;
view: ViewMode;
pathStyle: PathStyle;
colours: boolean;
}

const PREFS_KEY = "tools.json.prefs";
const DRAFT_KEY = "tools.json.draft";
const EXACT_KEY = "tools.json.exact";
const MAX_SAVED = 2_000_000;
const VIEWS: readonly ViewMode[] = ["json", "yaml", "csv", "ts", "schema"];

/** Each released setting is read independently, retaining older and partial preferences. */
export function parsePrefs(value: unknown): Prefs {
const raw = value && typeof value === "object" ? (value as Partial<Prefs>) : {};
return {
indent: raw.indent === "4" || raw.indent === "tab" ? raw.indent : "2",
sortKeys: raw.sortKeys === true,
view: raw.view && VIEWS.includes(raw.view) ? raw.view : "json",
pathStyle: raw.pathStyle === "pointer" ? "pointer" : "js",
colours: raw.colours !== false,
};
}

export const readPrefs = () => parsePrefs(readStored(PREFS_KEY));
export const writePrefs = (prefs: Prefs) => writeStored(PREFS_KEY, prefs);
export const readDraft = () => readStored(DRAFT_KEY, "session");
export const writeDraft = (text: string, dropped: boolean) =>
writeStored(DRAFT_KEY, text.length > MAX_SAVED || dropped ? false : text, "session");
export function readExactChoices(): string[] {
const raw = readStored(EXACT_KEY, "session");
return Array.isArray(raw) ? raw.filter((v): v is string => typeof v === "string") : [];
}
export const writeExactChoices = (places: ReadonlySet<string>) =>
writeStored(EXACT_KEY, [...places], "session");
104 changes: 28 additions & 76 deletions apps/json/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { bindHeader } from "./lib/header.js";
import {
readPrefs,
writePrefs,
readDraft,
writeDraft,
readExactChoices,
writeExactChoices,
type Prefs,
type ViewMode,
type PathStyle,
type Indent,
} from "./lib/preferences.js";
import {
bindDock,
bindMenu,
bindThemeToggle,
byId,
copyText,
downloadText,
formatBytes,
onFileDrop,
pickFiles,
readStored,
registerServiceWorker,
setPressed,
writeStored,
} from "@tools/ui";
import { createEditor, type LineLexer } from "@tools/editor";
import { applyEdits, REPAIRS, type Edit, type JsonNode, type RepairKind } from "./lib/ast.js";
Expand Down Expand Up @@ -110,9 +119,6 @@ const exportView = createEditor(exportEl, {
// ---------- State ----------

/** JSON edits the document; the others show it converted, read-only. */
type ViewMode = "json" | ExportKind;
type PathStyle = "js" | "pointer";
type Indent = "2" | "4" | "tab";

let fileName = "data.json";
const analysis = createAnalysis();
Expand All @@ -137,50 +143,24 @@ const find = bindFind([editor, exportView], () => ({

// ---------- Storage ----------

/**
* Settings persist (localStorage, shared by every tab). The draft is the person's data, so
* it lives only as long as the tab (sessionStorage): a reload keeps it, closing the tab ends it.
*/
const PREFS_KEY = "tools.json.prefs";
const DRAFT_KEY = "tools.json.draft";
/** The Exact values turned on, kept beside the draft they were chosen for. */
const EXACT_KEY = "tools.json.exact";
/** Past this, a draft is not worth the storage quota it would eat: `false` is kept instead. */
const MAX_SAVED = 2_000_000;
/** The tab reloaded after dropping a draft too large to keep: said until the next change. */
let droppedDraft = false;
/** Only text changes write the draft; settings changes leave it alone. */
let draftChanged = true;

interface Prefs {
indent: Indent;
sortKeys: boolean;
view: ViewMode;
pathStyle: PathStyle;
colours: boolean;
}

const VIEWS: readonly ViewMode[] = ["json", "yaml", "csv", "ts", "schema"];

function restore(): void {
// Each setting is checked: storage holds whatever was last put there.
const prefs = (readStored(PREFS_KEY) ?? {}) as Partial<Prefs>;
if (prefs.indent === "2" || prefs.indent === "4" || prefs.indent === "tab") {
indentChoice = prefs.indent;
}
setOn(sortKeys, prefs.sortKeys === true);
if (prefs.view && VIEWS.includes(prefs.view)) mode = prefs.view;
if (prefs.pathStyle === "pointer") pathStyle = "pointer";
if (prefs.colours === false) colours = false;
const prefs = readPrefs();
indentChoice = prefs.indent;
setOn(sortKeys, prefs.sortKeys);
mode = prefs.view;
pathStyle = prefs.pathStyle;
colours = prefs.colours;
// A tab with no draft yet opens on the sample. Cleared, the draft is "" and stays empty.
const draft = readStored(DRAFT_KEY, "session");
const draft = readDraft();
showDocument(typeof draft === "string" ? draft : draft === false ? "" : SAMPLE);
// After the document is in: putting it there was a change, and a change ends the warning.
droppedDraft = draft === false;
const exact = readStored(EXACT_KEY, "session");
if (Array.isArray(exact)) {
for (const place of exact) if (typeof place === "string") exactPlaces.add(place);
}
for (const place of readExactChoices()) exactPlaces.add(place);
showOptions();
}

Expand All @@ -192,19 +172,19 @@ function savePrefs(): void {
pathStyle,
colours,
};
writeStored(PREFS_KEY, prefs);
writePrefs(prefs);
}

function saveDraft(): void {
if (!draftChanged) return;
draftChanged = false;
const text = documentText();
// False while the empty editor still stands for a draft too large to keep: every reload says so.
writeStored(DRAFT_KEY, text.length > MAX_SAVED || droppedDraft ? false : text, "session");
writeDraft(text, droppedDraft);
}

function saveExactChoices(): void {
writeStored(EXACT_KEY, [...exactPlaces], "session");
writeExactChoices(exactPlaces);
}

/** Switches (settings that are on or off) keep theirs in aria-checked, as role="switch" has it. */
Expand Down Expand Up @@ -582,6 +562,7 @@ function showLayout(text: string): void {
}

function rewrite(minify: boolean): void {
if (stale) validate();
if (!currentAnalysis.doc) return;
replaceText(
printJson(currentAnalysis.doc.root, { indent: minify ? 0 : indent(), sortKeys: isOn(sortKeys) })
Expand All @@ -590,6 +571,7 @@ function rewrite(minify: boolean): void {

/** Makes almost-JSON strict in place, keeping the layout: only the offending bits change. */
function fix(): void {
if (stale) validate();
if (currentAnalysis.fault?.edits)
replaceText(applyEdits(documentText(), currentAnalysis.fault.edits));
}
Expand Down Expand Up @@ -647,6 +629,7 @@ function fitPath(): void {
// ---------- Views ----------

function setMode(next: ViewMode): void {
if (stale) validate();
mode = next;
codeEl.classList.toggle("hidden", mode !== "json");
for (const b of viewButtons) setPressed(b, b.dataset.view === mode);
Expand Down Expand Up @@ -807,37 +790,7 @@ wrapBtn.addEventListener("click", () => {
runNested("wrap");
});

// On a phone the file actions fold into the "⋯" menu; Find joins them when the header is full. Its
// items are made from the bar's buttons each time it opens, so they carry the same names and
// the same disabled state, and pressing one presses that button.
const more = byId("more");
const moreBtn = byId<HTMLButtonElement>("more-btn");
const fileButtons = [byId("open"), downloadBtn, copyBtn, clearBtn] as HTMLButtonElement[];
const findButton = byId<HTMLButtonElement>("find-open");
const moreMenu = bindMenu(moreBtn, more, {
onOpen: () =>
more.replaceChildren(
...[
...fileButtons,
...(getComputedStyle(findButton).display === "none" ? [findButton] : []),
].map((b) => {
const item = document.createElement("button");
item.type = "button";
item.className = "ui-menu-item";
item.disabled = b.disabled;
item.append(b.querySelector("svg")!.cloneNode(true), b.title.replace(/ —.*/, ""));
item.addEventListener("click", () => {
moreMenu.close();
b.click();
});
return item;
})
),
});

// Help docks under the header and stays open until its button closes it; a reload of the tab
// keeps it open.
bindDock(byId("help"), byId("help-btn"), { key: "tools.json.help" }).restore();
bindHeader();

pathBtn.addEventListener("click", () => void copyText(pathBtn.textContent ?? "", pathBtn));
// A phone's status bar: folded away until this opens it over the editor (see styles.css).
Expand Down Expand Up @@ -874,7 +827,6 @@ for (const view of [editor, exportView]) {
});
}

bindThemeToggle(byId("theme-toggle"));
restore();
editor.setIndent(indent());
showColours();
Expand Down
Loading
Loading