mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 13:46:32 +03:00
Add platform axis (web / ios / android / adaptive) (#269)
* Add a platform axis (web / ios / android / adaptive) to the skill Orthogonal to register: register decides whether design IS or SERVES the product; platform decides the delivery target and which native conventions apply. Set `## Platform` in PRODUCT.md; a missing field defaults to `web`, so legacy projects are unaffected. - extractPlatform() in skill/scripts/context.mjs (mirrors extractRegister); the CLI appends a NEXT STEP directive to read the native reference(s). `adaptive` (Flutter / RN / KMP shipping both iOS and Android) loads both ios.md and android.md. - New reference/ios.md (Apple HIG distilled) and reference/android.md (Material 3 distilled); reference/web.md is a thin pointer. The native refs frame register's role as narrow: platform conformance is the bar, brand lives in the expressive layer the platform gives you, never by breaking the rails. - Setup step 5 loads the native reference(s) when platform is native. Live mode and the detect CLI stay web-only, gated off ios/android/adaptive. - init asks platform right after register; adapt/audit/animate/layout carry short platform divergence notes; all secondary spots thread `adaptive`. - a11y stays in audit.md (loading it at design time makes output timid), so the native refs carry no Accessibility section; audit.md's Platform section owns native a11y. - Tests: extractPlatform unit coverage + skill-behavior scenario 10 (PRODUCT.md platform ios -> agent loads ios.md). Source-first: only skill/, scripts/, tests/, CLAUDE.md, NOTICE.md, the changelog and version are committed; the sync workflow regenerates the provider trees and ./plugin on merge. ios.md / android.md are distilled from the MIT-licensed ehmo/platform-design-skills; attribution in NOTICE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review: gate web tools on native platforms, drop version churn Maintainer-review fixes applied with AI assistance (Claude Code), on top of the rebased platform-axis commit: - Design hook (post-edit and Cursor pre-edit) now resolves the project platform via loadContext + extractPlatform and skips its web rule scan for ios / android / adaptive projects, so React Native / Flutter code never draws web-shaped findings (new hook-lib resolveProjectPlatform / isNativePlatform helpers, covered by unit and subprocess tests). - context.mjs CLI warns on an unrecognized ## Platform value (e.g. a toolchain name like `flutter`) instead of silently defaulting to web; extractRegister / extractPlatform now share extractSectionValue. - Removed reference/web.md: nothing loaded it; CLAUDE.md carries the "web has no extra rulebook" explanation. - init.md: skip live-mode config (Step 6) for native platforms; note the per-app PRODUCT.md pattern for repos shipping web + native. - android.md: Material-everywhere apps that also ship on iPhone still owe iOS OS guarantees (safe areas, Reduce Motion, edge-swipe back). - ios.md: reworded a design-time line that framed Dynamic Type as an accessibility check (a11y stays owned by audit.md). - Renumbered the new skill-behavior scenario to 14 after main's 10-13; updated CLAUDE.md scenario list; added android + unrecognized-value CLI test cases. - No version or changelog changes: versioning happens at release time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tighten platform reference prose Editorial pass on the platform-axis text, applied with AI assistance (Claude Code) under maintainer direction: - ios.md / android.md rewritten to house style: single-line paragraphs (no hard wraps), one-sentence scope intro, deduplicated intro/slop-test, register-compression down to two sentences. In-file attribution paragraphs removed (NOTICE.md owns attribution); "read on top of the register reference" cruft removed (SKILL step 5 and the context.mjs directive already say it). Bans sections dropped: they restated the rules above them; the two additive items (tab-bar overload, hover-dependent affordances) folded into rules. ~40% smaller each. - Sub-command Platform sections (adapt, audit, animate, layout), SKILL step 5, init.md platform prose, and the context.mjs directive trimmed the same way. Build (prose validators, counts) and both test runners green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Treat an empty PRODUCT.md section as absent, not the next heading Copilot review catch: extractSectionValue read the next `## ...` heading as the section value when a field was left empty, which made the CLI warn "value `## Product Purpose` is not recognized". Stop at the next heading and return null instead. Regression tests for extractPlatform, extractRegister, and the CLI warning path. Applied with AI assistance (Claude Code) under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Only read a token list of both native targets as adaptive Bugbot catch: after the exact platform tokens failed, any Platform line containing the words ios and android was classified adaptive, so negated or explanatory prose ("web only, not ios or android") silently loaded both native refs and skipped the hook, with no warning. The combo parse now accepts only list separators and the two platform words; anything else falls through to the CLI's unrecognized-value WARNING. Regression tests added. Applied with AI assistance (Claude Code) under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Paul Bakaus
parent
149396d91f
commit
3e38e595c7
@@ -25,6 +25,23 @@ Sub-command reference files add a short `## Register` section near the top *only
|
||||
|
||||
**a11y lives in `audit.md`**, not in SKILL.md, `brand.md`, or `product.md`. Models over-cautious themselves into safe, underdesigned output when reminded about accessibility at design time. The audit command is the dedicated place for that check.
|
||||
|
||||
### Platform (web / ios / android / adaptive)
|
||||
|
||||
A second axis, **orthogonal to register**. Register answers "does design IS or SERVES the product"; platform answers "what's the delivery target and which native conventions apply":
|
||||
|
||||
- **web** — a website or web app (including responsive mobile web). The default. No extra rulebook and no reference file: the General rules in SKILL.md and the register reference cover it.
|
||||
- **ios** — a native iOS / iPadOS app. Loads `reference/ios.md` (Apple HIG distilled) on top of the register reference.
|
||||
- **android** — a native Android app. Loads `reference/android.md` (Material Design 3 distilled) on top of the register reference.
|
||||
- **adaptive** — a cross-platform app shipping both iOS and Android from one codebase (Flutter, React Native, KMP) that adapts per OS. Loads **both** `reference/ios.md` and `reference/android.md`. A Flutter/RN app that uses one look on both platforms (Material-everywhere is the Flutter default) is not adaptive; it takes that single platform's value.
|
||||
|
||||
PRODUCT.md carries a `## Platform` section with a bare value (`web` / `ios` / `android` / `adaptive`). It's parsed by `extractPlatform()` in `skill/scripts/context.mjs` (mirroring `extractRegister()`); a **missing field defaults to `web`** so legacy projects are unaffected. A line that names both native targets (e.g. `ios, android`) is also read as `adaptive`; any other unrecognized value falls back to web **and** the `context.mjs` CLI prints a WARNING directive naming the bad value, so a toolchain name or typo never silently gets web guidance. `context.mjs` appends a NEXT STEP directive to read the native reference(s) when the value is `ios`, `android`, or `adaptive` (both). `init` (Step 3) asks platform right after register.
|
||||
|
||||
`ios.md` and `android.md` are distilled from the MIT-licensed [ehmo/platform-design-skills](https://github.com/ehmo/platform-design-skills); attribution is in `NOTICE.md`.
|
||||
|
||||
Sub-command reference files add a short `## Platform` section *only where guidance diverges for native*. Don't restate the platform files — link instead. Sub-commands carrying one today: `adapt`, `audit`, `animate`, `layout`.
|
||||
|
||||
**Live mode, the `detect` CLI, and the design hook are web-only.** They operate on a browser / HTML rules, so SKILL.md's routing skips live and `detect.mjs` for any native (`ios` / `android` / `adaptive`) project, and the hook (`hook-lib.mjs` `resolveProjectPlatform` / `isNativePlatform`, also used by `hook-before-edit.mjs`) skips its scan when PRODUCT.md declares a native platform — a React Native project is made of exactly the `.tsx` / `.ts` / `.js` files the hook watches.
|
||||
|
||||
## CSS
|
||||
|
||||
Plain hand-written CSS, no Tailwind. Imported into Astro pages/layouts via frontmatter `import` statements; Vite resolves `@import` chains automatically.
|
||||
@@ -184,7 +201,7 @@ IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1 bun run test:skill-behavior # dump
|
||||
|
||||
**Auth** lives in repo-root `.env` (copied from `~/code/impeccable-evals/.env`, gitignored). Providers skip cleanly when their key is unset; they don't fail.
|
||||
|
||||
**Nine scenarios:**
|
||||
**Fourteen scenarios:**
|
||||
1. empty workspace → agent loads `reference/init.md`
|
||||
2. PRODUCT.md only → loads `brand.md`
|
||||
3. PRODUCT.md + DESIGN.md → loads `brand.md` + consults the design system
|
||||
@@ -194,6 +211,11 @@ IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1 bun run test:skill-behavior # dump
|
||||
7. `/impeccable audit` → loads `reference/audit.md`
|
||||
8. existing SvelteKit project → agent reads at least one project code file
|
||||
9. `context.mjs` emits `UPDATE_AVAILABLE` (seeded newer version) → agent surfaces it but does **not** auto-run `npx impeccable skills update`
|
||||
10. scoped command with no PRODUCT.md → proceeds without forcing init
|
||||
11. `/impeccable shape` with no PRODUCT.md → diverts into `reference/init.md`
|
||||
12. natural-language build intent with no PRODUCT.md → diverts into `reference/init.md`
|
||||
13. `/impeccable teach` → diverts into `reference/init.md` (alias)
|
||||
14. PRODUCT.md with `## Platform: ios` → `context.mjs` emits the native NEXT STEP and the agent loads `reference/ios.md`
|
||||
|
||||
**Baseline.** The 21-22 / 24 baseline (with stable gpt scenario 6/7 failures) was measured on the old cheap tier (`claude-haiku-4-5` / `gpt-5.4-mini`). It needs re-measuring on the current `claude-sonnet-4-6` / `gpt-5.5` lineup; the production-tier models are expected to do better on the sub-command routing scenarios the old gpt tier failed. See `tests/skill-behavior/README.md`.
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Third-Party Notices
|
||||
|
||||
This project includes content derived from third-party work, used under the terms of its original license.
|
||||
|
||||
## Platform Design Skills
|
||||
|
||||
The `skill/reference/ios.md` and `skill/reference/android.md` platform reference files are distilled from ehmo's `platform-design-skills` (Apple Human Interface Guidelines and Material Design 3 rules), rewritten in Impeccable's voice.
|
||||
|
||||
**Original work:** https://github.com/ehmo/platform-design-skills
|
||||
**Original license:** MIT
|
||||
**Author:** ehmo
|
||||
+4
-1
@@ -239,7 +239,10 @@ function validateProse(rootDir) {
|
||||
};
|
||||
|
||||
const scan = (absPath, rel) => {
|
||||
if (excludedPrefixes.some(p => rel === p || rel.startsWith(p + '/'))) return;
|
||||
// Normalize to POSIX separators so the forward-slash excludedPrefixes match
|
||||
// on Windows, where path.join() produces backslash-separated rel paths.
|
||||
const relPosix = rel.split(path.sep).join('/');
|
||||
if (excludedPrefixes.some(p => relPosix === p || relPosix.startsWith(p + '/'))) return;
|
||||
const stat = fs.statSync(absPath);
|
||||
if (stat.isDirectory()) {
|
||||
for (const entry of fs.readdirSync(absPath)) {
|
||||
|
||||
+4
-3
@@ -19,7 +19,8 @@ You MUST do these steps before proceeding:
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node {{scripts_path}}/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
|
||||
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node {{scripts_path}}/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
|
||||
## Design guidance
|
||||
|
||||
@@ -157,10 +158,10 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
|
||||
|
||||
**If `scan.targets` is non-empty, run `node {{scripts_path}}/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node {{scripts_path}}/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
|
||||
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
|
||||
|
||||
## Platform
|
||||
|
||||
Everything below is responsive web, mobile web included. Native targets (`ios` / `android` / `adaptive`): adapting means conforming to the platform's navigation model, controls, and touch sizing, never reflowing a web layout. Load [ios.md](ios.md) / [android.md](android.md) (`adaptive` loads both).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Android platform
|
||||
|
||||
For native Android apps: Jetpack Compose, Android Views, React Native, Expo, Flutter shipping to Android hardware.
|
||||
|
||||
On native, register narrows. Material Design 3 governs structure, navigation, and interaction whatever the register; brand expresses through Material's theming (color roles, type scale, shape, motion). A Material-everywhere cross-platform app that also ships to iPhone still owes iOS its OS guarantees on that hardware: safe-area insets, Reduce Motion, edge-swipe back.
|
||||
|
||||
## The Android slop test
|
||||
|
||||
Would a fluent Android user trust this app, or trip on off-spec components? The most common tell is an iOS app wearing Android's skin: a bottom-only navigation copied from iPhone, a back arrow that ignores the system Back gesture, Cupertino-shaped switches and dialogs. Material 3 is the rulebook; follow its components and theme the brand through it.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Material navigation, matched to size.** Navigation bar (bottom, 3–5 destinations) on compact width; navigation rail or drawer on expanded width. Never ship a phone bottom-bar untouched on a tablet. <!-- rule:android-layout-adaptive-nav -->
|
||||
- **System Back always works.** Honor the predictive Back gesture and Back button; never trap the user or hijack the gesture. <!-- rule:android-layout-system-back -->
|
||||
- **Edge-to-edge with window insets.** Apply the status bar, navigation bar, display cutout, and IME insets so content never hides behind system bars or the keyboard. <!-- rule:android-layout-window-insets -->
|
||||
- **Top app bar for screen context**; pair with a FAB when the screen has a single primary action. <!-- rule:android-layout-top-app-bar -->
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **48×48 dp minimum** for every touch target, with at least 8 dp between them. <!-- rule:android-touch-target-48dp -->
|
||||
|
||||
## Typography
|
||||
|
||||
- **Material type scale.** Display, Headline, Title, Body, Label roles (large/medium/small each). Map text to roles; never hand-pick sizes per screen. <!-- rule:android-typo-type-scale -->
|
||||
- **Roboto is the system face**; theme a brand face in through the type scale, keeping body, labels, and controls legible and consistent. <!-- rule:android-typo-system-font -->
|
||||
- **sp units, never fixed px**, so type follows the system font-size setting. <!-- rule:android-typo-scalable-sp -->
|
||||
|
||||
## Color & theming
|
||||
|
||||
- **Material color roles** (primary, on-primary, surface, surface-variant, secondary-container, outline, error). Role tokens resolve light/dark and contrast variants automatically; raw hex breaks there. <!-- rule:android-color-role-tokens -->
|
||||
- **Dynamic Color (Material You)** where it fits: derive the scheme from the user's wallpaper on Android 12+, with a static fallback. <!-- rule:android-color-dynamic-color -->
|
||||
- **Dark theme is a first-class scheme.** Design and test it; never a quick invert. <!-- rule:android-color-dark-theme -->
|
||||
- **Tonal elevation.** Convey elevation through the standard surface tonal levels (plus shadow where appropriate); no arbitrary drop shadows. <!-- rule:android-color-tonal-elevation -->
|
||||
|
||||
## Components & motion
|
||||
|
||||
- **Material components.** Buttons (filled / tonal / outlined / text), FAB, switches, chips, snackbars, bottom sheets, Material dialogs, navigation bar/rail/drawer. Never port iOS controls or invent equivalents. <!-- rule:android-components-material -->
|
||||
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task. <!-- rule:android-components-single-fab -->
|
||||
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt. <!-- rule:android-components-snackbar -->
|
||||
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut. <!-- rule:android-motion-material-and-reduce -->
|
||||
@@ -10,6 +10,10 @@ Brand: motion is part of the voice; one well-rehearsed entrance beats scattered
|
||||
|
||||
Product: 150–250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
|
||||
|
||||
## Platform
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): motion mirrors the system. Match platform navigation transitions and honor the OS Reduce Motion setting; see the Motion sections of [ios.md](ios.md) and [android.md](android.md) (`adaptive` follows each OS).
|
||||
|
||||
---
|
||||
|
||||
## Assess Animation Opportunities
|
||||
|
||||
@@ -2,6 +2,10 @@ Run systematic **technical** quality checks and generate a comprehensive report.
|
||||
|
||||
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
|
||||
|
||||
## Platform
|
||||
|
||||
The dimensions below are written for web. Native (`ios` / `android` / `adaptive`) audits translate: accessibility means **VoiceOver / TalkBack** correctness (labels, roles, reading order, Dynamic Type / scalable text reflow); touch targets are 44 pt (iOS) / 48 dp (Android); appearance covers Dark Mode / dark theme. `detect.mjs` is web-only; never run it against native code. See [ios.md](ios.md) / [android.md](android.md) (`adaptive` audits both).
|
||||
|
||||
## Diagnostic Scan
|
||||
|
||||
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
|
||||
|
||||
+28
-6
@@ -16,6 +16,7 @@ Decision tree:
|
||||
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
|
||||
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 and offer to run `/impeccable document` for DESIGN.md.
|
||||
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
|
||||
- **PRODUCT.md exists but has no `## Platform` section (legacy)**: add it the same way, but only when the project is native (`ios` / `android` / `adaptive`) or the user wants it explicit; a missing field already means `web`.
|
||||
- **Both exist**: {{ask_instruction}} Ask which file to refresh. Skip the one the user doesn't want changed.
|
||||
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
|
||||
|
||||
@@ -41,6 +42,13 @@ Also form a **register hypothesis** from what you find:
|
||||
|
||||
Register is a hypothesis at this point, not a decision; Step 3 confirms it.
|
||||
|
||||
Also form a **platform hypothesis**:
|
||||
|
||||
- Native signals: React Native / Expo (`react-native`, `expo`), Flutter (`pubspec.yaml`, `flutter`), SwiftUI / UIKit (`.swift`, `.xcodeproj`, an `ios/` app target), Jetpack Compose / Android (`build.gradle`, an `android/` app module, `AndroidManifest.xml`). An `ios/` and/or `android/` directory that is a real app target, not just a Capacitor/Cordova wrapper around a website.
|
||||
- Web signals (the default): a web framework (Vite, Next, Nuxt, SvelteKit, Astro), an HTML entry, a CSS/Tailwind setup, no native app target.
|
||||
|
||||
Values: `web` / `ios` / `android` / `adaptive` (one codebase, ships both, adapts per OS). Mobile web is still `web`. Like register, this is a hypothesis; Step 3 confirms it.
|
||||
|
||||
Note what you've learned and what remains unclear. Also note any rough edges worth a follow-up command (thin hierarchy, flat or gray palette, missing error/empty states, dull copy); Step 7 turns these into concrete recommendations without re-analyzing.
|
||||
|
||||
## Step 3: Ask strategic questions (for PRODUCT.md)
|
||||
@@ -55,12 +63,12 @@ If the repo is empty or the user's brief is sparse, run a short interview before
|
||||
- Ask **2-3 questions per round**, then wait for answers.
|
||||
- Use inferred answers as hypotheses or options, not as finished facts.
|
||||
- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
|
||||
- Round 1 should establish register, users/purpose, and desired outcome.
|
||||
- Round 1 should establish register, platform, users/purpose, and desired outcome.
|
||||
- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
|
||||
|
||||
### Minimum viable interview
|
||||
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, **platform confirmation** (`web` / `ios` / `android` / `adaptive`), users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. Never let the template's default `web` stand unconfirmed for a native or cross-platform repo. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
|
||||
### Register (ask first; it shapes everything below)
|
||||
|
||||
@@ -70,6 +78,14 @@ If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this l
|
||||
|
||||
If the signal is genuinely split (e.g. a product with a big marketing landing), {{ask_instruction}} Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
|
||||
|
||||
### Platform (ask right after register)
|
||||
|
||||
Every project targets **web** (includes responsive mobile web), **ios**, **android**, or **adaptive** (one codebase, ships both, adapts per OS: Flutter, React Native, KMP). Platform picks the native rulebook: HIG for `ios`, Material 3 for `android`, both for `adaptive`, none for `web`.
|
||||
|
||||
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [web / ios / android / adaptive] project. Does that match?"* For cross-platform apps, decide by the **design language the app renders**, not the toolchain: one look on both platforms (Flutter's Material-everywhere default) takes that platform's value; genuine per-OS adaptation (Cupertino on iOS, Material on Android) is `adaptive`. When in doubt, `web`.
|
||||
|
||||
A monorepo shipping both a website and a native app gets a PRODUCT.md per app, each with its own `## Platform`; the root PRODUCT.md carries the primary surface's platform.
|
||||
|
||||
### Users & Purpose
|
||||
- Who uses this? What's their context when using it?
|
||||
- What job are they trying to get done?
|
||||
@@ -101,6 +117,10 @@ Synthesize into a strategic document:
|
||||
|
||||
product
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
[Who they are, their context, the job to be done]
|
||||
|
||||
@@ -120,7 +140,7 @@ product
|
||||
[WCAG level, known user needs, considerations]
|
||||
```
|
||||
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary.
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary. Platform is `web`, `ios`, `android`, or `adaptive`, also a bare value; omit the section only on legacy files you're leaving untouched, otherwise write `web` explicitly.
|
||||
|
||||
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it; merge into that content rather than starting from scratch.
|
||||
|
||||
@@ -137,6 +157,8 @@ If the user prefers to skip, mention they can run `/impeccable document` any tim
|
||||
|
||||
## Step 6: Configure live mode (when code exists)
|
||||
|
||||
**Skip this step when the platform is native** (`ios` / `android` / `adaptive`): live mode drives a browser overlay. A hybrid wrapper or Expo web target serving HTML doesn't change that.
|
||||
|
||||
If the project has code with HTML entries and a dev server (the same "code exists" condition that puts `/impeccable document` in scan mode), pre-configure live mode now. You already identified the framework and the served HTML entry in Step 2, so this is nearly free, and it spares the user the first-time setup detour when they later run `/impeccable live`.
|
||||
|
||||
**Skip this step for empty / pre-implementation projects** (nothing to inject into yet). Tell the user live mode will configure itself the first time they run it once there's code.
|
||||
@@ -154,16 +176,16 @@ Writing the config file is harmless and needs no consent; only the CSP **source-
|
||||
## Step 7: Recommend starting points, then wrap up
|
||||
|
||||
Summarize tersely:
|
||||
- Register captured (brand / product)
|
||||
- Register captured (brand / product) and platform captured (web / ios / android / adaptive)
|
||||
- What was written (PRODUCT.md, DESIGN.md, live config, or a subset)
|
||||
- The 3-5 strategic principles from PRODUCT.md that will guide future work
|
||||
- If DESIGN.md or live config is pending, one line on how to set it up later
|
||||
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register and to what you saw, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register **and platform**, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
|
||||
- **Build something new**: `/impeccable craft <feature>` (shape, then build end-to-end) or `/impeccable shape <feature>` (plan first). Lead with this for empty or early-stage projects.
|
||||
- **Improve what's there**: name the specific surface. `/impeccable critique <page>` for a scored UX review; `/impeccable audit <area>` for a11y / perf / responsive checks; `/impeccable polish <component>` for a pre-ship pass. When the crawl flagged a specific weakness, point the matching command at it: thin hierarchy or spacing → `layout`, flat or gray palette → `colorize`, missing error / empty states → `harden` or `onboard`, dull or unclear copy → `clarify`.
|
||||
- **Iterate visually**: `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place.
|
||||
- **Iterate visually** (web only): `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place. **Skip this group for native platforms.**
|
||||
|
||||
The full command menu is one bare `/impeccable` away; keep this list short and pointed.
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# iOS platform
|
||||
|
||||
For native iOS / iPadOS apps: SwiftUI, UIKit, React Native, Expo, Flutter shipping to Apple hardware.
|
||||
|
||||
On native, register narrows. HIG conformance governs structure, navigation, and interaction whatever the register; brand expresses through the expressive layer the platform provides (tint, type, motion, content). Calm, Duolingo, and Spotify carry strong identity entirely inside HIG conventions.
|
||||
|
||||
## The iOS slop test
|
||||
|
||||
Would a fluent iPhone user trust this app, or pause at off-spec controls? The tell is "ported from a website": reinvented navigation bars, custom back gestures, web-shaped buttons, hover-dependent affordances. Default to the platform's components; depart only for a reason the user would thank you for.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Safe area.** Lay out inside the safe-area insets. No controls under the notch, Dynamic Island, home indicator, or rounded corners. <!-- rule:ios-layout-safe-area -->
|
||||
- **System navigation.** Tab bar for 2–5 top-level sections (sections, never actions), navigation stack for hierarchy, sheet for self-contained tasks. No custom global nav, no mixed metaphors. <!-- rule:ios-layout-standard-navigation -->
|
||||
- **Edge-swipe back stays alive.** The left-edge back gesture is muscle memory; never disable or overlay it. <!-- rule:ios-layout-edge-swipe-back -->
|
||||
- **Large titles** on top-level screens, collapsing to inline on scroll. Deep detail screens stay inline. <!-- rule:ios-layout-large-titles -->
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **44×44 pt minimum** for every tappable control, with breathing room between adjacent targets. <!-- rule:ios-touch-target-44pt -->
|
||||
|
||||
## Typography
|
||||
|
||||
- **Dynamic Type.** Use the system text styles (Large Title through Caption) so text follows the user's reading size. No hard-coded point sizes. <!-- rule:ios-typo-dynamic-type -->
|
||||
- **San Francisco carries the UI.** Body, labels, and controls stay on SF Pro / SF Compact; a brand face may appear in display moments. <!-- rule:ios-typo-system-font -->
|
||||
- **11 pt floor**; Body is 17 pt. <!-- rule:ios-typo-minimum-size -->
|
||||
|
||||
## Color & materials
|
||||
|
||||
- **Semantic system colors** (label, secondaryLabel, systemBackground, separator, tint). They adapt to Dark Mode and increased contrast automatically; raw hex breaks there. <!-- rule:ios-color-semantic-system -->
|
||||
- **Dark Mode is a first-class appearance.** Design and test both. <!-- rule:ios-color-dark-mode -->
|
||||
- **One tint color** drives interactive elements; decoration is not its job. <!-- rule:ios-color-single-tint -->
|
||||
- **System materials** for blur and translucency behind bars and sheets; no hand-rolled glassmorphism. <!-- rule:ios-color-system-materials -->
|
||||
|
||||
## Components & controls
|
||||
|
||||
- **Platform controls.** Switch, segmented control, stepper, system pickers, action sheets, alerts, context menus, swipe actions. Reinventing these for flavor is the most common native slop. <!-- rule:ios-components-native-controls -->
|
||||
- **SF Symbols** for iconography: baseline-aligned, Dynamic Type-aware, weight and scale variants. Don't mix in a web icon set. <!-- rule:ios-components-sf-symbols -->
|
||||
- **Deliberate modality.** Sheet for a focused dismissible sub-task, full-screen cover for immersion. Clear Cancel/Done; honor swipe-to-dismiss unless data loss requires a guard. <!-- rule:ios-components-modality -->
|
||||
- **Grouped/inset lists** for settings-shaped content; no bespoke card stacks. <!-- rule:ios-components-grouped-lists -->
|
||||
|
||||
## Motion
|
||||
|
||||
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient. <!-- rule:ios-motion-system-transitions -->
|
||||
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides. <!-- rule:ios-motion-reduce-motion -->
|
||||
@@ -8,6 +8,10 @@ Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-b
|
||||
|
||||
Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
|
||||
|
||||
## Platform
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): structure follows platform navigation (iOS tab bar / navigation stack; Android navigation bar / rail / drawer), safe-area / window insets, and spec touch targets (44 pt iOS, 48 dp Android). See [ios.md](ios.md) and [android.md](android.md); `adaptive` lays out per OS.
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Layout
|
||||
|
||||
@@ -21,7 +21,7 @@ import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractRegister } from './context.mjs';
|
||||
import { loadContext, extractRegister, extractPlatform } from './context.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
@@ -197,6 +197,7 @@ export async function gatherSignals(cwd = process.cwd()) {
|
||||
designPath: ctx.designPath,
|
||||
hasCode: hasCode(cwd),
|
||||
register: extractRegister(ctx.product),
|
||||
platform: extractPlatform(ctx.product),
|
||||
},
|
||||
critique: { latest: latestCritique(cwd) },
|
||||
git,
|
||||
|
||||
+68
-12
@@ -693,24 +693,60 @@ function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first non-empty line under a bare `## <heading>` section of
|
||||
* PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
|
||||
* section is absent. The heading match is exact (`\s*$`) so near-miss
|
||||
* headings like `## Register guidelines` don't shadow the real field.
|
||||
*/
|
||||
export function extractSectionValue(product, heading) {
|
||||
if (!product) return null;
|
||||
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
|
||||
const lines = product.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (headingRe.test(lines[i].trim())) {
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const next = lines[j].trim();
|
||||
// A new heading before any value means the section is empty.
|
||||
if (/^#{1,6}\s/.test(next)) return null;
|
||||
if (next) return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
||||
* for a `## Register` section and reading the first non-empty line that
|
||||
* follows it. Returns null when the file is legacy / register-less.
|
||||
*/
|
||||
export function extractRegister(product) {
|
||||
if (!product) return null;
|
||||
const lines = product.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^##\s+Register\b/i.test(lines[i].trim())) {
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const next = lines[j].trim();
|
||||
if (!next) continue;
|
||||
const word = next.toLowerCase();
|
||||
if (word === 'brand' || word === 'product') return word;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
|
||||
return word === 'brand' || word === 'product' ? word : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
|
||||
* by looking for a `## Platform` section and reading the first non-empty line
|
||||
* that follows it. `adaptive` is for cross-platform apps (Flutter, React
|
||||
* Native) that ship both iOS and Android from one codebase; a line that names
|
||||
* both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
|
||||
* when the file is legacy / platform-less, which the skill treats as `web`
|
||||
* (the default the general rules already assume).
|
||||
*/
|
||||
export function extractPlatform(product) {
|
||||
const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
|
||||
if (!value) return null;
|
||||
if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
|
||||
// A short list naming both native targets (`ios, android`, `ios and
|
||||
// android`) = adaptive. Only list separators and the two platform words may
|
||||
// appear; anything else (prose, negations) is unrecognized and falls
|
||||
// through to the CLI's WARNING path.
|
||||
const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
|
||||
if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
|
||||
&& tokens.includes('ios') && tokens.includes('android')) {
|
||||
return 'adaptive';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -889,6 +925,26 @@ async function cli() {
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
|
||||
parts.push(next);
|
||||
const platform = extractPlatform(ctx.product);
|
||||
const nativeRefs =
|
||||
platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
|
||||
if (nativeRefs.length) {
|
||||
const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
|
||||
const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
|
||||
parts.push(
|
||||
`NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to the register reference.`,
|
||||
);
|
||||
} else if (!platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
// toolchain like `flutter`, a typo) would otherwise silently fall back to
|
||||
// web — the wrong default exactly when the user tried to say "native".
|
||||
const rawPlatform = extractSectionValue(ctx.product, 'Platform');
|
||||
if (rawPlatform) {
|
||||
parts.push(
|
||||
`WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
appendDesignSystemNote,
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
isNativePlatform,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
renderTemplate,
|
||||
resolveCacheCwd,
|
||||
resolveProjectCwd,
|
||||
resolveProjectPlatform,
|
||||
truthy,
|
||||
writeAuditLog,
|
||||
} from './hook-lib.mjs';
|
||||
@@ -433,6 +435,12 @@ async function main() {
|
||||
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
// Web rule engine, native project: stand aside (see resolveProjectPlatform).
|
||||
const platform = resolveProjectPlatform(cwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
|
||||
return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
|
||||
* truthy(value)
|
||||
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
|
||||
* resolveProjectPlatform(cwd) / isNativePlatform(platform)
|
||||
* normalizeIgnoreValue(value)
|
||||
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
|
||||
* bumpEditCount(cache, sessionId, filePath) -> number
|
||||
@@ -39,6 +40,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { extractPlatform, loadContext } from './context.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -170,6 +172,26 @@ export function resolveCacheCwd(primaryFile, sessionCwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The detector's rules are web rules (HTML/CSS shapes), but a React Native or
|
||||
// Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
|
||||
// .js), so without this gate every native screen edit would draw web-shaped
|
||||
// findings that contradict the native platform references. PRODUCT.md's
|
||||
// `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
|
||||
// the scan entirely. Resolution goes through loadContext so the hook reads the
|
||||
// same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
|
||||
export function resolveProjectPlatform(cwd) {
|
||||
try {
|
||||
const ctx = loadContext(cwd);
|
||||
return extractPlatform(ctx && ctx.product);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativePlatform(platform) {
|
||||
return platform === 'ios' || platform === 'android' || platform === 'adaptive';
|
||||
}
|
||||
|
||||
export function readConfig(cwd) {
|
||||
const config = cloneDefaultConfig();
|
||||
// Hook runtime settings live under `hook`; detector filters live under
|
||||
@@ -1509,6 +1531,11 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const platform = resolveProjectPlatform(projectCwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const cache = readCache(projectCwd);
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const det = detector || await loadDetector();
|
||||
|
||||
+118
-1
@@ -21,7 +21,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import { loadContext, resolveContextDir, resolveProjectRoot } from '../skill/scripts/context.mjs';
|
||||
import { loadContext, resolveContextDir, resolveProjectRoot, extractRegister, extractPlatform } from '../skill/scripts/context.mjs';
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const SCRIPT_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'scripts', 'context.mjs');
|
||||
@@ -730,6 +730,65 @@ describe('loadContext (IMPECCABLE_CONTEXT_DIR escape hatch)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPlatform', () => {
|
||||
it('returns null when the product is empty or platform-less', () => {
|
||||
assert.equal(extractPlatform(null), null);
|
||||
assert.equal(extractPlatform('# P\n\nno platform here\n'), null);
|
||||
});
|
||||
|
||||
it('reads web / ios / android / adaptive case-insensitively', () => {
|
||||
assert.equal(extractPlatform('## Platform\n\nweb\n'), 'web');
|
||||
assert.equal(extractPlatform('## Platform\n\nios\n'), 'ios');
|
||||
assert.equal(extractPlatform('## platform\n\nANDROID\n'), 'android');
|
||||
assert.equal(extractPlatform('## Platform\n\nAdaptive\n'), 'adaptive');
|
||||
});
|
||||
|
||||
it('reads a line naming both native targets as adaptive', () => {
|
||||
assert.equal(extractPlatform('## Platform\n\nios, android\n'), 'adaptive');
|
||||
assert.equal(extractPlatform('## Platform\n\nandroid and ios\n'), 'adaptive');
|
||||
assert.equal(extractPlatform('## Platform\n\nios/android\n'), 'adaptive');
|
||||
});
|
||||
|
||||
it('does not read prose mentioning both targets as adaptive', () => {
|
||||
// Negations and explanations must fall through to the unrecognized-value
|
||||
// warning, never silently classify as cross-platform native.
|
||||
assert.equal(extractPlatform('## Platform\n\nweb only, not ios or android\n'), null);
|
||||
assert.equal(extractPlatform('## Platform\n\nios first, android later this year\n'), null);
|
||||
});
|
||||
|
||||
it('returns null for an unrecognized value', () => {
|
||||
assert.equal(extractPlatform('## Platform\n\ndesktop\n'), null);
|
||||
assert.equal(extractPlatform('## Platform\n\nflutter\n'), null);
|
||||
});
|
||||
|
||||
it('ignores a near-miss heading and reads the real one', () => {
|
||||
// `## Platform notes` must not be mistaken for the `## Platform` field.
|
||||
const product = '## Platform notes\n\nsome prose here\n\n## Platform\n\nios\n';
|
||||
assert.equal(extractPlatform(product), 'ios');
|
||||
// Same precision for the register heading.
|
||||
const reg = '## Register guidelines\n\nblah\n\n## Register\n\nbrand\n';
|
||||
assert.equal(extractRegister(reg), 'brand');
|
||||
});
|
||||
|
||||
it('reads the first non-empty line after the heading', () => {
|
||||
assert.equal(extractPlatform('## Platform\n\n\nios\n'), 'ios');
|
||||
});
|
||||
|
||||
it('treats an empty section followed by another heading as absent', () => {
|
||||
// An empty `## Platform` must not swallow the next heading as its value
|
||||
// (which would surface a nonsense "value `## Product Purpose` is not
|
||||
// recognized" warning from the CLI).
|
||||
assert.equal(extractPlatform('## Platform\n\n## Product Purpose\n\nAn app.\n'), null);
|
||||
assert.equal(extractRegister('## Register\n\n## Users\n\nAnglers.\n'), null);
|
||||
});
|
||||
|
||||
it('is independent of the register field', () => {
|
||||
const product = '# P\n\n## Register\n\nproduct\n\n## Platform\n\nandroid\n';
|
||||
assert.equal(extractRegister(product), 'product');
|
||||
assert.equal(extractPlatform(product), 'android');
|
||||
});
|
||||
});
|
||||
|
||||
describe('context.mjs CLI', () => {
|
||||
it('emits NO_PRODUCT_MD directive when no PRODUCT.md is found', async () => {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
@@ -789,6 +848,64 @@ describe('context.mjs CLI', () => {
|
||||
assert.match(res.stdout, /NEXT STEP: You MUST now read the matching register reference/);
|
||||
assert.match(res.stdout, /reference\/brand\.md.*reference\/product\.md/);
|
||||
});
|
||||
|
||||
it('appends a native platform directive for an ios project', async () => {
|
||||
write('PRODUCT.md', '# Acme\n\n## Register\n\nproduct\n\n## Platform\n\nios\n');
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } });
|
||||
assert.equal(res.status, 0);
|
||||
assert.match(res.stdout, /This project targets `ios`\./);
|
||||
assert.match(res.stdout, /read `reference\/ios\.md`/);
|
||||
});
|
||||
|
||||
it('appends both native directives for an adaptive project', async () => {
|
||||
write('PRODUCT.md', '# Acme\n\n## Register\n\nproduct\n\n## Platform\n\nadaptive\n');
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } });
|
||||
assert.equal(res.status, 0);
|
||||
assert.match(res.stdout, /targets `adaptive` \(both iOS and Android\)/);
|
||||
assert.match(res.stdout, /reference\/ios\.md` and `reference\/android\.md`/);
|
||||
});
|
||||
|
||||
it('appends no native platform directive for a web project', async () => {
|
||||
write('PRODUCT.md', '# Acme\n\n## Register\n\nproduct\n\n## Platform\n\nweb\n');
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } });
|
||||
assert.equal(res.status, 0);
|
||||
assert.equal(res.stdout.includes('This project targets'), false);
|
||||
assert.equal(res.stdout.includes('reference/ios.md'), false);
|
||||
});
|
||||
|
||||
it('appends a native platform directive for an android project', async () => {
|
||||
write('PRODUCT.md', '# Acme\n\n## Register\n\nproduct\n\n## Platform\n\nandroid\n');
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } });
|
||||
assert.equal(res.status, 0);
|
||||
assert.match(res.stdout, /This project targets `android`\./);
|
||||
assert.match(res.stdout, /read `reference\/android\.md`/);
|
||||
});
|
||||
|
||||
it('warns on an unrecognized platform value instead of silently defaulting to web', async () => {
|
||||
// The likeliest misconfiguration is a toolchain name where the target
|
||||
// belongs. Silent fallback to web would give web guidance to the exact
|
||||
// projects that tried to declare themselves native.
|
||||
write('PRODUCT.md', '# Acme\n\n## Register\n\nproduct\n\n## Platform\n\nflutter\n');
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } });
|
||||
assert.equal(res.status, 0);
|
||||
assert.match(res.stdout, /WARNING: PRODUCT\.md's `## Platform` value `flutter` is not recognized/);
|
||||
assert.match(res.stdout, /treating the project as `web`/);
|
||||
assert.equal(res.stdout.includes('This project targets'), false);
|
||||
});
|
||||
|
||||
it('emits no warning for an empty Platform section', async () => {
|
||||
write('PRODUCT.md', '# Acme\n\n## Register\n\nproduct\n\n## Platform\n\n## Users\n\nAnglers.\n');
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8', env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' } });
|
||||
assert.equal(res.status, 0);
|
||||
assert.equal(res.stdout.includes('WARNING: PRODUCT.md'), false);
|
||||
assert.equal(res.stdout.includes('This project targets'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context.mjs update check', () => {
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
runHook,
|
||||
payload,
|
||||
extractFindingIgnoreValue,
|
||||
resolveProjectPlatform,
|
||||
isNativePlatform,
|
||||
} from '../skill/scripts/hook-lib.mjs';
|
||||
import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs';
|
||||
|
||||
@@ -1116,6 +1118,29 @@ rounded:
|
||||
assert.equal(r.audit.skipped, 'config-disabled');
|
||||
});
|
||||
|
||||
it('skips the scan when PRODUCT.md declares a native platform', async () => {
|
||||
// The web rule engine has no business flagging React Native screens; the
|
||||
// hook watches .tsx/.ts/.js, which is exactly what a native project is
|
||||
// made of, so the platform field gates the whole scan.
|
||||
for (const platform of ['ios', 'android', 'adaptive']) {
|
||||
writeFixture('PRODUCT.md', `# App\n\n## Register\n\nproduct\n\n## Platform\n\n${platform}\n`);
|
||||
const file = writeFixture('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('side-tab', 1)]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, `native-${platform}`)), env: {}, cwd, detector: det });
|
||||
assert.equal(r.stdout, '', `expected silence for platform ${platform}`);
|
||||
assert.equal(r.audit.skipped, 'native-platform');
|
||||
assert.equal(r.audit.platform, platform);
|
||||
}
|
||||
});
|
||||
|
||||
it('still scans when PRODUCT.md declares web (or has no platform field)', async () => {
|
||||
writeFixture('PRODUCT.md', '# App\n\n## Register\n\nproduct\n\n## Platform\n\nweb\n');
|
||||
const file = writeFixture('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'web-platform')), env: {}, cwd, detector: det });
|
||||
assert.match(r.stdout, /Side-tab/);
|
||||
});
|
||||
|
||||
it('only unlocks design-system detector findings when DESIGN.md exists', async () => {
|
||||
const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }');
|
||||
const det = designAwareDetector();
|
||||
@@ -2098,6 +2123,31 @@ describe('runHook() — configured template extensions (issue #316)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProjectPlatform() / isNativePlatform()', () => {
|
||||
let cwd;
|
||||
beforeEach(() => { cwd = mkTmp(); });
|
||||
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
it('reads the platform from PRODUCT.md via the same resolution the skill uses', () => {
|
||||
fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\n## Platform\n\nios\n');
|
||||
assert.equal(resolveProjectPlatform(cwd), 'ios');
|
||||
});
|
||||
|
||||
it('returns null when PRODUCT.md is absent or platform-less', () => {
|
||||
assert.equal(resolveProjectPlatform(cwd), null);
|
||||
fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\nno platform field\n');
|
||||
assert.equal(resolveProjectPlatform(cwd), null);
|
||||
});
|
||||
|
||||
it('isNativePlatform is true only for ios / android / adaptive', () => {
|
||||
assert.equal(isNativePlatform('ios'), true);
|
||||
assert.equal(isNativePlatform('android'), true);
|
||||
assert.equal(isNativePlatform('adaptive'), true);
|
||||
assert.equal(isNativePlatform('web'), false);
|
||||
assert.equal(isNativePlatform(null), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cursor hook scripts', () => {
|
||||
let cwd;
|
||||
beforeEach(() => { cwd = mkTmp(); });
|
||||
@@ -2139,6 +2189,33 @@ describe('Cursor hook scripts', () => {
|
||||
assert.equal(entries[0].blockedFindings, 1);
|
||||
});
|
||||
|
||||
it('preToolUse allows writes with findings when the project platform is native', () => {
|
||||
// Same slop content the deny test blocks, but the project declares a
|
||||
// native platform, so the web rule engine must stand aside.
|
||||
fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\n## Platform\n\nios\n');
|
||||
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
||||
cwd: path.resolve('.'),
|
||||
input: JSON.stringify({
|
||||
hook_event_name: 'preToolUse',
|
||||
cwd,
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: path.join(cwd, 'src/Card.html'),
|
||||
content: `
|
||||
<style>
|
||||
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
||||
</style>
|
||||
<div class="card">Hello</div>
|
||||
`,
|
||||
},
|
||||
}),
|
||||
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
assert.deepEqual(JSON.parse(out), { permission: 'allow' });
|
||||
});
|
||||
|
||||
it('preToolUse allows clean proposed writes', () => {
|
||||
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
||||
cwd: path.resolve('.'),
|
||||
|
||||
@@ -54,6 +54,7 @@ The trace is the source of truth, not the model's free-form reply.
|
||||
| 11 | empty workspace; prompt is `/impeccable shape ...` | runs `context.mjs`, diverts into `reference/init.md`, and does **not** start writing HTML/CSS |
|
||||
| 12 | empty workspace; prompt is natural-language build intent with no command word | runs `context.mjs`, diverts into `reference/init.md`, and does **not** start writing HTML/CSS |
|
||||
| 13 | empty workspace; prompt is `/impeccable teach` | runs `context.mjs` and diverts into `reference/init.md` because `teach` aliases `init` |
|
||||
| 14 | PRODUCT.md with `## Register: product` + `## Platform: ios` (native iOS app); prompt is `/impeccable craft a tide detail screen` | `context.mjs` runs and emits a NEXT STEP pointing at `reference/ios.md` (proven via captured bash output); agent loads `reference/ios.md` (Setup step 5, native conventions on top of the register reference) |
|
||||
|
||||
Scenario 9 passed on all three current-lineup providers (`claude-sonnet-4-6`,
|
||||
`gpt-5.5`, `gemini-3.1-flash-lite`) on 2026-05-28.
|
||||
|
||||
@@ -72,6 +72,47 @@ landing page.
|
||||
- Never lead with screenshots. Lead with the idea.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Native iOS app fixture: product register, `## Platform` set to `ios`.
|
||||
* Exercises Setup step 5 — when context.mjs reports the platform is native,
|
||||
* the agent must also load `reference/ios.md` (Apple HIG) on top of the
|
||||
* register reference. Product register because this is app UI, not marketing.
|
||||
*/
|
||||
export const PRODUCT_MD_SAMPLE_IOS = `# Tideline
|
||||
|
||||
## Register
|
||||
product
|
||||
|
||||
## Platform
|
||||
ios
|
||||
|
||||
## Product Purpose
|
||||
Tideline is a native iOS app for coastal anglers: tide tables, solunar
|
||||
windows, and a logbook. It SERVES the task — get in, read the conditions,
|
||||
log a catch — so fluent iPhone users should trust it instantly rather than
|
||||
relearn navigation. Earned familiarity over novelty.
|
||||
|
||||
## Users
|
||||
Saltwater anglers checking conditions dockside on an iPhone, often one-handed
|
||||
in bright sun and sometimes offline. They live in Apple Weather, Notes, and
|
||||
Maps and expect the same gestures and controls here.
|
||||
|
||||
## Brand
|
||||
Calm, legible, marine. Identity shows through color, type accent, and the
|
||||
logbook's character — never by reinventing the navigation bar or the back
|
||||
gesture.
|
||||
|
||||
## Anti-references
|
||||
- Web dashboards ported into a WebView
|
||||
- Custom toggles and bespoke tab bars that fight the platform
|
||||
- Cluttered, metric-theater home screens
|
||||
|
||||
## Strategic Principles
|
||||
- Platform conformance is the structural bar; brand lives in the expressive layer.
|
||||
- Standard navigation, SF Symbols, Dynamic Type, Dark Mode first-class.
|
||||
- One accent tint drives interactive elements.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Tiny static landing page fixture for scenarios that invoke sub-commands
|
||||
* (polish, audit) without standing up a full framework project. Gives the
|
||||
|
||||
@@ -28,6 +28,7 @@ import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from '.
|
||||
import {
|
||||
PRODUCT_MD_SAMPLE,
|
||||
PRODUCT_MD_SAMPLE_NO_REGISTER,
|
||||
PRODUCT_MD_SAMPLE_IOS,
|
||||
DESIGN_MD_SAMPLE,
|
||||
MINIMAL_LANDING_HTML,
|
||||
SVELTE_PROJECT_FILES,
|
||||
@@ -554,5 +555,45 @@ for (const modelId of resolveModelList()) {
|
||||
cleanupWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
it('scenario 14: native iOS project (agent loads ios.md on top of register)', async () => {
|
||||
// PRODUCT.md sets `## Platform` to `ios`. context.mjs emits a NEXT STEP
|
||||
// directive to read reference/ios.md for native conventions. Setup step 5
|
||||
// requires it on top of the register reference. The detector / live mode
|
||||
// are web-only, so the only platform-specific obligation is loading the
|
||||
// native reference — that's what this asserts.
|
||||
const workspace = prepareWorkspace({
|
||||
files: { 'PRODUCT.md': PRODUCT_MD_SAMPLE_IOS },
|
||||
});
|
||||
try {
|
||||
const { trace, text } = await runTurn({
|
||||
workspace,
|
||||
model,
|
||||
userPrompt: '/impeccable craft a tide detail screen for the project in this workspace',
|
||||
maxSteps: 6,
|
||||
});
|
||||
logTrace('S14', 'native-ios', modelId, trace, { textSample: text.slice(0, 400) });
|
||||
const loadCalls = bashCommandsMatching(trace, 'context.mjs');
|
||||
assert.ok(
|
||||
loadCalls.length >= 1,
|
||||
`expected agent to run context.mjs at least once; got ${loadCalls.length}.\n` +
|
||||
`bashCommands: ${JSON.stringify(trace.bashCommands, null, 2)}`,
|
||||
);
|
||||
// Proof the native directive actually entered the agent's view.
|
||||
assert.ok(
|
||||
trace.bashOutputs.some((o) => /reference\/ios\.md/.test(o)),
|
||||
`context.mjs should have emitted a NEXT STEP pointing at reference/ios.md (platform is ios).\n` +
|
||||
`bashOutputs: ${JSON.stringify(trace.bashOutputs, null, 2)}`,
|
||||
);
|
||||
// The core property: the agent loads ios.md (Setup step 5).
|
||||
assert.ok(
|
||||
fileLoaded(trace, 'ios.md'),
|
||||
`agent should load ios.md when PRODUCT.md platform is ios.\n` +
|
||||
`Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanupWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user