From 2f3f33c226e49a93ee16de8e3cbe884b0cefc8db Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 2 Sep 2026 04:43:21 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Member=20detail=20page=20=E2=80=94=20open?= =?UTF-8?q?=20one=20person's=20duties=20without=20asking=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `duly_member`: a read-only `type: 'record'` page over `sys_user` that shows a manager one person's whole picture with nothing entered — header, what is open right now, what this period asks of them, what they hold permanently, what has moved lately, and what was put on them by someone else. Reached by opening a person: the Team nav group gains a `People` entry, since a `type: 'page'` nav item routes through objectui's `PageView`, which mounts no record context. `kind: 'full'` rather than `slotted` is a product decision — the default-page synthesizer emits a related list per object with an FK to `sys_user`, which on this stack includes the work-log object this page must never show. Five platform gaps were measured against the installed 17.2.0 packages and are recorded on the page rather than worked around; the page's header comment names each one and what it does instead. The load-bearing one: no record-context token exists for filter values (`CONTEXT_TOKENS` is `{current_user_id}` + `{current_org_id}`, both the VIEWER), so `element:number` cannot be scoped to the person and "Right now" is three record-bound related lists with their own count badges instead of three org-wide figures. `test/member-page.test.ts` pins the four "not on this page" invariants, all of which fail silently: the work-log object is greped out of the source and walked out of the parsed page, no writing component type is declared, every highlight chip is `readonly`, and no aggregate or peer comparison exists anywhere. Gates: validate 0, typecheck 0, test 0 (768 passed / 30 files), build 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/apps/duly.app.ts | 24 ++ src/pages/index.ts | 5 +- src/pages/member.page.ts | 483 ++++++++++++++++++++++++++++++ src/translations/authored-text.ts | 91 ++++++ src/translations/zh-CN.ts | 26 ++ test/i18n-coverage.test.ts | 8 + test/member-page.test.ts | 440 +++++++++++++++++++++++++++ test/raw-import.d.ts | 23 ++ 8 files changed, 1099 insertions(+), 1 deletion(-) create mode 100644 src/pages/member.page.ts create mode 100644 test/member-page.test.ts create mode 100644 test/raw-import.d.ts diff --git a/src/apps/duly.app.ts b/src/apps/duly.app.ts index 4b017d6..0990229 100644 --- a/src/apps/duly.app.ts +++ b/src/apps/duly.app.ts @@ -48,6 +48,30 @@ export const DulyApp = App.create({ // nav item carries `dashboardName` (resolved against the dashboards // barrel), never an `objectName` — nothing on it is entered. { id: 'nav_duty_health', type: 'dashboard', dashboardName: 'duly_duty_health', label: 'Duty health', icon: 'activity' }, + // The way into `duly_member` (`src/pages/member.page.ts`) — "点开任何 + // 一个人看全貌". A record page is reached by opening a RECORD, so the + // nav entry is the people list, not the page: a `type: 'page'` item + // routes through objectui's `PageView`, which mounts no + // `RecordContextProvider`, and every `record:related_list` on that page + // would then have a null parent and render nothing. Placed directly + // under the dashboard because the dashboard names who to look at and + // this is where you go to look at them. + // + // `requiresObject` is load-bearing twice over. It is the platform's own + // idiom for pointing nav at a RUNTIME-provided object, and without it + // `defineStack` refuses the stack outright: the cross-reference check + // resolves `objectName` against `config.objects` only, and exempts an + // entry that declares the dependency. It is also the right runtime + // behaviour — the entry hides instead of 404-ing where `sys_user` is + // not registered. + { + id: 'nav_people', + type: 'object', + objectName: 'sys_user', + requiresObject: 'sys_user', + label: 'People', + icon: 'users-round', + }, { id: 'nav_late', type: 'object', objectName: 'duly_task', viewName: 'late', label: 'Late', icon: 'alert-circle' }, { id: 'nav_stalled', type: 'object', objectName: 'duly_task', viewName: 'stalled', label: 'Not moving', icon: 'pause-circle' }, { id: 'nav_assignments', type: 'object', objectName: 'duly_assignment', viewName: 'sent_by_me', label: 'Assignments', icon: 'send' }, diff --git a/src/pages/index.ts b/src/pages/index.ts index 8370bca..8270f49 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,4 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { MemberPage } from './member.page.js'; + // // Barrel for src/pages/. // @@ -13,4 +16,4 @@ // makes `name` optional and fails the assignment. A named array is `never[]` // while empty and infers correctly the moment something is pushed into it. -export const dulyPages = []; +export const dulyPages = [MemberPage]; diff --git a/src/pages/member.page.ts b/src/pages/member.page.ts new file mode 100644 index 0000000..3931ce6 --- /dev/null +++ b/src/pages/member.page.ts @@ -0,0 +1,483 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePage } from '@objectstack/spec/ui'; + +/** + * `duly_member` — the manager's view of ONE person, entered by nobody. + * + * The screen that removes the "can you send me a status update" mail. A manager + * opens a person and reads six things in order: who they are, what is on fire + * right now, what this period asks of them, what they hold permanently, what + * has moved lately, and what was put on them by someone else. + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHAT IS DELIBERATELY NOT HERE + * ═══════════════════════════════════════════════════════════════════════════ + * + * **The person's work log. Not filtered, not counted, not "0 entries".** The + * object is `readScope: 'own'` for every position + * (`src/security/permission-sets.ts`), and the only widening the product allows + * is a record's own `visibility: 'manager'` flag. A count is not a redaction: a + * manager shown "14 entries" has learned that the log exists, that it is being + * watched, and roughly how much is in it — which is enough to stop people + * keeping one, and the log is the one record this module exists to produce. + * + * Its machine name is deliberately absent from this file, including from this + * comment, because the card's acceptance is a GREP and a grep cannot read + * intent: a hit is a hit, and the next author to run it should get silence + * rather than a paragraph to judge. The name, the reasoning and the guard live + * together in `test/member-page.test.ts`, which greps this source and walks the + * parsed page. + * + * **Any editable control.** A manager's only write in this product is + * assigning, and that lives on `duly_assignment` with its own action. So this + * page authors no `record:details` (whose `inlineEdit` defaults ON where the + * object is editable), no `record:quick_actions`, no `element:form` / + * `element:button` / `element:text_input` / `element:record_picker`, no `add` + * picker and no `actions` list on any related list. The highlight chips carry + * `readonly: true`, which the renderer's `HeaderHighlight` gate enforces. + * See "MEASURED PLATFORM GAPS" §3 for the one affordance this page cannot + * close from metadata. + * + * **Any comparison to other people.** No percentile, no team average, no rank, + * no "N of M". Their own trend over time is fine; a position in a distribution + * is a performance score, and item counts are never ranked or compared anywhere + * in this product (`AGENTS.md` — product invariants). + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHY THIS IS A `record` PAGE OVER `sys_user`, AND HOW IT IS REACHED + * ═══════════════════════════════════════════════════════════════════════════ + * + * Everything on this page has to be scoped to the person being read. Measured + * on `@objectstack/spec` 17.2.0, exactly ONE authorable component binds itself + * to the record in context: `record:related_list`, via `relationshipField` (the + * child field holding this record's `relationshipValueField`, default `id`). + * Every other data-bearing component takes a `FilterCondition`, whose entire + * dynamic vocabulary is `CONTEXT_TOKENS` — `{current_user_id}` and + * `{current_org_id}` (`@objectstack/spec/data`, `context-tokens.zod.ts`) — both + * of which name the VIEWER, never the record. See gap §1. + * + * So the page is `type: 'record'`, `object: 'sys_user'`, and it is reached the + * way record pages are reached: by opening a person. The Team nav group gains a + * `sys_user` entry ("People") for that; a `type: 'page'` nav item would route + * through `PageView`, which mounts no `RecordContextProvider`, so every related + * list below would have a null parent and render nothing. + * + * `kind: 'full'` with explicit regions, NOT `kind: 'slotted'`, and that is a + * product decision rather than a style one. A slotted page falls through to + * `buildDefaultPageSchema`, whose `tabs` synthesizer generates one related list + * per object holding an FK to `sys_user` — which on this stack includes the + * work-log object named above. The one thing this page must never show is the + * thing the default layout would add for free. + * + * ═══════════════════════════════════════════════════════════════════════════ + * MEASURED PLATFORM GAPS — filed, not worked around (AGENTS.md rule 9) + * ═══════════════════════════════════════════════════════════════════════════ + * + * Each of these was measured against the installed 17.2.0 packages before it + * was written down, and each is filed at `objectstack-ai/objectui` rather than + * faked here. What the page does INSTEAD is named in each entry. + * + * **§1 — No record-context token for filter values.** `element:number` + * (`object` + `aggregate: 'count'` + `filter`) is the component that renders a + * number, and its `filter` is a plain `FilterCondition`. `resolveContextTokens` + * (`@object-ui/core`, `filter-tokens.ts`) re-exports the spec's `CONTEXT_TOKENS` + * verbatim and resolves those two names only; `ElementNumberRenderer` passes + * `props.filter` straight to the adapter with nothing record-shaped added. So a + * count of THIS person's open tasks is not authorable — an `element:number` on + * this page would count the whole org, which is both wrong and the + * comparison-to-peers this card forbids. + * → INSTEAD: "Right now" is three `record:related_list`s. Each is correctly + * record-scoped, and each renders a real count: `RelatedList` draws a badge + * carrying the server `total` for the collection, not the loaded page. + * + * **§2 — `record:related_list` cannot group.** `RecordRelatedListProps` has + * `sort`, `filter`, `limit` and `columns`, and no `groupBy`; `grouping` exists + * only on a grid LIST VIEW, which a related list does not render (it draws its + * own table). "Grouped by frequency" is therefore not authorable at this + * position. + * → INSTEAD: "This period" leads with the `frequency` column and sorts on it, + * so each rhythm reads as a contiguous block. That is weaker than grouping + * and it is not pretended otherwise. + * + * **§3 — A page cannot declare its related lists read-only.** There is no + * `readonly` on `PageComponentSchema` — deliberately, ruled 2026-08-12: + * "editability lives on fields". That ruling does not reach a related list's + * "+ New" / row-edit / row-delete affordances, which are not fields: they are + * resolved by the HOST (`RelatedRecordActionsBridge`) from the CHILD object's + * `userActions` intersected with the principal's grant, and `duly_task` grants + * `allowCreate` / `allowEdit` to every Duly position. So a viewer of this page + * can be offered "+ New" on a task list, and no key on this page can say + * otherwise. Object-level `userActions` would close it everywhere, which is a + * different (and wrong) change. + * → INSTEAD: this page declares nothing editable it CAN decline (no `add`, no + * `actions`, no editable component types, `readonly` chips) and the residue + * is filed. `test/member-page.test.ts` pins the authorable half. + * + * **§4 — Related-list columns cannot cross a lookup.** `RelatedList` resolves + * lookup LABELS but has no dotted-path column support, so "who assigned each" + * cannot be `assignment.assigner` on a `duly_task` list. + * → INSTEAD: "Assigned to them" is two lists — the tasks themselves (carrying + * the `assignment` they came from), and the assignments the person is named + * on, which carry `assigner` as an ordinary column. + * + * **§5 — Two record pages for one object resolve by declaration order.** + * `@objectstack/platform-objects` ships `sys_user_detail` + * (`type: 'record'`, `object: 'sys_user'`, `isDefault: true`), and + * `usePageAssignment` picks among candidates by `(b.priority ?? 0) - + * (a.priority ?? 0)` — but `priority` is not a key `PageSchema` declares, so an + * author cannot write the tiebreaker the renderer reads. With both pages at 0 + * the winner is metadata load order. + * → INSTEAD: nothing, because there is nothing authorable. `isDefault` is + * left `false` here so this page never claims to be the default while the + * platform's page claims the same thing, and the observed resolution on + * this checkout is recorded in the PR. + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHAT AN EMPTY PAGE MEANS HERE + * ═══════════════════════════════════════════════════════════════════════════ + * + * Every list below reads `duly_task` / `duly_duty`, both `sharingModel: + * 'private'`, both granted to managers at `readScope: 'unit_and_below'` + * (ADR-0057). This checkout is open-edition: `@objectstack/security-enterprise` + * is absent, the depth scopes fall back to owner-only, and the page therefore + * looks EMPTY for anyone but yourself. That is the edition, not a defect + * (AGENTS.md rule 7). `test/member-page.test.ts` asserts the authored scopes + * and filters, never resolved rows — resolved rows here would only ever pin the + * fallback. + */ +export const MemberPage = definePage({ + name: 'duly_member', + label: 'Member', + description: + 'One person, whole: what is open right now, what this period asks of them, what they hold permanently, and what has moved lately — read-only, entered by nobody.', + icon: 'user-round', + + type: 'record', + object: 'sys_user', + template: 'default', + kind: 'full', + + // Left FALSE deliberately — see gap §5. The platform's own `sys_user_detail` + // already declares `isDefault: true` for this object, and two pages both + // claiming it would make the flag a lie on whichever one loses. + isDefault: false, + + regions: [ + // ── 1. Header — who this is ─────────────────────────────────────────── + { + name: 'header', + width: 'full', + components: [ + { + id: 'member_identity', + type: 'record:highlights', + properties: { + // `readonly: true` is not decoration on these three: the chip + // renderer's own gate refuses inline editing on a chip carrying it + // (`HeaderHighlight`), which is the enforced half of "nothing on + // this page is editable". All three are `readonly` on `sys_user` + // too — `manager_id` and `primary_business_unit_id` under ADR-0092 + // — and saying it here keeps the page's promise independent of the + // platform object's flags. + fields: [ + { name: 'name', readonly: true }, + { name: 'primary_business_unit_id', readonly: true }, + { name: 'manager_id', readonly: true }, + ], + layout: 'horizontal', + }, + }, + { + // Position is the fourth thing the header owes, and it is NOT a + // column on `sys_user`: a position is assigned through the + // `sys_user_position` junction, whose `position` holds + // `sys_position.name` (ADR-0057 D4 / ADR-0090 D3). A junction is a + // related list or it is nothing — there is no scalar to promote into + // a chip. This is the same shape the platform's own user page uses. + id: 'member_position', + type: 'record:related_list', + properties: { + objectName: 'sys_user_position', + relationshipField: 'user_id', + title: 'Position', + columns: ['position', 'business_unit_id'], + limit: 3, + // A person holds one or two positions, not a list worth paging. + showViewAll: false, + }, + }, + ], + }, + + // ── The body, in reading order ──────────────────────────────────────── + { + name: 'main', + width: 'full', + components: [ + // ── 2. Right now ────────────────────────────────────────────────── + { + id: 'heading_right_now', + type: 'element:text', + properties: { + content: { en: 'Right now', 'zh-CN': '当下' }, + variant: 'heading', + }, + }, + { + id: 'right_now_note', + type: 'element:text', + properties: { + content: { + en: 'Open work, then the part of it that is past its grace, then the part that has not been touched in a fortnight. The third is the one worth acting on: it is the only one that fires before a deadline does.', + 'zh-CN': '先是未完成的工作,再是其中已过宽限期的部分,最后是两周无人触碰的部分。值得立刻处理的是第三项——只有它会在到期之前就发出信号。', + }, + variant: 'caption', + }, + }, + { + id: 'right_now_open', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Open', + // `status` is stored and indexed; there is no `is_open` to ask and + // there never will be (AGENTS.md rule 5). + filter: [{ field: 'status', operator: 'in', value: ['open', 'in_progress'] }], + columns: ['subject', 'status', 'due_date', 'period_key'], + sort: [{ field: 'due_date', order: 'asc' }], + limit: 5, + }, + }, + { + id: 'right_now_late', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Late', + // Late = past the grace the duty granted AT DISPATCH, and still + // open. `late_after` is `due_date + duty.grace_days` stamped once + // on the row, so this is an ordinary date comparison against a + // stored, indexed column — the same filter `duly_task`'s `late` + // view asks, deliberately, so one person is not late on one screen + // and on time on another (#48). + filter: [ + { field: 'late_after', operator: 'less_than', value: '{today}' }, + { field: 'status', operator: 'in', value: ['open', 'in_progress'] }, + ], + // `late_after` is carried, not just filtered on: a list that will + // not show you why it thinks a task is late is the complaint #48 + // was about. + columns: ['subject', 'status', 'late_after', 'period_key'], + sort: [{ field: 'late_after', order: 'asc' }], + limit: 5, + }, + }, + { + id: 'right_now_stalled', + type: 'record:related_list', + // The emphasis the card asks for, expressed in the one styling + // channel that is build-independent on a metadata-authored page + // (ADR-0065 scoped styles — an authored Tailwind `className` would + // silently produce no CSS, since the build-time Tailwind never scans + // runtime metadata). + responsiveStyles: { + large: { + borderLeft: '3px solid #8C6512', + paddingLeft: '12px', + }, + }, + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Not moving', + // Stagnation: open, and untouched for a fortnight. The earliest + // honest warning a manager gets, because it fires long before the + // due date does. + filter: [ + { field: 'status', operator: 'in', value: ['open', 'in_progress'] }, + { field: 'last_update_at', operator: 'less_than', value: '{14_days_ago}' }, + ], + columns: ['subject', 'status', 'last_update_at', 'due_date'], + sort: [{ field: 'last_update_at', order: 'asc' }], + limit: 5, + }, + }, + + { id: 'rule_after_right_now', type: 'element:divider' }, + + // ── 3. This period ──────────────────────────────────────────────── + { + id: 'heading_this_period', + type: 'element:text', + properties: { + content: { en: 'This period', 'zh-CN': '本周期' }, + variant: 'heading', + }, + }, + { + id: 'this_period_note', + type: 'element:text', + properties: { + content: { + en: 'The duties the organisation put on this person — from the role catalog, or assigned by a manager. Self-declared duties are their own record-keeping and are not listed here. Frequency leads the row so a monthly rhythm reads as one block and an annual one as another.', + 'zh-CN': '组织交给这个人的职责——来自岗位职责库,或由主管指派。自行申报的职责属于本人的记录,不列在这里。频率排在每行最前,因此按月的节奏与按年的节奏各自读作一块。', + }, + variant: 'caption', + }, + }, + { + id: 'this_period_duties', + type: 'record:related_list', + properties: { + objectName: 'duly_duty', + relationshipField: 'owner', + title: 'Governed duties', + // `source` is the CALIBER column and the only one this product + // lets a metric or a manager-facing lens read: `catalog` and + // `assigned` are governed, `self` is the person's own note to + // themselves and is never scored, ranked or surfaced up the line. + // + // `form: 'recurring'` because this section is about the RHYTHM. + // One-offs have no period and standing duties have no task by + // construction — the latter get their own section below, which is + // the whole point of the two being different sections. + filter: [ + { field: 'source', operator: 'in', value: ['catalog', 'assigned'] }, + { field: 'form', operator: 'equals', value: 'recurring' }, + { field: 'status', operator: 'equals', value: 'active' }, + ], + // Frequency first — see gap §2: sorting is as close to grouping as + // this component gets, so the grouping key has to be the thing the + // eye lands on. + columns: ['frequency', 'name', 'due_anchor', 'status'], + sort: [ + { field: 'frequency', order: 'asc' }, + { field: 'name', order: 'asc' }, + ], + limit: 20, + }, + }, + + { id: 'rule_after_this_period', type: 'element:divider' }, + + // ── 4. Standing duties ──────────────────────────────────────────── + { + id: 'heading_standing', + type: 'element:text', + properties: { + content: { en: 'Standing duties', 'zh-CN': '常设职责' }, + variant: 'heading', + }, + }, + { + id: 'standing_note', + type: 'element:text', + properties: { + content: { + en: 'These never complete. "Keep the register current", "answer the duty phone" — they are held, not finished, so there is nothing here to tick. A control that implied otherwise would be a bug, not a convenience.', + 'zh-CN': '这些永远不会完成。「保持台账更新」「接听值班电话」——它们是被持有的,不是被做完的,所以这里没有可勾选的东西。任何暗示可以勾掉的控件都是缺陷,而不是便利。', + }, + variant: 'caption', + }, + }, + { + id: 'standing_duties', + type: 'record:related_list', + properties: { + objectName: 'duly_duty', + relationshipField: 'owner', + title: 'Held permanently', + // Straight from `duly_duty.form`, NOT joined through tasks: a + // standing duty never generates one, so a task-side query would + // return exactly nothing and read as "this person holds none". + filter: [{ field: 'form', operator: 'equals', value: 'standing' }], + // No `status` column and no completion anything. `duly_duty.status` + // is active / paused / retired — lifecycle, not completion — and + // putting it beside these rows invites reading a state machine into + // work that has none. `business_unit` instead: whose register it is. + columns: ['name', 'business_unit', 'source'], + sort: [{ field: 'name', order: 'asc' }], + limit: 20, + }, + }, + + { id: 'rule_after_standing', type: 'element:divider' }, + + // ── 5. Recent activity ──────────────────────────────────────────── + { + id: 'heading_recent', + type: 'element:text', + properties: { + content: { en: 'Recent activity', 'zh-CN': '最近动态' }, + variant: 'heading', + }, + }, + { + id: 'recent_activity', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Last touched', + // No filter: "what has moved" includes the things that moved into + // `done`, `skipped` and `cancelled`, which are exactly the states a + // manager is looking for evidence of. `last_update_at` is stamped + // by `task.hook.ts` on every status change and note edit, and it is + // indexed. + columns: ['subject', 'status', 'last_update_at', 'period_key'], + sort: [{ field: 'last_update_at', order: 'desc' }], + limit: 20, + }, + }, + + { id: 'rule_after_recent', type: 'element:divider' }, + + // ── 6. Assigned to them ─────────────────────────────────────────── + { + id: 'heading_assigned', + type: 'element:text', + properties: { + content: { en: 'Assigned to them', 'zh-CN': '他人指派' }, + variant: 'heading', + }, + }, + { + id: 'assigned_tasks', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Assigned work', + filter: [{ field: 'source', operator: 'equals', value: 'assigned' }], + // `assignment` is the fan-out this task came out of. The person who + // raised it lives one hop further on (`duly_assignment.assigner`), + // which a related-list column cannot reach — see gap §4 and the + // sibling list below, which can. + columns: ['subject', 'assignment', 'status', 'due_date'], + sort: [{ field: 'due_date', order: 'asc' }], + limit: 20, + }, + }, + { + id: 'assigned_by_whom', + type: 'record:related_list', + properties: { + objectName: 'duly_assignment', + // `assignees` is `multiple: true` — one assignment names N people + // and fans out into N independently-owned tasks. This is the + // authorable answer to "showing who assigned each": `assigner` is + // an ordinary column here, where on the task list it is a hop away. + relationshipField: 'assignees', + title: 'Who assigned these', + columns: ['subject', 'assigner', 'due_date', 'status'], + sort: [{ field: 'due_date', order: 'asc' }], + limit: 20, + }, + }, + ], + }, + ], +}); diff --git a/src/translations/authored-text.ts b/src/translations/authored-text.ts index 3993035..ab27bbb 100644 --- a/src/translations/authored-text.ts +++ b/src/translations/authored-text.ts @@ -161,6 +161,30 @@ const translate = (key: (ctx: KeyContext) => readonly string[] | undefined): Ver const id = (ctx: KeyContext): string | undefined => str(ctx.ids.name); +/** + * The `id` of the page component a string was found inside. + * + * A page nests its labels two levels below the surface root + * (`regions[i].components[j].properties.title`), so `ctx.parent` is the + * `properties` bag — which carries no identity. The addressable id is on the + * component, one level up, and the only way back to it is the concrete path: + * `ctx.ids` IS the page node for a `simpleSurfaces` surface, so walk it. + * + * Addressing by the authored `id` rather than by position is what keeps a + * bundle key stable when a component moves — reordering the page must not + * silently re-point every translation, which `regions.1.components.7` would. + */ +const pageComponentId = (ctx: KeyContext): string | undefined => { + const [regionsKey, regionIndex, componentsKey, componentIndex] = ctx.path; + if (regionsKey !== 'regions' || componentsKey !== 'components') return undefined; + const regions = ctx.ids.regions; + if (!Array.isArray(regions)) return undefined; + const region = regions[Number(regionIndex)]; + if (!isRec(region) || !Array.isArray(region.components)) return undefined; + const component = region.components[Number(componentIndex)]; + return isRec(component) ? str(component.id) : undefined; +}; + /** * Subtrees the walk does not descend into, each with the reason. An opaque * subtree holds machine values only — a field payload, a filter, a binding @@ -200,6 +224,18 @@ const OPAQUE: Readonly> = { 'permissionSet.objects': 'per-object CRUD scopes', 'permissionSet.fieldPermissions': 'per-field read/write flags', 'permissionSet.tabPermissions': 'per-app tab visibility', + // ── page ────────────────────────────────────────────────────────────── + // The same three value bags `view.*` above declares opaque, at their + // page-component position. A related list's `columns` / `sort` / `filter` + // carry exactly what a view's do — field paths, directions, operators, + // stored values and `{date-macros}` — and the prose net below is the + // backstop if one of them ever grows a sentence. + 'page.regions[].components[].properties.columns': 'column list — field paths', + 'page.regions[].components[].properties.sort': 'sort list — field paths and directions', + 'page.regions[].components[].properties.filter': + 'related-list filter rules — field paths, operators and machine values / date macros', + 'page.regions[].components[].responsiveStyles': + 'per-breakpoint CSS declarations (ADR-0065 scoped styles) — property names and values', }; /** @@ -312,6 +348,7 @@ const VERDICTS: Readonly> = { 'app.navigation[].children[].objectName': machine('bound object'), 'app.navigation[].children[].viewName': machine('bound view'), 'app.navigation[].children[].dashboardName': machine('bound dashboard'), + 'app.navigation[].children[].requiresObject': machine('capability gate — the runtime object name this entry needs registered'), // ── dashboard ───────────────────────────────────────────────────────── 'dashboard.name': machine('dashboard name'), @@ -332,6 +369,60 @@ const VERDICTS: Readonly> = { 'dashboard.widgets[].values[]': machine('dataset measure names'), 'dashboard.widgets[].colorVariant': machine('tile colour role'), + // ── page ────────────────────────────────────────────────────────────── + 'page.name': machine('page name — its routing identity'), + 'page.icon': machine('icon name'), + 'page.type': machine('page kind'), + 'page.kind': machine('page override mode'), + 'page.template': machine('layout template name'), + 'page.object': machine('bound object'), + 'page.label': translate((c) => (id(c) ? ['pages', id(c)!, 'label'] : undefined)), + 'page.description': translate((c) => (id(c) ? ['pages', id(c)!, 'description'] : undefined)), + 'page.regions[].name': machine('layout region name'), + 'page.regions[].width': machine('layout region width'), + 'page.regions[].components[].id': machine('component id — the translation key itself'), + 'page.regions[].components[].type': machine('component kind'), + 'page.regions[].components[].properties.objectName': machine('bound object'), + 'page.regions[].components[].properties.relationshipField': machine('field on the child object holding this record'), + 'page.regions[].components[].properties.layout': machine('highlight strip orientation'), + 'page.regions[].components[].properties.variant': machine('text style variant'), + // `element:text`'s `content` is the ONE string that component renders, and + // `PageTranslation.components` has no key for it — measured on + // `@objectstack/spec` 17.2.0: the face is + // `title | description | label | placeholder | emptyText | submitLabel`, + // its alias table maps `text → label` and `caption → description`, and + // `content` appears nowhere in either. The face's own comment says the keys + // were derived from the copy props components declare, so this is a missed + // one rather than a deliberate exclusion (the two exclusions it DOES make + // are named there: `help` and `subtitle`). + // + // The route that works is the other one the same comment blesses: + // `content` is `I18nLabelSchema`, which accepts an inline `{ en, 'zh-CN' }` + // locale map resolved by `pickLocalized` at render — the route the + // platform's own `sys-user.page.ts` uses for its eight `element:text` + // nodes. So these strings ARE localized; they are localized at the + // authoring site instead of in the bundle, which is why they are excused + // here rather than keyed. Filed upstream; see the PR body. When the key + // lands, replace these two verdicts with a `translate` on + // `…properties.content` and move the copy into the bundles. + 'page.regions[].components[].properties.content.en': + untranslatable('`element:text` copy, localized inline — the bundle has no `content` key for this component'), + 'page.regions[].components[].properties.content.zh-CN': + untranslatable('`element:text` copy, localized inline — the bundle has no `content` key for this component'), + 'page.regions[].components[].properties.fields[].name': machine('field name'), + // The two strings a page component actually SHOWS. Both live under + // `properties` because that is where the renderer reads them from — a + // component's own top-level `label` is not what `record:related_list` draws + // as its heading, nor what `element:text` renders as its body. + 'page.regions[].components[].properties.title': translate((c) => { + const component = pageComponentId(c); + return id(c) && component ? ['pages', id(c)!, 'components', component, 'title'] : undefined; + }), + 'page.regions[].components[].properties.content': translate((c) => { + const component = pageComponentId(c); + return id(c) && component ? ['pages', id(c)!, 'components', component, 'content'] : undefined; + }), + // ── action ──────────────────────────────────────────────────────────── // An object-bound action is addressed under its object; an object-less one // under `globalActions`. `validateTranslationReferences` refuses the wrong diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index cf2b1b9..e2384cf 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -464,6 +464,7 @@ export const dulyChinese = defineTranslationBundle({ nav_board: { label: '看板' }, group_team: { label: '团队' }, nav_duty_health: { label: '职责健康度' }, + nav_people: { label: '成员' }, nav_late: { label: '逾期' }, nav_stalled: { label: '停滞' }, nav_assignments: { label: '指派' }, @@ -478,6 +479,31 @@ export const dulyChinese = defineTranslationBundle({ }, }, + pages: { + duly_member: { + // 「看全貌」页:主管打开一个人,不必先问他任何问题。 + label: '成员', + description: + '一个人的全貌:当下有什么未完成、本周期应尽什么、长期持有什么、最近动了什么——只读,不需要任何人录入。', + components: { + // 岗位来自 `sys_user_position` 联结表,不是 `sys_user` 上的字段。 + member_position: { title: '岗位' }, + right_now_open: { title: '未完成' }, + // 逾期 = 过了派发当时该职责给出的宽限期,且仍未完成。 + right_now_late: { title: '逾期' }, + // 与「停滞」视图同名:同一个判断在产品里只能有一种说法。 + right_now_stalled: { title: '停滞' }, + // 「受治理」= 来自岗位职责库或主管指派;自行申报的不在此列。 + this_period_duties: { title: '受治理的职责' }, + // ⛔ 不要写成「常设任务」:常设职责永不产生任务,也永不完成。 + standing_duties: { title: '长期持有' }, + recent_activity: { title: '最近触碰' }, + assigned_tasks: { title: '被指派的工作' }, + assigned_by_whom: { title: '由谁指派' }, + }, + }, + }, + dashboards: { duly_duty_health: { label: '职责健康度', diff --git a/test/i18n-coverage.test.ts b/test/i18n-coverage.test.ts index 256a053..f3faf16 100644 --- a/test/i18n-coverage.test.ts +++ b/test/i18n-coverage.test.ts @@ -267,6 +267,8 @@ describe('untranslatable display text is declared rather than dropped', () => { 'job.description', 'job.label', 'object.validations[].message', + 'page.regions[].components[].properties.content.en', + 'page.regions[].components[].properties.content.zh-CN', 'permissionSet.description', 'permissionSet.label', 'position.description', @@ -306,6 +308,12 @@ describe('untranslatable display text is declared rather than dropped', () => { // checked below — so this is a gap that CLOSED, pinned at zero so it // cannot silently reopen as inline copy. expect(count('flow.nodes[].config.'), 'inline notification copy — closed by #69').toBe(0); + // `element:text` copy on `duly_member`, which IS end-user-facing and IS + // localized — inline, because `PageTranslation.components` has no + // `content` key (see the verdict's own note). Eight nodes × two locales. + // The number is the size of the filed spec gap; when the key lands these + // strings move into the bundles and this drops to 0. + expect(count('page.regions'), '`element:text` copy localized inline').toBe(16); }); }); diff --git a/test/member-page.test.ts b/test/member-page.test.ts new file mode 100644 index 0000000..bd2becd --- /dev/null +++ b/test/member-page.test.ts @@ -0,0 +1,440 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +// The member page's own SOURCE TEXT, comments included — see the grep below +// for why the text and not the parsed object. `?raw` is Vite's text import, +// which vitest resolves natively; `test/raw-import.d.ts` declares it for tsc. +import PAGE_SOURCE from '../src/pages/member.page.ts?raw'; + +import { MemberPage } from '../src/pages/member.page.js'; +import { dulyPages } from '../src/pages/index.js'; +import { dulyApps } from '../src/apps/index.js'; +import { Assignment, Duty, Task } from '../src/objects/index.js'; +import { ManagerPermissionSet, MemberPermissionSet, AdminPermissionSet } from '../src/security/index.js'; + +/** + * `duly_member` — the member detail page. + * + * ── These assert the AUTHORED page, never resolved rows ────────────────── + * Same reason `test/security.test.ts` gives, and it is not a convenience: every + * list on this page reads `duly_task` / `duly_duty`, both `sharingModel: + * 'private'`, both granted to managers at `readScope: 'unit_and_below'`. This + * checkout runs the OPEN edition — `@objectstack/security-enterprise` is absent + * — so those depths fall back to owner-only and every one of these lists is + * empty for anyone but themselves. A test that booted a kernel and counted rows + * would be measuring the edition, and it would keep passing on the day somebody + * deleted a filter. What a page IS, is its declaration; so this walks it. + * + * ── What each group is actually pinning ────────────────────────────────── + * The four "not on this page" rules from the card are product invariants, and + * each fails SILENTLY if broken: a log-entry count reads as a helpful number, a + * "+ New" button reads as a feature, a peer comparison reads as a dashboard. + * None of them turns anything red on its own. That is what this file is for. + */ + +type Rec = Record; + +const page = MemberPage as unknown as Rec; +const regions = page.regions as Array<{ name: string; components: Rec[] }>; +const components: Rec[] = regions.flatMap((region) => region.components); +const props = (component: Rec): Rec => (component.properties ?? {}) as Rec; +const byId = (id: string): Rec => { + const found = components.find((component) => component.id === id); + if (!found) throw new Error(`no component with id ${id} on duly_member`); + return found; +}; +/** Every `record:related_list` on the page, in reading order. */ +const relatedLists = components.filter((component) => component.type === 'record:related_list'); +/** A filter rule's value, by field, on one related list. */ +const ruleFor = (component: Rec, field: string): Rec | undefined => + ((props(component).filter as Rec[] | undefined) ?? []).find((rule) => rule.field === field); + +// ─── It is wired, and it is the shape that carries record context ──────── + +describe('duly_member — wiring', () => { + it('is in the pages barrel', () => { + // A page not in its own barrel is dead metadata: it type-checks, it reads + // as wired, and the runtime never loads it (AGENTS.md rule 2). + expect(dulyPages).toContain(MemberPage); + }); + + it('is a record page bound to sys_user', () => { + // The binding is the whole design. `record:related_list` is the only + // authorable component that scopes itself to the record in context, and it + // only has a context on a `type: 'record'` page. + expect(page.type).toBe('record'); + expect(page.object).toBe('sys_user'); + }); + + it('is `full`, not `slotted` — the synthesizer would add the work log', () => { + // Not a style preference. A slotted page falls through to + // `buildDefaultPageSchema`, whose `tabs` synthesizer emits one related list + // per object holding an FK to `sys_user` — which on this stack includes + // `duly_log_entry.owner`. The one object this page must never show is the + // one the default layout adds for free. + expect(page.kind).toBe('full'); + expect(page.slots).toBeUndefined(); + expect(regions.length).toBeGreaterThan(0); + }); + + it('does not claim to be the default page for sys_user', () => { + // `@objectstack/platform-objects` ships `sys_user_detail` with + // `isDefault: true` for this same object, and `usePageAssignment` breaks + // the tie on a `priority` key `PageSchema` does not declare — so an author + // cannot write the tiebreaker the renderer reads. Two pages both claiming + // the default would make the flag a lie on whichever one loses; this one + // does not claim it. See the page's gap §5. + expect(page.isDefault).toBe(false); + }); + + it('is reachable from the Team nav group', () => { + const app = (dulyApps as Rec[])[0] as { navigation: Array }; + const team = app.navigation.find((group) => group.id === 'group_team'); + expect(team, 'the Team nav group').toBeDefined(); + const people = (team!.children ?? []).find((item) => item.id === 'nav_people'); + expect(people, 'the People entry that opens a person').toBeDefined(); + // A record page is reached by opening a RECORD, so the entry is the people + // list. `requiresObject` is what lets nav name a runtime-provided object at + // all — without it `defineStack` refuses the whole stack, because the + // cross-reference check resolves `objectName` against `config.objects` only. + expect(people!.type).toBe('object'); + expect(people!.objectName).toBe('sys_user'); + expect(people!.requiresObject).toBe('sys_user'); + }); + + it('every component carries a stable id', () => { + // Bundle keys are `pages.duly_member.components..title`. A component + // without an id is addressed by nothing, so its title can never be + // translated — and the i18n gate cannot see the hole, because a key that + // was never derived is not a key that went missing. + expect(components.filter((component) => typeof component.id !== 'string')).toEqual([]); + const ids = components.map((component) => component.id); + expect(new Set(ids).size, 'two components share an id').toBe(ids.length); + }); +}); + +// ─── ⛔ The work log is not on this page, in any form ───────────────────── + +describe('duly_member — `duly_log_entry` is absent', () => { + it('is not named anywhere in the source', () => { + // A GREP, deliberately, and not a walk of the parsed page. The rule is not + // "no list is bound to it" — it is that a manager must not learn the log + // exists. A count, a `visibleWhen` mentioning it, a comment promising to + // add it later: all of them are how the next author decides it is fine. + // `readScope: 'own'` on the object is the enforcement; this is the promise. + expect(PAGE_SOURCE.includes('duly_log_entry')).toBe(false); + }); + + it('binds no component to it', () => { + // The parsed half of the same rule, so a renamed object or a computed + // string cannot slip past the grep above. + const objects = components + .map((component) => props(component).objectName) + .filter((name): name is string => typeof name === 'string'); + expect(objects).not.toContain('duly_log_entry'); + // And positively: the page reads these three and nothing else. + expect([...new Set(objects)].sort()).toEqual(['duly_assignment', 'duly_duty', 'duly_task', 'sys_user_position']); + }); +}); + +// ─── ⛔ Nothing on the page is editable (the authorable half) ───────────── + +describe('duly_member — read-only', () => { + /** + * The component types that write. `record:details` is on the list because its + * `inlineEdit` defaults ON wherever the object itself is editable — declaring + * the component is enough to make the page editable, no key required. + */ + const WRITING_TYPES = [ + 'record:details', + 'record:quick_actions', + 'element:form', + 'element:button', + 'element:text_input', + 'element:record_picker', + 'record:chatter', + 'record:discussion', + ]; + + it('declares no component type that writes', () => { + const offenders = components + .map((component) => String(component.type)) + .filter((type) => WRITING_TYPES.includes(type)); + expect(offenders).toEqual([]); + }); + + it('offers no add-existing picker and no row actions', () => { + // The two write affordances a related list CAN be told not to offer. + for (const list of relatedLists) { + expect(props(list).add, `${String(list.id)} declares an Add picker`).toBeUndefined(); + expect(props(list).actions, `${String(list.id)} declares row actions`).toBeUndefined(); + } + }); + + it('marks every highlight chip readonly', () => { + // `readonly: true` is the enforced half: `HeaderHighlight` refuses inline + // editing on a chip carrying it. A bare field name would be editable. + const highlights = components.filter((component) => component.type === 'record:highlights'); + expect(highlights.length).toBe(1); + const fields = props(highlights[0]!).fields as Array; + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect(field.readonly, `${String(field.name)} is an editable chip`).toBe(true); + } + }); + + it('records the residue it cannot close — the host-resolved "+ New"', () => { + // NOT a pass for the gap: a pin on the page's own account of it, so the + // note cannot be deleted while the gap is open. A related list's create / + // edit / delete affordances are resolved by the host from the CHILD + // object's `userActions` and the principal's grant, and `duly_task` grants + // `allowCreate` to every Duly position — so the page can be shown a "+ New" + // it has no key to decline. When a page-level switch lands, this assertion + // is what tells the next author to use it and delete the note. + expect(MemberPermissionSet.objects?.duly_task?.allowCreate).toBe(true); + expect(ManagerPermissionSet.objects?.duly_task?.allowCreate).toBe(true); + expect(AdminPermissionSet.objects?.duly_task?.allowCreate).toBe(true); + expect(PAGE_SOURCE).toContain('§3 — A page cannot declare its related lists read-only'); + }); +}); + +// ─── ⛔ No comparison to other people ───────────────────────────────────── + +describe('duly_member — no comparison to peers', () => { + it('renders no aggregate element at all', () => { + // `element:number` is the only component that renders a computed figure, + // and it cannot be scoped to the record anyway (gap §1) — so one here would + // be counting the whole org beside this person's name, which is the + // comparison the card forbids, arrived at by accident. + const aggregates = components.filter((component) => + String(component.type).startsWith('element:number') + || String(component.type) === 'object-metric'); + expect(aggregates).toEqual([]); + }); + + it('binds nothing to another person', () => { + // Every data-bearing component is scoped to THIS record through + // `relationshipField`, and none carries a filter naming a user — no + // `{current_user_id}` (that is the VIEWER, not the person being read), no + // hard-coded owner, no unit-wide slice to rank this person inside. + for (const list of relatedLists) { + expect(props(list).relationshipField, `${String(list.id)} is unbound`).toBeTruthy(); + const rules = (props(list).filter as Rec[] | undefined) ?? []; + for (const rule of rules) { + expect( + JSON.stringify(rule.value).includes('current_user_id'), + `${String(list.id)} filters on the viewer, not the record`, + ).toBe(false); + } + } + }); +}); + +// ─── The six sections, in reading order ────────────────────────────────── + +describe('duly_member — content', () => { + it('reads in the order the card asks for', () => { + // Reading order IS the design: a manager scans down and stops when they + // have what they came for. Pinned as the sequence of data-bearing and + // heading components, so a reordering is a deliberate edit rather than a + // diff nobody notices. + const spine = components + .filter((component) => component.type !== 'element:divider') + .map((component) => String(component.id)); + expect(spine).toEqual([ + // 1. Header — who this is + 'member_identity', + 'member_position', + // 2. Right now + 'heading_right_now', + 'right_now_note', + 'right_now_open', + 'right_now_late', + 'right_now_stalled', + // 3. This period + 'heading_this_period', + 'this_period_note', + 'this_period_duties', + // 4. Standing duties + 'heading_standing', + 'standing_note', + 'standing_duties', + // 5. Recent activity + 'heading_recent', + 'recent_activity', + // 6. Assigned to them + 'heading_assigned', + 'assigned_tasks', + 'assigned_by_whom', + ]); + }); + + it('heads the page with the person, not with their numbers', () => { + const identity = byId('member_identity'); + expect(identity.type).toBe('record:highlights'); + expect((props(identity).fields as Rec[]).map((field) => field.name)) + .toEqual(['name', 'primary_business_unit_id', 'manager_id']); + // Position is the fourth thing the header owes and is NOT a column on + // `sys_user`: it is assigned through the `sys_user_position` junction, + // whose `position` holds `sys_position.name`. A junction is a related list + // or it is nothing. + const position = byId('member_position'); + expect(props(position).objectName).toBe('sys_user_position'); + expect(props(position).relationshipField).toBe('user_id'); + }); + + it('asks `status` and dates directly, never a stored flag', () => { + // AGENTS.md rule 5. There is no `is_late` / `is_open` to filter on, and a + // filter naming a formula field silently matches nothing — so the three + // "Right now" lenses have to be built out of stored, indexed columns. + const stored = new Set(Object.keys(Task.fields)); + for (const list of relatedLists.filter((l) => props(l).objectName === 'duly_task')) { + for (const rule of ((props(list).filter as Rec[] | undefined) ?? [])) { + expect(stored.has(String(rule.field)), `${String(rule.field)} is not a stored column`).toBe(true); + } + } + }); + + it('measures "late" against the grace the duty granted, like the `late` view', () => { + // One person must not be late on this page and on time on the Late list. + // Both read `late_after` — `due_date + duty.grace_days` stamped once at + // dispatch — which is what #48 settled. + const late = byId('right_now_late'); + expect(ruleFor(late, 'late_after')).toEqual({ + field: 'late_after', + operator: 'less_than', + value: '{today}', + }); + expect(ruleFor(late, 'status')?.value).toEqual(['open', 'in_progress']); + // And it SHOWS the column it judged on: a list that will not tell you why + // it thinks a task is late is the complaint #48 was about. + expect(props(late).columns).toContain('late_after'); + }); + + it('calls stagnation the same fortnight the `stalled` view does', () => { + const stalled = byId('right_now_stalled'); + expect(ruleFor(stalled, 'last_update_at')).toEqual({ + field: 'last_update_at', + operator: 'less_than', + value: '{14_days_ago}', + }); + expect(ruleFor(stalled, 'status')?.value).toEqual(['open', 'in_progress']); + }); + + it('emphasises the third number, which is the one that fires early', () => { + // "Not moving" is the only one of the three that warns before a deadline + // exists, so it is the one the card asks to be emphasised. ADR-0065 scoped + // styles, because an authored Tailwind `className` produces no CSS on a + // metadata page — the build-time Tailwind never scans runtime metadata. + const stalled = byId('right_now_stalled'); + expect(stalled.responsiveStyles, 'the emphasis was dropped').toBeTruthy(); + expect(byId('right_now_open').responsiveStyles).toBeUndefined(); + expect(byId('right_now_late').responsiveStyles).toBeUndefined(); + }); + + it('counts only GOVERNED duties for the period, never self-declared ones', () => { + // `source` is the caliber column, and `self` is the person's own + // record-keeping — surfaced to them, never scored or read up the line. + const period = byId('this_period_duties'); + expect(props(period).objectName).toBe('duly_duty'); + expect(props(period).relationshipField).toBe('owner'); + expect(ruleFor(period, 'source')?.value).toEqual(['catalog', 'assigned']); + expect(ruleFor(period, 'form')?.value).toBe('recurring'); + // Frequency leads and sorts, which is as close to "grouped by frequency" as + // `record:related_list` gets — it has no `groupBy` (gap §2). If that ever + // becomes authorable, this is the assertion that should change. + expect((props(period).columns as string[])[0]).toBe('frequency'); + expect((props(period).sort as Rec[])[0]).toEqual({ field: 'frequency', order: 'asc' }); + }); + + it('lists standing duties from `form`, not through tasks, and offers no tick', () => { + const standing = byId('standing_duties'); + expect(props(standing).objectName).toBe('duly_duty'); + expect(ruleFor(standing, 'form')).toEqual({ field: 'form', operator: 'equals', value: 'standing' }); + // A standing duty generates no task BY CONSTRUCTION, so a task-side query + // would return nothing and read as "this person holds none". + expect(props(standing).objectName).not.toBe('duly_task'); + // `standing` is a real option on the object — a filter naming a value the + // select does not carry matches nothing, silently. + const forms = (Duty.fields.form as unknown as { options: Array<{ value: string }> }).options; + expect(forms.map((option) => option.value)).toContain('standing'); + // No completion affordance anywhere near them, and no `status` column + // either: `duly_duty.status` is active/paused/retired — lifecycle, not + // completion — and putting it beside these rows invites reading a state + // machine into work that has none. + expect(props(standing).columns).not.toContain('status'); + expect(props(standing).add).toBeUndefined(); + expect(props(standing).actions).toBeUndefined(); + }); + + it('shows the last 20 task events by `last_update_at`', () => { + const recent = byId('recent_activity'); + expect(props(recent).objectName).toBe('duly_task'); + expect(props(recent).limit).toBe(20); + expect(props(recent).sort).toEqual([{ field: 'last_update_at', order: 'desc' }]); + // No status filter: "what has moved" includes what moved into done, + // skipped and cancelled, which is what a manager is looking for. + expect(props(recent).filter).toBeUndefined(); + }); + + it('shows assigned work AND who assigned it', () => { + const tasks = byId('assigned_tasks'); + expect(ruleFor(tasks, 'source')).toEqual({ field: 'source', operator: 'equals', value: 'assigned' }); + // `assigned` is a real option on `duly_task.source`. + const sources = (Task.fields.source as unknown as { options: Array<{ value: string }> }).options; + expect(sources.map((option) => option.value)).toContain('assigned'); + + // The assigner is one lookup hop from the task + // (`duly_task.assignment → duly_assignment.assigner`) and a related-list + // column cannot cross a lookup (gap §4) — so the answer to "showing who + // assigned each" is the sibling list, where `assigner` is an ordinary + // column on the object being listed. + const who = byId('assigned_by_whom'); + expect(props(who).objectName).toBe('duly_assignment'); + expect(props(who).relationshipField).toBe('assignees'); + expect(props(who).columns).toContain('assigner'); + expect(Object.keys(Assignment.fields)).toContain('assigner'); + // `assignees` is the multi-user field one assignment fans out across; the + // relationship only holds because it is the field naming the people. + expect((Assignment.fields.assignees as unknown as { multiple?: boolean }).multiple).toBe(true); + }); +}); + +// ─── Every binding names something real ────────────────────────────────── + +describe('duly_member — no dangling binding', () => { + const DECLARED: Record> = { + duly_task: new Set(Object.keys(Task.fields)), + duly_duty: new Set(Object.keys(Duty.fields)), + duly_assignment: new Set(Object.keys(Assignment.fields)), + }; + + it('every column, filter field and sort key resolves on its own object', () => { + // The page half of what `test/metadata-bindings.test.ts` does for views and + // nav. Field paths are resolved at author time NOWHERE in the UI layer, so + // `pnpm validate` and `pnpm build` both exit 0 on a misspelt related-list + // column — it renders an empty column and reports success. + // + // `sys_user_position` is a PLATFORM object: nothing on disk carries its + // field set, so its bindings are a boundary rather than a resolution and + // are deliberately not judged here (the same narrowing `metadata-bindings` + // states for a hop into `sys_user`). + const findings: string[] = []; + for (const list of relatedLists) { + const object = String(props(list).objectName); + const fields = DECLARED[object]; + if (!fields) continue; + const referenced: string[] = [ + ...((props(list).columns as string[] | undefined) ?? []), + ...((props(list).filter as Rec[] | undefined) ?? []).map((rule) => String(rule.field)), + ...((props(list).sort as Rec[] | undefined) ?? []).map((rule) => String(rule.field)), + String(props(list).relationshipField), + ]; + for (const field of referenced) { + if (!fields.has(field)) findings.push(`${String(list.id)} · ${object}.${field}`); + } + } + expect(findings, 'a related-list binding that names no declared field').toEqual([]); + }); +}); diff --git a/test/raw-import.d.ts b/test/raw-import.d.ts new file mode 100644 index 0000000..af04707 --- /dev/null +++ b/test/raw-import.d.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Vite's `?raw` import suffix, declared for `tsc`. + * + * Vitest runs on Vite, which resolves `import x from './f.ts?raw'` to the + * file's TEXT. TypeScript has no idea, so without this the import is an + * unresolved module and `pnpm typecheck` fails while `pnpm test` passes — the + * two-gates-disagree shape. + * + * Why a raw import rather than `node:fs`: this repo ships no `@types/node` + * (nothing in `src/` needs it, and `tsconfig.json` names no `types`), so + * `readFileSync` does not type-check here. Adding the dependency to read one + * file in one test is a wider change than the test is worth, and + * `package.json` is a file the parallel batch also touches. + * + * Used by `test/member-page.test.ts`, which greps the member page's SOURCE — + * comments included — for an object name that must never appear in it. + */ +declare module '*?raw' { + const content: string; + export default content; +} From d48c49bd8a2a15f57530a9681c565077501a4fe2 Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 2 Sep 2026 04:57:02 +0000 Subject: [PATCH 2/3] Browser verification: drop the unauthorable list, shut the appended comment box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from driving the seeded demo as the admin, neither visible to any gate: `assigned_by_whom` — a `duly_assignment` related list bound on `assignees` — was REFUSED by the driver, 400 INVALID_FILTER: `RelatedList` builds its parent filter as bare equality and `assignees` is `multiple: true`, which this driver stores as a JSON TEXT column. `$contains` is the working spelling and it is not reachable from `RecordRelatedListProps`. The list is removed rather than left rendering an error; "showing who assigned each" is the one line of the card that is not fully delivered, and it is recorded as gap §6 and filed. `RecordDetailView` auto-appends a chatter panel — comment box, reactions, threaded replies, all hard-coded on — to every record page whose object does not set `enable.feeds: false`. `sys_user` is a locked platform object, and the renderer's own opt-out (`disableDiscussion`) is not a `PageSchema` key, so it cannot be written. Placing an EXPLICIT `record:discussion` suppresses the append and takes its config back: all three writes off, leaving the record's read-only history. Verified in the browser — the composer is gone (0 textareas / contenteditables on the page). Also measured and now recorded: the page wins the `sys_user` assignment against the platform's `sys_user_detail` on this checkout, but by list position only (both `priority ?? 0`, stable sort) — gap §5 stands. Gates: validate 0, typecheck 0, test 0 (769 passed / 30 files), build 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/pages/member.page.ts | 98 +++++++++++++++++++++++++------ src/translations/authored-text.ts | 1 + src/translations/zh-CN.ts | 1 - test/member-page.test.ts | 52 +++++++++++----- 4 files changed, 117 insertions(+), 35 deletions(-) diff --git a/src/pages/member.page.ts b/src/pages/member.page.ts index 3931ce6..3631709 100644 --- a/src/pages/member.page.ts +++ b/src/pages/member.page.ts @@ -117,9 +117,31 @@ import { definePage } from '@objectstack/spec/ui'; * **§4 — Related-list columns cannot cross a lookup.** `RelatedList` resolves * lookup LABELS but has no dotted-path column support, so "who assigned each" * cannot be `assignment.assigner` on a `duly_task` list. - * → INSTEAD: "Assigned to them" is two lists — the tasks themselves (carrying - * the `assignment` they came from), and the assignments the person is named - * on, which carry `assigner` as an ordinary column. + * → INSTEAD: the task list carries the `assignment` column, which names the + * fan-out the task came out of. One click from the assigner, not zero. + * + * **§6 — `record:related_list` cannot bind to a multi-value field.** The other + * route to the assigner was a list of `duly_assignment` bound on `assignees` + * (`multiple: true`) — the field that actually names this person. It was + * authored, run against the seeded demo, and REFUSED by the driver, because + * `RelatedList` builds its parent filter as bare equality + * (`{[relationshipField]: parentId}`) with no membership spelling available to + * the author: + * + * GET /api/v1/data/duly_assignment?filter=["assignees","=",""] + * 400 INVALID_FILTER — The bare equality spelling { "assignees": value } + * WAS NOT APPLIED: "assignees" is a multi-value (or otherwise JSON-valued) + * field, stored by this driver as a JSON TEXT column … Use "$contains" for + * membership … + * + * Credit where it is due: the driver refuses LOUDLY and names the working + * spelling, so this is a gap rather than a silent wrong answer. But + * `$contains` is not reachable from `RecordRelatedListProps` — the parent + * filter is the component's, not the author's — so the list cannot be written + * correctly at all. + * → INSTEAD: nothing. The list was removed rather than left rendering an + * error, and "showing who assigned each" is the one line of this card that + * is not fully delivered. Recorded on the issue and filed upstream. * * **§5 — Two record pages for one object resolve by declaration order.** * `@objectstack/platform-objects` ships `sys_user_detail` @@ -133,6 +155,28 @@ import { definePage } from '@objectstack/spec/ui'; * platform's page claims the same thing, and the observed resolution on * this checkout is recorded in the PR. * + * **§7 — The auto-appended discussion panel cannot be declined by a page, and + * it carries a comment box.** Found in the browser, not in the schema. + * `RecordDetailView` appends a `RecordChatterPanel` to every record page when + * the object does not set `enable.feeds: false`, hard-coded with + * `showCommentInput: true`, `enableReactions: true`, `enableThreading: true`. + * `sys_user` is a platform object under `protection: { lock: 'full' }`, so the + * object-side switch is not ours; the renderer's own opt-out — + * `assignedPage.disableDiscussion === true`, named in its comment — is NOT a + * key `PageSchema` declares, and `PageSchema` is a `strictObject`, so writing + * it is a hard parse error. Same shape as §5: the renderer reads a key the + * author cannot write. + * → INSTEAD, and this one IS closed: placing an EXPLICIT + * `record:discussion` suppresses the auto-append + * (`hasExplicitDiscussion`), and the explicit node's config is the + * author's. `feed.showCommentInput: false` is honoured — the composer is + * gated on `config?.showCommentInput !== false` in + * `RecordActivityTimeline`, with objectui's own test pinning + * "hides it even when the host CAN persist a comment". So the page ships + * the record's history with every write affordance off, which is what + * "read-only" has to mean here. Filed anyway: closing a write surface + * should not require declaring the component that opens it. + * * ═══════════════════════════════════════════════════════════════════════════ * WHAT AN EMPTY PAGE MEANS HERE * ═══════════════════════════════════════════════════════════════════════════ @@ -452,29 +496,45 @@ export const MemberPage = definePage({ relationshipField: 'owner', title: 'Assigned work', filter: [{ field: 'source', operator: 'equals', value: 'assigned' }], - // `assignment` is the fan-out this task came out of. The person who - // raised it lives one hop further on (`duly_assignment.assigner`), - // which a related-list column cannot reach — see gap §4 and the - // sibling list below, which can. + // `assignment` is the fan-out this task came out of, and it is as + // close to "who assigned each" as this page can get: the assigner + // lives one hop further on (`duly_assignment.assigner`) and BOTH + // routes to it are closed — see gaps §4 and §6. The assignment's + // own name is one click from the answer, which is the honest + // remainder rather than a fake. columns: ['subject', 'assignment', 'status', 'due_date'], sort: [{ field: 'due_date', order: 'asc' }], limit: 20, }, }, + + // ── Not a seventh section — a write surface being shut ──────────── + // This node exists to REPLACE the panel the host would otherwise + // append, not to add one. See gap §7: `RecordDetailView` appends a + // chatter panel with a comment box, reactions and threaded replies to + // every record page whose object does not opt out, `sys_user` cannot + // opt out (platform object, locked), and the renderer's own + // `disableDiscussion` escape is unauthorable. Declaring the component + // is what takes its configuration back. + // + // All three writes off. What remains is the record's own change + // history, which is read-only, is not the work log, and is not a + // comparison to anybody. A comment box on a person's record is worse + // than merely editable here — it is where a performance note would go, + // on a page whose entire premise is that the manager enters nothing. { - id: 'assigned_by_whom', - type: 'record:related_list', + id: 'member_history', + type: 'record:discussion', properties: { - objectName: 'duly_assignment', - // `assignees` is `multiple: true` — one assignment names N people - // and fans out into N independently-owned tasks. This is the - // authorable answer to "showing who assigned each": `assigner` is - // an ordinary column here, where on the task list it is a hop away. - relationshipField: 'assignees', - title: 'Who assigned these', - columns: ['subject', 'assigner', 'due_date', 'status'], - sort: [{ field: 'due_date', order: 'asc' }], - limit: 20, + position: 'bottom', + collapsible: false, + feed: { + showCommentInput: false, + enableReactions: false, + enableThreading: false, + showFilterToggle: false, + showSubscriptionToggle: false, + }, }, }, ], diff --git a/src/translations/authored-text.ts b/src/translations/authored-text.ts index ab27bbb..d0214a2 100644 --- a/src/translations/authored-text.ts +++ b/src/translations/authored-text.ts @@ -385,6 +385,7 @@ const VERDICTS: Readonly> = { 'page.regions[].components[].properties.objectName': machine('bound object'), 'page.regions[].components[].properties.relationshipField': machine('field on the child object holding this record'), 'page.regions[].components[].properties.layout': machine('highlight strip orientation'), + 'page.regions[].components[].properties.position': machine('panel dock position'), 'page.regions[].components[].properties.variant': machine('text style variant'), // `element:text`'s `content` is the ONE string that component renders, and // `PageTranslation.components` has no key for it — measured on diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index e2384cf..794d86d 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -499,7 +499,6 @@ export const dulyChinese = defineTranslationBundle({ standing_duties: { title: '长期持有' }, recent_activity: { title: '最近触碰' }, assigned_tasks: { title: '被指派的工作' }, - assigned_by_whom: { title: '由谁指派' }, }, }, }, diff --git a/test/member-page.test.ts b/test/member-page.test.ts index bd2becd..6ae7b67 100644 --- a/test/member-page.test.ts +++ b/test/member-page.test.ts @@ -134,7 +134,7 @@ describe('duly_member — `duly_log_entry` is absent', () => { .filter((name): name is string => typeof name === 'string'); expect(objects).not.toContain('duly_log_entry'); // And positively: the page reads these three and nothing else. - expect([...new Set(objects)].sort()).toEqual(['duly_assignment', 'duly_duty', 'duly_task', 'sys_user_position']); + expect([...new Set(objects)].sort()).toEqual(['duly_duty', 'duly_task', 'sys_user_position']); }); }); @@ -153,8 +153,6 @@ describe('duly_member — read-only', () => { 'element:button', 'element:text_input', 'element:record_picker', - 'record:chatter', - 'record:discussion', ]; it('declares no component type that writes', () => { @@ -164,6 +162,26 @@ describe('duly_member — read-only', () => { expect(offenders).toEqual([]); }); + it('shuts the discussion panel rather than letting the host append one', () => { + // `record:chatter` / `record:discussion` is NOT on the list above, and that + // is the whole subtlety of gap §7. `RecordDetailView` appends a chatter + // panel — comment box, reactions, threaded replies, all hard-coded on — to + // every record page whose object does not set `enable.feeds: false`. + // `sys_user` is a locked platform object so that switch is not ours, and + // the renderer's own `disableDiscussion` opt-out is not a `PageSchema` key. + // Declaring the component is the ONLY way to own its configuration, so the + // page declares exactly one, with every write off. Removing this node does + // not remove the panel — it hands it back to the host with the writes ON, + // which is why this is pinned as a positive assertion and not an absence. + const discussions = components.filter((component) => + component.type === 'record:discussion' || component.type === 'record:chatter'); + expect(discussions.length, 'exactly one, or the host appends its own').toBe(1); + const feed = (props(discussions[0]!).feed ?? {}) as Rec; + expect(feed.showCommentInput, 'a comment box on a person\'s record').toBe(false); + expect(feed.enableReactions, 'a reaction is a write').toBe(false); + expect(feed.enableThreading, 'a reply is a write').toBe(false); + }); + it('offers no add-existing picker and no row actions', () => { // The two write affordances a related list CAN be told not to offer. for (const list of relatedLists) { @@ -266,7 +284,8 @@ describe('duly_member — content', () => { // 6. Assigned to them 'heading_assigned', 'assigned_tasks', - 'assigned_by_whom', + // Not a section — the shut discussion panel (gap §7). + 'member_history', ]); }); @@ -385,19 +404,22 @@ describe('duly_member — content', () => { const sources = (Task.fields.source as unknown as { options: Array<{ value: string }> }).options; expect(sources.map((option) => option.value)).toContain('assigned'); - // The assigner is one lookup hop from the task - // (`duly_task.assignment → duly_assignment.assigner`) and a related-list - // column cannot cross a lookup (gap §4) — so the answer to "showing who - // assigned each" is the sibling list, where `assigner` is an ordinary - // column on the object being listed. - const who = byId('assigned_by_whom'); - expect(props(who).objectName).toBe('duly_assignment'); - expect(props(who).relationshipField).toBe('assignees'); - expect(props(who).columns).toContain('assigner'); + // …and the `assignment` column, which is as close to "who assigned each" + // as this page can get. Both routes to `duly_assignment.assigner` are + // closed: a related-list column cannot cross a lookup (gap §4), and a + // related list bound on `assignees` is refused by the driver, because + // `RelatedList` builds its parent filter as bare equality and `assignees` + // is `multiple: true` (gap §6 — measured, 400 INVALID_FILTER). + expect(props(tasks).columns).toContain('assignment'); expect(Object.keys(Assignment.fields)).toContain('assigner'); - // `assignees` is the multi-user field one assignment fans out across; the - // relationship only holds because it is the field naming the people. expect((Assignment.fields.assignees as unknown as { multiple?: boolean }).multiple).toBe(true); + // The pin on the gap: no list on this page binds a multi-value field, and + // the page says why. Delete this when a membership binding lands. + for (const list of relatedLists) { + expect(props(list).relationshipField, 'a related list bound to a multi-value field') + .not.toBe('assignees'); + } + expect(PAGE_SOURCE).toContain('§6 — `record:related_list` cannot bind to a multi-value field'); }); }); From e399e78c8995c0f78ffe9c92b3dd7c4564981e90 Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 2 Sep 2026 05:02:27 +0000 Subject: [PATCH 3/3] Merge main; converge on #110's `node:fs` shim and cite the filed gap numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #110 landed `test/node-builtins.d.ts` — a narrow `declare module 'node:fs'` exposing `readFileSync`, with the reasoning that `@types/node` stays out of a metadata package. That is the same problem `test/raw-import.d.ts` solved here with Vite's `?raw`, and one way to read a file in a test beats two, so this drops the `?raw` declaration and uses the merged shim. Its `URL` overload is what makes `node:url` unnecessary. Also stamps every gap in the page header with the issue it was filed as, so a gap without a number reads as a workaround wearing a comment: §1 no record-context filter token objectui#7297 §2 related list cannot group objectui#7301 §3 no page-level read-only for a related list objectui#7300 §4 related-list columns cannot cross a lookup objectui#7301 §5 `priority` read but not declared objectui#7298 §6 related list cannot bind a multi-value field objectui#7299 §7 `disableDiscussion` read but not declared objectui#7298 Gates re-run on the merge: validate 0, typecheck 0, test 0 (775 passed / 31 files), build 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/pages/member.page.ts | 22 +++++++++++++--------- test/member-page.test.ts | 18 +++++++++++++----- test/raw-import.d.ts | 23 ----------------------- 3 files changed, 26 insertions(+), 37 deletions(-) delete mode 100644 test/raw-import.d.ts diff --git a/src/pages/member.page.ts b/src/pages/member.page.ts index 3631709..89cc2fa 100644 --- a/src/pages/member.page.ts +++ b/src/pages/member.page.ts @@ -75,10 +75,12 @@ import { definePage } from '@objectstack/spec/ui'; * ═══════════════════════════════════════════════════════════════════════════ * * Each of these was measured against the installed 17.2.0 packages before it - * was written down, and each is filed at `objectstack-ai/objectui` rather than - * faked here. What the page does INSTEAD is named in each entry. + * was written down, and each is FILED at `objectstack-ai/objectui` rather than + * faked here. What the page does INSTEAD is named in each entry, and every + * entry carries its issue number — a gap with no number is a workaround + * wearing a comment (AGENTS.md rule 9). * - * **§1 — No record-context token for filter values.** `element:number` + * **§1 — No record-context token for filter values** (objectui#7297). `element:number` * (`object` + `aggregate: 'count'` + `filter`) is the component that renders a * number, and its `filter` is a plain `FilterCondition`. `resolveContextTokens` * (`@object-ui/core`, `filter-tokens.ts`) re-exports the spec's `CONTEXT_TOKENS` @@ -91,7 +93,7 @@ import { definePage } from '@objectstack/spec/ui'; * record-scoped, and each renders a real count: `RelatedList` draws a badge * carrying the server `total` for the collection, not the loaded page. * - * **§2 — `record:related_list` cannot group.** `RecordRelatedListProps` has + * **§2 — `record:related_list` cannot group** (objectui#7301). `RecordRelatedListProps` has * `sort`, `filter`, `limit` and `columns`, and no `groupBy`; `grouping` exists * only on a grid LIST VIEW, which a related list does not render (it draws its * own table). "Grouped by frequency" is therefore not authorable at this @@ -100,7 +102,7 @@ import { definePage } from '@objectstack/spec/ui'; * so each rhythm reads as a contiguous block. That is weaker than grouping * and it is not pretended otherwise. * - * **§3 — A page cannot declare its related lists read-only.** There is no + * **§3 — A page cannot declare its related lists read-only** (objectui#7300). There is no * `readonly` on `PageComponentSchema` — deliberately, ruled 2026-08-12: * "editability lives on fields". That ruling does not reach a related list's * "+ New" / row-edit / row-delete affordances, which are not fields: they are @@ -114,13 +116,14 @@ import { definePage } from '@objectstack/spec/ui'; * `actions`, no editable component types, `readonly` chips) and the residue * is filed. `test/member-page.test.ts` pins the authorable half. * - * **§4 — Related-list columns cannot cross a lookup.** `RelatedList` resolves + * **§4 — Related-list columns cannot cross a lookup** (objectui#7301). `RelatedList` resolves * lookup LABELS but has no dotted-path column support, so "who assigned each" * cannot be `assignment.assigner` on a `duly_task` list. * → INSTEAD: the task list carries the `assignment` column, which names the * fan-out the task came out of. One click from the assigner, not zero. * - * **§6 — `record:related_list` cannot bind to a multi-value field.** The other + * **§6 — `record:related_list` cannot bind to a multi-value field** + * (objectui#7299). The other * route to the assigner was a list of `duly_assignment` bound on `assignees` * (`multiple: true`) — the field that actually names this person. It was * authored, run against the seeded demo, and REFUSED by the driver, because @@ -143,7 +146,8 @@ import { definePage } from '@objectstack/spec/ui'; * error, and "showing who assigned each" is the one line of this card that * is not fully delivered. Recorded on the issue and filed upstream. * - * **§5 — Two record pages for one object resolve by declaration order.** + * **§5 — Two record pages for one object resolve by declaration order** + * (objectui#7298). * `@objectstack/platform-objects` ships `sys_user_detail` * (`type: 'record'`, `object: 'sys_user'`, `isDefault: true`), and * `usePageAssignment` picks among candidates by `(b.priority ?? 0) - @@ -156,7 +160,7 @@ import { definePage } from '@objectstack/spec/ui'; * this checkout is recorded in the PR. * * **§7 — The auto-appended discussion panel cannot be declined by a page, and - * it carries a comment box.** Found in the browser, not in the schema. + * it carries a comment box** (objectui#7298, the second instance there). Found in the browser, not in the schema. * `RecordDetailView` appends a `RecordChatterPanel` to every record page when * the object does not set `enable.feeds: false`, hard-coded with * `showCommentInput: true`, `enableReactions: true`, `enableThreading: true`. diff --git a/test/member-page.test.ts b/test/member-page.test.ts index 6ae7b67..364a54a 100644 --- a/test/member-page.test.ts +++ b/test/member-page.test.ts @@ -1,11 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; -// The member page's own SOURCE TEXT, comments included — see the grep below -// for why the text and not the parsed object. `?raw` is Vite's text import, -// which vitest resolves natively; `test/raw-import.d.ts` declares it for tsc. -import PAGE_SOURCE from '../src/pages/member.page.ts?raw'; +import { describe, expect, it } from 'vitest'; import { MemberPage } from '../src/pages/member.page.js'; import { dulyPages } from '../src/pages/index.js'; @@ -33,6 +30,17 @@ import { ManagerPermissionSet, MemberPermissionSet, AdminPermissionSet } from '. * None of them turns anything red on its own. That is what this file is for. */ +/** + * The member page's own SOURCE TEXT, comments included — see the grep below for + * why the text and not the parsed object. + * + * `node:fs` rather than Vite's `?raw`, which also works: `test/node-builtins.d.ts` + * already declares `readFileSync` narrowly for `test/import-samples.test.ts`, and + * one way to read a file in a test beats two. Its `URL` overload is what makes + * `node:url` unnecessary here. + */ +const PAGE_SOURCE = readFileSync(new URL('../src/pages/member.page.ts', import.meta.url), 'utf8'); + type Rec = Record; const page = MemberPage as unknown as Rec; diff --git a/test/raw-import.d.ts b/test/raw-import.d.ts deleted file mode 100644 index af04707..0000000 --- a/test/raw-import.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Vite's `?raw` import suffix, declared for `tsc`. - * - * Vitest runs on Vite, which resolves `import x from './f.ts?raw'` to the - * file's TEXT. TypeScript has no idea, so without this the import is an - * unresolved module and `pnpm typecheck` fails while `pnpm test` passes — the - * two-gates-disagree shape. - * - * Why a raw import rather than `node:fs`: this repo ships no `@types/node` - * (nothing in `src/` needs it, and `tsconfig.json` names no `types`), so - * `readFileSync` does not type-check here. Adding the dependency to read one - * file in one test is a wider change than the test is worth, and - * `package.json` is a file the parallel batch also touches. - * - * Used by `test/member-page.test.ts`, which greps the member page's SOURCE — - * comments included — for an object name that must never appear in it. - */ -declare module '*?raw' { - const content: string; - export default content; -}