zz-meridian 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adopt.js +41 -3
- package/dist/files.js +31 -11
- package/dist/migrations.js +27 -0
- package/dist/ownership.js +19 -2
- package/dist/session.js +29 -5
- package/package.json +1 -1
- package/payload/.env.example +8 -0
- package/payload/AGENTS.md +1 -1
- package/payload/CHANGELOG.md +58 -0
- package/payload/README.md +35 -0
- package/payload/app/(dashboard)/(home)/page.tsx +2 -2
- package/payload/app/(dashboard)/business/page.tsx +2 -2
- package/payload/app/(dashboard)/keys/README.md +1 -1
- package/payload/app/(dashboard)/members/README.md +1 -1
- package/payload/app/(dashboard)/members/actions.ts +4 -2
- package/payload/app/(dashboard)/requests/[id]/actions.ts +4 -2
- package/payload/app/(dashboard)/requests/[id]/page.tsx +7 -5
- package/payload/app/(dashboard)/settings/actions.ts +3 -1
- package/payload/app/(dashboard)/settings/page.tsx +8 -6
- package/payload/app/(dashboard)/usage/page.tsx +2 -2
- package/payload/app/api/export/requests/route.ts +11 -4
- package/payload/app/embed/proposal/view.tsx +20 -5
- package/payload/app/icon.ts +6 -4
- package/payload/app/layout.tsx +1 -1
- package/payload/app/sign-in/actions.ts +1 -1
- package/payload/app/sign-in/gated-panel.tsx +1 -1
- package/payload/app/system/(atlas)/[section]/[id]/page.tsx +6 -3
- package/payload/app/system/(atlas)/page.tsx +7 -1
- package/payload/decisions/0010-the-adopter-contract.md +4 -1
- package/payload/docs/agents.md +1 -1
- package/payload/docs/data-display.md +1 -0
- package/payload/docs/distribution.md +5 -2
- package/payload/docs/testing.md +3 -3
- package/payload/package.json +1 -1
- package/payload/scripts/audit.ts +21 -1
- package/payload/scripts/brand.ts +10 -4
- package/payload/scripts/interactions.ts +76 -61
- package/payload/scripts/keyboard.ts +19 -3
- package/payload/scripts/lib/atlas.ts +0 -4
- package/payload/scripts/lib/cls.ts +19 -0
- package/payload/scripts/lib/layer-rules.ts +8 -18
- package/payload/scripts/lib/settle.ts +4 -1
- package/payload/scripts/verify.ts +2 -3
- package/payload/scripts/vitals.ts +5 -4
- package/payload/skills/zz-meridian/SKILL.md +11 -6
- package/payload/skills/zz-meridian/references/agents.md +27 -14
- package/payload/skills/zz-meridian/references/cache.md +1 -1
- package/payload/skills/zz-meridian/references/customize.md +5 -4
- package/payload/skills/zz-meridian/references/existing-project.md +3 -1
- package/payload/skills/zz-meridian/references/ownership.md +1 -1
- package/payload/skills/zz-meridian/references/update.md +1 -1
- package/payload/src/app.config.ts +2 -0
- package/payload/src/components/base/shell/index.tsx +21 -4
- package/payload/src/components/charts/bar-list/index.tsx +9 -1
- package/payload/src/components/charts/column-chart/index.tsx +7 -2
- package/payload/src/components/charts/sparkline/index.tsx +4 -1
- package/payload/src/components/charts/trend-chart/index.tsx +12 -4
- package/payload/src/components/patterns/activity-feed/index.tsx +3 -1
- package/payload/src/components/patterns/appearance-menu/index.tsx +17 -16
- package/payload/src/components/patterns/assistant/index.tsx +8 -2
- package/payload/src/components/patterns/filter-bar/index.tsx +4 -1
- package/payload/src/components/patterns/incident-card/index.tsx +5 -2
- package/payload/src/components/patterns/metric-tile/README.md +2 -0
- package/payload/src/components/patterns/metric-tile/index.tsx +14 -3
- package/payload/src/components/patterns/metric-tile/preview.tsx +12 -2
- package/payload/src/components/patterns/proposal/index.tsx +6 -2
- package/payload/src/components/patterns/shell-tools/index.tsx +2 -1
- package/payload/src/components/ui/card/index.tsx +8 -3
- package/payload/src/components/ui/checkbox/index.tsx +6 -0
- package/payload/src/components/ui/copy-field/index.tsx +4 -2
- package/payload/src/components/ui/delta/index.tsx +5 -0
- package/payload/src/components/ui/key-value/index.tsx +8 -1
- package/payload/src/components/ui/pagination/index.tsx +3 -1
- package/payload/src/components/ui/progress/index.tsx +6 -2
- package/payload/src/components/ui/radio-group/index.tsx +31 -0
- package/payload/src/components/ui/status-dot/index.tsx +11 -3
- package/payload/src/components/ui/switch/index.tsx +5 -0
- package/payload/src/components/ui/toast/index.tsx +3 -1
- package/payload/src/data/member-mutations.ts +5 -1
- package/payload/src/lib/assistant/tools.ts +4 -1
- package/payload/src/lib/collection.ts +12 -4
- package/payload/src/lib/format.ts +18 -5
- package/payload/src/lib/host.ts +7 -2
- package/payload/src/lib/safe-markdown.ts +20 -3
- package/payload/src/styles/base.css +3 -0
- package/payload/src/system/card-stage.tsx +2 -7
- package/payload/src/system/content.ts +13 -1
- package/payload/src/system/hero.tsx +7 -5
- package/payload/src/system/page-stage.tsx +8 -3
- package/payload/src/system/specimen.tsx +1 -1
- package/payload/src/system/token-view.tsx +7 -4
- package/payload/src/views/customer.tsx +5 -2
- package/payload/src/views/home.tsx +2 -0
- package/payload/src/views/members.tsx +17 -1
- package/payload/src/views/now.tsx +2 -1
- package/payload/src/views/request.tsx +19 -4
- package/payload/src/views/settings.tsx +14 -19
- package/payload/src/views/usage.tsx +1 -1
- package/payload/tests/action-needs.test.tsx +6 -2
- package/payload/tests/agents.test.tsx +29 -0
- package/payload/tests/assistant-safety.test.ts +17 -3
- package/payload/tests/atlas-layers.test.ts +13 -0
- package/payload/tests/card-header-title.test.tsx +15 -0
- package/payload/tests/collection.test.ts +38 -1
- package/payload/tests/data-access.test.ts +5 -1
- package/payload/tests/env-not-shared.test.ts +24 -0
- package/payload/tests/filter-bar-search.test.tsx +29 -0
- package/payload/tests/format.test.ts +20 -2
- package/payload/tests/key-value.test.tsx +13 -0
- package/payload/tests/marks-name-what-they-encode.test.tsx +98 -0
- package/payload/tests/metric-tile-pointed-empty.test.tsx +44 -0
- package/payload/tests/page-frame-title.test.tsx +31 -0
- package/payload/tests/prose.test.tsx +7 -0
- package/payload/tests/radio-group.test.tsx +35 -0
- package/payload/tests/setup.ts +12 -1
- package/payload/tests/ticket-owner.test.ts +9 -0
- package/payload/tokens/accent.jade.tokens.json +0 -1
package/dist/adopt.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import { VERSION, brandArgs, effectiveBrand, inside, installArgs, installSkill, jsoncExtras, packageManager, payloadBytes, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
|
|
11
|
+
import { VERSION, brandArgs, effectiveBrand, inside, installArgs, installSkill, jsoncExtras, packageManager, payloadBytes, payloadFiles, readJsonc, readPayload, relativeImports, skillPaths, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
|
|
12
12
|
import { BRIEF_TEMPLATE, managedBlock, upsertManagedBlock } from './context.js';
|
|
13
13
|
import { FIXED, adoptSetOf, managedPaths } from './ownership.js';
|
|
14
14
|
/** The template's dependencies a dashboard built from these files needs. */
|
|
@@ -92,8 +92,15 @@ export function projectConfig(template, pages) {
|
|
|
92
92
|
.replace(' workspace?: WorkspaceId;', '')
|
|
93
93
|
.replace(/export const nav: NavGroup\[\] = \[[\s\S]*?\n\];/, `/** Written by zz-meridian adopt from the project's pages: give each its own icon and group them. */\nexport const nav: NavGroup[] = [\n {\n items: [\n${items}\n ],\n },\n];`);
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* `scripts/verify.config.ts` as a project gets it. The sample's persona rows are what used `EVERY_ROUTE`, so replacing the
|
|
97
|
+
* config object leaves the constant declared and never read — which the gate's own lint fails, in the project adopt just
|
|
98
|
+
* wrote, 0.7 s after the other steps passed. Exported so a test can hold the rewrite to that.
|
|
99
|
+
*/
|
|
100
|
+
export function verifyConfig() {
|
|
101
|
+
return readPayload('scripts/verify.config.ts')
|
|
102
|
+
.replace(/^const EVERY_ROUTE = \[[^\n]*\n/m, '')
|
|
103
|
+
.replace(/const config: VerifyConfig = \{[\s\S]*?\n\};/, `const config: VerifyConfig = {
|
|
97
104
|
// One detail page per state worth seeing (a normal record, a failed one, a missing one), with ids from your data.
|
|
98
105
|
detailRoutes: [],
|
|
99
106
|
// What a route a persona may not open draws: the product's choice in src/app.config.ts.
|
|
@@ -123,6 +130,18 @@ export default defineConfig({
|
|
|
123
130
|
});
|
|
124
131
|
`;
|
|
125
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The ESLint configuration a project already has, or `null`. A flat `eslint.config.*` is what ESLint 9 reads; an
|
|
135
|
+
* `.eslintrc.*` is the legacy form it no longer loads. A function rather than a block inside `adopt` because the
|
|
136
|
+
* question is the project's alone — does it have one, and is it the form the installed ESLint reads — and a test can put
|
|
137
|
+
* a file in a folder and ask it, where running the whole `adopt` would need the payload a checkout has only after a build.
|
|
138
|
+
*/
|
|
139
|
+
export function eslintConfigOf(root) {
|
|
140
|
+
const found = ['eslint.config.ts', 'eslint.config.mts', 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
|
|
141
|
+
'.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc', '.eslintrc.yml', '.eslintrc.yaml']
|
|
142
|
+
.find((f) => fs.existsSync(path.join(root, f)));
|
|
143
|
+
return found ? { file: found, legacy: /^\.eslintrc/.test(found) } : null;
|
|
144
|
+
}
|
|
126
145
|
/**
|
|
127
146
|
* What `adopt` writes for a team's `tsconfig.json`, and what it must say about it: the file Meridian's code needs (its
|
|
128
147
|
* scripts import with `.ts` extensions, its code is ES2022, `@meridian/*` reaches its files), the team's own kept beside
|
|
@@ -224,6 +243,14 @@ https://github.com/zhixuan312/zz-meridian/blob/master/skills/zz-meridian/referen
|
|
|
224
243
|
// Meridian's scripts are ES modules; a project whose package.json is not "type": "module" gets that for scripts/ only.
|
|
225
244
|
if (pkg.type !== 'module')
|
|
226
245
|
own('scripts/package.json', '{\n "type": "module"\n}\n');
|
|
246
|
+
// The agent skill is copied in at the end, after every other write. Checking it here is what stops `adopt` replacing a
|
|
247
|
+
// team's own edited copy of it: the skill was written unconditionally, so an edited `SKILL.md` in the project was lost
|
|
248
|
+
// without a word and the command still exited 0. It is checked against the same rule as every other file.
|
|
249
|
+
for (const { rel, payload } of skillPaths()) {
|
|
250
|
+
const abs = path.join(root, rel);
|
|
251
|
+
if (fs.existsSync(abs) && !fs.readFileSync(abs).equals(Buffer.from(payloadBytes(payload))))
|
|
252
|
+
conflicts.push(rel);
|
|
253
|
+
}
|
|
227
254
|
if (conflicts.length) {
|
|
228
255
|
return fail(`these files already exist with other content, so nothing was written:\n${conflicts.map((c) => ` ${c}`).join('\n')}
|
|
229
256
|
Rename or move them, then run adopt again.`);
|
|
@@ -300,6 +327,17 @@ Rename or move them, then run adopt again.`);
|
|
|
300
327
|
writes.set('vitest.config.ts', vitestConfig(atAlias));
|
|
301
328
|
else
|
|
302
329
|
notes.push('kept your vitest config: add tests/setup.ts to its setupFiles and passWithNoTests for the gate');
|
|
330
|
+
// The gate's lint step runs `eslint .`, and a project with no configuration fails it 0.7 s in with ESLint's own message
|
|
331
|
+
// about a missing file — which says nothing about Meridian. Measured on a project created by create-next-app with
|
|
332
|
+
// --no-eslint and then adopted: every other gate step passed and the lint was the only red.
|
|
333
|
+
const eslint = eslintConfigOf(root);
|
|
334
|
+
if (!eslint) {
|
|
335
|
+
writes.set('eslint.config.ts', readPayload('eslint.config.ts'));
|
|
336
|
+
notes.push('added eslint.config.ts: the gate runs the lint, and this project had no ESLint configuration');
|
|
337
|
+
}
|
|
338
|
+
else if (eslint.legacy) {
|
|
339
|
+
notes.push(`kept ${eslint.file}, which ESLint 9 no longer reads: the gate's lint fails until it becomes an eslint.config.mjs`);
|
|
340
|
+
}
|
|
303
341
|
if (!fs.readdirSync(path.join(root, dir)).some((f) => /^(icon|favicon)\./.test(f)))
|
|
304
342
|
writes.set(`${dir}/icon.ts`, exactImports(`${dir}/icon.ts`, readPayload('app/icon.ts'), known));
|
|
305
343
|
const ignore = path.join(root, '.gitignore');
|
package/dist/files.js
CHANGED
|
@@ -17,10 +17,25 @@ export const VERSION = JSON.parse(fs.readFileSync(path.resolve(path.dirname(file
|
|
|
17
17
|
*/
|
|
18
18
|
export const shippedPath = (rel) => (rel === '.gitignore' ? 'gitignore' : rel);
|
|
19
19
|
export const projectPath = (rel) => (rel === 'gitignore' ? '.gitignore' : rel);
|
|
20
|
+
/**
|
|
21
|
+
* The message a checkout gets when it asks for the payload and nobody has built it. `cli/payload/` is a build output, so
|
|
22
|
+
* a fresh clone has none, and without this the failure was a bare `ENOENT` on whichever file the reader wanted first, or
|
|
23
|
+
* — worse — an empty list, which a test read as "the template has no skill files".
|
|
24
|
+
*/
|
|
25
|
+
const NOT_BUILT = 'cli/payload/ has not been built: run `node cli/scripts/build-payload.ts` (or `--worktree` to build what the working tree holds) and run this again.';
|
|
20
26
|
/** A payload file's bytes, by its project path. */
|
|
21
|
-
export
|
|
27
|
+
export function payloadBytes(rel) {
|
|
28
|
+
if (!fs.existsSync(PAYLOAD))
|
|
29
|
+
throw new Error(NOT_BUILT);
|
|
30
|
+
const abs = path.join(PAYLOAD, shippedPath(rel));
|
|
31
|
+
if (!fs.existsSync(abs))
|
|
32
|
+
throw new Error(`${rel} is not in cli/payload/, which was built before that file was added or left it out: run \`node cli/scripts/build-payload.ts\` again.`);
|
|
33
|
+
return fs.readFileSync(abs);
|
|
34
|
+
}
|
|
22
35
|
/** Every file under a payload folder, as project paths. Symbolic links are never followed. */
|
|
23
36
|
export function payloadFiles(dir = '') {
|
|
37
|
+
if (!fs.existsSync(PAYLOAD))
|
|
38
|
+
throw new Error(NOT_BUILT);
|
|
24
39
|
const out = [];
|
|
25
40
|
const walk = (rel) => {
|
|
26
41
|
for (const e of fs.readdirSync(path.join(PAYLOAD, rel), { withFileTypes: true })) {
|
|
@@ -153,17 +168,22 @@ export function writeManifest(root, m) {
|
|
|
153
168
|
const sorted = Object.fromEntries(Object.entries(m.files).sort(([a], [b]) => a.localeCompare(b)));
|
|
154
169
|
writeIn(root, '.meridian/manifest.json', JSON.stringify({ ...m, files: sorted }, null, 2) + '\n');
|
|
155
170
|
}
|
|
156
|
-
/**
|
|
157
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Where the skill goes: the folders each agent reads in a repository, Codex (`.agents`) and Claude Code (`.claude`), and
|
|
173
|
+
* the file in the payload each is written from. The same list is what `adopt` checks before it writes anything, so a
|
|
174
|
+
* team's own copy is never overwritten behind their back.
|
|
175
|
+
*/
|
|
176
|
+
export function skillPaths() {
|
|
158
177
|
const files = payloadFiles('skills/zz-meridian');
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
return ['.agents/skills/zz-meridian', '.claude/skills/zz-meridian'].flatMap((base) => files.map((f) => ({ rel: `${base}/${f.slice('skills/zz-meridian/'.length)}`, payload: f })));
|
|
179
|
+
}
|
|
180
|
+
/** The agent skill, into the folders each agent reads in a repository. */
|
|
181
|
+
export function installSkill(root, record) {
|
|
182
|
+
for (const { rel, payload } of skillPaths()) {
|
|
183
|
+
const content = payloadBytes(payload);
|
|
184
|
+
writeIn(root, rel, content);
|
|
185
|
+
if (record)
|
|
186
|
+
record[rel] = sha256(content);
|
|
167
187
|
}
|
|
168
188
|
}
|
|
169
189
|
/** Brand flags passed through to scripts/brand.ts, in the order it reads them. */
|
package/dist/migrations.js
CHANGED
|
@@ -540,7 +540,34 @@ export const RELEASE_MIGRATIONS = [
|
|
|
540
540
|
instructions: `Read "The four layers" and "The path an existing product follows" in references/data-layers.md. Thirty-eight modules moved into the logic layer, and each import of one in your own \`src/views/\` producers and pages names the path it left: ${MOVED_LIST}. Rewrite each import to the path the module has now, or move your own module beside the work it does. Two symbols left a card of Meridian's, and both must be rewritten because the update replaces the card: \`afterStart\`, which the incident card exported, is \`@/logic/after-start\`, and \`summarise\`, which the status list re-exported, is \`@/logic/summarise\`. Every other path your own files name is still there, so a project that imported neither symbol keeps building as it was. The six layer rules stay off until your own \`scripts/check.local.ts\` exports \`fourLayers = true\`; turn them on once your figures have moved, and the gate and the build are what show the work is done.`,
|
|
541
541
|
checks: GATE_AND_BUILD,
|
|
542
542
|
},
|
|
543
|
+
{
|
|
544
|
+
id: 'direct-query-escape',
|
|
545
|
+
since: '0.30.0',
|
|
546
|
+
applies: (root) => matching(root, (rel, src) => /^(?:app|src\/(?:logic|views|lib))\//.test(rel) && SOURCE.test(rel) && /\.query\s*\(/.test(src.replace(/\/\/[^\n]*/g, '')) && !/allow-no-direct-query:/.test(src)),
|
|
547
|
+
summary: 'A direct `.query(` call is allowed only where the call states its own escape: `// allow-no-direct-query: <why>`, on that line.',
|
|
548
|
+
instructions: 'Since 0.30.0 `node scripts/check.ts` reports every `.query(` call in `app/`, `src/logic/`, `src/views/` and `src/lib/`, and keeps the run passing only where the call\'s own line carries the escape the check prints — `// allow-no-direct-query: read() cannot serve this — <why>`. Before this release the rule allowed five sites of the template\'s by line number, so a file of your own that read a collection directly passed as long as the call sat on one of those lines; that list is gone, because a comment added above a call moved it off the line and turned a passing project red for no reason. Read every file named above. Where the read has to be direct — a server action reading the record it is about to change, a streamed export, the sign-in resolving a person before an `AccessScope` exists — state the reason on that line in the form the check prints. Everywhere else, read through `read()`, `readAll()` or `aggregate()` from `@/data/read`, which ask the caller\'s grant.',
|
|
549
|
+
checks: ['gate'],
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
id: 'app-description',
|
|
553
|
+
since: '0.30.0',
|
|
554
|
+
applies: appDescriptionMissing,
|
|
555
|
+
summary: 'The product describes itself in one sentence: `description` in the `app` object of `src/app.config.ts`.',
|
|
556
|
+
instructions: 'The release\'s `app/layout.tsx` shows `app.description` as the document\'s description — the line a search engine and a link preview put under the name — where it used to write a sentence of the template\'s own. `src/app.config.ts` is yours and an update does not touch it, so the key is missing there until you add it. Add `description: \'<one sentence about your product>\',` to the `app` object beside `name`. A project that keeps its own `app/layout.tsx` needs the key all the same, because the layout a project brings over from the release reads it.',
|
|
557
|
+
checks: ['gate'],
|
|
558
|
+
},
|
|
543
559
|
];
|
|
560
|
+
/** The `app` object of a project's configuration, as `src/app.config.ts` writes it. */
|
|
561
|
+
const APP_OBJECT = /^export const app = \{[\s\S]*?^\} as const;$/m;
|
|
562
|
+
/** 0.30.0: the release's layout reads `app.description`, and a project's own configuration has no such key. */
|
|
563
|
+
function appDescriptionMissing(root) {
|
|
564
|
+
const rel = 'src/app.config.ts';
|
|
565
|
+
const body = text(root, rel);
|
|
566
|
+
if (body === null)
|
|
567
|
+
return null;
|
|
568
|
+
const object = APP_OBJECT.exec(body)?.[0];
|
|
569
|
+
return object && !/\bdescription\s*:/.test(object) ? [rel] : null;
|
|
570
|
+
}
|
|
544
571
|
/**
|
|
545
572
|
* A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
|
|
546
573
|
* keeps `unobserve() {}` out of it: a fake that answers the callback and also stubs `unobserve` is the release's own
|
package/dist/ownership.js
CHANGED
|
@@ -17,7 +17,7 @@ export const OWNERSHIP = [
|
|
|
17
17
|
'Managed: the distributed scripts under `scripts/`, except `scripts/verify.config.ts`, the size baseline `optional:scripts/verify.baseline.json` (first-load JS and the HTML of each page) and a team-local `optional:scripts/check.local.ts`.',
|
|
18
18
|
'Managed: the library helpers `src/lib/cn.ts`, `src/lib/format.ts`, `src/lib/format-date.ts`, `src/lib/period.ts`, `src/lib/color.ts`, `src/lib/host.ts`, `src/lib/preferences.ts`, `src/lib/csv.ts`, `src/lib/safe-markdown.ts`, `src/lib/logo.ts`, `src/lib/collection.ts`, `src/lib/explore.ts`, `src/lib/live.ts`, `src/lib/shared-context.ts`, `src/lib/agent-guidance.ts`, `src/lib/insight.ts` and `src/lib/facts.ts`, and the assistant prompt `src/lib/assistant/prompt.ts`.',
|
|
19
19
|
'Managed: the three `src/logic/` helpers a shipped component imports — `src/logic/after-start.ts`, `src/logic/derivations.ts` and `src/logic/summarise.ts` — so a payload\'s cards build in an adopted project; every other module under `src/logic/` is the team\'s.',
|
|
20
|
-
'Managed: `src/views/console-chrome.tsx
|
|
20
|
+
'Managed: `src/views/console-chrome.tsx`, `tests/setup.ts`, and — in a project `create` made — every test the release ships. A shipped test pins the behaviour of the module it covers, so a release that changes one changes the other; a test the team edits is staged for its decision like any other edited file.',
|
|
21
21
|
'Managed: `optional:scripts/package.json`, only when the adopt or create that set the project up generated it for this project shape.',
|
|
22
22
|
'Managed: both installed skill trees, `optional:.agents/skills/zz-meridian/` and `optional:.claude/skills/zz-meridian/`, taken from the release\'s skill payload.',
|
|
23
23
|
'Managed: the brand token and stylesheet paths under `tokens/` and `src/styles/` that the release generated from the recorded brand.',
|
|
@@ -27,6 +27,15 @@ export const OWNERSHIP = [
|
|
|
27
27
|
];
|
|
28
28
|
/** The rule as a markdown bullet list, for `references/ownership.md`. */
|
|
29
29
|
export const renderOwnership = () => OWNERSHIP.map((l) => `- ${l}`).join('\n');
|
|
30
|
+
/**
|
|
31
|
+
* The managed set as one path pattern, for a caller that holds a manifest but no payload list — the consumer smoke checks
|
|
32
|
+
* a manifest read from a tarball it never enumerates. `managedPaths` is the rule; this is that rule said once more in a
|
|
33
|
+
* regex, and `tests/cli-ownership.test.ts` holds the two together over the payload paths it knows. The tests clause is the
|
|
34
|
+
* `create` route's, so it is permissive for an adopted project — whose manifest never names a test but `tests/setup.ts`.
|
|
35
|
+
*/
|
|
36
|
+
export const MANAGED_PATH = /^(tokens\/|src\/styles\/|src\/components\/|scripts\/|src\/lib\/|\.agents\/|\.claude\/)|^(src\/views\/console-chrome\.tsx|tests\/(?:[^/]+\.test\.tsx?|setup\.ts)|src\/logic\/(?:after-start|derivations|summarise)\.ts)$/;
|
|
37
|
+
/** A path inside the managed folders that is the team's all the same: their own views, their app, their docs, their config. */
|
|
38
|
+
export const NEVER_MANAGED = (p) => p === 'src/app.config.ts' || p === 'scripts/verify.config.ts' || p.startsWith('app/') || p.startsWith('docs/') || (p.startsWith('src/views/') && p !== 'src/views/console-chrome.tsx');
|
|
30
39
|
/** The raw `.meridian/manifest.json`, checked field by field; anything off throws an Error that names it. */
|
|
31
40
|
export function parseManifest(text) {
|
|
32
41
|
let raw;
|
|
@@ -75,8 +84,16 @@ export function adoptSetOf(payloadFiles) {
|
|
|
75
84
|
* routes. `generated` is every path a replay or a real run produced; only the ones under `tokens/` or `src/styles/`
|
|
76
85
|
* and `scripts/package.json` become managed, so a generated page or config stays the team's.
|
|
77
86
|
*/
|
|
78
|
-
export function managedPaths(payloadFiles,
|
|
87
|
+
export function managedPaths(payloadFiles, route, generated = []) {
|
|
79
88
|
const out = new Set(adoptSetOf(payloadFiles));
|
|
89
|
+
// A created project's tests are Meridian's: it wrote every one of them, and each pins the behaviour of the module it
|
|
90
|
+
// covers. Left the team's, a release that changed `splitFigure` could not change the test that reads it, and the
|
|
91
|
+
// project's own gate failed after an update that was otherwise correct (0.29.0's consumer smoke). Adopt still copies
|
|
92
|
+
// none of them: that project has a test suite of its own.
|
|
93
|
+
if (route === 'create')
|
|
94
|
+
for (const f of payloadFiles)
|
|
95
|
+
if (/^tests\/[^/]+\.test\.tsx?$/.test(f))
|
|
96
|
+
out.add(f);
|
|
80
97
|
for (const g of generated)
|
|
81
98
|
if (g.startsWith('tokens/') || g.startsWith('src/styles/') || g === 'scripts/package.json')
|
|
82
99
|
out.add(g);
|
package/dist/session.js
CHANGED
|
@@ -69,12 +69,28 @@ const pidAlive = (pid) => {
|
|
|
69
69
|
return e.code === 'EPERM';
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* The session folders under `.meridian/update/`, oldest name first. Only folders: the same folder holds whatever else a
|
|
74
|
+
* person's tools leave there — Finder's `.DS_Store`, an editor's swap file — and one of those read as a session is a
|
|
75
|
+
* version that does not exist. `loadSession` filtered for that and this did not, so a stray file made `status` report a
|
|
76
|
+
* session of version `.DS_Store` and made `update` prescribe `npx zz-meridian@.DS_Store update --resume`, which then
|
|
77
|
+
* refused, leaving no way forward. One list, read the same way by both.
|
|
78
|
+
*/
|
|
79
|
+
export function sessionDirs(root) {
|
|
80
|
+
const base = path.join(root, '.meridian', 'update');
|
|
81
|
+
if (!fs.existsSync(base))
|
|
82
|
+
return [];
|
|
83
|
+
return fs.readdirSync(base).filter((n) => { try {
|
|
84
|
+
return fs.lstatSync(path.join(base, n)).isDirectory();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
} }).sort();
|
|
89
|
+
}
|
|
72
90
|
/** The phase of the one session folder under `.meridian/update/`, or a note when it cannot be read. */
|
|
73
91
|
function sessionState(root) {
|
|
74
92
|
const base = path.join(root, '.meridian', 'update');
|
|
75
|
-
|
|
76
|
-
return null;
|
|
77
|
-
const [first] = fs.readdirSync(base).sort();
|
|
93
|
+
const [first] = sessionDirs(root);
|
|
78
94
|
if (!first)
|
|
79
95
|
return null;
|
|
80
96
|
let phase = 'unreadable';
|
|
@@ -154,7 +170,15 @@ function plan(ctx, manifest, startedAt) {
|
|
|
154
170
|
const inBase = baseManaged.has(p);
|
|
155
171
|
const inTarget = targetManaged.has(p);
|
|
156
172
|
const managed = inBase || inTarget;
|
|
157
|
-
|
|
173
|
+
// What the recorded release wrote at this path: the manifest's hash, or — for a project `create` made, which received
|
|
174
|
+
// every payload file — the recorded release's own bytes. The second term is what made 0.29.0's tests unclassifiable:
|
|
175
|
+
// a test that release shipped but did not record (it managed none but `tests/setup.ts` then) had no hash to compare
|
|
176
|
+
// with, so an unedited copy of it collided instead of being replaced, and the update stopped for a decision on a file
|
|
177
|
+
// nobody had touched.
|
|
178
|
+
const recorded = manifest.files[p]
|
|
179
|
+
?? (manifest.route === 'create' && sourcePayload.has(p) ? source.hashes.get(p) : undefined)
|
|
180
|
+
?? (inBase && !sourcePayload.has(p) ? source.hashes.get(p) : undefined)
|
|
181
|
+
?? null;
|
|
158
182
|
const targetHash = inTarget ? target.hashes.get(p) ?? null : null;
|
|
159
183
|
if (targetHash)
|
|
160
184
|
targetFiles[p] = targetHash;
|
|
@@ -498,7 +522,7 @@ function serializeManifest(m) {
|
|
|
498
522
|
*/
|
|
499
523
|
function loadSession(ctx) {
|
|
500
524
|
const base = path.join(ctx.root, '.meridian', 'update');
|
|
501
|
-
const names =
|
|
525
|
+
const names = sessionDirs(ctx.root);
|
|
502
526
|
if (names.length === 0)
|
|
503
527
|
return 'there is no update session in .meridian/update/; start one with npx zz-meridian@latest update';
|
|
504
528
|
if (names.length > 1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/.env.example
CHANGED
|
@@ -14,3 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
# The provider's address. Required for "openai-compatible"; optional for "anthropic".
|
|
16
16
|
# ASSISTANT_BASE_URL=
|
|
17
|
+
|
|
18
|
+
# The demo gate: see src/lib/demo-gate.ts. The Docker image (Dockerfile) refuses to start without a password, and the
|
|
19
|
+
# product is ungated without one, so this is the whole of "run the demo". It is server-only and read on every request.
|
|
20
|
+
# DEMO_PASSWORD=
|
|
21
|
+
|
|
22
|
+
# What the session cookie is signed with. Left unset it is derived from DEMO_PASSWORD, so a session survives a restart
|
|
23
|
+
# either way; set it to sign everyone out when the password changes, or to keep a session across a password change.
|
|
24
|
+
# DEMO_SECRET=
|
package/payload/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## This is NOT the Next.js you know
|
|
4
4
|
|
|
5
5
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
6
6
|
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
|
|
4
4
|
|
|
5
|
+
## [0.30.0] · 2026-10-25
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- **A direct `.query(` call states its own escape, on the line it is on.** `noDirectQuery` allowed five sites of the template's by absolute line number, and each was wrong the moment a comment above it changed length — this release moved `src/lib/assistant/tools.ts`'s call from 132 to 135 with an unrelated fix. The list is gone: the rule reports every `.query(` call in `app/`, `src/logic/`, `src/views/` and `src/lib/`, and keeps the run passing only where the call's own line carries `// allow-no-direct-query: read() cannot serve this — <why>`, the escape the check prints.
|
|
10
|
+
- **What a team does.** Run `update`; it reports `migration:direct-query-escape` for each of your files that queries directly. Where the read must be direct — a server action reading the record it is about to change, a streamed export, the sign-in resolving a person before an `AccessScope` exists — put the escape on that line with the reason. Everywhere else read through `read()`, `readAll()` or `aggregate()` from `@/data/read`, which ask the caller's grant.
|
|
11
|
+
- **Doing nothing.** Your own file that queries directly fails `node scripts/check.ts` on the next `pnpm gate`, in the check's own words, naming the line. Nothing else changes: the rule governs the same four folders it did, and a project with no direct read at all is unaffected.
|
|
12
|
+
- **`src/app.config.ts` gains `description`, the product's one sentence.** The release's `app/layout.tsx` shows `app.description` where it wrote a sentence of the template's own, and that layout is the one a project brings over when it takes the release's pages. The configuration is yours, and an update does not touch it.
|
|
13
|
+
- **What a team does.** Run `update`; it reports `migration:app-description` for your configuration. Add `description: '<one sentence about your product>',` to the `app` object beside `name`.
|
|
14
|
+
- **Doing nothing.** A project that keeps its own `app/layout.tsx` sees no failure, and the key costs it nothing. A project that brings the release's layout over fails `pnpm gate` on the type check — `Property 'description' does not exist` — until the key is there.
|
|
15
|
+
|
|
16
|
+
### New
|
|
17
|
+
|
|
18
|
+
- **The accent picker is a radio group.** `AccentSwatches` in `@/components/ui/radio-group` is one row of swatches built on the radio primitive: the row is one Tab stop, the arrow keys move the choice, and the chosen swatch is announced as chosen. Settings and the Atlas's card stage both draw it, where three hand-built rows of `role="radio"` buttons each drew their own — every swatch a stop, and an arrow key that did nothing. The appearance menu keeps pressed buttons (`role="group"` with `aria-pressed`), because inside a menu the arrow keys belong to the menu.
|
|
19
|
+
- **`app.description` names the product in a sentence.** It is the line a search engine and a link preview show under the name, and it sits in `src/app.config.ts` beside `name`, `workspace` and `accent` instead of being written into `app/layout.tsx`. Both files are yours, so a project that has already set its own sentence keeps it; `update` reports `migration:app-description` where the configuration has no such key (see **Breaking**).
|
|
20
|
+
- **The README carries the prompt that models a product's data first.** One paragraph to hand the agent before the build sentence: the collections with their key, tenant, hidden, page-only and derived fields; the day and the time zone it is counted in; the money and its currency; each figure with its unit, its window and a definition a stranger could apply to a row; the grants in the `resource:operation` form; the three questions a page must be able to answer; and what an agent may propose against what stays the page's own. It asks for one table the build reads, so a wrong rendering can be told from a wrong model.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **An approval a guard refused is still yours to send.** A tool call's id was marked used before the change ran, so a change that threw — the grant withdrawn between the approval and the run, a record that no longer fits — wrote nothing and still answered "This change was already applied." to the second try. The id is released when the change throws.
|
|
25
|
+
- **A screen never says a thing was copied before it was.** Four Copy controls on a request's page raised "copied" without waiting for the clipboard, on the page whose whole purpose is handing somebody an exact command or id. One helper awaits the write and says what to do when the browser refuses it.
|
|
26
|
+
- **A page is told the assistant is on only where the route serves it.** `scripts/lib/settle.ts` read every answer but 404 from `/api/assistant` as "on", so a page behind a refused session waited its whole bound for a slot that was never coming.
|
|
27
|
+
- **`brand` refuses a contrast run that measured nothing.** A `scripts/contrast.ts` that crashed printed no summary, and its empty output read as "no failures": the accent shipped unchecked. A hue still failing after the eighth step stops the command too.
|
|
28
|
+
- **The check states the direct read's escape on the call's own line.** `noDirectQuery` allowed five sites by absolute line number, and each was wrong the moment a comment above it changed length. The five state `// allow-no-direct-query: read() cannot serve this — …` where the call is, the escape the check already prints, so the rule no longer has a list to fall behind.
|
|
29
|
+
- **A walk goes on after a route that stops answering.** `scripts/interactions.ts` and `scripts/keyboard.ts` ended the suite at the first page that stopped answering, losing the evidence for every route after it. Each route is walked in a guard of its own, as `scripts/audit.ts` measures each route.
|
|
30
|
+
- **The Atlas reads its entries once per request, not twice.** The walk reads 73 specifications and a request-rendered Atlas page asked for them from the sidebar and again from the article. React's `cache` is the request's scope, so one read serves both and an edited README is still read again on the next request.
|
|
31
|
+
- **An Atlas page whose view tool threw is drawn without the tool's output.** A `Suspense` covers a read that is pending, not one that rejected, so one failing tool took the whole entry page.
|
|
32
|
+
- **`verify` names `--shard` among the options it takes**, and a shard is passed with `--perf` alone: it cannot be combined with `--full` or `--timed`, which `docs/testing.md` now says where it said "beside the other tiers".
|
|
33
|
+
- **The request page asks its reads at once.** The record, the grant behind Replay and the endpoints were three waits in a row; only the customer's address depends on the record.
|
|
34
|
+
- **The tab icon reads the accent the product picks.** `app/icon.ts` took the resolver's default, which is the shipped preset only until a product changes one of the two by hand.
|
|
35
|
+
- **Three routes lose a `Suspense` that wraps nothing**, and the product's one-line description moves into its config (above).
|
|
36
|
+
- **Seven components, each corrected where a reader named the defect.** A copy field's label sat on an element that takes no accessible name; a progress bar with a drawn label was unnamed; a toast raised before `Toaster` mounted was never shown; two incident updates stamped in one millisecond collided; the pager drew for a list that fits its page; two chart memos omitted the geometry they draw with; and a checkbox's and a switch's `className` are documented for the element they style.
|
|
37
|
+
- **Two timers outlived what set them.** The front door's sweep cleared its start timeout and left the interval running, and a host request held its timer for the whole bound after its answer arrived.
|
|
38
|
+
- **The skill's copy of the agent guide is the template's guide again.** It had drifted by four passages — the `data-assistant="omit"` rule, the 409 a thread answers when the session changed hands, the questions a view may offer, and how `pnpm verify --full` walks the assistant — and nothing compared the two. `tests/cli-skill-agents-sync.test.ts` does now, and `update` replaces an unedited copy with this one.
|
|
39
|
+
- **The skill and the docs name the paths the code has.** A prepared query beside a view is `src/logic/<view>-explore.ts` and a view's context file `src/logic/*-context.ts`, both moved in 0.28.0 while the skill still said `src/views/`; `assistantTools` takes `(collections, writer, guard, views?, explore?)`; `brand` is run through the package, not as `node scripts/brand.ts`.
|
|
40
|
+
- **`.env.example` names `DEMO_PASSWORD` and `DEMO_SECRET`**, and `AGENTS.md`'s generated block starts at the level Next writes it, so `next dev` stops re-creating the file as a change.
|
|
41
|
+
- **Three things nothing read are gone**: `scripts/lib/atlas.ts`'s `hasAtlas`, `verify`'s unused `reportSuite` return, and jade's empty `light: {}` token override.
|
|
42
|
+
|
|
43
|
+
## [0.29.0] · 2026-10-25
|
|
44
|
+
|
|
45
|
+
### New
|
|
46
|
+
|
|
47
|
+
- **A mark says what it encodes** ([#94](https://github.com/zhixuan312/zz-meridian/issues/94)). A colour, a fill or a size a reader is meant to decode now carries its words, built from the data the component already holds, so it is right at the call site rather than added page by page. A Bar list row names its row, its figure and its share (`Checkout · 420 · 42.0% of 1,000`, and without a total too); a Trend chart's marked day names the day and what it is marked for; its dashed stretch into a period still running says "Today so far, dashed"; a Sparkline says the range it draws, which was the one chart carrying no readout; a Delta says whether its colour reads better or worse, which the colour alone could not; a Status dot takes a `label` and becomes an image to assistive technology where it stands with no word beside it, and an activity's tone says what it means ("Went well", "Needs attention", "Failed"). `ColumnChart` and `BarList` take `highlightLabel` — what makes the accented row or column the one the card is about — and say it on that mark and in the hidden table's caption. `docs/data-display.md`'s `## Marks` now states the rule, so a new chart is held to it.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **A card's head never draws its title over its controls** ([#87](https://github.com/zhixuan312/zz-meridian/issues/87)). `CardHeader` gave the title `min-w-0 flex-1` and the actions `shrink-0`, so in a narrow column the title's box collapsed: at 1024px the second column of `<Row split="2/3">` is 229px, a head with a `Select size="sm"` and two labels took 138 of them, and the title's first word was drawn 32px past its own box, across the controls. The head wraps now, and the title's box has a floor (8rem, the width a word or two needs), so the actions drop to their own line when the two cannot share one. A title that wraps breaks a word too long for the line instead of drawing it over its neighbour.
|
|
52
|
+
- **A page headed by a sentence that opens with its own name announces it once** ([#88](https://github.com/zhixuan312/zz-meridian/issues/88)). `PageTitle` drew the name out of sight and then the sentence, so "USDC: no rule met…" was read as "USDC : USDC: no rule met…" in the heading and twice in a screen reader's list of headings. A sentence that is a plain string is compared with the name, and one that reads data takes `speaksName` on `PageTitle` or `PageFrame`; the name stays in the markup either way, so the heading is in the prerendered shell, and a sentence still to come leaves the name read.
|
|
53
|
+
- **A Key value list wraps a value on a phone instead of cutting it behind a title no phone shows** ([#89](https://github.com/zhixuan312/zz-meridian/issues/89)). Every value truncated unless its item set `wrap`, with the whole of it in the row's `title` — which a pointer reads and a phone cannot. Below the width where a sentence value cannot fit beside its 8rem label (26rem of row) the value wraps as `wrap` asks for; above it the one line and the title stay.
|
|
54
|
+
- **The CLS observer bounds a session window at 5 s** ([#90](https://github.com/zhixuan312/zz-meridian/issues/90)). It summed shifts into a session while each was within 1 s of the last, which is half of web-vitals' definition: a page that filled in over half a minute reported one session the length of the page, so `verify --timed` could fail a page inside its budget and the number could not be compared with another tool's. `scripts/lib/cls.ts` is the rule now, both bounds with it, and `tests/cli-vitals-cls.test.ts` runs the source the page runs.
|
|
55
|
+
- **A collection may hide or page-own a field it computes** ([#91](https://github.com/zhixuan312/zz-meridian/issues/91)). `visibleFields` and `writableFields` built their `omit` masks from the names alone, so a `hidden` or `pageOnlyFields` entry for a joined label or a derived total — which both doc comments allow — threw on a key the shape never had, on every render. A name the shape does not hold is skipped; the ones it holds are still masked, and the write schema stays strict, so a hidden or page-owned field sent anyway is refused.
|
|
56
|
+
- **An image's address is read the way the URL parser reads it** ([#92](https://github.com/zhixuan312/zz-meridian/issues/92)). `safe-markdown` accepted a `src` with no scheme that was not written `//`, so `\\host/p.png`, `/\host/p.png` and `\/host/p.png` passed while the browser resolved each to `//host/p.png` and fetched a third party — the case the rule exists to stop. The address is now resolved against a fixed origin with the backslashes normalised, and kept only when it lands back on it.
|
|
57
|
+
- **A filter bar's search has a floor, so its hint reads the same on a 2x display as on a 1x one** ([#93](https://github.com/zhixuan312/zz-meridian/issues/93)). The field took whatever five facets left: 129px at a 1440px window on a Retina screen, where the placeholder needs 134, so the same page read "Headline, address, ru" there and the whole hint on a laptop. It is 10rem wide at least, and the facets truncate their own labels to make the room.
|
|
58
|
+
- **The environment is put back before every test** ([#95](https://github.com/zhixuan312/zz-meridian/issues/95)). Files share a worker (`pool: 'vmThreads'`), so a file that stubbed a store's variables in a `beforeAll` left them set for the file beside it — seen as an intermittent gate where a view test found a store configured and asked a stub for a snapshot. `tests/setup.ts` restores the environment before each test, so a stub belongs in a `beforeEach`, which runs after it.
|
|
59
|
+
- **A created project's tests move with the release that changes them.** A test the template ships pins the behaviour of the module it covers, and every shipped test but `tests/setup.ts` was the team's, so an update could change `src/lib/format.ts` and leave `tests/format.test.ts` asserting what it did before: a project created with 0.28.0 and updated to 0.29.0 failed its own gate on `splitFigure`. `create` now records the tests it writes as managed, so an update replaces an unedited one with the release's; a test your team edited is staged for your decision, as any other edited file is. `adopt` still copies none of them, because a project it joins has a suite of its own.
|
|
60
|
+
|
|
61
|
+
- **A figure keeps its symbol and its sign when it is split or shortened, and an amount too small for 20 places never reads as zero** ([#96](https://github.com/zhixuan312/zz-meridian/issues/96)). `splitFigure` failed on a leading mark-and-sign figure (`-$298.43` came back whole), so a tile set it in its number face, or lost both the `$` and the minus when it shortened; `fitFigure` re-built a shortened figure with the app's own currency, so `HK$12,345,678` read `$12.3M`. The minus belongs to the mark, and a figure shortens with the mark and the sign it was given. `formatCost` below 5e-5 keeps its two leading digits in exponent form (`$1.0e-21`) where `toFixed` printed a row of zeroes.
|
|
62
|
+
|
|
5
63
|
## [0.28.0] · 2026-10-24
|
|
6
64
|
|
|
7
65
|
### Breaking
|
package/payload/README.md
CHANGED
|
@@ -118,6 +118,41 @@ call a live API, say so in the sentence: `verify --full` presses every control,
|
|
|
118
118
|
fake API first. The package (`cli/`, decision 0009) copies files and nothing depends on it afterwards; its commands are
|
|
119
119
|
in `cli/README.md`.
|
|
120
120
|
|
|
121
|
+
## Model your data before a page is built
|
|
122
|
+
|
|
123
|
+
A Meridian page is only as good as the model under it: a figure needs a definition, a unit and a window; a table needs a
|
|
124
|
+
key and a grant; an agent needs to know what it may count and what it may only propose. One prompt settles that before
|
|
125
|
+
anything is drawn, and its answer outlives the build — it is the file the next person reads.
|
|
126
|
+
|
|
127
|
+
Paste this into the same agent, before the build sentence:
|
|
128
|
+
|
|
129
|
+
> Model my data for Meridian, before any page is built. Read this folder first — a schema, a migration, an OpenAPI file
|
|
130
|
+
> or an existing data layer answers most of this — and ask me only what the folder does not say, in one batch of at most
|
|
131
|
+
> twelve questions. Then write the model into `docs/brief.md` (the file `adopt` and `create` leave for it), answering:
|
|
132
|
+
>
|
|
133
|
+
> 1. **Collections.** Each one's name, its key, the tenant it belongs to, every field with its type, and which of them a
|
|
134
|
+
> reader may not see, which only a page may write, and which are derived rather than stored. The contract is the one
|
|
135
|
+
> `src/lib/collection.ts` declares.
|
|
136
|
+
> 2. **The day.** Which field says when a record happened, and what a day means for it — the time zone the product
|
|
137
|
+
> counts in, so "today" and "the last 7 complete days" mean one thing on every figure.
|
|
138
|
+
> 3. **Money.** Which fields are amounts, in which currency, and whether one row can be in a different currency from the
|
|
139
|
+
> next.
|
|
140
|
+
> 4. **Figures.** For every figure the dashboard will show: its name, its unit, what it changes against (the window
|
|
141
|
+
> before, a target, a baseline), and its definition in one sentence a stranger could apply to a row.
|
|
142
|
+
> 5. **Grants.** What each role may read and write, in the `resource:operation` form `src/data/access.ts` asks, and which
|
|
143
|
+
> collection each grant names. Say what a person with the smallest role still has to see for the product to make
|
|
144
|
+
> sense — that is what decides which views they get.
|
|
145
|
+
> 6. **Questions.** For each page: the three questions someone on that page would ask that the page's own text does not
|
|
146
|
+
> answer — what must be countable, groupable or comparable, and over which collections.
|
|
147
|
+
> 7. **Writes.** What an agent may propose on each page, and what stays the page's own — the fields a change must never
|
|
148
|
+
> set.
|
|
149
|
+
>
|
|
150
|
+
> Where the answer is a choice, give me the two or three options with the figure each produces, and recommend one. End
|
|
151
|
+
> with one table: figure · definition · source · unit · window. The build reads that table, not the prose above it.
|
|
152
|
+
|
|
153
|
+
Then give the build sentence above. The agent reads the brief first, and every figure on the page it builds is one the
|
|
154
|
+
brief defines — which is also how you tell a wrong rendering from a wrong model.
|
|
155
|
+
|
|
121
156
|
## Privacy and feedback
|
|
122
157
|
|
|
123
158
|
Meridian takes nothing from you. The package, the template and the skill have no telemetry, no analytics and no
|
|
@@ -4,7 +4,7 @@ import { ExportButton } from '@/components/patterns/export-button';
|
|
|
4
4
|
import { Freshness } from '@/components/patterns/freshness';
|
|
5
5
|
import { PeriodSelect } from '@/components/patterns/period-select';
|
|
6
6
|
import { HomeBody } from '@/views/home';
|
|
7
|
-
import { PeriodActionsRoom
|
|
7
|
+
import { PeriodActionsRoom } from '@/views/period-actions';
|
|
8
8
|
import { gate, gatedMetadata } from '@/data/access';
|
|
9
9
|
import { RefusalScreen } from '@/views/refusal';
|
|
10
10
|
import { DEMO_NOW, DEMO_UPDATED_AT } from '@/data/sample';
|
|
@@ -83,7 +83,7 @@ async function Actions({ searchParams }: { searchParams: SearchParams }) {
|
|
|
83
83
|
const { data } = await readHome(searchParams);
|
|
84
84
|
return (
|
|
85
85
|
<>
|
|
86
|
-
<
|
|
86
|
+
<PeriodSelect value={data.period} />
|
|
87
87
|
<ExportButton rows={dayRows(data.series)} filename={`home-${data.period}.csv`} noun="days" />
|
|
88
88
|
</>
|
|
89
89
|
);
|
|
@@ -9,7 +9,7 @@ import { Freshness } from '@/components/patterns/freshness';
|
|
|
9
9
|
import { Busy, LeadRoom } from '@/components/patterns/page-loading';
|
|
10
10
|
import { PeriodSelect } from '@/components/patterns/period-select';
|
|
11
11
|
import { parsePeriod } from '@/lib/period';
|
|
12
|
-
import { PeriodActionsRoom
|
|
12
|
+
import { PeriodActionsRoom } from '@/views/period-actions';
|
|
13
13
|
import { dayRows } from '@/logic/home-context';
|
|
14
14
|
import { RevenueBody } from '@/views/revenue';
|
|
15
15
|
import { revenueTool } from '@/logic/tools';
|
|
@@ -75,7 +75,7 @@ async function Actions({ searchParams }: { searchParams: SearchParams }) {
|
|
|
75
75
|
const { data } = await readPeriod(period);
|
|
76
76
|
return (
|
|
77
77
|
<>
|
|
78
|
-
<
|
|
78
|
+
<PeriodSelect value={period} />
|
|
79
79
|
<ExportButton rows={dayRows(data.days?.series ?? [])} filename={`revenue-${period}.csv`} noun="days" />
|
|
80
80
|
</>
|
|
81
81
|
);
|
|
@@ -32,7 +32,7 @@ The body (rows 1 and 2 and the line on rotating a key under them, under the mast
|
|
|
32
32
|
|
|
33
33
|
## Data
|
|
34
34
|
|
|
35
|
-
The keys are the `keys` collection (`src/data/collections.ts`), read through `
|
|
35
|
+
The keys are the `keys` collection (`src/data/collections.ts`), read through `readAll('keys')` on every request. Create and Revoke are server actions (`actions.ts`) that write the caller's own collection (`collectionFor(scope, 'keys')`), then the view refreshes the route. A rejected create is said inside the sheet; a rejected revoke shows a critical toast and puts the row back. The secret is generated on the server and returned once, to the banner, which is the only place it is ever shown. Its Copy field is marked `data-assistant="omit"`, so a question asked or an Ask pressed while the banner is up sends the page text without it: the assistant reads that a key was created, never its value. The collection stores the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret, so the table shows the hint and nothing in the browser or the cache can reveal or copy a listed key. To replace a key, create a new one and revoke the old.
|
|
36
36
|
|
|
37
37
|
## Embed view
|
|
38
38
|
|
|
@@ -35,7 +35,7 @@ The body (rows 1 and 2) is the records template, `Records` from `src/components/
|
|
|
35
35
|
|
|
36
36
|
## Data
|
|
37
37
|
|
|
38
|
-
The `members` collection in `src/data/collections.ts`, read through `
|
|
38
|
+
The `members` collection in `src/data/collections.ts`, read through `readAll('members', { sort: { field: 'joined', dir: 'desc' } })` at request time, so the person's grant is asked on the path every page reads by. Server actions in `actions.ts` call `members.create` (status `Invited`, joined today by the data's clock, never active), `members.update` (status), `members.remove` and `assignRole`; the view refreshes the page after each and keeps no copy of the rows. Last active is relative to the data's clock.
|
|
39
39
|
|
|
40
40
|
Every one of those writes passes `src/data/member-mutations.ts`, which re-resolves the caller, checks the operation's grant and refuses a change or removal of an Owner without `workspace:update`, one's own role or add-ons, suspending or removing oneself, and any change that would leave no active Owner. The page reads that boundary per row, once, and is handed only what it allows: `memberAccess(id)` gives the row's booleans, its role choices, its add-on choices and its reason, and `invitableRoles()` gives the roles an invitation may carry. The page decides nothing from a role of its own. A role or add-on change is one `assignRole` write, with its Activity line in the same step; the matrix in row 2 asks `whoCan` over the same `GRANTS` the boundary uses.
|
|
41
41
|
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { clock } from '@/data/collections';
|
|
4
4
|
import { can, collectionFor, resolveAccess, Unauthenticated } from '@/data/access';
|
|
5
|
-
import { assignMemberRole, REFUSALS } from '@/data/member-mutations';
|
|
5
|
+
import { assignMemberRole, MemberRefusal, REFUSALS } from '@/data/member-mutations';
|
|
6
6
|
import type { AnyCollection } from '@/lib/collection';
|
|
7
7
|
import { dayOf } from '@/lib/format-date';
|
|
8
8
|
import type { Result } from '@/views/members';
|
|
9
9
|
import { STATUSES, TEAMS, type Member } from '@/data/sample';
|
|
10
10
|
import { MAIN_ROLES, type AddOn, type MainRole } from '@/data/roles';
|
|
11
11
|
|
|
12
|
+
// A refusal the boundary wrote for the person is shown as it is; anything else that threw — a collection that rejected a
|
|
13
|
+
// shape, a connection that dropped — is not theirs to read, and says only that it did not go through.
|
|
12
14
|
const fail = (e: unknown): Result => ({
|
|
13
15
|
ok: false,
|
|
14
|
-
error: e instanceof Unauthenticated ? REFUSALS.signIn : e instanceof
|
|
16
|
+
error: e instanceof Unauthenticated ? REFUSALS.signIn : e instanceof MemberRefusal && e.message ? e.message : 'The change did not go through.',
|
|
15
17
|
});
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -20,7 +20,7 @@ export async function replayRequest(id: string): Promise<ReplayResult> {
|
|
|
20
20
|
const scope = await resolveAccess();
|
|
21
21
|
if (!(await can(scope, 'requests', 'create'))) return { ok: false, error: 'You do not have permission to replay requests.' };
|
|
22
22
|
const requests = collectionFor(scope, 'requests');
|
|
23
|
-
const original = (await requests.query({ where: [{ field: 'id', op: 'eq', value: id }] })).rows[0] as RequestRow | undefined; // read() cannot serve this
|
|
23
|
+
const original = (await requests.query({ where: [{ field: 'id', op: 'eq', value: id }] })).rows[0] as RequestRow | undefined; // allow-no-direct-query: read() cannot serve this — a server action reads the record it is about to change, uncached
|
|
24
24
|
if (!original) return { ok: false, error: `No request with id ${id}.` };
|
|
25
25
|
const status = original.method === 'POST' ? 201 : 200;
|
|
26
26
|
const replay = await requests.create!({
|
|
@@ -31,6 +31,8 @@ export async function replayRequest(id: string): Promise<ReplayResult> {
|
|
|
31
31
|
updateTag(collectionTag(scope.tenantId, 'requests'));
|
|
32
32
|
return { ok: true, id: String(replay.id), status };
|
|
33
33
|
} catch (e) {
|
|
34
|
-
|
|
34
|
+
// Only the answer written for the person: a collection that rejected the record says why in its own words, which are
|
|
35
|
+
// not theirs to read, so anything but the missing session is a sentence this action chose.
|
|
36
|
+
return { ok: false, error: e instanceof Unauthenticated ? 'Sign in again to replay this request.' : 'The request was not replayed.' };
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -52,13 +52,15 @@ export default async function RequestPage({ params }: { params: Promise<{ id: st
|
|
|
52
52
|
const { id } = await params;
|
|
53
53
|
const denied = await gate('request');
|
|
54
54
|
if (denied) return RefusalScreen(denied);
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
55
|
+
// Everything the page needs that does not depend on the record is asked beside it, in one wait: the record, the
|
|
56
|
+
// grant behind Replay and the endpoints that give the route its p95 were three waits in a row before.
|
|
57
|
+
//
|
|
58
58
|
// After the gate: what this person may do with the record, so Replay is drawn only where it may be used. A Viewer
|
|
59
59
|
// holds `requests:read` without `requests:create`, so it is withheld for them and nothing is drawn in its place.
|
|
60
|
-
const mayReplay = await may(ACTIONS['replay-request'].needs);
|
|
61
|
-
|
|
60
|
+
const [{ request: r, observedAt }, mayReplay, endpoints] = await Promise.all([byId(id), may(ACTIONS['replay-request'].needs), readEndpoints()]);
|
|
61
|
+
// Rendered here rather than thrown, so the screen is in the first HTML (see MissingPage).
|
|
62
|
+
if (!r) return <MissingPage />;
|
|
63
|
+
const customerHref = await customerPage(r.customer);
|
|
62
64
|
const route = endpoints.find((e) => e.method === r.method && e.route === r.route);
|
|
63
65
|
return <RequestView request={r} trace={traceOf(r)} payloads={payloadsOf(r)} routeP95={route?.p95 ?? null} now={observedAt} mayReplay={mayReplay} replay={replayRequest} customerHref={customerHref} />;
|
|
64
66
|
}
|
|
@@ -9,11 +9,13 @@ import type { Result } from '@/views/members';
|
|
|
9
9
|
/** The name a Settings save carries; the address is not editable here. */
|
|
10
10
|
export type WorkspaceInput = { name: string };
|
|
11
11
|
|
|
12
|
+
// The refusals this action chose are shown as they are; a write that threw anything else — a collection rejecting a
|
|
13
|
+
// shape, a connection dropping — is not the person's to read, and says only that it did not go through.
|
|
12
14
|
const fail = (e: unknown): Result => ({
|
|
13
15
|
ok: false,
|
|
14
16
|
error: e instanceof Unauthenticated ? 'Sign in again to make this change.'
|
|
15
17
|
: e instanceof AccessDenied ? 'You do not have permission to make this change.'
|
|
16
|
-
:
|
|
18
|
+
: 'The change did not go through.',
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
/**
|