diff --git a/browser/index.html b/browser/index.html index 59601e4..796348a 100644 --- a/browser/index.html +++ b/browser/index.html @@ -4,8 +4,8 @@ Skill 文档检查 · SkillSync
SkillSync本机目录核验 ↗
-

检查 Skill 文档

粘贴 SKILL.md,读取 YAML 文档头和正文。内容只在当前标签页处理。

-
最多 24 KiB。网页检查文档格式,本机 CLI 可继续检查来源、权限和交付物。 +

DOCUMENT INSPECTOR

检查 Skill 文档

粘贴 SKILL.md,读取 YAML 文档头和正文。内容只在当前标签页处理。

本机处理 · 无网络请求
+
最多 24 KiB。网页检查文档格式,本机 CLI 可继续检查来源、权限和交付物。
-

解析结果

等待检查

运行检查后,这里会列出文档头字段和正文长度。

+

解析结果

等待检查

运行检查后,这里会列出文档头字段和正文长度。

检查范围

读取 YAML 文档头与正文。来源身份、执行行为及目录权限,请使用本机核验台检查。

diff --git a/browser/main.ts b/browser/main.ts index a12286d..4f58160 100644 --- a/browser/main.ts +++ b/browser/main.ts @@ -4,6 +4,8 @@ const status = document.querySelector("#status")!; const message = document.querySelector("#message")!; const locate = document.querySelector("#locate")!; let errorLocation: {start: number; end: number; line: number; column: number} | undefined; +function setState(value: string) { document.querySelector(".result-panel")!.dataset.state = value; } +function updateMeta() { document.querySelector("#document-meta")!.textContent = input.value.split("\n").length + " 行 · " + new TextEncoder().encode(input.value).length + " B"; } function resetLocation() { errorLocation = undefined; locate.hidden = true; @@ -32,6 +34,7 @@ function tooLarge(value: string) { return value.length > byteLimit || encoder.encode(value).length > byteLimit; } function oversized() { + setState("error"); resetLocation(); fields.replaceChildren(); status.textContent = "文档过大"; @@ -50,6 +53,7 @@ input.addEventListener("beforeinput", event => { if (event.cancelable && event.inputType.startsWith("insert")) checkInsertion(event, event.data); }); function clear() { + setState("idle"); updateMeta(); resetLocation(); status.textContent = "等待检查"; message.textContent = "文档已更新,请重新检查。"; @@ -63,6 +67,7 @@ function check() { } const parsed = parseSkillDocument(input.value); if (!parsed.ok) { + setState("error"); status.textContent = "无法解析文档"; errorLocation = parsed.location; if (errorLocation) { @@ -73,6 +78,7 @@ function check() { } return; } + setState("valid"); status.textContent = "文档可解析"; const {frontmatter, body} = parsed.value; const missing = ["name","description"].filter(key => typeof frontmatter[key] !== "string" || !frontmatter[key].trim()); @@ -85,8 +91,11 @@ function check() { } } input.value = example; +updateMeta(); input.addEventListener("input",clear); -document.querySelector("#check")!.addEventListener("click",check); +function inspect() { check(); document.getElementById("result-heading")!.focus(); } +document.querySelector("#check")!.addEventListener("click", inspect); +input.addEventListener("keydown", event => { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); inspect(); } }); document.querySelector("#example")!.addEventListener("click", () => { if (input.value.trim() && input.value !== example && !window.confirm("用示例替换当前文档?")) return; input.value = example; diff --git a/browser/style.css b/browser/style.css index 8f158b3..cc9475e 100644 --- a/browser/style.css +++ b/browser/style.css @@ -1,12 +1,3 @@ -* { box-sizing: border-box } body { margin: 0; color: #202c36; background: #fbfcfd; font: 16px/1.55 system-ui,sans-serif } header,main { max-width: 68rem; padding: 1rem clamp(1.15rem,4vw,3rem); margin:auto } header { display:flex; align-items:center; gap:.7rem; min-height:4.5rem; border-bottom:1px solid #d9dfe5 } header a { margin-left:auto; color:#35556e; font-size:.9rem } main { padding-top:3.2rem } h1 { font-size:clamp(1.9rem,5vw,3rem); letter-spacing:-.045em; line-height:1.1; margin:0 } h2,label { display:block; font-size:1.08rem; font-weight:600; margin:0 0 1rem } main>p,small { color:#54616e } .columns { display:grid; grid-template-columns:1.25fr 1fr; gap:1rem; margin-top:2rem } section { min-width:0; padding:clamp(1rem,3vw,1.6rem); border:1px solid #d9dfe5; border-radius:12px; background:#fff } textarea { width:100%; height:18rem; padding:1rem; border:1px solid #9aa9b7; border-radius:6px; resize:vertical; font: .94rem/1.6 ui-monospace,monospace } small { display:block; margin-top:.5rem } button { border:1px solid #284862; color:#fff; background:#284862; border-radius:6px; padding:.63rem 1rem; font:inherit; cursor:pointer } button.secondary { color:#284862; background:#fff } .actions { display:flex; gap:.6rem; flex-wrap:wrap; margin-top:1rem } dd { margin:0 0 .7rem; overflow-wrap:anywhere } dt { color:#596c7b; font-size:.9rem } :focus-visible { outline:3px solid #648dad; outline-offset:2px } @media(max-width:650px){.columns{grid-template-columns:1fr}main{padding-top:2rem}} -textarea.diagnostic-measure { - position: fixed; - inset-inline-start: -9999px; - visibility: hidden; - min-height: 0; - height: 1px; - border: 0; - overflow: hidden; - resize: none; -} +:root{color-scheme:light;font:14px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#223344;background:#f5f7f9;--line:#dce3e9;--accent:#294e70;--muted:#5b6c7a}*{box-sizing:border-box}body{margin:0}header,main{max-width:1280px;padding:20px clamp(20px,4vw,48px);margin:auto}header{display:flex;gap:12px;align-items:center;min-height:76px;border-bottom:1px solid var(--line)}header strong{font-size:16px;letter-spacing:-.03em}header a{margin-left:auto;color:var(--muted);font-size:12px;text-underline-offset:4px}.workspace-heading{display:flex;justify-content:space-between;align-items:center;gap:24px;margin:28px 0 30px}.eyebrow{font-size:10px;letter-spacing:.15em;color:var(--muted);margin:0 0 12px}h1{font-size:32px;line-height:1.25;letter-spacing:-.04em;font-weight:600;margin:0}.workspace-heading p:not(.eyebrow){color:var(--muted);margin:12px 0 0}.privacy-label{font-size:11px;color:#446659;white-space:nowrap;border:1px solid #cfded5;padding:7px 12px;border-radius:6px;background:#f1f7f3}.columns{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:24px;align-items:start}section{min-width:0;border:1px solid var(--line);background:#fff;border-radius:10px;box-shadow:0 2px 5px #243d5304;overflow:hidden}.panel-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 24px;border-bottom:1px solid var(--line);background:#fcfdfe}.panel-heading label,.panel-heading h2{font-size:14px;font-weight:600;margin:0}#document-meta{font:11px ui-monospace,monospace;color:var(--muted)}textarea{display:block;width:100%;height:380px;min-height:200px;max-height:70vh;padding:24px;resize:vertical;border:0;border-bottom:1px solid var(--line);border-radius:0;background:#fcfdfe;color:#243c52;font:14px/1.8 ui-monospace,SFMono-Regular,monospace;tab-size:2}small{display:block;margin:14px 24px;color:var(--muted);font-size:12px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:0 24px 24px}.actions::after{content:'⌘ / Ctrl + Enter';margin-left:auto;color:var(--muted);font-size:11px}button{font:inherit;min-height:42px;padding:9px 16px;border:1px solid var(--accent);border-radius:6px;background:var(--accent);color:#fff;cursor:pointer;touch-action:manipulation}button.secondary{color:var(--accent);background:#fff;border-color:#b6c4d0}button:hover{background:#203d57;color:#fff}.result-panel{position:sticky;top:24px}.result-panel>strong{display:block;font-size:21px;letter-spacing:-.02em;margin:24px 24px 10px;font-weight:550}.result-panel>p{margin:0 24px 20px;color:var(--muted);overflow-wrap:anywhere}.result-panel>#locate{margin:0 24px 20px}.result-indicator{width:7px;height:7px;border-radius:50%;background:#a3afb8}.result-panel[data-state=error] .result-indicator{background:#a44a35}.result-panel[data-state=error]>strong{color:#92412f}.result-panel[data-state=valid] .result-indicator{background:#507b60}dl{margin:0 24px 24px}dt{color:var(--muted);font:12px ui-monospace,monospace;border-top:1px solid var(--line);padding-top:12px}dd{margin:4px 0 14px;overflow-wrap:anywhere}.scope-note{padding:18px 24px;background:#f7f9fb;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.scope-note strong{font-weight:550;color:#344c60}.scope-note p{margin:5px 0 0}:focus-visible{outline:2px solid #517b9d;outline-offset:3px}.skip-link{position:fixed;top:8px;left:8px;z-index:10;transform:translateY(-200%);padding:10px;background:var(--accent);color:#fff}.skip-link:focus{transform:none}[hidden]{display:none!important}textarea.diagnostic-measure{position:fixed;inset-inline-start:-9999px;visibility:hidden;min-height:0;height:1px;border:0;overflow:hidden;resize:none} +@media(max-width:760px){.workspace-heading{display:block;margin-top:20px}.privacy-label{display:inline-block;margin-top:12px}h1{font-size:27px}.columns{grid-template-columns:1fr;gap:16px}.result-panel{position:static}textarea{font-size:16px;height:300px}.panel-heading{padding:16px 20px}.actions{padding:0 20px 20px}.actions::after{display:none}.result-panel>strong{margin:20px 20px 10px}.result-panel>p{margin-inline:20px}.result-panel>#locate{margin-left:20px}small{margin:14px 20px}button{min-height:44px}header a{font-size:11px}} diff --git a/docs/PRODUCT_EXPERIENCE.md b/docs/PRODUCT_EXPERIENCE.md index b074a34..68954b8 100644 --- a/docs/PRODUCT_EXPERIENCE.md +++ b/docs/PRODUCT_EXPERIENCE.md @@ -23,3 +23,7 @@ node dist/cli/index.js gui --state-dir /absolute/private/report-directory ## Live browser navigation The static entry page accepts an explicit user-activated top-level navigation, including a reload that retains cross-site Fetch Metadata. The literal loopback Host, Origin, API nonce and mutation checks remain enforced. Cross-site iframes, script/API reads and writes are rejected. This fixes a real Chrome launch refusal without changing file-selection or repair authority. + +## 工作区与窄屏操作 + +浏览器文档检查采用编辑区与诊断区,支持 Ctrl/⌘ + Enter。当前行数与 UTF-8 字节数随输入更新。本机核验台将目录范围和结论并列,有警告或未确认项时在主标题标出;历史只读与确认写文件的条件保持原要求。 diff --git a/src/workbench/journey-summary.ts b/src/workbench/journey-summary.ts index cdf3a26..64e97bf 100644 --- a/src/workbench/journey-summary.ts +++ b/src/workbench/journey-summary.ts @@ -8,7 +8,9 @@ export function journeySummary(kind:'skill'|'artifacts',report:VerificationRepor const value=report as VerificationReport;actionable=value.issues.filter(issue=>['fail','warn'].includes(issue.finding.status)).length; counts=[{label:'阻断问题',count:value.summary.fail},{label:'建议核对',count:value.summary.warn},{label:'尚未确认',count:value.summary.unknown}]; }else{const value=report as ArtifactReport;counts=[{label:'已读取文件',count:value.files.length},{label:'问题定位',count:value.findings.length},{label:'未完成对账',count:value.reconciliations?.filter(check=>check.status==='unknown').length||0}];} + const skillCounts=kind==='skill'?(report as VerificationReport).summary:undefined; + const headline=skillCounts?.fail ? '存在需要处理的项目' : skillCounts?.unknown ? '检查完成 · '+skillCounts.unknown+' 项尚未确认' : skillCounts?.warn ? '检查完成 · '+skillCounts.warn+' 项建议核对' : report.exitCode===0?'所声明的检查通过':'存在需要处理的项目'; const canPlan=!readOnly&&kind==='skill'&&!incomplete&&actionable>0; - return {title:readOnly?'历史证据 · 只读':incomplete?'本次检查尚未完整':report.exitCode===0?'所声明的检查通过':'存在需要处理的项目', + return {title:readOnly?'历史证据 · 只读':incomplete?'本次检查尚未完整':headline, description:readOnly?'打开历史不会重新核验或恢复修复权限。可重新核验当前目录再决定下一步。':incomplete?'先查看报告中的输入、容量或未完成原因,修正后重新核验;当前结果不能作为完整通过。':kind==='artifacts'?'交付检查不提供自动修改。按报告修正原文件或清单,再明确重新检查;本报告会保留。':canPlan?'可先生成处理计划,区分安全修改与人工步骤;审阅并明确确认后才会写文件。':'可查看报告或保存摘要。继续修改文件后,请重新核验。',counts,canPlan,readOnly}; } diff --git a/src/workbench/page.ts b/src/workbench/page.ts index c1e47be..1ad260f 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -4,7 +4,7 @@ export function workbenchPage(nonce: string): string {

核验一个目录

只读检查
  1. 01选择范围
  2. 02查看结论
  3. 03保存证据
选择要完成的任务

-

后台操作

正在核对后台状态…

尚未核验

+

后台操作

正在核对后台状态…

尚未核验

只检查本机文件,不运行 Skill。

报告保存位置

报告、计划和回执保存在本机私有目录;历史功能启用前的文件仍保留。

`; } @@ -33,6 +33,13 @@ main{max-width:1240px}.page-heading{padding-bottom:22px;margin-bottom:24px}.work .history-workspace{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(270px,.85fr);gap:24px;align-items:start}.history-browser{min-width:0}.history-comparison{margin:0;position:sticky;top:20px;padding:20px;border-radius:13px;background:#f3f7fa}.history-comparison>h3{margin-top:0}.history-comparison p{font-size:12px;color:#657886}.history-comparison-counts{gap:14px}.history-filters{background:#f6f8fa;padding:16px;border:1px solid #dce4ea;border-radius:12px}.history-filters label:first-child{margin-top:0}#history-list>p{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:16px;border:1px solid #dce4ea;border-radius:12px;background:#fff}#history-list>p>span{flex-basis:100%;font-size:12px;line-height:1.8}#history-list button{margin:0;font-size:11px;min-height:34px;padding:5px 9px}#history-info{font-size:12px;color:#657886} @media(max-width:1000px){.history-workspace{grid-template-columns:minmax(0,1fr)}.history-comparison{position:static}}@media(max-width:720px){#task-single,#task-batch,#task-history,#result,#fix{padding:18px;border-radius:14px}.page-heading h1{font-size:26px}.workbench-journey{gap:8px;font-size:10px}.workbench-journey li{gap:5px;line-height:1.5}.workbench-journey span{width:22px;height:22px}.workbench-journey li:not(:last-child)::after{display:none}.selection-options{grid-template-columns:minmax(0,1fr)}.selection-options label{min-height:88px;padding:17px 14px}.operation-reconnect{grid-template-columns:minmax(0,1fr);gap:8px}.operation-reconnect button{justify-self:start;min-height:44px}#summary-counts{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}#summary-counts li{padding:13px}#summary-counts strong{font-size:26px}.read-only-pill{font-size:10px}#history-list button{min-height:44px}} + +/* Reading order: scope, outcome, then full evidence. */ +.summary-context{font:12px ui-monospace,monospace;color:#526b7d;overflow-wrap:anywhere;padding:10px 12px;border:1px solid #dce4ea;border-radius:5px;background:#f7fafc} +#task-summary[data-attention=true]{border-left:3px solid #9b742f;padding-left:18px}#summary-counts li:first-child{background:#f6f9fb;border-color:#dae3e9;color:#29445d}#summary-counts li:first-child span{color:#6c7d8b} +@media(min-width:1100px){main{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:0 24px;align-items:start}.page-heading,#task-batch,#task-history,footer{grid-column:1/-1}#task-single{grid-column:1;grid-row:2 / span 3}.operation-reconnect,.operation-status{grid-column:2}#result{grid-column:2;grid-row:4 / span 4}#fix{grid-column:1}#report{min-height:420px}.selection-options{grid-template-columns:1fr}.operation-reconnect{grid-template-columns:1fr auto}.operation-reconnect h2{grid-column:1/-1}} +@media(max-width:720px){.app-sidebar{position:sticky;top:0;z-index:10;padding:12px 18px}.app-brand{padding-bottom:10px}.task-nav{margin-top:0}.sidebar-label{display:none}.task-nav button{font-size:12px}#result-title,#selection,#fix-title{scroll-margin-top:150px}} + `; export const workbenchScript = `"use strict"; @@ -91,7 +98,7 @@ function syncMode(){const artifacts=$('kind').value==='artifacts';$('mode-skill' for(const [id,kind] of [['mode-skill','skill'],['mode-artifacts','artifacts']])$(id).onchange=()=>{if($(id).checked){$('kind').value=kind;invalidate();syncMode();}}; function fieldError(id,message){$(id+'-error').textContent=message;$(id).setAttribute('aria-invalid',String(Boolean(message)));if(message)$(id).focus();} function validateInputs(){fieldError('root','');fieldError('contract','');if(!$('root').value.trim()){fieldError('root',$('kind').value==='artifacts'?'请选择或填写交付目录。':'请选择或填写 Skill 目录。');return false;}if($('kind').value==='artifacts'&&!$('contract').value.trim()){fieldError('contract','交付验收需要一份 YAML 或 JSON 清单,请选择清单文件。');return false;}return true;} -function showTaskSummary(value){$('task-summary').hidden=false;summaryCanPlan=Boolean(value?.canPlan)&&!value?.readOnly;$('summary-title').textContent=value?.title||'核验结果';$('summary-next').textContent=value?.description||'查看完整报告后,再决定下一步。';$('summary-counts').replaceChildren();for(const item of (value?.counts||[])){const li=document.createElement('li'),label=document.createElement('span'),count=document.createElement('strong');label.textContent=item.label+':';count.textContent=String(item.count);li.append(label,count);$('summary-counts').append(li);}$('plan').hidden=!summaryCanPlan;$('plan').disabled=busy||!selected||!summaryCanPlan;} +function showTaskSummary(value){$('summary-context').textContent=$('root').value;$('task-summary').setAttribute('data-attention',String(Boolean(value?.counts?.some(c=>['阻断问题','建议核对','尚未确认'].includes(c.label)&&c.count>0)))); $('task-summary').hidden=false;summaryCanPlan=Boolean(value?.canPlan)&&!value?.readOnly;$('summary-title').textContent=value?.title||'核验结果';$('summary-next').textContent=value?.description||'查看完整报告后,再决定下一步。';$('summary-counts').replaceChildren();for(const item of (value?.counts||[])){const li=document.createElement('li'),label=document.createElement('span'),count=document.createElement('strong');label.textContent=item.label+':';count.textContent=String(item.count);li.append(label,count);$('summary-counts').append(li);}$('plan').hidden=!summaryCanPlan;$('plan').disabled=busy||!selected||!summaryCanPlan;} $('result-details').onclick=()=>{$('report').scrollIntoView?.({block:'start',behavior:'auto'});$('report').focus();}; $('result-recheck').onclick=()=>{if(busy)return;setTask('single');return verify(false);}; syncMode(); diff --git a/tests/workbench/journey-summary.test.ts b/tests/workbench/journey-summary.test.ts index f7f298c..d8a94d1 100644 --- a/tests/workbench/journey-summary.test.ts +++ b/tests/workbench/journey-summary.test.ts @@ -3,5 +3,9 @@ import type {VerificationReport} from '../../src/cli/commands/verify';import typ const skill={exitCode:1,summary:{fail:2,warn:1,unknown:0},issues:[{finding:{status:'fail'}}]} as VerificationReport; it('turns current Skill evidence into a plan next-step without claiming application',()=>{const result=journeySummary('skill',skill);expect(result.canPlan).toBe(true);expect(result.counts[0].count).toBe(2);expect(result.description).toContain('明确确认');}); it('historical evidence never regains mutation authority',()=>expect(journeySummary('skill',skill,true)).toMatchObject({readOnly:true,canPlan:false})); +it('a successful exit with warnings must surface the warning in the headline',()=>{ + const warning={...skill,exitCode:0,summary:{...skill.summary,fail:0,warn:1,unknown:0}}; + expect(journeySummary('skill',warning).title).toBe('检查完成 · 1 项建议核对'); +}); it('incomplete evidence never offers a repair plan',()=>{expect(journeySummary('skill',{...skill,exitCode:2}).canPlan).toBe(false);expect(journeySummary('skill',{...skill,exitCode:4}).title).toContain('尚未完整');}); it('artifact outcomes direct users to their files instead of safe-fix promises',()=>{const result=journeySummary('artifacts',{exitCode:1,files:[{}],findings:[{}],reconciliations:[]} as ArtifactReport);expect(result.canPlan).toBe(false);expect(result.description).toContain('不提供自动修改');expect(result.counts[0].count).toBe(1);});