Architecture

Architecture

This page gives an overview of how speckit-eye is put together. The exact interfaces are defined in the feature’s contracts, and this page links to them instead of repeating them:

  • contracts/ of 001: the CLI (cli.md), the live-update protocol (routes.md), and the recognized tasks.md format (tasks-md-format.md)
  • contracts/ of 002: page paths, the DOM of every page, the browser modules and the view state they keep (routes.md), the structured spec.md view (spec-md-structure.md) and the search index (search-index.md)
  • the data models of 001 (the project model, counting and state rules, and how the active item is chosen) and 002 (statuses, ranks, shares, warning groups, documents and task files)

Data flow

Data flows in one direction:

project  →  parse  →  model  →  render  →  serve / build
 (read)     (pure)    (pure)    (pure)       (write / listen)
  1. project reads the input files through a ProjectReader and returns them unparsed. This is the only code that reads the target project, and it never writes to it (FR-006).
  2. parse turns tasks.md and spec.md text into phases, tasks, stories, warnings and the blocks of the structured spec.md view.
  3. model builds the Project: features, counts, stages and statuses, totals, task states, the files and kind of each task, feature orders, percent shares, warning groups, document groups, change signatures and the active selection.
  4. render turns the model into a site map, a Map from page path to { type, body }. The functions in this layer return strings (or the bytes of a font) and have no side effects.
  5. serve answers HTTP requests from that map. build writes the map to the --out folder.

No layer calls back into an earlier one, and I/O happens only at the two ends. Everything in between is a pure function. The ends take their I/O (fs, http.createServer, fs.watch, timers, browser globals) as parameters, so unit tests can run the whole flow with fakes.

A sixth part runs in the browser: the modules in src/client/ add behavior to the rendered pages (see Browser modules). The pages never depend on them for content or navigation.

Modules

Folder Module Responsibility
bin/ speckit-eye.js One-line entry point: calls run() from src/cli/main.js
src/cli/ args.js Parses the command line into a mode and options (--base, --home), or a usage error; see Command line
main.js Connects reader, model, renderer, server, watcher and build; reads the packaged assets (stylesheet, browser modules, fonts as bytes); handles exit codes and console output
src/project/ reader.js ProjectReader: all reads of input files, limited to the project root
scan.js Finds features, artifacts, assessments, feature.json and the git branch, and returns the raw files
artifacts.js Artifact kind, order and title, source path → page URL, and file-name validation
src/parse/ lines.js Line iteration that skips fenced code blocks and HTML comments
tasks.js tasks.md parser: phases, tasks, markers, dependencies and warnings
spec.js spec.md parser: title and user stories
spec-structure.js spec.md → blocks with line ranges for the structured view (metadata, input, clarifications, stories, requirements, entities, plain); the blocks cover every line of the file (spec-md-structure.md)
src/model/ build-model.js Builds the Project model from the scan result, including statuses, feature numbers, overview stats, document groups and tabs
task-state.js Task states (done, next, blocked, open), their display names, waitingOn, and change signatures
task-files.js The file paths, kind (“Go test”, “Vue”) and FR/SC references of a task, from its text
shares.js Largest-remainder percentages: every proportional width (stats bar segments, phase rail) adds up to exactly 100
ranks.js The four feature orders of the tree (In progress first, Number, Least complete, Name A–Z)
warnings.js Warning groups with a title, a note and merged line ranges (“L14–L16”)
active.js Chooses the active feature, phase, story and next task
src/render/ html.js Auto-escaping html tagged template and raw()
markdown.js Markdown to HTML with markdown-it (raw HTML off), task lists, link rewriting, an inline renderer and #L<line> anchors for tasks.md
icons.js Inline SVG icons copied from Lucide (ISC notice in the file); the only source of icons
layout.js Shared page shell: head (theme script, stylesheet, app.js), sidebar, icon rail, mobile menu, search dialog and tooltip hosts, footer
overview.js Overview body: page head, stats card, Up next bar, feature tree
taskmap.js Task map (stacked, grouped or bars) and its legend
task-text.js Inline task text with formatting and FR/SC chips; kind and file chips
feature.js Feature page body: header, ring, document tabs, phase rail, warnings banner, task list, detail panel host
reader.js Document page body: document list, header with “Raw markdown”, reading column, “On this page”
spec-view.js The structured spec.md blocks → HTML
search-index.js Model → assets/search-index.json (search-index.md)
theme-script.js The text of assets/theme.js, generated from applyStoredTheme in src/client/prefs.js
site.js Model → site map; decides which pages and assets exist
src/serve/ handler.js Pure (req, res) handler: route lookup, headers, CSP, 404s, /__events dispatch; sends text and binary bodies
server.js Thin listener: binds to 127.0.0.1, falls back to another port when 4747 is busy
watcher.js Watches specs/ and .specify/ with fs.watch and calls onChange after a short debounce. On Linux, where Node’s recursive watch follows files by inode and misses writes after a rename-save, it watches each folder separately instead, re-creating a watch whose folder was replaced
events.js Server-Sent Events hub: hello, change, keep-alive pings
src/build/ build.js Writes the site map to --out (text and binary files); checks and replaces the build marker
src/client/ app.js Browser bootstrap: runs init of the modules of the current page, wires the theme switch, and exposes save/reinit to live.js
prefs.js Safe localStorage access for the four viewer preferences (theme, order, filter, map mode) and applyStoredTheme
tree.js Overview tree: order, depth, “Open tasks only”, and reveal() of a task
taskmap.js Task map: mode toggle, hover highlight in the tree, delayed tooltip, click → tree.reveal
feature.js Feature page: phase rail ↔ list, filters and text filter, expand/collapse all, selection, detail panel, “Copy ID”, the #task-… address
reader.js Document reader: contents highlight and progress, “Expand all”, raw view, requirement area chips
search.js Search dialog: ⌘K / Ctrl+K, index loading, matching, keyboard navigation
live.js Serve mode only: swaps in new content and keeps the viewer’s state on each change
src/styles/ input.css Tailwind CSS source: theme tokens, @font-face rules, w-pct utilities and the components, compiled to dist/styles.css
scripts/ copy-assets.js Copies the bundled fonts and their licences from the @fontsource packages into dist/fonts/ (pnpm run build:assets)
build-site.js Builds the documentation site into site/: docmd from docs/, then this repository’s dashboard into site/status/ (pnpm run docs:build; see Documentation site)
scripts/release/ release.js Release rules for the Bump version and Release workflows: bump, verify, notes, release-commit, pack-check (see Release automation)

