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
67 changes: 35 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,49 @@
# schema-apps

Single-page HTML apps for [schema.org](https://schema.org) types. Each app is a
**self-contained `.html` file** holding a **JSON-LD data island** plus an
auto-generated **read view** and **edit form** on top of it. No build, no
dependencies, no framework — open the file and it works. Served straight from
the `gh-pages` branch (this branch is the site).
Single-page apps for [schema.org](https://schema.org) types. Each app is a
JSON-LD **data island** plus an auto-generated **render + edit UI**. All styling
lives in one shared **`app.css`** and all behaviour in one shared **`app.js`**,
so every app stays tiny — just its data and a small config — and the whole
collection is restyled by editing a single file. Served straight from the
`gh-pages` branch (this branch is the site).

Live: `https://solid-apps.github.io/schema-apps/person.html`
Live: <https://solid-apps.github.io/schema-apps/>

## The app contract
## Anatomy of an app

Every `<type>.html` (the reference is [`person.html`](./person.html)) must:
`person.html` is the reference. Each `<type>.html` contains only:

1. Contain **exactly one** JSON-LD island:
`<script type="application/ld+json" id="data"> … </script>` that `JSON.parse`s,
with `"@context": "https://schema.org"` and `"@type"` equal to the type.
2. Declare its config in the page script: `const TYPE = "Person"` and a
`FIELDS` array (`{prop, label, type}` per property), and a `TITLE_PROP`.
3. Be named the **kebab-case** of the type — `Person` → `person.html`,
`JobPosting` → `job-posting.html`.
4. Include the contract elements: `#data` (island), `#view` (render target),
and `<form id="edit">`.
5. **Render and edit with no errors**: on load it shows the island's data, and
typing in the form writes changes back into the island live.
1. A JSON-LD island: `<script type="application/ld+json" id="data"> … </script>`
with `"@context": "https://schema.org"` and `"@type"`.
2. A mount point: `<main id="app"></main>`.
3. A config object:
```js
window.APP = {
type: "Person", titleProp: "name", icon: "👤", accent: "#2d6cdf",
fields: [ { prop: "name", label: "Name", type: "text" }, … ],
};
```
4. The two shared includes: `<link rel="stylesheet" href="./app.css">` and
`<script src="./app.js">`.

## Making an app for a new type
The filename is the kebab-case of the type (`JobPosting` → `job-posting.html`).

Copy `person.html`, then change **only**:
## Add an app for a new type

- the island's `@type` and sample data,
- `const TYPE`, the `FIELDS` array, and `TITLE_PROP`,
- the `<title>` and the `<h1 id="type-label">` (the script overwrites the
latter from `TYPE` anyway).
Copy `person.html`, then change **only** the island, `window.APP`, and the
`<title>`. Use real schema.org property names. Do **not** touch `app.css`,
`app.js`, or any other app.

The generic engine (render / form / write-back) is identical across every app —
do not rewrite it. Use real schema.org property names for `prop` (e.g. `Event`:
`startDate`, `location`, `organizer`).
## Design

All visual design is in **`app.css`** — edit it once and every app updates.
Each app sets its own `--accent` via `window.APP.accent`; everything else (the
header band, icon, rows, edit form, source view) is shared.

## The gate

`npm run check` (CI on every push/PR to `gh-pages`) runs `gate/check.mjs`, which
loads each app in a real DOM (jsdom) and fails if the island is malformed, the
`@type`/filename don't match, the contract elements are missing, or the app
throws / doesn't render. A broken app cannot merge — `gh-pages` is branch-
protected and requires this check.
loads each app in a real DOM (jsdom, with `app.js` inlined) and fails if the
island is malformed, the `@type`/filename don't match, the wiring is missing, or
the app throws / doesn't render. `gh-pages` is branch-protected and requires this
check, so a broken app cannot merge.
108 changes: 108 additions & 0 deletions app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/* schema-apps shared styles — restyle every app by editing this one file.
Each app sets its own --accent via window.APP.accent. */
:root {
--accent: #2d6cdf;
--accent-2: color-mix(in oklab, var(--accent) 70%, #7c3aed);
--ink: #14182b;
--muted: #6b7280;
--line: #ececf1;
--card: #ffffff;
--radius: 18px;
--shadow: 0 1px 2px rgba(16,24,40,.04), 0 12px 32px -12px rgba(16,24,40,.18);
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font: 16px/1.6 var(--font);
color: var(--ink);
min-height: 100vh;
background:
radial-gradient(1200px 600px at 12% -10%, color-mix(in oklab, var(--accent) 14%, transparent), transparent 60%),
radial-gradient(900px 500px at 110% 10%, color-mix(in oklab, var(--accent-2) 14%, transparent), transparent 55%),
#f6f7fb;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 660px; margin: clamp(1.5rem, 6vh, 4rem) auto; padding: 0 1rem; }

.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
animation: rise .5s cubic-bezier(.2,.7,.2,1) both;
}
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .card { animation: none; } }

/* header band */
.hd {
position: relative;
padding: 1.5rem 1.6rem 1.3rem;
color: #fff;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.hd::after { /* subtle sheen */
content: ""; position: absolute; inset: 0;
background: radial-gradient(120% 120% at 0% 0%, rgba(255,255,255,.22), transparent 45%);
pointer-events: none;
}
.hd .icon {
width: 52px; height: 52px; display: grid; place-items: center;
font-size: 28px; line-height: 1; border-radius: 14px;
background: rgba(255,255,255,.18); backdrop-filter: blur(4px);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.25);
}
.hd .type { margin-top: .9rem; font-size: .72rem; letter-spacing: .14em; text-transform: uppercase; opacity: .9; font-weight: 600; }
.hd .title { margin: .1rem 0 0; font-size: 1.7rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.15; }

/* body */
.bd { padding: 1.1rem 1.6rem 1.6rem; }
.rows { display: grid; }
.row {
display: grid; grid-template-columns: 140px 1fr; gap: 1rem;
padding: .7rem .6rem; margin: 0 -.6rem; border-radius: 10px;
border-top: 1px solid var(--line);
transition: background .15s ease;
}
.row:first-child { border-top: 0; }
.row:hover { background: color-mix(in oklab, var(--accent) 6%, transparent); }
.row .k { color: var(--muted); font-size: .82rem; font-weight: 600; padding-top: .1rem; }
.row .v { font-weight: 500; word-break: break-word; }
.row .v a { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in oklab, var(--accent) 35%, transparent); }
.row .v a:hover { border-bottom-color: var(--accent); }

