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
20 changes: 20 additions & 0 deletions .cursor/rules/we-schema.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,26 @@ Each loop:
{ "type": "$each", "props": { "items": { "$store": "storeName.arrayProperty" }, "as": "itemName" }, "children": [ ... ] }
Renders children once for each item. The "as" name becomes a context key. Defaults to "item" — omit "as" unless you need a different name.

Each row also gets two context keys describing its position in the list:
- { "$index": ... } — read as "$index", the 0-based position.
- "$prev" — the previous item, absent on the first row. Read fields off it like any context ref: "$prev.author".

"$prev" is what makes **grouping** expressible — collapsing consecutive rows by the same author so
a run of messages shows one avatar and byline instead of repeating them. Without it a row can only
ask about itself, and the compact form is unreachable by any prop or theme:
{
"type": "$if",
"props": {
"condition": { "$eq": ["$message.author", "$prev.author"] },
"then": { "...": "compact row — no avatar, no byline" },
"else": { "...": "full row" }
}
}
The first row has no "$prev" at all, so the condition is false there and it keeps its byline —
which is what a feed wants, and why absent must not read as "same as the last item".

Both shadow in a nested $each, exactly as the item does: the inner "$index" restarts at 0.

Conditional rendering:
{ "type": "$if", "props": { "condition": ..., "then": { ... }, "else": { ... } } }
Renders "then" node if condition is truthy, else renders "else" node.
Expand Down
20 changes: 20 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,26 @@ Each loop:
{ "type": "$each", "props": { "items": { "$store": "storeName.arrayProperty" }, "as": "itemName" }, "children": [ ... ] }
Renders children once for each item. The "as" name becomes a context key. Defaults to "item" — omit "as" unless you need a different name.

Each row also gets two context keys describing its position in the list:
- { "$index": ... } — read as "$index", the 0-based position.
- "$prev" — the previous item, absent on the first row. Read fields off it like any context ref: "$prev.author".

"$prev" is what makes **grouping** expressible — collapsing consecutive rows by the same author so
a run of messages shows one avatar and byline instead of repeating them. Without it a row can only
ask about itself, and the compact form is unreachable by any prop or theme:
{
"type": "$if",
"props": {
"condition": { "$eq": ["$message.author", "$prev.author"] },
"then": { "...": "compact row — no avatar, no byline" },
"else": { "...": "full row" }
}
}
The first row has no "$prev" at all, so the condition is false there and it keeps its byline —
which is what a feed wants, and why absent must not read as "same as the last item".

Both shadow in a nested $each, exactly as the item does: the inner "$index" restarts at 0.

Conditional rendering:
{ "type": "$if", "props": { "condition": ..., "then": { ... }, "else": { ... } } }
Renders "then" node if condition is truthy, else renders "else" node.
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ jobs:
packages/models/src/generated/coreManifest.ts \
|| { echo '::error::Generated files are stale — run `pnpm build` and commit the regenerated output.'; exit 1; }

# 12.6k LOC of template data that `tsc` cannot judge: an unknown component
# type, a misspelled prop, a `$routes` outlet with no `routes` array and an
# orphan `$local` all typecheck cleanly and then render nothing. The
# validator has always existed and been thorough — it was reachable only
# from the AI editor and the CLI, so nothing ran it on the way in.
- name: Validate schemas
run: pnpm validate:schemas

# Runs after Build so every package's dist types exist. Only packages
# that define a `typecheck` script participate; coverage is being grown
# package by package.
Expand Down
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,26 @@ Each loop:
{ "type": "$each", "props": { "items": { "$store": "storeName.arrayProperty" }, "as": "itemName" }, "children": [ ... ] }
Renders children once for each item. The "as" name becomes a context key. Defaults to "item" — omit "as" unless you need a different name.

Each row also gets two context keys describing its position in the list:
- { "$index": ... } — read as "$index", the 0-based position.
- "$prev" — the previous item, absent on the first row. Read fields off it like any context ref: "$prev.author".

"$prev" is what makes **grouping** expressible — collapsing consecutive rows by the same author so
a run of messages shows one avatar and byline instead of repeating them. Without it a row can only
ask about itself, and the compact form is unreachable by any prop or theme:
{
"type": "$if",
"props": {
"condition": { "$eq": ["$message.author", "$prev.author"] },
"then": { "...": "compact row — no avatar, no byline" },
"else": { "...": "full row" }
}
}
The first row has no "$prev" at all, so the condition is false there and it keeps its byline —
which is what a feed wants, and why absent must not read as "same as the last item".

Both shadow in a nested $each, exactly as the item does: the inner "$index" restarts at 0.

