Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/context-layer-ui-copy-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Context Layer UI copy boundary

This file contains implementation guidance for agents and maintainers. None of
this guidance is public page copy.

## Public copy contract

The site-wide casing, punctuation, and type-role rules live in
[`sierra-editorial-formatting.md`](./sierra-editorial-formatting.md). Apply that
contract to every reader-facing Context Layer route and to copy generated after
page load.

Reader-visible prose must do one of two things:

- explain the Context Layer protocol, its limits, or its current implementation
status;
- describe an action the reader can take.

Do not publish sentences about how the page, renderer, asset, or editorial
pipeline was constructed. Keep theme selection, canvas opacity, viewport
behavior, layout order, breakpoint goals, stylesheet packaging, archive
location, API configuration, allowlisted navigation, and publication workflow
in internal notes or verification code.

Machine-facing artifacts such as `llms.txt`, schemas, source documents, and the
agent navigation manifest remain public when the protocol needs them. Do not
promote those endpoints as ordinary reader metadata unless a reader explicitly
enters a developer or machine-integration surface.

## Deployment boundary

- `site/context-layer/` is the canonical public documentation source.
- Vercel project `context-layer-public` must deploy with `site/` as its root.
- The standalone deployment rewrites `/` to the Context Layer index. It must not
replace the Sierra Catalina apex deployment.
- The Sierra host owns `/`, `robots.txt`, `sitemap.xml`, and the guide API. Its
scoped proxy exposes only the Context Layer routes from this deployment.

## Publication checks

- Run `npm run format:context-layer` after regenerating public HTML.
- Run `npm run verify:context-layer`, `npm run test`, `npm run lint`, and
`npm run release:hygiene` before publication.
- Review rendered text, not source files alone.
- Verify every canonical route at preview and production widths.
- Compare production responses with the canonical deployment so proxy drift
cannot hide stale copy.
- Treat sentences copied from agent instructions, design specifications, build
logs, or editorial workflow documents as suspect until rewritten for readers.
68 changes: 68 additions & 0 deletions docs/sierra-editorial-formatting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Sierra editorial formatting

This is an internal implementation contract for agents and maintainers. It is
not public page copy.

## Reader-facing language

- Lowercase ordinary prose, headings, navigation, labels, controls, metadata,
status messages, accessible names, and generated UI responses.
- Preserve `I`, proper names, acronyms, normative protocol keywords, versioned
identifiers, and established technical casing such as `AI`, `API`, `OpenAI`,
`OAuth`, `DPoP`, `GitHub`, `ActivityPub`, `SwiftUI`, and `macOS`.
- Use `&` instead of the standalone word `and`. Do not put a comma before `&`.
- In editorial narrative and interface copy, prefer single quotation marks,
use brackets rather than parentheses for asides, and do not use an em dash.
Normative specifications may retain standards punctuation where changing it
would reduce precision.
- Keep code, schemas, payloads, command output, URLs, email addresses,
user-authored input, and protocol source documents byte-for-byte intact.

Do not use CSS as the editorial compliance mechanism. `text-transform` changes
presentation without fixing the DOM, copied text, accessible names, metadata,
or runtime-generated copy, and it cannot safely distinguish `AI` from ordinary
prose. A deliberately transformed short metadata label may remain decorative
only after its source text already satisfies this contract; body copy,
headlines, and technical identifiers must not depend on a transform.

## Type roles

- Display headings: Cormorant Garamond.
- Body copy: Lora.
- Navigation, indexes, labels, controls, and technical metadata: DM Mono.
- DM Sans is structural only and must not replace the display or body roles.

## Enforcement boundaries

`site/context-layer/assets/context-layer-editorial.mjs` is the shared formatter
for reader-facing copy. Its strict mode normalizes authored site copy against an
explicit casing glossary. Its conservative `preserveUnknownCase` mode is for
model-generated prose that has already been instructed to use lowercase
ordinary prose; that mode avoids destroying unfamiliar proper names and
acronyms. User-authored input must cross the UI boundary unchanged.

`scripts/format-context-layer-site.mjs` applies strict normalization to the
seven generated pages in `site/context-layer/_pages/`. It skips technical
elements (`code`, `kbd`, `pre`, `samp`, `script`, `style`, `svg`, `textarea`,
and `var`) and never formats URL-bearing or data-bearing attributes. It does not
rewrite the Markdown protocol sources, schemas, fixtures, downloads, or
implementation artifacts published beside those pages.

Runtime UI must select the boundary explicitly:

- authored interface strings use strict normalization;
- generated guide prose uses conservative normalization;
- user questions and voice transcripts are rendered exactly as received;
- JSON and other technical renderings never pass through the prose formatter.

Run these commands before publication:

```text
npm run format:context-layer
npm run verify:context-layer
npm run test:site
```

