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
12 changes: 7 additions & 5 deletions .github/workflows/deploy-tools.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Deploys the central tools site to THIS repo's GitHub Pages: the editor and
# feed previewer are served under /editor and /preview (matching the
# tools.opentechevents.org/<tool>?repo=… URLs from DESIGN.md; /import and
# /publish will join them in later phases).
# Deploys the central tools site to THIS repo's GitHub Pages: the editor,
# feed previewer and embeddable widget are served under /editor, /preview
# and /embed (matching the tools.opentechevents.org/<tool>?repo=… URLs from
# DESIGN.md; /import and /publish will join them in later phases).
#
# One-time prerequisite: Settings → Pages → Source = "GitHub Actions".
# Custom domain (tools.opentechevents.org) is configured there too, later —
Expand Down Expand Up @@ -37,9 +37,10 @@ jobs:

- name: Assemble site
run: |
mkdir -p _site/editor _site/preview
mkdir -p _site/editor _site/preview _site/embed
cp -R apps/editor/dist/. _site/editor/
cp -R apps/preview/dist/. _site/preview/
cp -R apps/embed/dist/. _site/embed/
# Served at tools.opentechevents.org/dashboard-checks.js; the
# ote-template forks load it from their dashboard. No build step:
# the file is the artifact.
Expand All @@ -54,6 +55,7 @@ jobs:
<ul>
<li><a href="./editor/">Event editor</a> — create and edit OTE events without writing JSON</li>
<li><a href="./preview/">Feed previewer</a> — inspect generated JSON, ICS and RSS exports</li>
<li><a href="./embed/">Embeddable widget</a> — &lt;ote-events&gt;: drop an OTE feed into any website</li>
</ul>
</html>
EOF
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Central monorepo for the [OpenTechEvents](https://github.com/OpenTechEvents)
organizer kit: npm connectors (pure functions, no UI), reusable GitHub Actions
workflows, and (phase 2) the web dashboard/editor/previewer. Design rationale lives in
[DESIGN.md](DESIGN.md); the spec lives in
workflows, and (phase 2) the web dashboard/editor/previewer/embeddable widget.
Design rationale lives in [DESIGN.md](DESIGN.md); the spec lives in
[opentechevents-spec](https://github.com/OpenTechEvents/opentechevents-spec).

## Packages
Expand Down
80 changes: 80 additions & 0 deletions apps/embed/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# apps/embed

The embeddable `<ote-events>` Web Component (issue #27) plus the playground
page that doubles as its documentation. Vanilla TypeScript, no framework —
same esbuild pattern as `apps/editor`/`apps/preview`.

## Two esbuild entry points, two very different audiences

`build.mjs` bundles **two** entry points into `dist/`:

- `src/main.ts` → `dist/ote-events.js` — the actual deliverable. This is
what a consumer's `<script type="module" src="...">` loads on their own
site. Keep it minimal: it must never pull in `src/playground.ts` or
anything playground-only.
- `src/playground.ts` → `dist/playground.js` — wires the demo page's
controls and snippet generator. Loads `dist/ote-events.js` the same way an
external site would (see `index.html`), so the playground also serves as a
real-usage smoke test of the widget.

If you add code that both files need, put it in a third module they both
import — don't import one entry point from the other.

## Static `index.html`/`styles.css` are copied once, not watched

Same gotcha as `apps/editor`/`apps/preview`: `pnpm dev` copies `index.html`
and `styles.css` into `dist/` **once**, at server startup. After editing
either, re-run the copy (or restart `pnpm dev`) before reloading the browser.

## The widget only fetches native JSON OTE feeds — on purpose

`icsToPreviewFeed`/`rssToPreview` (from `@opentechevents/preview-feed`) exist
for `apps/preview`'s diagnostic tabs, not for this widget. Wiring ICS/RSS
into `<ote-events feed="...">` would drag `ical.js` and a `DOMParser`-based
XML parser into `dist/ote-events.js` for a case issue #27's acceptance
criteria never asked for — OTE's canonical publish format for a site is
JSON. `@opentechevents/preview-feed` is built with `"sideEffects": false`
specifically so esbuild tree-shakes those unused converters (and their
heavier deps) out of this bundle when `src/element.ts` only imports
`jsonToPreviewFeed`. If ICS/RSS `feed=` support is ever requested, that's a
deliberate scope change to discuss, not a bug to quietly fix.

## Theming: `--ote-*` CSS custom properties, not `!important` overrides

`src/theme.css.ts` defines the widget's internal look inside its shadow
root using a `--ote-*`-prefixed set of custom properties (`--ote-bg`,
`--ote-accent`, etc.). CSS custom properties inherit through the shadow
boundary even though everything else in the shadow root is encapsulated —
so a host page can retheme the widget with plain CSS:

```css
ote-events {
--ote-accent: #e91e63;
}
```

`theme="light"/"dark"/"auto"` needs no JavaScript: the CSS `:host([theme="dark"])`
selectors read the attribute directly. An unrecognized `theme` value simply
falls through to the light defaults — that's intentional, not a bug.

## Testing the custom element needs jsdom, not the default Node environment

`element.test.ts` uses a `// @vitest-environment jsdom` pragma (kept
file-scoped, not global, so `attrs.test.ts`'s pure-function tests stay on
the faster default Node environment). `packages/preview-feed/test/rss.test.ts`
has the same pragma for the same reason — `rssToPreviewFeed` needs
`DOMParser`.

## Browser-testing the Copy button: clipboard permission blocks CDP

`playground.ts`'s Copy button calls `navigator.clipboard.writeText(...)`.
Clicking it through Claude-in-Chrome's `computer` tool triggers a real
Chrome clipboard-permission prompt that can hang the CDP `Runtime.evaluate`
call for the tab ("the renderer may be frozen or unresponsive") — the same
category of issue as the `confirm()`/`beforeunload` gotcha documented in
`apps/editor/CLAUDE.md`. It recovered on its own after a plain `navigate`
back to the page; `force: true` wasn't needed. This is an
automation-environment limitation, not a code defect — the button works
normally for a real user's click in a real browser. Don't spend time
forcing it through CDP; to verify the snippet text itself, read
`#snippet-code`'s `textContent` directly instead of exercising the button.
50 changes: 50 additions & 0 deletions apps/embed/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { copyFileSync, mkdirSync } from "node:fs";

import * as esbuild from "esbuild";

const serve = process.argv.includes("--serve");

const common = {
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
sourcemap: true,
minify: !serve,
logLevel: "info",
};

// The real deliverable: what a consumer's <script src="..."> loads. Keep it
// on its own entry point so playground.js is never bundled into it.
const widget = { ...common, entryPoints: ["src/main.ts"], outfile: "dist/ote-events.js" };

// The demo/docs page's own script — wires the attribute controls and the
// copy-paste snippet generator. Loads dist/ote-events.js the same way an
// external site would (see index.html), so it also doubles as a real-usage
// smoke test of the widget.
const playground = {
...common,
entryPoints: ["src/playground.ts"],
outfile: "dist/playground.js",
};

mkdirSync("dist", { recursive: true });
for (const file of ["index.html", "styles.css"]) {
copyFileSync(file, `dist/${file}`);
}

if (serve) {
const widgetCtx = await esbuild.context(widget);
const playgroundCtx = await esbuild.context(playground);
await widgetCtx.watch();
await playgroundCtx.watch();
const port = Number(process.env.PORT) || undefined;
const server = await widgetCtx.serve({
servedir: "dist",
...(port !== undefined && { port }),
});
console.log(`Embed playground running at http://localhost:${server.port}/`);
} else {
await esbuild.build(widget);
await esbuild.build(playground);
}
96 changes: 96 additions & 0 deletions apps/embed/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ote-events — embeddable OTE events widget</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<header>
<h1>&lt;ote-events&gt;</h1>
<p>
A single Web Component that renders upcoming events from any
<a href="https://github.com/OpenTechEvents/opentechevents-spec" target="_blank" rel="noopener">OTE</a>
JSON feed. No framework, no build step, no package to install — one
<code>&lt;script&gt;</code> tag and one element. This page doubles as
its documentation: every control below maps to one HTML attribute, and
the snippet at the bottom always reflects exactly what you're seeing.
</p>
</header>

<main>
<section class="panel controls" aria-labelledby="controls-heading">
<h2 id="controls-heading">Try it</h2>

<div class="field">
<label for="feed-input">feed</label>
<input type="url" id="feed-input" value="https://combuilderses.github.io/events/feed.json" />
<p class="hint">Required. The URL of an OTE <code>feed.json</code> document.</p>
</div>

<div class="field">
<label for="limit-input">limit</label>
<input type="number" id="limit-input" min="1" step="1" value="6" />
<p class="hint">Maximum number of events to show. Default: 6.</p>
</div>

<div class="field">
<label for="layout-select">layout</label>
<select id="layout-select">
<option value="list" selected>list</option>
<option value="cards">cards</option>
</select>
<p class="hint">Presentation of the same event data. Default: list.</p>
</div>

<div class="field">
<label for="theme-select">theme</label>
<select id="theme-select">
<option value="auto" selected>auto</option>
<option value="light">light</option>
<option value="dark">dark</option>
</select>
<p class="hint">"auto" follows the visitor's OS/browser preference.</p>
</div>

<div class="field">
<label for="lang-select">lang</label>
<select id="lang-select">
<option value="auto" selected>auto</option>
<option value="en">en</option>
<option value="es">es</option>
</select>
<p class="hint">Language of the widget's own UI text (loading/empty/error messages). "auto" follows the visitor's browser language.</p>
</div>

<div class="field field-checkbox">
<label for="show-past-checkbox">
<input type="checkbox" id="show-past-checkbox" />
show-past
</label>
<p class="hint">Include events whose start date has already passed. Default: off.</p>
</div>
</section>

<section class="panel" aria-labelledby="preview-heading">
<h2 id="preview-heading">Live preview</h2>
<div class="widget-frame">
<ote-events id="preview-widget" feed="https://combuilderses.github.io/events/feed.json" limit="6"></ote-events>
</div>
</section>

<section class="panel" aria-labelledby="snippet-heading">
<h2 id="snippet-heading">Copy-paste snippet</h2>
<p class="hint">This is the exact markup for the configuration above.</p>
<div class="snippet-box">
<pre><code id="snippet-code"></code></pre>
<button type="button" id="copy-button">Copy</button>
</div>
</section>
</main>

<script type="module" src="./ote-events.js"></script>
<script type="module" src="./playground.js"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions apps/embed/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@opentechevents/embed",
"version": "0.1.0",
"description": "Embeddable <ote-events> web component: drop an OTE feed into any website",
"license": "MIT",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --serve",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@opentechevents/preview-feed": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.28.1",
"jsdom": "^30.0.1",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
}
}
33 changes: 33 additions & 0 deletions apps/embed/src/attrs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export const DEFAULT_LIMIT = 6;

// theme="light|dark|auto" has no parser here: theme.css.ts's :host()
// selectors read the raw attribute directly (see element.ts), and an
// unrecognized value simply falls back to the light defaults — no JS
// validation needed.
export type LangAttr = "en" | "es" | "auto";
export type Lang = "en" | "es";
export type Layout = "list" | "cards";

export function parseLimit(value: string | null): number {
if (!value) return DEFAULT_LIMIT;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_LIMIT;
}

export function parseLangAttr(value: string | null): LangAttr {
return value === "en" || value === "es" ? value : "auto";
}

/** Resolves "auto" against the runtime's own language, for a concrete UI-string lookup. */
export function resolveLang(attr: LangAttr, navigatorLanguage: string): Lang {
if (attr !== "auto") return attr;
return navigatorLanguage.toLowerCase().startsWith("es") ? "es" : "en";
}

export function parseShowPast(value: string | null): boolean {
return value === "true";
}

export function parseLayout(value: string | null): Layout {
return value === "cards" ? "cards" : "list";
}
Loading