Conditional rendering:
{ "type": "$if", "props": { "condition": ..., "then": { ... }, "else": { ... } } }
Renders "then" node if condition is truthy, else renders "else" node.
Expand Down
2 changes: 2 additions & 0 deletions apps/we-preview/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
shots/
67 changes: 67 additions & 0 deletions apps/we-preview/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# we-preview — WE with nothing behind it

The fourth host, beside `we-web` / `we-electron` / `we-tauri`. It runs **the whole application** —
the same `<App/>`, the same thirteen stores, the same renderer, the same design system — over
`@we/backend-inmemory` instead of an AD4M executor.

```sh
pnpm --filter @we/app-preview dev # http://localhost:3100
pnpm --filter @we/app-preview build
```

No executor, no agent setup, no neighbourhood, no network. It boots in a headless browser in a
couple of seconds, which is what makes a render → screenshot → adjust loop possible at all.

## What it is not

It is **not** a stripped-down template preview. The shell is itself templates — `Sidebar`,
`Settings`, `Profile`, `BootScreen`, `TemplateEditor`, `ModuleRail`, the marketplace and spaces
surfaces all live in `@we/template-shell` — so all of it renders here, and you can click around.
The only difference from `we-web` is which `BackendConnector` `PlatformProvider` receives.

That property is the point. A harness with stubbed stores would drift from the real ones, and every
screenshot would then be of a fiction — templates matched against behaviour the application does not
have. Here the stores are the real stores.

## Why it is a separate app rather than a flag on we-web

Apps *are* deployments in this monorepo, which is what the seed file expresses. A preview
deployment wants modules off and no `ad4m` block, and it must not drag `@we/backend-inmemory` or
fixture data into the production web bundle — which a runtime `?backend=inmemory` flag would, unless
fought. The cost of the split is one 25-line entry and a platform adapter.

## The seed is derived, not declared

There is deliberately no `we-preview.seed.json`. `templates` is not read at runtime:
`pnpm --filter @we/app-shell generate-templates` compiles the **root** seed's list into
`bundledTemplates.generated.ts`, one registry for the whole monorepo. A second seed naming a
different set would declare templates this build cannot import. So `src/index.tsx` spreads the root
seed and overrides only what this host genuinely differs on — `modules: []`, `apps: []`, no `ad4m`.

Set `modules` back to the root list to photograph module chrome. It is off by default because the
globe mounts Cesium and the call module wants media devices: neither survives a headless screenshot
usefully, and a spinning globe makes every render of the same template differ from the last.

## What it cannot show truthfully

Worth knowing before pointing it at shell design work rather than at templates:

- **Backend-specific settings surfaces render degraded.** `createInMemoryBackendPorts` omits the
optional `runtime` port and this host omits `AccountHost`, so RuntimeSettings, LanguageSettings,
HostSettings and AccountSettings show their capability-gated empty states. Both omissions are
supported states, feature-detected member by member — the same shape the web host has.
- **Join and publish are simulated** against `inmemory://` URIs. Useful rather than limiting: those
flows become screenshottable.
- **The agent starts unlocked.** A locked agent is the port's honest default and what the
executor-free boot suite exercises, but here it would put a password prompt in front of every
screenshot.

## Typecheck

```sh
pnpm --filter @we/app-preview typecheck
```

Clean, and it typechecks the shell's source along with its own — which `we-web` cannot do, because
its tsconfig lacks the `@shared` / `@solid` path aliases that exist only in Vite (audit P3-1). If
you are copying this app as a starting point, copy its `tsconfig.json` too.
18 changes: 18 additions & 0 deletions apps/we-preview/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WE Preview</title>
<!-- Inline, so a headless run reports no 404. Every console error the shoot script surfaces
should be worth reading; a missing favicon in every report trains you to ignore the list. -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='4' fill='%236b5cff'/%3E%3C/svg%3E" />
</head>

<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>