The verifier must fail when ordinary capitals or the standalone word `and`
reappear outside protected technical content, or when a code, generated-copy,
or user-input boundary regresses.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
"scripts": {
"test": "npm run test:contracts && npm run test:site && npm run test:local-core && npm run test:proof",
"test:contracts": "node --test tests/repository-boundary.test.mjs tests/reference-implementation.test.mjs",
"test:site": "node --test tests/public-site.test.mjs",
"test:site": "node --test tests/public-site.test.mjs tests/editorial-formatting.test.mjs",
"test:local-core": "node --test tests/local-core.test.mjs tests/receipt-log-hardening.test.mjs",
"test:proof": "node --test tests/local-core-demo.test.mjs tests/test-vectors.test.mjs",
"demo:local-core": "node examples/local-core-demo.mjs",
"format:context-layer": "node scripts/format-context-layer-site.mjs",
"verify:context-layer": "node scripts/verify-context-layer-editorial.mjs",
"release:hygiene": "node scripts/release-hygiene.mjs",
"lint": "node node_modules/eslint/bin/eslint.js ."
},
Expand Down
142 changes: 142 additions & 0 deletions scripts/format-context-layer-site.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { formatEditorialText } from "../site/context-layer/assets/context-layer-editorial.mjs";

const root = resolve(import.meta.dirname, "..");

export const contextLayerPageNames = Object.freeze([
"index",
"demo",
"architecture",
"specification",
"implementation",
"code",
"essay",
]);

const protectedElements = new Set([
"code",
"kbd",
"pre",
"samp",
"script",
"style",
"svg",
"textarea",
"var",
]);

const voidElements = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);

/**
* Format authored reader-facing HTML while retaining technical elements,
* URL-bearing attributes, form values, and data payloads exactly as supplied.
*/
export function formatContextLayerHtml(html) {
const stack = [];

return html
.split(/(<[^>]+>)/g)
.map((part) => {
if (!part.startsWith("<")) {
return stack.some((name) => protectedElements.has(name))
? part
: formatEditorialText(part, { html: true });
}

const closing = part.match(/^<\/([a-z0-9-]+)/i);
if (closing) {
const name = closing[1].toLowerCase();
const index = stack.lastIndexOf(name);
if (index !== -1) stack.splice(index, 1);
return formatEditorialAttributes(part);
}

const opening = part.match(/^<([a-z0-9-]+)/i);
if (opening && !/\/>$/.test(part)) {
const name = opening[1].toLowerCase();
if (!voidElements.has(name)) stack.push(name);
}

return formatEditorialAttributes(part);
})
.join("");
}

export async function formatContextLayerPages({ check = false } = {}) {
const changed = [];

for (const pageName of contextLayerPageNames) {
const pagePath = resolve(root, `site/context-layer/_pages/${pageName}.html`);
const html = await readFile(pagePath, "utf8");
const formatted = formatContextLayerHtml(html);
if (formatted === html) continue;

changed.push(pageName);
if (!check) await writeFile(pagePath, formatted, "utf8");
}

return changed;
}

if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const check = process.argv.includes("--check");
const changed = await formatContextLayerPages({ check });

if (check && changed.length > 0) {
throw new Error(`editorial formatting required: ${changed.join(", ")}`);
}

const action = check ? "verified" : "formatted";
console.log(`${action} ${contextLayerPageNames.length} Context Layer pages with the Sierra editorial contract.`);
}

function formatEditorialAttributes(tag) {
if (isProtectedTag(tag)) return tag;

let formatted = tag.replace(
/\b(alt|aria-description|aria-label|data-guide-prompt|placeholder|title)=("([^"]*)"|'([^']*)')/gi,
(match, name, quotedValue, doubleValue, singleValue) => {
const quote = quotedValue[0];
const value = doubleValue ?? singleValue ?? "";
return `${name}=${quote}${formatEditorialText(value, { html: true })}${quote}`;
},
);

if (
/^<meta\b/i.test(formatted) &&
/\b(?:name|property)=["'](?:description|og:description|og:image:alt|og:title|twitter:description|twitter:image:alt|twitter:title)["']/i.test(formatted)
) {
formatted = formatted.replace(
/\bcontent=("([^"]*)"|'([^']*)')/i,
(match, quotedValue, doubleValue, singleValue) => {
const quote = quotedValue[0];
const value = doubleValue ?? singleValue ?? "";
return `content=${quote}${formatEditorialText(value, { html: true })}${quote}`;
},
);
}

return formatted;
}

function isProtectedTag(tag) {
const name = tag.match(/^<\/?([a-z0-9-]+)/i)?.[1]?.toLowerCase();
return name ? protectedElements.has(name) : false;
}
Loading
Loading