The files under scripts/ and docmd.config.js are development tools: they are not part of the published package.

Command line

src/cli/args.js turns the command line into one of four modes: help, version, serve (--serve <dir>) and build (--build <dir> --out <folder>). Two options only apply to a build:

  • --base <path> is the URL path the build will be served under; every link, stylesheet, script and font address starts with it.
  • --home <url> must be an absolute http:// or https:// address. It is passed to render/layout.js, which adds a “Home” link to it under the project name in the sidebar, in the icon rail of document pages and in the mobile menu. Without it the pages have no Home link. Serve mode refuses it, because a local dashboard is not part of another site.

The full list of options and messages is in the cli.md contract of 001 and the cli-home.md contract of 003; Usage describes them for users.

Why render/site.js decides which pages exist

Serve mode and build mode must show the same pages at the same paths (FR-031). To guarantee that, only site.js decides which pages and assets exist and what they contain. Serve mode looks up request paths in the map it returns, and build mode writes each entry of that map to a file. This has three consequences:

  • The two modes cannot drift apart, because there is no second list of pages.
  • The server never reads a file based on a request path. A path that is not a key in the map returns 404, including .. tricks and source files (FR-007).
  • A test of site.js covers the page set of both modes at once.

What is in the site map

Path Content
index.html Overview
features/<dir>/index.html Feature page, one per feature
features/<dir>/<rel>.html, constitution.html, assessments/<slug>/<rel>.html Document pages (reader layout)
assets/styles.css The compiled stylesheet
assets/theme.js Blocking theme script, generated in memory by theme-script.js
assets/app.js and assets/<module>.js The browser modules, copied from src/client/ as they are (no bundler)
assets/live.js Live-update client, serve mode only
assets/search-index.json Search index, built by search-index.js; the same in both modes
assets/fonts/*.woff2, assets/fonts/OFL-*.txt Bundled fonts and their licences

Most bodies are strings. Font files are Uint8Array bodies: the handler sends them and build.js writes them unchanged, so fonts go through the same site map as every page instead of a second list of files.

Browser modules

The pages are complete HTML without JavaScript: the tree, phases, task rows and collapsible document parts are native <details> elements, tabs and map squares are links, and the mobile menu is a <details>. Behavior that needs scripts is added by small ES modules in src/client/, loaded as assets/<module>.js. Controls that only work with scripts (search, theme switch, order, depth, filters, map mode, “Expand all”) are rendered hidden and shown by their module.

Every module follows one convention:

  • init(root, deps) wires the module to the page. It is idempotent: it runs on load and again after every live update. deps injects document, window, storage, timers, fetch and the module’s saved state, so the modules are unit tested with happy-dom instead of a browser.
  • save(root), for a module that holds page-local state (tree depth, feature filters and selection, reader views), returns that state before a live update replaces the page content.

app.js is the only script tag besides theme.js (and live.js in serve mode). It reads data-page from <body> (overview, feature or document), wires the theme switch and search on every page, and runs init of the modules registered for that page type: tree.js and taskmap.js on the overview, feature.js on feature pages, reader.js on document pages. It exposes save() and reinit() for live.js.

Viewer preferences (theme, tree order, tree filter, map mode) are kept in localStorage through prefs.js, which wraps every access in try/catch, so a browser that refuses storage falls back to the defaults.

Styles, themes and the CSP

Every response in serve mode carries the Content Security Policy default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:. It blocks inline scripts, style attributes and requests to other servers, which keeps any HTML or script inside a Markdown file inert even if escaping were missed. The pages are built to live with it:

  • No style attributes. Data-driven widths (stats bar segments, phase rail blocks, feature bars) use the classes w-pct-0 … w-pct-100, a Tailwind utility generated for every value in input.css. The widths come from model/shares.js, so the parts of a bar always add up to 100 %.
  • No inline scripts. The theme script is a separate file, assets/theme.js, loaded as the first script in <head> so it runs before the first paint.
  • No other origins. Fonts and icons ship with the tool.

Theme tokens. Every color is a CSS custom property defined once on :root with light-dark(<light>, <dark>). color-scheme: light dark makes the page follow the operating system (System); assets/theme.js sets <html data-theme="light|dark"> from the stored choice, which pins color-scheme and with it every token. Regions that are dark in both themes (sidebar, icon rail, Up next bar, tooltip) carry the always-dark class, which sets color-scheme: dark on that subtree. The theme script is generated from applyStoredTheme in src/client/prefs.js, so the code that runs before paint is the same code the unit tests cover.

Fonts

The pages use Geist, Geist Mono and Instrument Serif. They ship with the package instead of being loaded from a font service, which the CSP and the “nothing leaves the page’s origin” rule would forbid:

  1. The fonts come from the @fontsource dev dependencies.
  2. scripts/copy-assets.js (run by pnpm run build:assets, which also compiles the stylesheet, and by prepack) copies the Latin and Latin-extended .woff2 files and each font’s licence (OFL-<family>.txt) into dist/fonts/. Its file selection is a pure function with unit tests.
  3. main.js reads dist/fonts/ as bytes and site.js publishes the files under assets/fonts/.
  4. The @font-face rules in input.css use URLs relative to the stylesheet (url(fonts/…)), so they resolve under any --base without rewriting.

Live updates

In serve mode, a change on disk reaches open pages in these steps (details in routes.md):

  1. Watcher: watcher.js sees a file event under specs/ or .specify/ (or a branch switch in the git HEAD) and calls onChange once after a short debounce (100 ms of quiet, at most 500 ms).
  2. Rescan: main.js scans the project again and rebuilds the model and the site map. Rescans run one at a time. A change that arrives during a rescan triggers one more rescan afterwards. If a rescan fails, the last good site stays in place.
  3. Version: when any page or asset differs from the current map, the new map replaces the old one and the model version goes up by one.
  4. SSE: events.js sends event: change with the new version to every open /__events stream.
  5. Client swap: live.js fetches the current page, collects the modules’ state with save() and the scroll positions of the window and of every [data-keep-scroll] element (tree card, task list, document list), replaces <main> and the live parts of the shell (the sidebar Features list), restores the viewer’s expanded items, calls reinit() so every module applies its preferences and saved state again, restores the scroll positions, and highlights items whose data-sig changed. If the connection drops, it shows a banner, and it catches up on the next hello.

Static builds skip all of this: they include neither live.js nor /__events.

Release automation

A release goes through three steps, each run by a GitHub Actions workflow (details in Releasing and the workflows.md contract):

Bump version (bump.yml)  →  pull request "Release vX.Y.Z"  →  Release (release.yml)
 manual, on main            reviewed and merged by hand        tag, npm, GitHub release
  1. Bump: the maintainer runs Bump version on main. It computes the new version, updates package.json and moves the CHANGELOG.md Unreleased entries under a dated section, pushes the result to release/next and opens or updates the pull request.
  2. Pull request: the change is reviewed like any other and merged into main.
  3. Release: the merge starts release.yml. It decides whether the commit is a release commit, runs the full CI (ci.yml, reused through workflow_call) and the release checks, waits for the owner’s approval in the npm environment, then tags the commit vX.Y.Z, publishes the packed tarball to npm with provenance and creates the GitHub release.

All release rules live in scripts/release/release.js, a script with pure, unit-tested functions and a main(argv, io) with injected I/O (release-cli.md). The workflow YAML only calls it:

Command Used by What it does
bump <kind> [--preid <id>], bump --version <X.Y.Z> bump.yml Computes the next version, writes package.json and the new changelog section; refuses an empty Unreleased section
release-commit --version … --head-ref … --tag-exists … release.yml (prepare) Decides whether a push to main is a release: merged from release/next, tag not there yet, changelog section present
verify --tag <vX.Y.Z> release.yml (verify) Checks that the tag, package.json and the changelog’s latest section name the same version, and prints the npm dist-tag (latest, or next for a pre-release)
notes --version <X.Y.Z> bump.yml, release.yml Prints that version’s changelog section: the pull request body and the GitHub release notes
pack-check <file-list.json> release.yml (verify), E2E Compares the files of pnpm pack with the allowed list

Where each release requirement of 003 is enforced:

Requirement Where
FR-009 tag created on merge, only from main release.yml: release-commit in the prepare job; the “commit belongs to main” step of the verify job; the tag is created by the publish job after approval
FR-010 versions agree, not yet on npm release.yml verify job: verify --tag, then npm view
FR-011 tests and package check before publishing release.yml: the ci job (ci.yml) and pack-check; publish needs both
FR-012 owner approval release.yml publish job: environment: npm
FR-013 short-lived credentials, provenance release.yml publish job: id-token: write, npm publish --provenance
FR-014 least privilege, pinned actions Every workflow: permissions: {} at the top, per-job permissions, actions pinned to a commit SHA
FR-015 GitHub release from the changelog release.yml publish job: gh release create --notes-file with the output of notes
FR-016 pre-releases on a separate channel verify prints the dist-tag; the publish job passes it to npm publish --tag
FR-017 locked installs Every workflow installs with pnpm install --frozen-lockfile

npm publish from a working copy is refused by the prepublishOnly script of package.json.

Documentation site

The documentation website is generated from the Markdown files in docs/, which stay the only source of the documentation (site.md):

  1. docmd (@docmd/core, a pinned dev dependency) builds docs/ into site/, with the navigation and settings in docmd.config.js.
  2. Dashboard: scripts/build-site.js (pnpm run docs:build) runs docmd and then this repository’s own bin/speckit-eye.js --build over the repository, writing its dashboard to site/status/ with --base <path>/status/ and --home <site URL>, so the dashboard links back to the docs. The dashboard is built from the same commit as the docs, with the tool’s code of that commit.
  3. Addresses: the site URL is defined once, as homepage in package.json; docmd.config.js and build-site.js derive the base path, the dashboard’s --base and its --home from it.
  4. Deploy: .github/workflows/pages.yml builds the site and checks its links (docmd validate) on pull requests that touch it, and on main (or on demand) also deploys site/ to GitHub Pages. Deploys never overlap, and a failed build leaves the previous site online.

site/ is generated and gitignored.