<script src="/src/index.tsx" type="module"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions apps/we-preview/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@we/app-preview",
"version": "0.1.0",
"description": "WE over an in-memory backend \u2014 the host the screenshot harness drives",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"typecheck": "tsc --noEmit",
"shoot": "node scripts/shoot.mjs"
},
"dependencies": {
"@we/app-shell": "workspace:*",
"@we/backend-inmemory": "workspace:*",
"@we/backend-shared": "workspace:*",
"@we/components": "workspace:*",
"@we/models": "workspace:*",
"@we/template-fixtures": "workspace:*",
"solid-js": "^1.9.5"
},
"devDependencies": {
"playwright-core": "^1.62.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vite-plugin-solid": "^2.11.11"
}
}
129 changes: 129 additions & 0 deletions apps/we-preview/scripts/measure.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Measure a reference screenshot: palette, and the vertical columns it is built from.
*
* ```sh
* node scripts/measure.mjs ~/ref/discord.png --row 0.5 --calibrate 72
* ```
*
* A screenshot has no intrinsic scale — 5112 pixels wide could be a 2556pt window at 2× or a 5112pt
* one at 1×, and every measurement means something different depending on which. Two ways out:
* pass `--window <cssPx>` if you know what the capture was taken at, or `--calibrate <cssPx>` with
* the known CSS width of the *first* column, which is usually the more reliable of the two because
* a platform's rail width is a published constant and nobody remembers their window size.
*
* Columns are found by scanning one horizontal row for runs of near-constant colour. That is crude
* and exactly right for this subject: an app chrome is vertical bands of flat fill, and the run
* boundaries are the rails, gutters and content columns you actually need the widths of.
*/
import { readFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';

import { chromium } from 'playwright-core';

const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
if (!file) {
console.error('usage: node scripts/measure.mjs <image.png> [--row 0.5] [--window 1440] [--calibrate 72]');
process.exit(1);
}
const flag = (name, fallback) => {
const i = args.indexOf(`--${name}`);
return i === -1 ? fallback : Number(args[i + 1]);
};
const rowFraction = flag('row', 0.5);
const windowWidth = flag('window', 0);
const calibrateFirst = flag('calibrate', 0);

const browser = await chromium.launch({ channel: 'chrome' });
const page = await browser.newPage();
const uri = `data:image/png;base64,${(await readFile(resolve(process.cwd(), file))).toString('base64')}`;

const result = await page.evaluate(
async ([dataUri, row]) => {
const bitmap = await createImageBitmap(await (await fetch(dataUri)).blob());
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const hex = (r, g, b) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;

// ── palette ──
const bins = new Map();
for (let i = 0; i < data.length; i += 16) {
if (data[i + 3] < 128) continue;
const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3);
const bin = bins.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
bin.n += 1;
bin.r += data[i];
bin.g += data[i + 1];
bin.b += data[i + 2];
bins.set(key, bin);
}
const total = [...bins.values()].reduce((s, b) => s + b.n, 0);
const palette = [...bins.values()]
.sort((a, b) => b.n - a.n)
.slice(0, 8)
.map((b) => ({
hex: hex(Math.round(b.r / b.n), Math.round(b.g / b.n), Math.round(b.b / b.n)),
share: +(b.n / total).toFixed(3),
}));

// ── columns, from the modal colour of each x across many rows ──
//
// A single scan line was the obvious approach and is useless: at any given y it crosses server
// icons, avatars and embedded images, so the "bands" it finds are whatever content happened to
// sit on that line. Taking the most common colour down each column ignores content — a rail is
// flat for hundreds of rows and an avatar is not — and leaves the chrome.
const sampleRows = [];
const rowCount = Math.min(240, bitmap.height);
for (let n = 0; n < rowCount; n += 1) sampleRows.push(Math.floor((n / rowCount) * bitmap.height));

const at = (x) => {
const counts = new Map();
for (const y of sampleRows) {
const i = (y * bitmap.width + x) * 4;
const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3);
const entry = counts.get(key) ?? { n: 0, c: [data[i], data[i + 1], data[i + 2]] };
entry.n += 1;
counts.set(key, entry);
}
let best = { n: 0, c: [0, 0, 0] };
for (const entry of counts.values()) if (entry.n > best.n) best = entry;
return best.c;
};
const near = (a, b, tol = 6) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) <= tol;

const runs = [];
let start = 0;
let colour = at(0);
for (let x = 1; x < bitmap.width; x += 1) {
const here = at(x);
if (near(here, colour)) continue;
runs.push({ from: start, to: x, width: x - start, hex: hex(...colour) });
start = x;
colour = here;
}
runs.push({ from: start, to: bitmap.width, width: bitmap.width - start, hex: hex(...colour) });

// Runs narrower than 8px are borders, dividers and text — real, but not columns.
return { width: bitmap.width, height: bitmap.height, palette, bands: runs.filter((r) => r.width >= 8) };
},
[uri, rowFraction],
);
await browser.close();

const scale = windowWidth ? result.width / windowWidth : calibrateFirst ? result.bands[0].width / calibrateFirst : 1;
const css = (px) => (scale === 1 ? `${px}px?` : `${Math.round(px / scale)}px`);

console.log(`\n${basename(file)} ${result.width}x${result.height}`);
console.log(
scale === 1
? ' scale unknown — pass --window or --calibrate, every width below is raw image pixels'
: ` scale ${scale.toFixed(2)}x → logical ${Math.round(result.width / scale)}x${Math.round(result.height / scale)}`,
);

console.log('\n palette');
for (const c of result.palette) console.log(` ${c.hex} ${(c.share * 100).toFixed(1)}%`);

console.log('\n vertical bands (modal colour per column)');
for (const b of result.bands) console.log(` ${b.hex} ${String(css(b.width)).padStart(7)} x ${css(b.from)}`);
Loading
Loading