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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- No `git add` / `commit` / `push` unless the user asks
- Branch or PR work follows [Git](#git)
- Run the [format gate](#format-gate) after editing, and the [verify gate](#verify-gate) before finishing
- **Saved formats:** the SVG and JSON apps are released. JSON keeps only settings (`tools.json.prefs` in localStorage, read field by field with a default for anything missing or unknown) and a per-tab draft; a change to its settings must still read the old ones. A change to the SVG app's IndexedDB documents (`ProjectFile`) or exported SVG keeps existing files opening: bump `PROJECT_VERSION` (`types.ts`) and add the step up from the previous version in `readProject` (`project-file.ts`); the database's own `DB_VERSION` (`storage.ts`) is for its stores. Exported SVG carries no marker, so an SVG change must still import files written before it. An app that is not released yet has no migration — when its stored format changes, fail closed and tell the person to clear that app's storage.
- **Saved formats:** the SVG, JSON and Time apps are released. JSON keeps only settings (`tools.json.prefs` in localStorage, read field by field with a default for anything missing or unknown) and a per-tab draft; a change to its settings must still read the old ones. A change to the SVG app's IndexedDB documents (`ProjectFile`) or exported SVG keeps existing files opening: bump `PROJECT_VERSION` (`types.ts`) and add the step up from the previous version in `readProject` (`project-file.ts`); the database's own `DB_VERSION` (`storage.ts`) is for its stores. Exported SVG carries no marker, so an SVG change must still import files written before it. Time keeps its settings (`tools.time.prefs`) and saved people (`tools.time.plan`, the same format as its share links) in localStorage, both read field by field with defaults; a change that cannot be read that way bumps `PLAN_VERSION` (`plan.ts`) and reads the previous version. An app that is not released yet has no migration — when its stored format changes, fail closed and tell the person to clear that app's storage.

## Clean changes

Expand All @@ -31,7 +31,7 @@
- **App wiring:** an app's `vite.config.ts` is `defineConfig(toolsApp("<slug>"))` from `@tools/ui/vite`, used to build it; the site's one dev server serves it (see [Running it](#running-it)). It sets the base path and output folder, and injects `<title>`, description, icon and manifest from the catalog — so an app's `index.html` must not set those itself (the build fails if it does). Each app keeps its own `public/icon.svg`. The page links its stylesheet in `index.html` (`<link rel="stylesheet" href="/src/styles.css">`, which `@import`s `@tools/ui/base.css`) rather than importing CSS from the script: a linked stylesheet holds the first paint, so the page never shows unstyled while the script loads under the dev server.
- **Header:** every app's page has one `<header data-tools-header>` holding only its own controls. The build writes its start from the catalog — "‹ Tools", the app's name and, unless it is stable, its status badge — styled by `@tools/ui/header.css` (base.css imports it). Put the far-end controls (theme switch, Help) in `.ui-header-end`. The build fails if the marker is missing or the page writes its own home link or name.
- **Light / dark:** every page follows the browser until someone presses a theme switch; from then on the choice (`tools.theme` in localStorage, shared by the whole site) is light or dark, never "system" again. The Vite plugin inlines a script that applies it before first paint. Colours are CSS custom properties with a dark base and a light override keyed on `prefers-color-scheme` and `<html data-theme>` (see `packages/ui/tokens.css`); an app adds a button and calls `bindThemeToggle(button)` from `@tools/ui`, and repaints anything drawn outside CSS (a canvas) on `THEME_EVENT`.
- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests).
- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). Dates are `YYYY-MM-DD` and times of day on the 24-hour clock in every app, whatever the browser's language: format a time with `hourCycle: "h23"`, and take dates and times in typed `.ui-date` and `.ui-clock` fields (`DATE_FIELD`, `CLOCK_FIELD`, `bindTimeFields`, `parseIsoDate`, `parseClock` and their `format…` in `time-fields.ts`), never a native `date`, `time` or `datetime-local` field, which follows the language into `10/13/2026` and AM/PM. In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests, JWT → Time).
- **Settings:** preferences for the whole app go in a Settings (cog) menu in the header; what belongs to the document being edited stays with the document (the SVG app's size, grid and background, in its Document panel).
- **Phone header:** the header collapses in levels, each where the one before stops fitting (measure what the header needs; do not guess). First "‹ Tools Name" reduces to "‹", at the app's `compactHeader` width in the catalog (the build writes that rule; 720px by default); an app whose name in full needs the room may drop "Tools" alone before that, at its `compactHome`. Then the app's own steps (JSON: the views become a dropdown at 645px, then the phone layout at 475px; SVG: the phone layout at 590px). The phone layout is one row that never wraps. What does not fit folds into a "⋯" menu, Help stays last in the bar, and the theme switch stays in the bar at every width. Buttons keep their size and spacing at every width (the header's measures in `header.css`: `--ui-control`, `--ui-group-gap`, `--ui-header-gap`); a level only moves, hides or folds things. Layout and button sizes follow the width alone, never the pointer (`pointer: coarse`), so a touch screen gets the same layout as a mouse at the same width; only what happens on a canvas (hit targets, drag thresholds) and which help is shown adapt to touch. The header stays a solid row at the top in every app; an app whose work is a canvas may float its drawing tools in a bar over the bottom of it (the SVG app). Buttons that belong together are joined into one control with `.ui-seg` (`header.css`).
- **Install and offline:** the site installs as one app — the index, with every tool inside its scope, so moving between tools never leaves the installed window. Every page links the one manifest at the site root (emitted by the index build; each listed app is a shortcut in it). Pages call `registerServiceWorker()` from `@tools/ui`, which registers the one worker at the site root; `scripts/build-site.ts` writes it last (`packages/ui/src/site-worker.ts` + `packages/ui/sw.js`) with a version hash and every file of every app to precache, so one visit makes all tools work offline and every deploy replaces the previous cache. Nothing is registered under the dev server.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The scaffold includes the shared theme toggle and Help dock. Put help content in
using the catalog name. `bindToolHelp(slug)` wires both header buttons and remembers the
Help dock's open state for the tab.

JWT, Codec, Codes and Digests keep versioned session drafts with `readDraft` / `writeDraft`
JWT, Codec, Codes, Digests and Time keep versioned session drafts with `readDraft` / `writeDraft`
from `@tools/ui`. These restore the current tab on reload, including entered keys, but a
fresh tab starts with defaults. Drafts are limited to 2 MB; an incompatible format asks
the user to clear that app's session storage. Native file selections must be made again
Expand Down
61 changes: 54 additions & 7 deletions apps/codes/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,60 @@ <h2 class="codes-step"><span aria-hidden="true">2</span>Content</h2>
><span>Title</span><input class="ui-input" id="ev-title" autocomplete="off"
/></label>
<div class="codes-pair">
<label class="ui-field"
><span>Starts</span><input class="ui-input" id="ev-start" type="datetime-local"
/></label>
<label class="ui-field"
><span>Ends · an hour later if empty</span
><input class="ui-input" id="ev-end" type="datetime-local"
/></label>
<div class="ui-field">
<span id="ev-start-label">Starts</span>
<div class="codes-when">
<input
class="ui-input ui-date"
type="text"
inputmode="numeric"
autocomplete="off"
spellcheck="false"
placeholder="YYYY-MM-DD"
maxlength="10"
id="ev-start-date"
aria-labelledby="ev-start-label"
/>
<input
class="ui-input ui-clock"
id="ev-start-time"
type="text"
inputmode="numeric"
autocomplete="off"
spellcheck="false"
placeholder="HH:MM"
maxlength="8"
aria-label="Start time, 24-hour"
/>
</div>
</div>
<div class="ui-field">
<span id="ev-end-label">Ends · an hour later if empty</span>
<div class="codes-when">
<input
class="ui-input ui-date"
type="text"
inputmode="numeric"
autocomplete="off"
spellcheck="false"
placeholder="YYYY-MM-DD"
maxlength="10"
id="ev-end-date"
aria-labelledby="ev-end-label"
/>
<input
class="ui-input ui-clock"
id="ev-end-time"
type="text"
inputmode="numeric"
autocomplete="off"
spellcheck="false"
placeholder="HH:MM"
maxlength="8"
aria-label="End time, 24-hour"
/>
</div>
</div>
</div>
<label class="ui-field"
><span>Location · optional</span
Expand Down
29 changes: 24 additions & 5 deletions apps/codes/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
bindTimeFields,
bindColorSwatch,
bindToolHelp,
formatClock,
parseClock,
debounce,
byId,
contrast,
Expand Down Expand Up @@ -116,8 +119,10 @@ const ids = [
"vc-country",
"vc-note",
"ev-title",
"ev-start",
"ev-end",
"ev-start-date",
"ev-start-time",
"ev-end-date",
"ev-end-time",
"ev-location",
"ev-description",
"symbology",
Expand Down Expand Up @@ -171,6 +176,16 @@ function showDesign(fields: Record<string, string | boolean>): void {

const input = (id: string) => byId<HTMLInputElement>(id);
const value = (id: string) => input(id).value;
/**
* A date field and a 24-hour time field as one moment, in the browser's time zone; an invalid
* Date, which the event reports, when either is missing or unreadable.
*/
function localTime(date: string, time: string): Date {
const clock = parseClock(time);
return date && clock
? new Date(`${date}T${formatClock(clock.minutes, clock.second)}`)
: new Date(NaN);
}
const checked = (id: string) => isOn(byId(id));
const oneOf = <T extends string>(options: readonly T[], candidate: string, fallback: T): T =>
options.includes(candidate as T) ? (candidate as T) : fallback;
Expand Down Expand Up @@ -283,9 +298,11 @@ function content(kind: Exclude<ContentType, "barcode">): string {
title: value("ev-title"),
location: value("ev-location"),
description: value("ev-description"),
// A datetime-local value is read in the browser's time zone.
start: new Date(value("ev-start")),
...(value("ev-end") ? { end: new Date(value("ev-end")) } : {}),
start: localTime(value("ev-start-date"), value("ev-start-time")),
// An end time alone is on the day the event starts.
...(value("ev-end-time").trim()
? { end: localTime(value("ev-end-date") || value("ev-start-date"), value("ev-end-time")) }
: {}),
});
}
}
Expand Down Expand Up @@ -642,6 +659,8 @@ function morph(shown: Element, next: Element): void {

// ---------- Controls ----------

bindTimeFields();

for (const id of ids) {
const element = byId(id);
if (element.getAttribute("role") === "switch") bindSwitch(element, update);
Expand Down
9 changes: 9 additions & 0 deletions apps/codes/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@
grid-template-columns: minmax(0, 1fr);
}
}
/* A date and a time on one line. */
.codes-when {
display: flex;
gap: 6px;
}
.codes-when .ui-date {
flex: 1;
min-width: 0;
}
.codes .codes-short {
min-height: 72px;
}
Expand Down
17 changes: 15 additions & 2 deletions apps/json/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
</button>
</div>
<!-- The same choice on a phone, where five buttons would take a row of their own. -->
<div class="view-picker">
<select class="view-select" id="view-select" aria-label="Show as">
<div class="ui-view-picker">
<select class="ui-view-select" id="view-select" aria-label="Show as">
<option value="json">JSON</option>
<option value="yaml">YAML</option>
<option value="csv">CSV</option>
Expand Down Expand Up @@ -215,6 +215,19 @@
</button>
</div>
</header>
<!-- The view kept in the settings, pressed in the header from the first paint rather than
JSON for a moment; main.ts reads the same setting and keeps it from then on. -->
<script>
try {
var view = (JSON.parse(localStorage.getItem("tools.json.prefs") || "{}") || {}).view;
if (["yaml", "csv", "ts", "schema"].indexOf(view) >= 0) {
var buttons = document.querySelectorAll(".views [data-view]");
for (var i = 0; i < buttons.length; i++)
buttons[i].setAttribute("aria-pressed", String(buttons[i].dataset.view === view));
document.getElementById("view-select").value = view;
}
} catch (e) {}
</script>

<!-- Docked right under the header, as in every Tools app (@tools/ui bindDock): it stays open
beside the work until its button closes it. -->
Expand Down
31 changes: 1 addition & 30 deletions apps/json/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,35 +53,6 @@ body {

/* Buttons joined into one control (JSON | YAML | …, indent, path style) are @tools/ui's .ui-seg. */

/* The views as one dropdown, for a phone (see the media query below); drawn as a header button
with its own chevron, since the native arrow differs on every platform. */
.view-picker {
position: relative;
display: none;
}
.view-picker > svg {
position: absolute;
right: 6px;
top: 50%;
width: 18px;
height: 18px;
transform: translateY(-50%);
color: var(--text);
pointer-events: none;
}
.view-select {
min-height: var(--ui-control);
padding: 0 28px 0 10px;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent-dim);
color: var(--text);
font: inherit;
font-size: var(--ui-control-font);
appearance: none;
cursor: pointer;
}

/* Choices that read as one scale (2, 4, Tab) take the same width, that of the widest. */
.ui-seg.equal {
display: inline-grid;
Expand Down Expand Up @@ -351,7 +322,7 @@ body:not([data-view="json"]) .text-area {
display: none;
}

.view-picker {
.json-header .ui-view-picker {
display: block;
}
}
Expand Down
3 changes: 3 additions & 0 deletions apps/jwt/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ <h2 id="header-title" class="jwt-title-header">Header</h2>
<section class="ui-card" aria-labelledby="payload-title">
<div class="ui-row">
<h2 id="payload-title" class="jwt-title-payload">Payload</h2>
<a class="ui-btn ui-btn--small jwt-link" id="to-time" href="../time/" hidden
><svg data-ui-icon="calendar"></svg>Time</a
>
<button
type="button"
class="ui-btn ui-icon-btn"
Expand Down
Loading
Loading