/* disclosures */
details { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: .9rem; }
summary {
cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: .4rem;
font-weight: 600; font-size: .9rem; color: var(--accent);
user-select: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▸"; transition: transform .15s ease; font-size: .8em; }
details[open] summary::before { transform: rotate(90deg); }

/* edit form */
form { margin-top: 1rem; display: grid; gap: .9rem; }
label { display: grid; gap: .3rem; font-size: .78rem; font-weight: 600; color: var(--muted); letter-spacing: .01em; }
input, textarea {
font: inherit; color: var(--ink); padding: .6rem .7rem;
border: 1px solid var(--line); border-radius: 10px; background: #fcfcfe;
transition: border-color .15s, box-shadow .15s;
}
input:focus, textarea:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent);
}
textarea { min-height: 4.5rem; resize: vertical; }

/* JSON-LD source */
pre {
margin: .9rem 0 0; padding: 1rem; border-radius: 12px; overflow: auto;
background: #0f1730; color: #d6e2ff; font: .8rem/1.5 ui-monospace, "SF Mono", Menlo, monospace;
}

footer { text-align: center; color: var(--muted); font-size: .8rem; margin-top: 1.1rem; }
footer a { color: var(--accent); text-decoration: none; }
75 changes: 75 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// schema-apps shared engine. Every app is just a JSON-LD island + a window.APP
// config; this builds the card, the read view and the edit form, and keeps the
// island in sync. Design lives in app.css — don't put styling here.
(function () {
const cfg = window.APP || {};
const island = document.getElementById("data");
let data = JSON.parse(island.textContent);
if (cfg.accent) document.documentElement.style.setProperty("--accent", cfg.accent);

const mount = document.getElementById("app");
mount.innerHTML = `
<div class="wrap">
<div class="card">
<header class="hd">
<div class="icon" id="icon"></div>
<div class="type" id="type-label"></div>
<h1 class="title" id="title"></h1>
</header>
<div class="bd">
<div class="rows" id="view"></div>
<details><summary>Edit</summary><form id="edit"></form></details>
<details><summary>JSON-LD source</summary><pre id="src"></pre></details>
</div>
</div>
<footer>A <a href="https://schema.org/${cfg.type}">schema.org/${cfg.type}</a> app ·
<a href="https://github.com/solid-apps/schema-apps">source</a></footer>
</div>`;

document.getElementById("icon").textContent = cfg.icon || "◆";
document.getElementById("type-label").textContent = cfg.type || "";

function writeIsland() {
island.textContent = "\n" + JSON.stringify(data, null, 2) + "\n";
document.getElementById("src").textContent = island.textContent.trim();
}

function render() {
document.getElementById("title").textContent = data[cfg.titleProp] || "(untitled)";
const view = document.getElementById("view");
view.innerHTML = "";
for (const f of cfg.fields) {
if (f.prop === cfg.titleProp) continue;
const val = data[f.prop];
if (val === undefined || val === "") continue;
const row = document.createElement("div"); row.className = "row";
const k = document.createElement("span"); k.className = "k"; k.textContent = f.label;
const v = document.createElement("span"); v.className = "v";
if (f.type === "url" || f.type === "email") {
const a = document.createElement("a");
a.href = (f.type === "email" ? "mailto:" : "") + val; a.textContent = val;
v.appendChild(a);
} else { v.textContent = val; }
row.append(k, v); view.appendChild(row);
}
}

function buildForm() {
const form = document.getElementById("edit");
form.innerHTML = "";
for (const f of cfg.fields) {
const label = document.createElement("label");
label.textContent = f.label;
const input = f.type === "textarea" ? document.createElement("textarea") : document.createElement("input");
if (f.type !== "textarea") input.type = f.type;
input.value = data[f.prop] || "";
input.addEventListener("input", () => {
if (input.value === "") delete data[f.prop]; else data[f.prop] = input.value;
writeIsland(); render();
});
label.appendChild(input); form.appendChild(label);
}
}

render(); buildForm(); writeIsland();
})();
Loading
Loading